mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Merge pull request #5 from mwisnowski/implement-ui
Release v2.0.1 – Web UI upgrade, theme reset, diagnostics, docs
This commit is contained in:
commit
9357a04541
82 changed files with 12566 additions and 246 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -4,13 +4,13 @@
|
|||
*.txt
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
test.py
|
||||
main.spec
|
||||
!requirements.txt
|
||||
__pycache__/
|
||||
build/
|
||||
csv_files/
|
||||
dist/
|
||||
logs/
|
||||
!config/deck.json
|
||||
RELEASE_NOTES.md
|
||||
RELEASE_NOTES.md
|
||||
*.bkp
|
64
CHANGELOG.md
64
CHANGELOG.md
|
@ -12,7 +12,24 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.1] - 2025-08-28
|
||||
|
||||
### Added
|
||||
- Web UI performance: optional virtualized grids/lists in Step 5 and Owned (enable with `WEB_VIRTUALIZE=1`).
|
||||
- Virtualization diagnostics overlay (when `SHOW_DIAGNOSTICS=1`); press `v` to toggle per‑grid overlays and a global summary bubble with visible range, totals, render time, and counters.
|
||||
- Image polish: lazy‑loading with responsive `srcset/sizes` and LQIP blur/fade‑in for Step 5 and Owned thumbnails and the commander preview image.
|
||||
- Short‑TTL fragment caching for template partials (e.g., finished deck summaries and config run summaries) to reduce re‑render cost.
|
||||
- Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons
|
||||
- New Deck modal consolidating steps 1–3 with optional Name for exports, Enter-to-select commander, and disabled browser autofill
|
||||
- Locks, Replace flow, Compare builds, and shareable permalinks for finished decks
|
||||
- Compare page: Copy summary action to copy diffs (Only in A/B and Changed counts) to clipboard
|
||||
- Finished Decks multi-select → Compare with fallback to "Latest two"; options carry modified-time for ordering
|
||||
- Permalinks include locks; global "Open Permalink…" entry exposed in header and Finished Decks
|
||||
- Replace flow supports session-local Undo and lock-aware validation
|
||||
- New Deck modal: inline summary of selected themes with order (1, 2, 3)
|
||||
- Theme combine mode (AND/OR) with tooltips and selection-order display in the Web UI
|
||||
- AND-mode creatures pre-pass: select "all selected themes" creatures first, then fill by weighted overlap; staged reasons show matched themes
|
||||
- Scryfall attribution footer in the Web UI
|
||||
- Owned-cards workflow:
|
||||
- Prompt (only if lists exist) to "Use only owned cards?"
|
||||
- Support multiple file selection; parse `.txt` (1 per line) and `.csv` (any `name` column)
|
||||
|
@ -20,16 +37,63 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- Recommendations export when owned-only deck is incomplete (~1.5× missing) to `deck_files/[stem]_recommendations.csv` and `.txt`
|
||||
- CSV export includes an `Owned` column when not using owned-only
|
||||
- Windows EXE build via PyInstaller is produced on tag and attached to GitHub Releases
|
||||
- Prefer-owned option in Review: bias selection toward owned cards while allowing unowned fallback (stable reorder + gentle weight boosts applied across creatures and spells)
|
||||
- Owned page enhancements: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling
|
||||
- Finished Decks: theme filters converted to a dropdown with shareable state
|
||||
- Staged build: optional "Show skipped stages" toggle to surface stages that added no cards with a clear annotation
|
||||
- Owned/Not-owned badges visible across views; consolidated CSS for consistent placement
|
||||
- Visual summaries: Mana Curve, Color Pips and Sources charts with cross-highlighting to cards; tooltips show per-color card lists and include a Copy action
|
||||
- Source detection: include non-land mana producers and colorless 'C'; basic lands reliably counted; fetch lands excluded as sources
|
||||
- Favicon support: `/favicon.ico` served (ICO with PNG fallback)
|
||||
- Diagnostics: `/healthz` endpoint returns `{status, version, uptime_seconds}`; responses carry `X-Request-ID`; unhandled errors return JSON with request_id
|
||||
- Diagnostics page and tools gated by `SHOW_DIAGNOSTICS`; Logs page gated by `SHOW_LOGS`; both off by default
|
||||
- Global error handling: friendly HTML templates for 404/4xx/500 with Request-ID and "Go home" link; JSON structure for HTMX/API
|
||||
- Request-ID middleware assigns `X-Request-ID` to all responses and includes it in JSON error payloads
|
||||
- `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics
|
||||
- Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists
|
||||
- Theme UX: Header includes a Reset Theme control to clear browser preference and reapply server default (THEME) or system mapping. Diagnostics page shows resolved theme and stored preference with a reset action.
|
||||
|
||||
Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outline.md`.
|
||||
|
||||
### Changed
|
||||
- Accessibility: respect OS “reduced motion” by disabling blur/fade transitions and smooth scrolling.
|
||||
- Static asset caching and compression tuned for the web service (cache headers + gzip) to improve load performance.
|
||||
- Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected)
|
||||
- Docker assets and docs updated:
|
||||
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
|
||||
- Compose and helper scripts updated accordingly
|
||||
- Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored
|
||||
- README/DOCKER/WINDOWS_DOCKER_GUIDE updated for Web UI, headless examples, and PowerShell-friendly commands
|
||||
- Headless: tag_mode (AND/OR) accepted from JSON and environment and exported in interactive run-config JSON
|
||||
- Owned lists are enriched at upload-time and persisted in an internal store; header rows skipped and duplicates deduped; per-request parsing removed
|
||||
- Builder Review (Step 4): "Use only owned cards" toggle moved here; Step 5 is status-only with "Edit in Review" for changes
|
||||
- Minor UI/CSS polish and consolidation across builder/owned pages
|
||||
- Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources
|
||||
- New Deck modal submits directly to build, removing the intermediate review step
|
||||
- Finished Decks banner and lists now prefer the custom Name provided in the modal
|
||||
- Step 5 Replace toggle now includes a tooltip clarifying that reruns will replace picks in that stage when enabled
|
||||
- Locks are enforced on rerun; the Locked section live-updates on unlock (row removal and chip refresh)
|
||||
- Compare page shows ▲/▼ indicators on Changed counts and preserves the "Changed only" toggle across interactions
|
||||
- Bracket selector shows numbered labels (e.g., "Bracket 3: Upgraded") and defaults to bracket 3 on new deck creation
|
||||
- List view highlight polished to wrap only the card name (no overrun of the row)
|
||||
- Total sources calculation updated to include 'C' properly
|
||||
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
|
||||
- Owned page UX: full-size preview now pops on thumbnail hover (not the name); selection highlight tightened to the thumbnail only and changed to white for better contrast; Themes in the hover popout render as a larger bullet list with a brighter "THEMES" label
|
||||
- Image robustness: standardized `data-card-name` on all Scryfall images and centralized retry logic (thumbnails + previews) with version fallbacks (small/normal/large) and a single cache-bust refresh on final failure; removed the previous hover-image cache to reduce complexity and overhead
|
||||
- Layout polish: fixed sidebar remains full-height under the banner with a subtle right-edge shadow for depth; grid updated to prevent content squish; extra scroll removed; footer pinned when content is short.
|
||||
- Deck Summary list view: rows use fixed tracks for count, ×, name, and owned columns (monospace tabular numerals) to ensure perfect alignment; highlight is an inset box-shadow on the name to avoid layout shifts; long names ellipsize with a tooltip; list starts directly under the type header and remains stable on full-screen widths
|
||||
|
||||
### Fixed
|
||||
- Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest`
|
||||
- Owned page internal server error resolved via hardened template context and centralized owned context builder
|
||||
- Web container crash resolved by removing invalid union type annotation in favicon route; route now returns a single Response type
|
||||
- Source highlighting consistency: charts now correctly cross-highlight corresponding cards in both list and thumbnail views
|
||||
- Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export
|
||||
- Fetch lands are no longer miscounted as mana sources
|
||||
- Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler
|
||||
- Windows PowerShell curl parsing issue documented with guidance in README
|
||||
- Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints
|
||||
- Banned list filtering applied consistently to all color/guild CSV generation paths with exact, case-insensitive matching on name/faceName (e.g., Hullbreacher, Dockside Extortionist, and Lutri are excluded)
|
||||
|
||||
---
|
||||
|
||||
|
|
127
DOCKER.md
127
DOCKER.md
|
@ -1,6 +1,6 @@
|
|||
# Docker Guide (concise)
|
||||
# Docker Guide
|
||||
|
||||
Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode.
|
||||
Run the MTG Deckbuilder (CLI and Web UI) in Docker with persistent volumes and optional headless mode.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
@ -21,6 +21,112 @@ docker run -it --rm `
|
|||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
```
|
||||
|
||||
## Web UI (new)
|
||||
|
||||
The web UI runs the same deckbuilding logic behind a browser-based interface.
|
||||
|
||||
### PowerShell (recommended)
|
||||
```powershell
|
||||
docker compose up --build --no-deps -d web
|
||||
```
|
||||
|
||||
Then open http://localhost:8080
|
||||
|
||||
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
|
||||
The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`.
|
||||
Compare view offers a Copy summary button to copy a plain-text diff of two runs. The sidebar has a subtle depth shadow for clearer separation.
|
||||
|
||||
Web UI feature highlights:
|
||||
- Locks: Click a card or the lock control in Step 5; locks persist across reruns.
|
||||
- Replace: Enable Replace in Step 5, click a card to open Alternatives (filters include Owned-only), then choose a swap.
|
||||
- Permalinks: Copy a permalink from Step 5 or a Finished deck; paste via “Open Permalink…” to restore.
|
||||
- Compare: Use the Compare page from Finished Decks; quick actions include Latest two and Swap A/B.
|
||||
|
||||
Virtualized lists and lazy images (opt‑in)
|
||||
- Set `WEB_VIRTUALIZE=1` to enable virtualization in Step 5 grids/lists and the Owned library for smoother scrolling on large sets.
|
||||
- Example (Compose):
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
- WEB_VIRTUALIZE=1
|
||||
```
|
||||
- Example (Docker Hub):
|
||||
```powershell
|
||||
docker run --rm -p 8080:8080 `
|
||||
-e WEB_VIRTUALIZE=1 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
-e SHOW_DIAGNOSTICS=1 ` # optional: enables diagnostics tools and overlay
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
|
||||
### Diagnostics and logs (optional)
|
||||
Enable internal diagnostics and a read-only logs viewer with environment flags.
|
||||
|
||||
- `SHOW_DIAGNOSTICS=1` — adds a Diagnostics nav link and `/diagnostics` tools
|
||||
- `SHOW_LOGS=1` — enables `/logs` and `/status/logs?tail=200`
|
||||
|
||||
When enabled:
|
||||
- `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail.
|
||||
- `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`.
|
||||
- Virtualization overlay: press `v` on pages with virtualized grids to toggle per-grid overlays and a global summary bubble.
|
||||
|
||||
Compose example (web service):
|
||||
```yaml
|
||||
environment:
|
||||
- SHOW_LOGS=1
|
||||
- SHOW_DIAGNOSTICS=1
|
||||
```
|
||||
|
||||
Docker Hub (PowerShell) example:
|
||||
```powershell
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 -e ENABLE_THEMES=1 -e THEME=system `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
|
||||
### Setup speed: parallel tagging (Web)
|
||||
First-time setup or stale data triggers card tagging. The web service uses parallel workers by default.
|
||||
|
||||
Configure via environment variables on the `web` service:
|
||||
- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging (default: 1)
|
||||
- `WEB_TAG_WORKERS=<N>` — number of worker processes (default: 4 in compose)
|
||||
|
||||
If parallel initialization fails, the service falls back to sequential tagging and continues.
|
||||
|
||||
### From Docker Hub (PowerShell)
|
||||
If you prefer not to build locally, pull `mwisnowski/mtg-python-deckbuilder:latest` and run uvicorn:
|
||||
```powershell
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
|
||||
Health check:
|
||||
```text
|
||||
GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "uptime_seconds": 123 }
|
||||
```
|
||||
|
||||
Theme preference reset (client-side): use the header’s Reset Theme control to clear the saved browser preference; the server default (THEME) applies on next paint.
|
||||
|
||||
## Volumes
|
||||
- `/app/deck_files` ↔ `./deck_files`
|
||||
- `/app/logs` ↔ `./logs`
|
||||
|
@ -47,6 +153,13 @@ docker run -it --rm `
|
|||
- DECK_CONFIG=/app/config/deck.json
|
||||
- DECK_COMMANDER, DECK_PRIMARY_CHOICE
|
||||
- DECK_ADD_LANDS, DECK_FETCH_COUNT
|
||||
- DECK_TAG_MODE=AND|OR (combine mode used by the builder)
|
||||
|
||||
### Web UI tuning env vars
|
||||
- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off)
|
||||
- WEB_TAG_WORKERS=<N> (process count; set based on CPU/memory)
|
||||
- WEB_VIRTUALIZE=1 (enable virtualization)
|
||||
- SHOW_DIAGNOSTICS=1 (enables diagnostics pages and overlay hotkey `v`)
|
||||
|
||||
## Manual build/run
|
||||
```powershell
|
||||
|
@ -60,11 +173,11 @@ docker run -it --rm `
|
|||
mtg-deckbuilder
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
|
||||
- Files not saving? Verify volume mounts and that folders exist
|
||||
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
|
||||
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
|
||||
## Troubleshooting
|
||||
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
|
||||
- Files not saving? Verify volume mounts and that folders exist
|
||||
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
|
||||
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
|
||||
|
||||
## Tips
|
||||
- Use `docker compose run`, not `up`, for interactive mode
|
||||
|
|
|
@ -48,3 +48,10 @@ WORKDIR /app/code
|
|||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
# Expose web port for the optional Web UI
|
||||
EXPOSE 8080
|
||||
|
||||
# Note: For the Web UI, start uvicorn in your orchestrator (compose/run) like:
|
||||
# uvicorn code.web.app:app --host 0.0.0.0 --port 8080
|
||||
# Phase 9: enable web list virtualization with env WEB_VIRTUALIZE=1
|
||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -1,28 +1,66 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## Highlights
|
||||
- Owned cards: prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`.
|
||||
- Owned-only builds filter the pool by your lists; if the deck can't reach 100, it remains incomplete and notes it.
|
||||
- Recommendations: on incomplete owned-only builds, exports `deck_files/[stem]_recommendations.csv` and `.txt` with ~1.5× missing cards, and prints a short notice.
|
||||
- Owned column: when not using owned-only, owned cards are marked with an `Owned` column in the final CSV.
|
||||
- Headless support: run non-interactively or via the menu's headless submenu.
|
||||
- Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored.
|
||||
- Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`.
|
||||
- Power bracket: set interactively or via `bracket_level` (env: `DECK_BRACKET_LEVEL`).
|
||||
- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
||||
- Docker: mount `./owned_cards` to `/app/owned_cards` to enable owned-cards features; `./config` to `/app/config` for JSON configs.
|
||||
- New Web UI: FastAPI + Jinja front-end with a staged build view and clear reasons per stage. Step 2 now includes AND/OR combine mode with tooltips and selection-order display. Footer includes Scryfall attribution per their guidelines.
|
||||
- AND/OR combine mode: OR (default) recommends across any selected themes with overlap preference; AND prioritizes multi-theme intersections. In creatures, an AND pre-pass selects "all selected themes" creatures first, then fills by weighted overlap. Staged reasons show which selected themes each all-theme creature hits.
|
||||
- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config.
|
||||
- Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV.
|
||||
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
|
||||
- Theming: Consolidated Light (Blend) as the only light palette; default THEME can be system|light|dark. Header includes a Reset Theme control to clear saved preference; diagnostics shows resolved theme and stored preference.
|
||||
- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
||||
- Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=<N>` (compose default: 4). Falls back to sequential if parallel init fails.
|
||||
- Phase 8 UI upgrade: Unified “New Deck” modal (steps 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
|
||||
- Compare page now includes a Copy summary button to quickly share diffs.
|
||||
- New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking.
|
||||
- Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled.
|
||||
- Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources.
|
||||
- Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback).
|
||||
- Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts.
|
||||
- Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads.
|
||||
- Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation.
|
||||
- Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label.
|
||||
- Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews.
|
||||
- Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability.
|
||||
- Finished Decks: banner and lists prefer the run’s custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display.
|
||||
- Replace toggle includes a tooltip explaining that reruns will replace that stage’s picks when enabled.
|
||||
- Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck.
|
||||
- Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal.
|
||||
|
||||
### Diagnostics and error handling
|
||||
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
|
||||
- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation.
|
||||
- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests).
|
||||
- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`.
|
||||
- Diagnostics page surfaces resolved theme and preference, with a Reset preference action.
|
||||
|
||||
## What’s new
|
||||
- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel.
|
||||
- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode.
|
||||
- Reporting: deck summary includes per-color card lists for Pips and Sources; colorless 'C' surfaced and totals corrected.
|
||||
- UI Polish: list-mode highlight wraps only the card name. Chart tooltips include a Copy action with hover persistence.
|
||||
- Exports: CSV gains fallback oracle text for basic lands (Plains/Island/Swamp/Mountain/Forest/Wastes) when missing.
|
||||
- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`).
|
||||
- Prefer-owned bias across creatures and spells selections; Review step includes a toggle next to the owned-only control.
|
||||
- Owned page features and performance improvements via upload-time enrichment and persistence.
|
||||
- Staged build UI can surface skipped stages when enabled.
|
||||
|
||||
## Docker
|
||||
- Single service; persistent volumes:
|
||||
- CLI and Web UI in the same image.
|
||||
- docker-compose includes a `web` service exposing port 8080 by default.
|
||||
- Persistent volumes:
|
||||
- /app/deck_files
|
||||
- /app/logs
|
||||
- /app/csv_files
|
||||
- /app/owned_cards
|
||||
- /app/config (mount `./config` for JSON configs)
|
||||
- /app/config
|
||||
|
||||
### Web UI performance tuning
|
||||
- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging during initial setup/tagging in the Web UI
|
||||
- `WEB_TAG_WORKERS=<N>` — number of worker processes (omit to auto-pick; compose default: 4)
|
||||
|
||||
### Quick Start
|
||||
```powershell
|
||||
# From Docker Hub
|
||||
# CLI from Docker Hub
|
||||
docker run -it --rm `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
|
@ -31,30 +69,49 @@ docker run -it --rm `
|
|||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
|
||||
# From source with Compose
|
||||
# Web UI from Docker Hub
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
|
||||
# From source with Compose (CLI)
|
||||
docker compose build
|
||||
docker compose run --rm mtg-deckbuilder
|
||||
|
||||
# Headless (optional)
|
||||
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
|
||||
# With JSON config
|
||||
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
|
||||
# From source with Compose (Web)
|
||||
docker compose build web
|
||||
docker compose up --no-deps web
|
||||
```
|
||||
|
||||
## Changes
|
||||
- Added owned-cards workflow, CSV Owned column, and recommendations export when owned-only builds are incomplete.
|
||||
- Docker assets updated to include `/app/owned_cards` volume and mount examples.
|
||||
- Windows release workflow now attaches a PyInstaller-built EXE to GitHub Releases.
|
||||
- Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer.
|
||||
- Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode.
|
||||
- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON.
|
||||
- Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples.
|
||||
- Docker: compose `web` service added; volumes clarified.
|
||||
- Visual summaries and diagnostics: added `/healthz` endpoint with version/uptime and request-id propagation on all responses.
|
||||
- Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes.
|
||||
- Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building.
|
||||
- Finished Decks page uses a dropdown theme filter with shareable state.
|
||||
- Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity.
|
||||
- Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths.
|
||||
- Data integrity: per-color/guild CSVs now consistently respect the Commander banned list using exact, case-insensitive name/faceName matching.
|
||||
|
||||
### Tagging updates
|
||||
- Explore/Map: fixed a pattern issue by treating "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
|
||||
- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
|
||||
- Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood.
|
||||
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched.
|
||||
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters; Energy enriched.
|
||||
- Spawn/Scion creators now map to Aristocrats and Ramp.
|
||||
|
||||
## Known Issues
|
||||
- First run downloads card data (takes a few minutes)
|
||||
- Use `docker compose run --rm` (not `up`) for interactive sessions
|
||||
- Use `docker compose run --rm` (not `up`) for interactive CLI sessions
|
||||
- Ensure volumes are mounted to persist files outside the container
|
||||
|
||||
## Links
|
||||
|
|
|
@ -41,6 +41,25 @@ docker run -it --rm `
|
|||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
```
|
||||
|
||||
### Optional: Web UI from Docker Hub
|
||||
Run the browser UI by mapping a port and starting uvicorn:
|
||||
```powershell
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-e WEB_VIRTUALIZE=1 ` # optional virtualization
|
||||
-e ENABLE_THEMES=1 -e THEME=system ` # optional theme selector and default
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
Then open http://localhost:8080
|
||||
|
||||
Tip: The header includes a Reset Theme control to clear your browser’s saved preference and re-apply the server’s default (or OS when THEME=system).
|
||||
|
||||
## Method 2: Command Prompt
|
||||
```cmd
|
||||
REM Create and navigate to workspace
|
||||
|
@ -151,3 +170,7 @@ C:\mtg-decks\
|
|||
├── deck_files\ # Your completed decks (.csv and .txt files)
|
||||
│ ├── Atraxa_Superfriends_20250821.csv
|
||||
│ ├── Atraxa_Superfriends_20250821.txt
|
||||
├── logs\\
|
||||
├── csv_files\\
|
||||
├── owned_cards\\
|
||||
└── config\\
|
||||
|
|
|
@ -228,6 +228,49 @@ class DeckBuilder(
|
|||
if hasattr(super(), 'add_spells_phase'):
|
||||
return super().add_spells_phase()
|
||||
raise NotImplementedError("Spell addition phase not implemented.")
|
||||
# ---------------------------
|
||||
# Lightweight confirmations (CLI pauses; web auto-continues)
|
||||
# ---------------------------
|
||||
def _pause(self, message: str = "Press Enter to continue...") -> None:
|
||||
try:
|
||||
_ = self.input_func(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def confirm_primary_theme(self) -> None:
|
||||
if getattr(self, 'primary_tag', None):
|
||||
self.output_func(f"Primary Theme: {self.primary_tag}")
|
||||
self._pause()
|
||||
|
||||
def confirm_secondary_theme(self) -> None:
|
||||
if getattr(self, 'secondary_tag', None):
|
||||
self.output_func(f"Secondary Theme: {self.secondary_tag}")
|
||||
self._pause()
|
||||
|
||||
def confirm_tertiary_theme(self) -> None:
|
||||
if getattr(self, 'tertiary_tag', None):
|
||||
self.output_func(f"Tertiary Theme: {self.tertiary_tag}")
|
||||
self._pause()
|
||||
|
||||
def confirm_ramp_spells(self) -> None:
|
||||
self.output_func("Confirm Ramp")
|
||||
self._pause()
|
||||
|
||||
def confirm_removal_spells(self) -> None:
|
||||
self.output_func("Confirm Removal")
|
||||
self._pause()
|
||||
|
||||
def confirm_wipes_spells(self) -> None:
|
||||
self.output_func("Confirm Board Wipes")
|
||||
self._pause()
|
||||
|
||||
def confirm_card_advantage_spells(self) -> None:
|
||||
self.output_func("Confirm Card Advantage")
|
||||
self._pause()
|
||||
|
||||
def confirm_protection_spells(self) -> None:
|
||||
self.output_func("Confirm Protection")
|
||||
self._pause()
|
||||
# Commander core selection state
|
||||
commander_name: str = ""
|
||||
commander_row: Optional[pd.Series] = None
|
||||
|
@ -238,6 +281,8 @@ class DeckBuilder(
|
|||
secondary_tag: Optional[str] = None
|
||||
tertiary_tag: Optional[str] = None
|
||||
selected_tags: List[str] = field(default_factory=list)
|
||||
# How to combine multiple selected tags when prioritizing cards: 'AND' or 'OR'
|
||||
tag_mode: str = 'AND'
|
||||
|
||||
# Future deck config placeholders
|
||||
color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G']
|
||||
|
@ -264,6 +309,8 @@ class DeckBuilder(
|
|||
use_owned_only: bool = False
|
||||
owned_card_names: set[str] = field(default_factory=set)
|
||||
owned_files_selected: List[str] = field(default_factory=list)
|
||||
# Soft preference: bias selection toward owned names without excluding others
|
||||
prefer_owned: bool = False
|
||||
|
||||
# Deck library (cards added so far) mapping name->record
|
||||
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
@ -941,6 +988,7 @@ class DeckBuilder(
|
|||
self.output_func("Owned-only mode: no recognizable name column to filter on; skipping filter.")
|
||||
except Exception as _e:
|
||||
self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}")
|
||||
# Soft prefer-owned does not filter the pool; biasing is applied later at selection time
|
||||
self._combined_cards_df = combined
|
||||
# Preserve original snapshot for enrichment across subsequent removals
|
||||
if self._full_cards_df is None:
|
||||
|
@ -1201,6 +1249,7 @@ class DeckBuilder(
|
|||
'ramp': bc.DEFAULT_RAMP_COUNT,
|
||||
'lands': bc.DEFAULT_LAND_COUNT,
|
||||
'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT,
|
||||
'fetch_lands': getattr(bc, 'FETCH_LAND_DEFAULT_COUNT', 3),
|
||||
'creatures': bc.DEFAULT_CREATURE_COUNT,
|
||||
'removal': bc.DEFAULT_REMOVAL_COUNT,
|
||||
'wipes': bc.DEFAULT_WIPES_COUNT,
|
||||
|
@ -1248,6 +1297,7 @@ class DeckBuilder(
|
|||
('ramp', 'Ramp Pieces'),
|
||||
('lands', 'Total Lands'),
|
||||
('basic_lands', 'Minimum Basic Lands'),
|
||||
('fetch_lands', 'Fetch Lands'),
|
||||
('creatures', 'Creatures'),
|
||||
('removal', 'Spot Removal'),
|
||||
('wipes', 'Board Wipes'),
|
||||
|
@ -1270,6 +1320,7 @@ class DeckBuilder(
|
|||
('ramp', 'Ramp'),
|
||||
('lands', 'Total Lands'),
|
||||
('basic_lands', 'Basic Lands (Min)'),
|
||||
('fetch_lands', 'Fetch Lands'),
|
||||
('creatures', 'Creatures'),
|
||||
('removal', 'Spot Removal'),
|
||||
('wipes', 'Board Wipes'),
|
||||
|
|
|
@ -376,6 +376,7 @@ DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
|||
'ramp': 'Enter desired number of ramp pieces (default: 8):',
|
||||
'lands': 'Enter desired number of total lands (default: 35):',
|
||||
'basic_lands': 'Enter minimum number of basic lands (default: 15):',
|
||||
'fetch_lands': 'Enter desired number of fetch lands (default: 3):',
|
||||
'creatures': 'Enter desired number of creatures (default: 25):',
|
||||
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
||||
'wipes': 'Enter desired number of board wipes (default: 2):',
|
||||
|
|
|
@ -66,13 +66,15 @@ def normalize_theme_list(raw) -> list[str]:
|
|||
|
||||
|
||||
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
|
||||
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
|
||||
can (reliably) produce each color.
|
||||
"""Build a matrix mapping card name -> {color: 0/1} indicating if that card
|
||||
can (reliably) produce each color of mana on the battlefield.
|
||||
|
||||
Heuristics:
|
||||
- Presence of basic land types in type line grants that color.
|
||||
- Text containing "add one mana of any color/colour" grants all colors.
|
||||
- Explicit mana symbols in rules text (e.g. "{R}") grant that color.
|
||||
Notes:
|
||||
- Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana.
|
||||
- Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst.
|
||||
- Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately.
|
||||
- For lands, we also infer from basic land types in the type line. For non-lands, we rely on text.
|
||||
- Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
@ -89,29 +91,84 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
if nm and nm not in lookup:
|
||||
lookup[nm] = r
|
||||
for name, entry in card_library.items():
|
||||
if 'land' not in str(entry.get('Card Type', '')).lower():
|
||||
continue
|
||||
row = lookup.get(name, {})
|
||||
tline = str(row.get('type', row.get('type_line', ''))).lower()
|
||||
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
|
||||
colors = {c: 0 for c in COLOR_LETTERS}
|
||||
if 'plains' in tline:
|
||||
colors['W'] = 1
|
||||
if 'island' in tline:
|
||||
colors['U'] = 1
|
||||
if 'swamp' in tline:
|
||||
colors['B'] = 1
|
||||
if 'mountain' in tline:
|
||||
colors['R'] = 1
|
||||
if 'forest' in tline:
|
||||
colors['G'] = 1
|
||||
if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field:
|
||||
for k in colors:
|
||||
entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower()
|
||||
tline_full = str(row.get('type', row.get('type_line', '')) or '').lower()
|
||||
# Land or permanent that could produce mana via text
|
||||
is_land = ('land' in entry_type) or ('land' in tline_full)
|
||||
text_field = str(row.get('text', row.get('oracleText', '')) or '').lower()
|
||||
# Skip obvious non-permanents (rituals etc.)
|
||||
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
|
||||
continue
|
||||
# Keep only candidates that are lands OR whose text indicates mana production
|
||||
produces_from_text = False
|
||||
tf = text_field
|
||||
if tf:
|
||||
# Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour"
|
||||
produces_from_text = (
|
||||
('add one mana of any color' in tf) or
|
||||
('add one mana of any colour' in tf) or
|
||||
('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf))
|
||||
)
|
||||
if not (is_land or produces_from_text):
|
||||
continue
|
||||
# Combine entry type and snapshot type line for robust parsing
|
||||
tline = (entry_type + ' ' + tline_full).strip()
|
||||
colors = {c: 0 for c in (COLOR_LETTERS + ['C'])}
|
||||
# Land type-based inference
|
||||
if is_land:
|
||||
if 'plains' in tline:
|
||||
colors['W'] = 1
|
||||
if 'island' in tline:
|
||||
colors['U'] = 1
|
||||
if 'swamp' in tline:
|
||||
colors['B'] = 1
|
||||
if 'mountain' in tline:
|
||||
colors['R'] = 1
|
||||
if 'forest' in tline:
|
||||
colors['G'] = 1
|
||||
# Text-based inference for both lands and non-lands
|
||||
if (
|
||||
'add one mana of any color' in tf or
|
||||
'add one mana of any colour' in tf or
|
||||
('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf))
|
||||
):
|
||||
for k in COLOR_LETTERS:
|
||||
colors[k] = 1
|
||||
for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]:
|
||||
if sym in text_field:
|
||||
colors[c] = 1
|
||||
matrix[name] = colors
|
||||
# Explicit colored/colorless symbols in add context
|
||||
if 'add' in tf:
|
||||
if '{w}' in tf:
|
||||
colors['W'] = 1
|
||||
if '{u}' in tf:
|
||||
colors['U'] = 1
|
||||
if '{b}' in tf:
|
||||
colors['B'] = 1
|
||||
if '{r}' in tf:
|
||||
colors['R'] = 1
|
||||
if '{g}' in tf:
|
||||
colors['G'] = 1
|
||||
if '{c}' in tf or 'colorless' in tf:
|
||||
colors['C'] = 1
|
||||
# Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes
|
||||
if not any(colors.values()) and is_land:
|
||||
nm = str(name)
|
||||
base = nm
|
||||
if nm.startswith('Snow-Covered '):
|
||||
base = nm[len('Snow-Covered '):]
|
||||
mapping = {
|
||||
'Plains': 'W',
|
||||
'Island': 'U',
|
||||
'Swamp': 'B',
|
||||
'Mountain': 'R',
|
||||
'Forest': 'G',
|
||||
'Wastes': 'C',
|
||||
}
|
||||
col = mapping.get(base)
|
||||
if col:
|
||||
colors[col] = 1
|
||||
# Only include cards that produced at least one color
|
||||
if any(colors.values()):
|
||||
matrix[name] = colors
|
||||
return matrix
|
||||
|
||||
|
||||
|
@ -158,6 +215,7 @@ __all__ = [
|
|||
'compute_spell_pip_weights',
|
||||
'parse_theme_tags',
|
||||
'normalize_theme_list',
|
||||
'prefer_owned_first',
|
||||
'compute_adjusted_target',
|
||||
'normalize_tag_cell',
|
||||
'sort_by_priority',
|
||||
|
@ -418,6 +476,32 @@ def sort_by_priority(df, columns: list[str]):
|
|||
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
|
||||
|
||||
|
||||
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
|
||||
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
|
||||
|
||||
- Adds a temporary column to flag ownership, sorts by it desc with mergesort, then drops it.
|
||||
- If the name column is missing or owned_names_lower empty, returns df unchanged.
|
||||
"""
|
||||
try:
|
||||
if df is None or getattr(df, 'empty', True):
|
||||
return df
|
||||
if not owned_names_lower:
|
||||
return df
|
||||
if name_col not in df.columns:
|
||||
return df
|
||||
tmp_col = '_ownedPref'
|
||||
# Avoid clobbering if already present
|
||||
while tmp_col in df.columns:
|
||||
tmp_col = tmp_col + '_x'
|
||||
ser = df[name_col].astype(str).str.lower().isin(owned_names_lower).astype(int)
|
||||
df = df.assign(**{tmp_col: ser})
|
||||
df = df.sort_values(by=[tmp_col], ascending=[False], kind='mergesort')
|
||||
df = df.drop(columns=[tmp_col])
|
||||
return df
|
||||
except Exception:
|
||||
return df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag-driven land suggestion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
@ -24,11 +24,56 @@ class CommanderSelectionMixin:
|
|||
# ---------------------------
|
||||
# Commander Selection
|
||||
# ---------------------------
|
||||
def _normalize_commander_query(self, s: str) -> str:
|
||||
"""Return a nicely capitalized search string (e.g., "inti, seneschal of the sun"
|
||||
-> "Inti, Seneschal of the Sun"). Keeps small words lowercase unless at a segment start,
|
||||
and capitalizes parts around hyphens/apostrophes.
|
||||
"""
|
||||
if not isinstance(s, str):
|
||||
return str(s)
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return s
|
||||
small = {
|
||||
'a','an','and','as','at','but','by','for','in','of','on','or','the','to','vs','v','with','from','into','over','per'
|
||||
}
|
||||
# Consider a new segment after these punctuation marks
|
||||
segment_breakers = {':',';','-','–','—','/','\\','(', '[', '{', '"', "'", ',', '.'}
|
||||
out_words: list[str] = []
|
||||
start_of_segment = True
|
||||
for raw in s.lower().split():
|
||||
word = raw
|
||||
# If preceding token ended with a breaker, reset segment
|
||||
if out_words:
|
||||
prev = out_words[-1]
|
||||
if prev and prev[-1] in segment_breakers:
|
||||
start_of_segment = True
|
||||
def cap_subparts(token: str) -> str:
|
||||
# Capitalize around hyphens and apostrophes
|
||||
def cap_piece(piece: str) -> str:
|
||||
return piece[:1].upper() + piece[1:] if piece else piece
|
||||
parts = [cap_piece(p) for p in token.split("'")]
|
||||
token2 = "'".join(parts)
|
||||
parts2 = [cap_piece(p) for p in token2.split('-')]
|
||||
return '-'.join(parts2)
|
||||
if start_of_segment or word not in small:
|
||||
fixed = cap_subparts(word)
|
||||
else:
|
||||
fixed = word
|
||||
out_words.append(fixed)
|
||||
# Next word is not start unless current ends with breaker
|
||||
start_of_segment = word[-1:] in segment_breakers
|
||||
# Post-process to ensure first character is capitalized if needed
|
||||
if out_words:
|
||||
out_words[0] = out_words[0][:1].upper() + out_words[0][1:]
|
||||
return ' '.join(out_words)
|
||||
|
||||
def choose_commander(self) -> str: # type: ignore[override]
|
||||
df = self.load_commander_data()
|
||||
names = df["name"].tolist()
|
||||
while True:
|
||||
query = self.input_func("Enter commander name: ").strip()
|
||||
query = self._normalize_commander_query(query)
|
||||
if not query:
|
||||
self.output_func("No input provided. Try again.")
|
||||
continue
|
||||
|
@ -66,7 +111,7 @@ class CommanderSelectionMixin:
|
|||
else:
|
||||
self.output_func("Invalid index.")
|
||||
continue
|
||||
query = choice # treat as new query
|
||||
query = self._normalize_commander_query(choice) # treat as new (normalized) query
|
||||
|
||||
def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override]
|
||||
row = df[df["name"] == name].iloc[0]
|
||||
|
|
|
@ -144,8 +144,17 @@ class LandFetchMixin:
|
|||
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined]
|
||||
|
||||
def run_land_step4(self, requested_count: int | None = None): # type: ignore[override]
|
||||
"""Public wrapper to add fetch lands. Optional requested_count to bypass prompt."""
|
||||
self.add_fetch_lands(requested_count=requested_count)
|
||||
"""Public wrapper to add fetch lands.
|
||||
|
||||
If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds.
|
||||
"""
|
||||
desired = requested_count
|
||||
try:
|
||||
if desired is None and getattr(self, 'ideal_counts', None) and 'fetch_lands' in self.ideal_counts:
|
||||
desired = int(self.ideal_counts['fetch_lands'])
|
||||
except Exception:
|
||||
desired = requested_count
|
||||
self.add_fetch_lands(requested_count=desired)
|
||||
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
|
||||
|
||||
__all__ = [
|
||||
|
|
|
@ -85,11 +85,74 @@ class CreatureAdditionMixin:
|
|||
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
||||
creature_df['_normTags'] = creature_df['_parsedThemeTags']
|
||||
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
|
||||
combine_mode = getattr(self, 'tag_mode', 'AND')
|
||||
base_top = 30
|
||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||||
total_added = 0
|
||||
added_names: List[str] = []
|
||||
# AND pre-pass: pick creatures that hit all selected themes first (if 2+ themes)
|
||||
all_theme_added: List[tuple[str, List[str]]] = []
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) >= 2:
|
||||
all_cnt = len(selected_tags_lower)
|
||||
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
|
||||
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
|
||||
remaining_capacity = max(0, desired_total - total_added)
|
||||
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
|
||||
if target_cap > 0:
|
||||
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
|
||||
subset_all = subset_all[~subset_all['name'].isin(added_names)]
|
||||
if not subset_all.empty:
|
||||
if 'edhrecRank' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
# Bias owned names ahead before weighting
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
|
||||
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
weighted_pool = []
|
||||
for nm in subset_all['name'].tolist():
|
||||
w = weight_strong
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
w *= owned_mult
|
||||
weighted_pool.append((nm, w))
|
||||
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
|
||||
for nm in chosen_all:
|
||||
if commander_name and nm == commander_name:
|
||||
continue
|
||||
row = subset_all[subset_all['name'] == nm].iloc[0]
|
||||
# Which selected themes does this card hit?
|
||||
selected_display_tags = [t for _r, t in themes_ordered]
|
||||
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
|
||||
try:
|
||||
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
|
||||
except Exception:
|
||||
hits = selected_display_tags
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role='all_theme',
|
||||
added_by='creature_all_theme',
|
||||
trigger_tag=", ".join(hits) if hits else None,
|
||||
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
|
||||
)
|
||||
added_names.append(nm)
|
||||
all_theme_added.append((nm, hits))
|
||||
total_added += 1
|
||||
if total_added >= desired_total:
|
||||
break
|
||||
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
|
||||
# Per-theme distribution
|
||||
per_theme_added: Dict[str, List[str]] = {r: [] for r,_t in themes_ordered}
|
||||
for role, tag in themes_ordered:
|
||||
w = weights.get(role, 0.0)
|
||||
|
@ -104,6 +167,9 @@ class CreatureAdditionMixin:
|
|||
continue
|
||||
tnorm = tag.lower()
|
||||
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
if (creature_df['_multiMatch'] >= 2).any():
|
||||
subset = subset[subset['_multiMatch'] >= 2]
|
||||
if subset.empty:
|
||||
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
||||
continue
|
||||
|
@ -111,11 +177,30 @@ class CreatureAdditionMixin:
|
|||
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in subset.columns:
|
||||
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
pool = subset.head(top_n).copy()
|
||||
pool = pool[~pool['name'].isin(added_names)]
|
||||
if pool.empty:
|
||||
continue
|
||||
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
if combine_mode == 'AND':
|
||||
weighted_pool = []
|
||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
else:
|
||||
weighted_pool = []
|
||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||
for nm in chosen:
|
||||
if commander_name and nm == commander_name:
|
||||
|
@ -142,15 +227,26 @@ class CreatureAdditionMixin:
|
|||
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
|
||||
if total_added >= desired_total:
|
||||
break
|
||||
# Fill remaining if still short
|
||||
if total_added < desired_total:
|
||||
need = desired_total - total_added
|
||||
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
|
||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
|
||||
if prioritized.empty:
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
multi_pool = prioritized
|
||||
else:
|
||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
if not multi_pool.empty:
|
||||
if 'edhrecRank' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
|
||||
fill = multi_pool['name'].tolist()[:need]
|
||||
for nm in fill:
|
||||
if commander_name and nm == commander_name:
|
||||
|
@ -173,7 +269,15 @@ class CreatureAdditionMixin:
|
|||
if total_added >= desired_total:
|
||||
break
|
||||
self.output_func(f"Fill pass added {min(need, len(fill))} extra creatures (shortfall compensation).")
|
||||
# Summary output
|
||||
self.output_func("\nCreatures Added:")
|
||||
if all_theme_added:
|
||||
self.output_func(f" All-Theme overlap: {len(all_theme_added)}")
|
||||
for nm, hits in all_theme_added:
|
||||
if hits:
|
||||
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
|
||||
else:
|
||||
self.output_func(f" - {nm}")
|
||||
for role, tag in themes_ordered:
|
||||
lst = per_theme_added.get(role, [])
|
||||
if lst:
|
||||
|
@ -190,3 +294,267 @@ class CreatureAdditionMixin:
|
|||
"""
|
||||
"""Public method for orchestration: delegates to add_creatures."""
|
||||
return self.add_creatures()
|
||||
|
||||
# ---------------------------
|
||||
# Per-theme creature sub-stages (for web UI staged confirms)
|
||||
# ---------------------------
|
||||
def _theme_weights(self, themes_ordered: List[tuple[str, str]]) -> Dict[str, float]:
|
||||
n_themes = len(themes_ordered)
|
||||
if n_themes == 1:
|
||||
base_map = {'primary': 1.0}
|
||||
elif n_themes == 2:
|
||||
base_map = {'primary': 0.6, 'secondary': 0.4}
|
||||
else:
|
||||
base_map = {'primary': 0.5, 'secondary': 0.3, 'tertiary': 0.2}
|
||||
weights: Dict[str, float] = {}
|
||||
boosted_roles: set[str] = set()
|
||||
if n_themes > 1:
|
||||
for role, tag in themes_ordered:
|
||||
w = base_map.get(role, 0.0)
|
||||
lt = tag.lower()
|
||||
if 'kindred' in lt or 'tribal' in lt:
|
||||
mult = getattr(bc, 'WEIGHT_ADJUSTMENT_FACTORS', {}).get(f'kindred_{role}', 1.0)
|
||||
w *= mult
|
||||
boosted_roles.add(role)
|
||||
weights[role] = w
|
||||
total = sum(weights.values())
|
||||
if total > 1.0:
|
||||
for r in list(weights):
|
||||
weights[r] /= total
|
||||
else:
|
||||
rem = 1.0 - total
|
||||
base_sum_unboosted = sum(base_map[r] for r,_t in themes_ordered if r not in boosted_roles)
|
||||
if rem > 1e-6 and base_sum_unboosted > 0:
|
||||
for r,_t in themes_ordered:
|
||||
if r not in boosted_roles:
|
||||
weights[r] += rem * (base_map[r] / base_sum_unboosted)
|
||||
else:
|
||||
weights['primary'] = 1.0
|
||||
return weights
|
||||
|
||||
def _creature_count_in_library(self) -> int:
|
||||
total = 0
|
||||
try:
|
||||
for _n, entry in getattr(self, 'card_library', {}).items():
|
||||
if str(entry.get('Role') or '').strip() == 'creature':
|
||||
total += int(entry.get('Count', 1))
|
||||
except Exception:
|
||||
pass
|
||||
return total
|
||||
|
||||
def _prepare_creature_pool(self):
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty or 'type' not in df.columns:
|
||||
return None
|
||||
creature_df = df[df['type'].str.contains('Creature', case=False, na=False)].copy()
|
||||
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
|
||||
if commander_name and 'name' in creature_df.columns:
|
||||
creature_df = creature_df[creature_df['name'] != commander_name]
|
||||
if creature_df.empty:
|
||||
return None
|
||||
if '_parsedThemeTags' not in creature_df.columns:
|
||||
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
||||
creature_df['_normTags'] = creature_df['_parsedThemeTags']
|
||||
selected_tags_lower: List[str] = []
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if t:
|
||||
selected_tags_lower.append(t.lower())
|
||||
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
|
||||
return creature_df
|
||||
|
||||
def _add_creatures_for_role(self, role: str):
|
||||
"""Add creatures for a single theme role ('primary'|'secondary'|'tertiary')."""
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty:
|
||||
self.output_func("Card pool not loaded; cannot add creatures.")
|
||||
return
|
||||
tag = getattr(self, f'{role}_tag', None)
|
||||
if not tag:
|
||||
return
|
||||
themes_ordered: List[tuple[str, str]] = []
|
||||
if getattr(self, 'primary_tag', None):
|
||||
themes_ordered.append(('primary', self.primary_tag))
|
||||
if getattr(self, 'secondary_tag', None):
|
||||
themes_ordered.append(('secondary', self.secondary_tag))
|
||||
if getattr(self, 'tertiary_tag', None):
|
||||
themes_ordered.append(('tertiary', self.tertiary_tag))
|
||||
weights = self._theme_weights(themes_ordered)
|
||||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||
current_added = self._creature_count_in_library()
|
||||
remaining = max(0, desired_total - current_added)
|
||||
if remaining <= 0:
|
||||
return
|
||||
w = float(weights.get(role, 0.0))
|
||||
if w <= 0:
|
||||
return
|
||||
import math as _math
|
||||
target = int(_math.ceil(desired_total * w * self._get_rng().uniform(1.0, 1.1)))
|
||||
target = min(target, remaining)
|
||||
if target <= 0:
|
||||
return
|
||||
creature_df = self._prepare_creature_pool()
|
||||
if creature_df is None:
|
||||
self.output_func("No creature rows in dataset; skipping.")
|
||||
return
|
||||
tnorm = str(tag).lower()
|
||||
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
||||
if subset.empty:
|
||||
self.output_func(f"Theme '{tag}' produced no creature candidates.")
|
||||
return
|
||||
if 'edhrecRank' in subset.columns:
|
||||
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in subset.columns:
|
||||
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
base_top = 30
|
||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||
pool = subset.head(top_n).copy()
|
||||
# Exclude any names already chosen
|
||||
existing_names = set(getattr(self, 'card_library', {}).keys())
|
||||
pool = pool[~pool['name'].isin(existing_names)]
|
||||
if pool.empty:
|
||||
return
|
||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||||
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
|
||||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||
added = 0
|
||||
for nm in chosen:
|
||||
row = pool[pool['name']==nm].iloc[0]
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role=role,
|
||||
added_by='creature_add',
|
||||
trigger_tag=tag,
|
||||
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
||||
)
|
||||
added += 1
|
||||
if added >= target:
|
||||
break
|
||||
self.output_func(f"Added {added} creatures for {role} theme '{tag}' (target {target}).")
|
||||
|
||||
def _add_creatures_fill(self):
|
||||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||
current_added = self._creature_count_in_library()
|
||||
need = max(0, desired_total - current_added)
|
||||
if need <= 0:
|
||||
return
|
||||
creature_df = self._prepare_creature_pool()
|
||||
if creature_df is None:
|
||||
return
|
||||
multi_pool = creature_df[~creature_df['name'].isin(set(getattr(self, 'card_library', {}).keys()))].copy()
|
||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
if multi_pool.empty:
|
||||
return
|
||||
if 'edhrecRank' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
fill = multi_pool['name'].tolist()[:need]
|
||||
added = 0
|
||||
for nm in fill:
|
||||
row = multi_pool[multi_pool['name']==nm].iloc[0]
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role='fill',
|
||||
added_by='creature_fill',
|
||||
synergy=int(row.get('_multiMatch', 0)) if '_multiMatch' in row else None
|
||||
)
|
||||
added += 1
|
||||
if added >= need:
|
||||
break
|
||||
if added:
|
||||
self.output_func(f"Fill pass added {added} extra creatures (shortfall compensation).")
|
||||
|
||||
# Public stage entry points (web orchestrator looks for these)
|
||||
def add_creatures_primary_phase(self):
|
||||
return self._add_creatures_for_role('primary')
|
||||
|
||||
def add_creatures_secondary_phase(self):
|
||||
return self._add_creatures_for_role('secondary')
|
||||
|
||||
def add_creatures_tertiary_phase(self):
|
||||
return self._add_creatures_for_role('tertiary')
|
||||
|
||||
def add_creatures_fill_phase(self):
|
||||
return self._add_creatures_fill()
|
||||
|
||||
def add_creatures_all_theme_phase(self):
|
||||
"""Staged pre-pass: when AND mode and 2+ tags, add creatures matching all selected themes first."""
|
||||
combine_mode = getattr(self, 'tag_mode', 'AND')
|
||||
tags = [t for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)] if t]
|
||||
if combine_mode != 'AND' or len(tags) < 2:
|
||||
return
|
||||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||
current_added = self._creature_count_in_library()
|
||||
remaining_capacity = max(0, desired_total - current_added)
|
||||
if remaining_capacity <= 0:
|
||||
return
|
||||
creature_df = self._prepare_creature_pool()
|
||||
if creature_df is None or creature_df.empty:
|
||||
return
|
||||
all_cnt = len(tags)
|
||||
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
|
||||
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
|
||||
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
|
||||
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
|
||||
existing_names = set(getattr(self, 'card_library', {}).keys())
|
||||
subset_all = subset_all[~subset_all['name'].isin(existing_names)]
|
||||
if subset_all.empty or target_cap <= 0:
|
||||
return
|
||||
if 'edhrecRank' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
|
||||
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
weighted_pool = []
|
||||
for nm in subset_all['name'].tolist():
|
||||
w = weight_strong
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
w *= owned_mult
|
||||
weighted_pool.append((nm, w))
|
||||
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
|
||||
added = 0
|
||||
for nm in chosen_all:
|
||||
row = subset_all[subset_all['name'] == nm].iloc[0]
|
||||
# Determine which selected themes this card hits for display
|
||||
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
|
||||
hits: List[str] = []
|
||||
try:
|
||||
hits = [t for t in tags if str(t).lower() in norm_tags]
|
||||
except Exception:
|
||||
hits = list(tags)
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role='all_theme',
|
||||
added_by='creature_all_theme',
|
||||
trigger_tag=", ".join(hits) if hits else None,
|
||||
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
|
||||
)
|
||||
added += 1
|
||||
if added >= target_cap:
|
||||
break
|
||||
if added:
|
||||
self.output_func(f"All-Theme AND Pre-Pass: added {added}/{target_cap} creatures (matching all {all_cnt} themes)")
|
||||
|
|
|
@ -57,6 +57,12 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
work = work[work['name'] != commander_name]
|
||||
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
|
||||
# Prefer-owned bias: stable reorder to put owned first while preserving prior sort
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
owned_lower = {str(n).lower() for n in owned_set}
|
||||
work = bu.prefer_owned_first(work, owned_lower)
|
||||
|
||||
rocks_target = min(target_total, math.ceil(target_total/3))
|
||||
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
|
||||
|
@ -143,6 +149,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
lt = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -201,6 +211,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -277,6 +291,12 @@ class SpellAdditionMixin:
|
|||
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
|
||||
conditional_df = sortit(conditional_df)
|
||||
unconditional_df = sortit(unconditional_df)
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
owned_lower = {str(n).lower() for n in owned_set}
|
||||
conditional_df = bu.prefer_owned_first(conditional_df, owned_lower)
|
||||
unconditional_df = bu.prefer_owned_first(unconditional_df, owned_lower)
|
||||
added_cond = 0
|
||||
added_cond_names: List[str] = []
|
||||
for _, r in conditional_df.iterrows():
|
||||
|
@ -349,6 +369,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -452,6 +476,7 @@ class SpellAdditionMixin:
|
|||
spells_df['_multiMatch'] = spells_df['_normTags'].apply(
|
||||
lambda lst: sum(1 for t in selected_tags_lower if t in lst)
|
||||
)
|
||||
combine_mode = getattr(self, 'tag_mode', 'AND')
|
||||
base_top = 40
|
||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||||
|
@ -473,6 +498,9 @@ class SpellAdditionMixin:
|
|||
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
|
||||
)
|
||||
]
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
if (spells_df['_multiMatch'] >= 2).any():
|
||||
subset = subset[subset['_multiMatch'] >= 2]
|
||||
if subset.empty:
|
||||
continue
|
||||
if 'edhrecRank' in subset.columns:
|
||||
|
@ -487,11 +515,32 @@ class SpellAdditionMixin:
|
|||
ascending=[False, True],
|
||||
na_position='last',
|
||||
)
|
||||
# Prefer-owned: stable reorder before trimming to top_n
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
pool = subset.head(top_n).copy()
|
||||
pool = pool[~pool['name'].isin(self.card_library.keys())]
|
||||
if pool.empty:
|
||||
continue
|
||||
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
|
||||
# Build weighted pool with optional owned multiplier
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
|
||||
weighted_pool: list[tuple[str, float]] = []
|
||||
if combine_mode == 'AND':
|
||||
for nm, mm in base_pairs:
|
||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
else:
|
||||
for nm, mm in base_pairs:
|
||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||
for nm in chosen:
|
||||
row = pool[pool['name'] == nm].iloc[0]
|
||||
|
@ -514,7 +563,13 @@ class SpellAdditionMixin:
|
|||
if total_added < remaining:
|
||||
need = remaining - total_added
|
||||
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
|
||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
|
||||
if prioritized.empty:
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
multi_pool = prioritized
|
||||
else:
|
||||
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
if not multi_pool.empty:
|
||||
if 'edhrecRank' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(
|
||||
|
@ -528,6 +583,10 @@ class SpellAdditionMixin:
|
|||
ascending=[False, True],
|
||||
na_position='last',
|
||||
)
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
|
||||
fill = multi_pool['name'].tolist()[:need]
|
||||
for nm in fill:
|
||||
row = multi_pool[multi_pool['name'] == nm].iloc[0]
|
||||
|
@ -585,6 +644,10 @@ class SpellAdditionMixin:
|
|||
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset.columns:
|
||||
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
row = subset.head(1)
|
||||
if row.empty:
|
||||
break
|
||||
|
|
|
@ -61,7 +61,7 @@ class ColorBalanceMixin:
|
|||
self,
|
||||
pip_weights: Optional[Dict[str, float]] = None,
|
||||
color_shortfall_threshold: float = 0.15,
|
||||
perform_swaps: bool = True,
|
||||
perform_swaps: bool = False,
|
||||
max_swaps: int = 5,
|
||||
rebalance_basics: bool = True
|
||||
):
|
||||
|
@ -93,54 +93,56 @@ class ColorBalanceMixin:
|
|||
self.output_func(" Deficits (need more sources):")
|
||||
for c, pip_share, s_share, gap in deficits:
|
||||
self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)")
|
||||
if not perform_swaps or not deficits:
|
||||
# We'll conditionally perform swaps; but even when skipping swaps we continue to basic rebalance.
|
||||
do_swaps = bool(perform_swaps and deficits)
|
||||
if not do_swaps:
|
||||
self.output_func(" (No land swaps performed.)")
|
||||
return
|
||||
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty:
|
||||
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
||||
return
|
||||
deficits.sort(key=lambda x: x[3], reverse=True)
|
||||
swaps_done: List[tuple[str,str,str]] = []
|
||||
overages: Dict[str,float] = {}
|
||||
for c in ['W','U','B','R','G']:
|
||||
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
||||
if over > 0:
|
||||
overages[c] = over
|
||||
if do_swaps:
|
||||
df = getattr(self, '_combined_cards_df', None)
|
||||
if df is None or df.empty:
|
||||
self.output_func(" Swap engine: card pool unavailable; aborting swaps.")
|
||||
else:
|
||||
deficits.sort(key=lambda x: x[3], reverse=True)
|
||||
overages: Dict[str,float] = {}
|
||||
for c in ['W','U','B','R','G']:
|
||||
over = source_share.get(c,0.0) - pip_weights.get(c,0.0)
|
||||
if over > 0:
|
||||
overages[c] = over
|
||||
|
||||
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
|
||||
return bu.select_color_balance_removal(self, exclude_colors, overages)
|
||||
def removal_candidate(exclude_colors: set[str]) -> Optional[str]:
|
||||
return bu.select_color_balance_removal(self, exclude_colors, overages)
|
||||
|
||||
def addition_candidates(target_color: str) -> List[str]:
|
||||
return bu.color_balance_addition_candidates(self, target_color, df)
|
||||
def addition_candidates(target_color: str) -> List[str]:
|
||||
return bu.color_balance_addition_candidates(self, target_color, df)
|
||||
|
||||
for color, _, _, gap in deficits:
|
||||
if len(swaps_done) >= max_swaps:
|
||||
break
|
||||
adds = addition_candidates(color)
|
||||
if not adds:
|
||||
continue
|
||||
to_add = None
|
||||
for cand in adds:
|
||||
if cand not in self.card_library:
|
||||
to_add = cand
|
||||
break
|
||||
if not to_add:
|
||||
continue
|
||||
to_remove = removal_candidate({color})
|
||||
if not to_remove:
|
||||
continue
|
||||
if not self._decrement_card(to_remove):
|
||||
continue
|
||||
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
|
||||
swaps_done.append((to_remove, to_add, color))
|
||||
current_counts = self._current_color_source_counts()
|
||||
total_sources = sum(current_counts.values()) or 1
|
||||
source_share = {c: current_counts[c]/total_sources for c in current_counts}
|
||||
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
|
||||
if new_gap <= color_shortfall_threshold:
|
||||
continue
|
||||
for color, _, _, gap in deficits:
|
||||
if len(swaps_done) >= max_swaps:
|
||||
break
|
||||
adds = addition_candidates(color)
|
||||
if not adds:
|
||||
continue
|
||||
to_add = None
|
||||
for cand in adds:
|
||||
if cand not in self.card_library:
|
||||
to_add = cand
|
||||
break
|
||||
if not to_add:
|
||||
continue
|
||||
to_remove = removal_candidate({color})
|
||||
if not to_remove:
|
||||
continue
|
||||
if not self._decrement_card(to_remove):
|
||||
continue
|
||||
self.add_card(to_add, card_type='Land', role='color-fix', sub_role='swap-add', added_by='color_balance')
|
||||
swaps_done.append((to_remove, to_add, color))
|
||||
current_counts = self._current_color_source_counts()
|
||||
total_sources = sum(current_counts.values()) or 1
|
||||
source_share = {c: current_counts[c]/total_sources for c in current_counts}
|
||||
new_gap = pip_weights.get(color,0.0) - source_share.get(color,0.0)
|
||||
if new_gap <= color_shortfall_threshold:
|
||||
continue
|
||||
|
||||
if swaps_done:
|
||||
self.output_func("\nColor Balance Swaps Performed:")
|
||||
|
@ -152,52 +154,54 @@ class ColorBalanceMixin:
|
|||
self.output_func(" Updated Source Shares:")
|
||||
for c in ['W','U','B','R','G']:
|
||||
self.output_func(f" {c}: {final_source_share.get(c,0.0)*100:5.1f}% (pip {pip_weights.get(c,0.0)*100:5.1f}%)")
|
||||
if rebalance_basics:
|
||||
try:
|
||||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
|
||||
if basics_present:
|
||||
total_basics = sum(e.get('Count',1) for e in basics_present.values())
|
||||
if total_basics > 0:
|
||||
desired_per_color: Dict[str,int] = {}
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
desired = pip_weights.get(c,0.0) * total_basics
|
||||
desired_per_color[c] = int(round(desired))
|
||||
drift = total_basics - sum(desired_per_color.values())
|
||||
if drift != 0:
|
||||
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
|
||||
i = 0
|
||||
while drift != 0 and ordered:
|
||||
c,_ = ordered[i % len(ordered)]
|
||||
desired_per_color[c] += 1 if drift>0 else -1
|
||||
drift += -1 if drift>0 else 1
|
||||
i += 1
|
||||
changes: List[tuple[str,int,int]] = []
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
target = max(0, desired_per_color.get(c,0))
|
||||
entry = self.card_library.get(basic_name)
|
||||
old = entry.get('Count',0) if entry else 0
|
||||
if old == 0 and target>0:
|
||||
for _ in range(target):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
changes.append((basic_name, 0, target))
|
||||
elif entry and old != target:
|
||||
if target > old:
|
||||
for _ in range(target-old):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
else:
|
||||
for _ in range(old-target):
|
||||
self._decrement_card(basic_name)
|
||||
changes.append((basic_name, old, target))
|
||||
if changes:
|
||||
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
|
||||
for nm, old, new in changes:
|
||||
self.output_func(f" {nm}: {old} -> {new}")
|
||||
except Exception as e: # pragma: no cover (defensive)
|
||||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||||
else:
|
||||
elif do_swaps:
|
||||
self.output_func(" (No viable swaps executed.)")
|
||||
|
||||
# Always consider basic-land rebalance when requested
|
||||
if rebalance_basics:
|
||||
try:
|
||||
basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {})
|
||||
basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()}
|
||||
if basics_present:
|
||||
total_basics = sum(e.get('Count',1) for e in basics_present.values())
|
||||
if total_basics > 0:
|
||||
desired_per_color: Dict[str,int] = {}
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
desired = pip_weights.get(c,0.0) * total_basics
|
||||
desired_per_color[c] = int(round(desired))
|
||||
drift = total_basics - sum(desired_per_color.values())
|
||||
if drift != 0:
|
||||
ordered = sorted(desired_per_color.items(), key=lambda kv: pip_weights.get(kv[0],0.0), reverse=(drift>0))
|
||||
i = 0
|
||||
while drift != 0 and ordered:
|
||||
c,_ = ordered[i % len(ordered)]
|
||||
desired_per_color[c] += 1 if drift>0 else -1
|
||||
drift += -1 if drift>0 else 1
|
||||
i += 1
|
||||
changes: List[tuple[str,int,int]] = []
|
||||
for c, basic_name in basic_map.items():
|
||||
if c not in ['W','U','B','R','G']:
|
||||
continue
|
||||
target = max(0, desired_per_color.get(c,0))
|
||||
entry = self.card_library.get(basic_name)
|
||||
old = entry.get('Count',0) if entry else 0
|
||||
if old == 0 and target>0:
|
||||
for _ in range(target):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
changes.append((basic_name, 0, target))
|
||||
elif entry and old != target:
|
||||
if target > old:
|
||||
for _ in range(target-old):
|
||||
self.add_card(basic_name, card_type='Land')
|
||||
else:
|
||||
for _ in range(old-target):
|
||||
self._decrement_card(basic_name)
|
||||
changes.append((basic_name, old, target))
|
||||
if changes:
|
||||
self.output_func("\nBasic Land Rebalance (toward pip distribution):")
|
||||
for nm, old, new in changes:
|
||||
self.output_func(f" {nm}: {old} -> {new}")
|
||||
except Exception as e: # pragma: no cover (defensive)
|
||||
self.output_func(f" Basic rebalance skipped (error: {e})")
|
||||
|
|
|
@ -108,6 +108,213 @@ class ReportingMixin:
|
|||
for cat, c in sorted(cat_counts.items(), key=lambda kv: (precedence_index.get(kv[0], 999), -kv[1], kv[0])):
|
||||
pct = (c / total_cards * 100) if total_cards else 0.0
|
||||
self.output_func(f" {cat:<15} {c:>3} ({pct:5.1f}%)")
|
||||
|
||||
# ---------------------------
|
||||
# Structured deck summary for UI (types, pips, sources, curve)
|
||||
# ---------------------------
|
||||
def build_deck_summary(self) -> dict:
|
||||
"""Return a structured summary of the finished deck for UI rendering.
|
||||
|
||||
Structure:
|
||||
{
|
||||
'type_breakdown': {
|
||||
'counts': { type: count, ... },
|
||||
'order': [sorted types by precedence],
|
||||
'cards': { type: [ {name, count}, ... ] },
|
||||
'total': int
|
||||
},
|
||||
'pip_distribution': {
|
||||
'counts': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n },
|
||||
'weights': { 'W': 0-1, ... }, # normalized weights (may not sum exactly to 1 due to rounding)
|
||||
},
|
||||
'mana_generation': { 'W': n, 'U': n, 'B': n, 'R': n, 'G': n, 'total_sources': n },
|
||||
'mana_curve': { '0': n, '1': n, '2': n, '3': n, '4': n, '5': n, '6+': n, 'total_spells': n }
|
||||
}
|
||||
"""
|
||||
# Build lookup to enrich type and mana values
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
combined_df = getattr(self, '_combined_cards_df', None)
|
||||
snapshot = full_df if full_df is not None else combined_df
|
||||
row_lookup: Dict[str, any] = {}
|
||||
if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns:
|
||||
for _, r in snapshot.iterrows(): # type: ignore[attr-defined]
|
||||
nm = str(r.get('name'))
|
||||
if nm and nm not in row_lookup:
|
||||
row_lookup[nm] = r
|
||||
|
||||
# Category classification (reuse export logic)
|
||||
precedence_order = [
|
||||
'Commander', 'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||
]
|
||||
precedence_index = {k: i for i, k in enumerate(precedence_order)}
|
||||
commander_name = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
|
||||
def classify(primary_type_line: str, card_name: str) -> str:
|
||||
if commander_name and card_name == commander_name:
|
||||
return 'Commander'
|
||||
tl = (primary_type_line or '').lower()
|
||||
if 'battle' in tl:
|
||||
return 'Battle'
|
||||
if 'planeswalker' in tl:
|
||||
return 'Planeswalker'
|
||||
if 'creature' in tl:
|
||||
return 'Creature'
|
||||
if 'instant' in tl:
|
||||
return 'Instant'
|
||||
if 'sorcery' in tl:
|
||||
return 'Sorcery'
|
||||
if 'artifact' in tl:
|
||||
return 'Artifact'
|
||||
if 'enchantment' in tl:
|
||||
return 'Enchantment'
|
||||
if 'land' in tl:
|
||||
return 'Land'
|
||||
return 'Other'
|
||||
|
||||
# Type breakdown (counts and per-type card lists)
|
||||
type_counts: Dict[str, int] = {}
|
||||
type_cards: Dict[str, list] = {}
|
||||
total_cards = 0
|
||||
for name, info in self.card_library.items():
|
||||
# Exclude commander from type breakdown per UI preference
|
||||
if commander_name and name == commander_name:
|
||||
continue
|
||||
cnt = int(info.get('Count', 1))
|
||||
base_type = info.get('Card Type') or info.get('Type', '')
|
||||
if not base_type:
|
||||
row = row_lookup.get(name)
|
||||
if row is not None:
|
||||
base_type = row.get('type', row.get('type_line', '')) or ''
|
||||
category = classify(base_type, name)
|
||||
type_counts[category] = type_counts.get(category, 0) + cnt
|
||||
total_cards += cnt
|
||||
type_cards.setdefault(category, []).append({
|
||||
'name': name,
|
||||
'count': cnt,
|
||||
'role': info.get('Role', '') or '',
|
||||
'tags': list(info.get('Tags', []) or []),
|
||||
})
|
||||
# Sort cards within each type by name
|
||||
for cat, lst in type_cards.items():
|
||||
lst.sort(key=lambda x: (x['name'].lower(), -int(x['count'])))
|
||||
type_order = sorted(type_counts.keys(), key=lambda k: precedence_index.get(k, 999))
|
||||
|
||||
# Pip distribution (counts and weights) for non-land spells only
|
||||
pip_counts = {c: 0 for c in ('W','U','B','R','G')}
|
||||
# For UI cross-highlighting: map color -> list of cards that have that color pip in their cost
|
||||
pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')}
|
||||
import re as _re_local
|
||||
total_pips = 0.0
|
||||
for name, info in self.card_library.items():
|
||||
ctype = str(info.get('Card Type', ''))
|
||||
if 'land' in ctype.lower():
|
||||
continue
|
||||
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
|
||||
if not isinstance(mana_cost, str):
|
||||
continue
|
||||
# Track which colors appear for this card's mana cost for card listing
|
||||
colors_for_card = set()
|
||||
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
|
||||
sym = match.upper()
|
||||
if len(sym) == 1 and sym in pip_counts:
|
||||
pip_counts[sym] += 1
|
||||
total_pips += 1
|
||||
colors_for_card.add(sym)
|
||||
elif '/' in sym:
|
||||
parts = [p for p in sym.split('/') if p in pip_counts]
|
||||
if parts:
|
||||
weight_each = 1 / len(parts)
|
||||
for p in parts:
|
||||
pip_counts[p] += weight_each
|
||||
total_pips += weight_each
|
||||
colors_for_card.add(p)
|
||||
elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color
|
||||
base = sym[0]
|
||||
if base in pip_counts:
|
||||
pip_counts[base] += 1
|
||||
total_pips += 1
|
||||
colors_for_card.add(base)
|
||||
if colors_for_card:
|
||||
cnt = int(info.get('Count', 1))
|
||||
for c in colors_for_card:
|
||||
pip_cards[c].append({'name': name, 'count': cnt})
|
||||
if total_pips <= 0:
|
||||
# Fallback to even distribution across color identity
|
||||
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
|
||||
if colors:
|
||||
share = 1 / len(colors)
|
||||
for c in colors:
|
||||
pip_counts[c] = share
|
||||
total_pips = 1.0
|
||||
pip_weights = {c: (pip_counts[c] / total_pips if total_pips else 0.0) for c in pip_counts}
|
||||
|
||||
# Mana generation from lands (color sources)
|
||||
try:
|
||||
from deck_builder import builder_utils as _bu
|
||||
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
|
||||
except Exception:
|
||||
matrix = {}
|
||||
source_counts = {c: 0 for c in ('W','U','B','R','G','C')}
|
||||
# For UI cross-highlighting: color -> list of cards that produce that color (typically lands, possibly others)
|
||||
source_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G','C')}
|
||||
for name, flags in matrix.items():
|
||||
copies = int(self.card_library.get(name, {}).get('Count', 1))
|
||||
for c in source_counts.keys():
|
||||
if int(flags.get(c, 0)):
|
||||
source_counts[c] += copies
|
||||
source_cards[c].append({'name': name, 'count': copies})
|
||||
total_sources = sum(source_counts.values())
|
||||
|
||||
# Mana curve (non-land spells)
|
||||
curve_bins = ['0','1','2','3','4','5','6+']
|
||||
curve_counts = {b: 0 for b in curve_bins}
|
||||
curve_cards: Dict[str, list] = {b: [] for b in curve_bins}
|
||||
total_spells = 0
|
||||
for name, info in self.card_library.items():
|
||||
ctype = str(info.get('Card Type', ''))
|
||||
if 'land' in ctype.lower():
|
||||
continue
|
||||
cnt = int(info.get('Count', 1))
|
||||
mv = info.get('Mana Value')
|
||||
if mv in (None, ''):
|
||||
row = row_lookup.get(name)
|
||||
if row is not None:
|
||||
mv = row.get('manaValue', row.get('cmc', None))
|
||||
try:
|
||||
val = float(mv) if mv not in (None, '') else 0.0
|
||||
except Exception:
|
||||
val = 0.0
|
||||
bucket = '6+' if val >= 6 else str(int(val))
|
||||
if bucket not in curve_counts:
|
||||
bucket = '6+'
|
||||
curve_counts[bucket] += cnt
|
||||
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||
total_spells += cnt
|
||||
|
||||
return {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
'order': type_order,
|
||||
'cards': type_cards,
|
||||
'total': total_cards,
|
||||
},
|
||||
'pip_distribution': {
|
||||
'counts': pip_counts,
|
||||
'weights': pip_weights,
|
||||
'cards': pip_cards,
|
||||
},
|
||||
'mana_generation': {
|
||||
**source_counts,
|
||||
'total_sources': total_sources,
|
||||
'cards': source_cards,
|
||||
},
|
||||
'mana_curve': {
|
||||
**curve_counts,
|
||||
'total_spells': total_spells,
|
||||
'cards': curve_cards,
|
||||
},
|
||||
'colors': list(getattr(self, 'color_identity', []) or []),
|
||||
}
|
||||
def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str:
|
||||
"""Export current decklist to CSV (enriched).
|
||||
Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv
|
||||
|
@ -136,22 +343,31 @@ class ReportingMixin:
|
|||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
# Collect themes in order
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Build a filename stem from either custom export base or commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
# Collect themes in order
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.csv"
|
||||
fname = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
|
@ -207,12 +423,21 @@ class ReportingMixin:
|
|||
except Exception:
|
||||
owned_set_lower = set()
|
||||
|
||||
# Fallback oracle text for basic lands to ensure CSV has meaningful text
|
||||
BASIC_TEXT = {
|
||||
'Plains': '({T}: Add {W}.)',
|
||||
'Island': '({T}: Add {U}.)',
|
||||
'Swamp': '({T}: Add {B}.)',
|
||||
'Mountain': '({T}: Add {R}.)',
|
||||
'Forest': '({T}: Add {G}.)',
|
||||
'Wastes': '({T}: Add {C}.)',
|
||||
}
|
||||
for name, info in self.card_library.items():
|
||||
base_type = info.get('Card Type') or info.get('Type','')
|
||||
base_mc = info.get('Mana Cost','')
|
||||
base_mv = info.get('Mana Value', info.get('CMC',''))
|
||||
role = info.get('Role','') or ''
|
||||
tags = info.get('Tags',[]) or []
|
||||
base_type = info.get('Card Type') or info.get('Type', '')
|
||||
base_mc = info.get('Mana Cost', '')
|
||||
base_mv = info.get('Mana Value', info.get('CMC', ''))
|
||||
role = info.get('Role', '') or ''
|
||||
tags = info.get('Tags', []) or []
|
||||
tags_join = '; '.join(tags)
|
||||
text_field = ''
|
||||
colors = ''
|
||||
|
@ -237,6 +462,9 @@ class ReportingMixin:
|
|||
power = row.get('power', '') or ''
|
||||
toughness = row.get('toughness', '') or ''
|
||||
text_field = row.get('text', row.get('oracleText', '')) or ''
|
||||
# If still no text and this is a basic, inject fallback oracle snippet
|
||||
if (not text_field) and (str(name) in BASIC_TEXT):
|
||||
text_field = BASIC_TEXT[str(name)]
|
||||
# Normalize and coerce text
|
||||
if isinstance(text_field, str):
|
||||
cleaned = text_field
|
||||
|
@ -260,7 +488,7 @@ class ReportingMixin:
|
|||
owned_flag = 'Y' if (name.lower() in owned_set_lower) else ''
|
||||
rows.append(((prec, name.lower()), [
|
||||
name,
|
||||
info.get('Count',1),
|
||||
info.get('Count', 1),
|
||||
base_type,
|
||||
base_mc,
|
||||
base_mv,
|
||||
|
@ -276,6 +504,7 @@ class ReportingMixin:
|
|||
text_field[:800] if isinstance(text_field, str) else str(text_field)[:800],
|
||||
owned_flag
|
||||
]))
|
||||
|
||||
# Now sort (category precedence, then alphabetical name)
|
||||
rows.sort(key=lambda x: x[0])
|
||||
|
||||
|
@ -314,21 +543,30 @@ class ReportingMixin:
|
|||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Prefer custom export base if provided; else fall back to commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.txt"
|
||||
if not filename.lower().endswith('.txt'):
|
||||
filename = filename + '.txt'
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
@ -423,21 +661,30 @@ class ReportingMixin:
|
|||
i += 1
|
||||
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Prefer a custom export base when present; else commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.json"
|
||||
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
|
@ -452,6 +699,7 @@ class ReportingMixin:
|
|||
"secondary_tag": getattr(self, 'secondary_tag', None),
|
||||
"tertiary_tag": getattr(self, 'tertiary_tag', None),
|
||||
"bracket_level": getattr(self, 'bracket_level', None),
|
||||
"tag_mode": (getattr(self, 'tag_mode', 'AND') or 'AND'),
|
||||
"use_multi_theme": True,
|
||||
"add_lands": True,
|
||||
"add_creatures": True,
|
||||
|
|
|
@ -198,24 +198,24 @@ def regenerate_csv_by_color(color: str) -> None:
|
|||
try:
|
||||
if color not in SETUP_COLORS:
|
||||
raise ValueError(f'Invalid color: {color}')
|
||||
|
||||
|
||||
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
|
||||
|
||||
|
||||
logger.info(f'Downloading latest card data for {color} cards')
|
||||
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||
|
||||
|
||||
logger.info('Loading and processing card data')
|
||||
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||
|
||||
|
||||
logger.info(f'Regenerating {color} cards CSV')
|
||||
# Use shared utilities to base-filter once then slice color
|
||||
base_df = filter_dataframe(df, [])
|
||||
# Use shared utilities to base-filter once then slice color, honoring bans
|
||||
base_df = filter_dataframe(df, BANNED_CARDS)
|
||||
base_df[base_df['colorIdentity'] == color_abv].to_csv(
|
||||
f'{CSV_DIRECTORY}/{color}_cards.csv', index=False
|
||||
)
|
||||
|
||||
|
||||
logger.info(f'Successfully regenerated {color} cards database')
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to regenerate {color} cards: {str(e)}')
|
||||
raise
|
||||
|
|
|
@ -36,7 +36,8 @@ from .setup_constants import (
|
|||
COLUMN_ORDER,
|
||||
TAGGED_COLUMN_ORDER,
|
||||
SETUP_COLORS,
|
||||
COLOR_ABRV
|
||||
COLOR_ABRV,
|
||||
BANNED_CARDS,
|
||||
)
|
||||
from exceptions import (
|
||||
MTGJSONDownloadError,
|
||||
|
@ -138,7 +139,8 @@ def save_color_filtered_csvs(df: pd.DataFrame, out_dir: Union[str, Path]) -> Non
|
|||
|
||||
# Base-filter once for efficiency, then per-color filter without redoing base filters
|
||||
try:
|
||||
base_df = filter_dataframe(df, [])
|
||||
# Apply full standard filtering including banned list once, then slice per color
|
||||
base_df = filter_dataframe(df, BANNED_CARDS)
|
||||
except Exception as e:
|
||||
# Wrap any unexpected issues as DataFrameProcessingError
|
||||
raise DataFrameProcessingError(
|
||||
|
@ -207,10 +209,16 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
|||
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||
logger.debug('Removed illegal sets')
|
||||
|
||||
# Remove banned cards
|
||||
for card in banned_cards:
|
||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
||||
logger.debug('Removed banned cards')
|
||||
# Remove banned cards (exact, case-insensitive match on name or faceName)
|
||||
if banned_cards:
|
||||
banned_set = {b.casefold() for b in banned_cards}
|
||||
name_lc = filtered_df['name'].astype(str).str.casefold()
|
||||
face_lc = filtered_df['faceName'].astype(str).str.casefold()
|
||||
mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set))
|
||||
before = len(filtered_df)
|
||||
filtered_df = filtered_df[mask]
|
||||
after = len(filtered_df)
|
||||
logger.debug(f'Removed banned cards: {before - after} filtered out')
|
||||
|
||||
# Remove special card types
|
||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
||||
|
@ -268,7 +276,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
|||
|
||||
# Apply base filtering
|
||||
with tqdm(total=1, desc='Applying base filtering') as pbar:
|
||||
filtered_df = filter_dataframe(df, [])
|
||||
filtered_df = filter_dataframe(df, BANNED_CARDS)
|
||||
pbar.update(1)
|
||||
|
||||
# Filter by color identity
|
||||
|
|
|
@ -6,6 +6,7 @@ import os
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
def run(
|
||||
command_name: str = "",
|
||||
|
@ -47,14 +48,27 @@ def run(
|
|||
scripted_inputs.append("0") # stop at primary
|
||||
# Bracket (meta power / style) selection; default to 3 if not provided
|
||||
scripted_inputs.append(str(bracket_level if isinstance(bracket_level, int) and 1 <= bracket_level <= 5 else 3))
|
||||
# Ideal count prompts (press Enter for defaults)
|
||||
for _ in range(8):
|
||||
scripted_inputs.append("")
|
||||
# Ideal count prompts (press Enter for defaults). Include fetch_lands if present.
|
||||
ideal_keys = {
|
||||
"ramp",
|
||||
"lands",
|
||||
"basic_lands",
|
||||
"fetch_lands",
|
||||
"creatures",
|
||||
"removal",
|
||||
"wipes",
|
||||
"card_advantage",
|
||||
"protection",
|
||||
}
|
||||
for key in bc.DECK_COMPOSITION_PROMPTS.keys():
|
||||
if key in ideal_keys:
|
||||
scripted_inputs.append("")
|
||||
|
||||
def scripted_input(prompt: str) -> str:
|
||||
if scripted_inputs:
|
||||
return scripted_inputs.pop(0)
|
||||
raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt)
|
||||
# Fallback to auto-accept defaults for any unexpected prompts
|
||||
return ""
|
||||
|
||||
builder = DeckBuilder(input_func=scripted_input)
|
||||
# Mark this run as headless so builder can adjust exports and logging
|
||||
|
|
|
@ -4501,7 +4501,7 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None:
|
|||
rules = []
|
||||
air_mask = tag_utils.create_text_mask(df, 'airbend')
|
||||
if air_mask.any():
|
||||
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] })
|
||||
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters', 'Leave the Battlefield'] })
|
||||
|
||||
water_mask = tag_utils.create_text_mask(df, 'waterbend')
|
||||
if water_mask.any():
|
||||
|
|
68
code/tests/test_alternatives_filters.py
Normal file
68
code/tests/test_alternatives_filters.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
import importlib
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
class FakeBuilder:
|
||||
def __init__(self):
|
||||
# Minimal attributes accessed by /build/alternatives
|
||||
self._card_name_tags_index = {
|
||||
'target card': ['ramp', 'mana'],
|
||||
'alt good': ['ramp', 'mana'],
|
||||
'alt owned': ['ramp'],
|
||||
'alt commander': ['ramp'],
|
||||
'alt in deck': ['ramp'],
|
||||
'alt locked': ['ramp'],
|
||||
'unrelated': ['draw'],
|
||||
}
|
||||
# Simulate pandas DataFrame mapping to preserve display casing
|
||||
# Represented as a simple mock object with .empty and .iterrows() for keys above
|
||||
class DF:
|
||||
empty = False
|
||||
def __init__(self, names):
|
||||
self._names = names
|
||||
def __getattr__(self, name):
|
||||
if name == 'empty':
|
||||
return False
|
||||
raise AttributeError
|
||||
def __iter__(self):
|
||||
return iter(self._names)
|
||||
# We'll emulate minimal API used: df[ df["name"].astype(str).str.lower().isin(pool) ]
|
||||
# To keep it simple, we won't rely on DF in this test; display falls back to lower-case names.
|
||||
self._combined_cards_df = None
|
||||
self.card_library = {}
|
||||
# Simulate deck names containing 'alt in deck'
|
||||
self.current_names = ['alt in deck']
|
||||
|
||||
|
||||
def _inject_fake_ctx(client: TestClient, commander: str, locks: list[str]):
|
||||
# Touch session to get sid cookie
|
||||
r = client.get('/build')
|
||||
assert r.status_code == 200
|
||||
sid = r.cookies.get('sid')
|
||||
assert sid
|
||||
# Import session service and mutate directly
|
||||
tasks = importlib.import_module('code.web.services.tasks')
|
||||
sess = tasks.get_session(sid)
|
||||
sess['commander'] = commander
|
||||
sess['locks'] = locks
|
||||
sess['build_ctx'] = {
|
||||
'builder': FakeBuilder(),
|
||||
'locks': {s.lower() for s in locks},
|
||||
}
|
||||
return sid
|
||||
|
||||
|
||||
def test_alternatives_filters_out_commander_in_deck_and_locked():
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
_inject_fake_ctx(client, commander='Alt Commander', locks=['alt locked'])
|
||||
# owned_only off
|
||||
r = client.get('/build/alternatives?name=Target%20Card&owned_only=0')
|
||||
assert r.status_code == 200
|
||||
body = r.text.lower()
|
||||
# Should include alt good and alt owned, but not commander, in deck, or locked
|
||||
assert 'alt good' in body or 'alt%20good' in body
|
||||
assert 'alt owned' in body or 'alt%20owned' in body
|
||||
assert 'alt commander' not in body
|
||||
assert 'alt in deck' not in body
|
||||
assert 'alt locked' not in body
|
52
code/tests/test_compare_diffs.py
Normal file
52
code/tests/test_compare_diffs.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _write_csv(p: Path, rows):
|
||||
p.write_text('\n'.join(rows), encoding='utf-8')
|
||||
|
||||
|
||||
def test_compare_diffs_with_temp_exports(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmpd:
|
||||
tmp = Path(tmpd)
|
||||
# Create two CSV exports with small differences
|
||||
a = tmp / 'A.csv'
|
||||
b = tmp / 'B.csv'
|
||||
header = 'Name,Count,Type,ManaValue\n'
|
||||
_write_csv(a, [
|
||||
header.rstrip('\n'),
|
||||
'Card One,1,Creature,2',
|
||||
'Card Two,2,Instant,1',
|
||||
'Card Three,1,Sorcery,3',
|
||||
])
|
||||
_write_csv(b, [
|
||||
header.rstrip('\n'),
|
||||
'Card Two,1,Instant,1', # decreased in B
|
||||
'Card Four,1,Creature,2', # only in B
|
||||
'Card Three,1,Sorcery,3',
|
||||
])
|
||||
# Touch mtime so B is newer
|
||||
os.utime(a, None)
|
||||
os.utime(b, None)
|
||||
|
||||
# Point DECK_EXPORTS at this temp dir
|
||||
monkeypatch.setenv('DECK_EXPORTS', str(tmp))
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
# Compare A vs B
|
||||
r = client.get(f'/decks/compare?A={a.name}&B={b.name}')
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Only in A: Card One
|
||||
assert 'Only in A' in body
|
||||
assert 'Card One' in body
|
||||
# Only in B: Card Four
|
||||
assert 'Only in B' in body
|
||||
assert 'Card Four' in body
|
||||
# Changed list includes Card Two with delta -1
|
||||
assert 'Card Two' in body
|
||||
assert 'Decreased' in body or '( -1' in body or '(-1)' in body
|
12
code/tests/test_compare_metadata.py
Normal file
12
code/tests/test_compare_metadata.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
import importlib
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_compare_options_include_mtime_attribute():
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get('/decks/compare')
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Ensure at least one option contains data-mtime attribute (present even with empty list structure)
|
||||
assert 'data-mtime' in body
|
91
code/tests/test_diagnostics.py
Normal file
91
code/tests/test_diagnostics.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
import os
|
||||
import importlib
|
||||
import types
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed
|
||||
|
||||
|
||||
def load_app_with_env(**env: str) -> types.ModuleType:
|
||||
for k, v in env.items():
|
||||
os.environ[k] = v
|
||||
import code.web.app as app_module # type: ignore
|
||||
importlib.reload(app_module)
|
||||
return app_module
|
||||
|
||||
|
||||
def test_healthz_ok_and_request_id_header():
|
||||
app_module = load_app_with_env()
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("status") in {"ok", "degraded"}
|
||||
assert "uptime_seconds" in data
|
||||
assert r.headers.get("X-Request-ID")
|
||||
|
||||
|
||||
def test_404_renders_html_when_accept_html():
|
||||
app_module = load_app_with_env()
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/this-does-not-exist", headers={"Accept": "text/html"})
|
||||
assert r.status_code == 404
|
||||
body = r.text.lower()
|
||||
assert "page not found" in body
|
||||
assert "go home" in body
|
||||
assert r.headers.get("X-Request-ID")
|
||||
|
||||
|
||||
def test_htmx_http_exception_returns_json_with_request_id():
|
||||
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/diagnostics/trigger-error", headers={"HX-Request": "true"})
|
||||
assert r.status_code == 418
|
||||
data = r.json()
|
||||
assert data.get("error") is True
|
||||
assert data.get("status") == 418
|
||||
assert data.get("request_id")
|
||||
assert r.headers.get("X-Request-ID")
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_500_json_with_request_id():
|
||||
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
|
||||
# Configure client to not re-raise server exceptions so we can assert the 500 response
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
r = client.get("/diagnostics/trigger-error?kind=unhandled", headers={"HX-Request": "true"})
|
||||
assert r.status_code == 500
|
||||
data = r.json()
|
||||
assert data.get("error") is True
|
||||
assert data.get("status") == 500
|
||||
assert data.get("request_id")
|
||||
assert r.headers.get("X-Request-ID")
|
||||
|
||||
|
||||
def test_status_sys_summary_and_flags():
|
||||
app_module = load_app_with_env(
|
||||
SHOW_LOGS="1",
|
||||
SHOW_DIAGNOSTICS="1",
|
||||
SHOW_SETUP="1",
|
||||
ENABLE_THEMES="1",
|
||||
ENABLE_PWA="1",
|
||||
ENABLE_PRESETS="1",
|
||||
APP_VERSION="testver",
|
||||
THEME="dark",
|
||||
)
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/status/sys")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("version") == "testver"
|
||||
assert isinstance(data.get("uptime_seconds"), int)
|
||||
assert isinstance(data.get("server_time_utc"), str)
|
||||
flags = data.get("flags") or {}
|
||||
assert flags.get("SHOW_LOGS") is True
|
||||
assert flags.get("SHOW_DIAGNOSTICS") is True
|
||||
assert flags.get("SHOW_SETUP") is True
|
||||
# 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"
|
44
code/tests/test_permalinks_and_locks.py
Normal file
44
code/tests/test_permalinks_and_locks.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
import base64
|
||||
import json
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_permalink_includes_locks_and_restores_notice(monkeypatch):
|
||||
# Lazy import to ensure fresh app state
|
||||
import importlib
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
# Seed a session with a commander and locks by calling /build and directly touching session via cookie path
|
||||
# Start a session
|
||||
r = client.get('/build')
|
||||
assert r.status_code == 200
|
||||
|
||||
# Now set some session state by invoking endpoints that mutate session
|
||||
# Simulate selecting commander and a lock
|
||||
# Use /build/from to load a permalink-like payload directly
|
||||
payload = {
|
||||
"commander": "Atraxa, Praetors' Voice",
|
||||
"tags": ["proliferate"],
|
||||
"bracket": 3,
|
||||
"ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4},
|
||||
"tag_mode": "AND",
|
||||
"flags": {"owned_only": False, "prefer_owned": False},
|
||||
"locks": ["Swords to Plowshares", "Sol Ring"],
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode('utf-8')
|
||||
token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
|
||||
r2 = client.get(f'/build/from?state={token}')
|
||||
assert r2.status_code == 200
|
||||
# Step 4 should contain the locks restored chip
|
||||
body = r2.text
|
||||
assert 'locks restored' in body.lower()
|
||||
|
||||
# Ask the server for a permalink now and ensure locks are present
|
||||
r3 = client.get('/build/permalink')
|
||||
assert r3.status_code == 200
|
||||
data = r3.json()
|
||||
# Prefer decoded state when token not provided
|
||||
state = data.get('state') or {}
|
||||
assert 'locks' in state
|
||||
assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"}
|
68
code/tests/test_replace_and_locks_flow.py
Normal file
68
code/tests/test_replace_and_locks_flow.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
import base64
|
||||
import json
|
||||
import importlib
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _decode_permalink_state(client: TestClient) -> dict:
|
||||
r = client.get('/build/permalink')
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
if data.get('state'):
|
||||
return data['state']
|
||||
# If only permalink token provided, decode it for inspection
|
||||
url = data.get('permalink') or ''
|
||||
assert '/build/from?state=' in url
|
||||
token = url.split('state=', 1)[1]
|
||||
pad = '=' * (-len(token) % 4)
|
||||
raw = base64.urlsafe_b64decode((token + pad).encode('ascii')).decode('utf-8')
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def test_replace_updates_locks_and_undo_restores(monkeypatch):
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
# Start session
|
||||
r = client.get('/build')
|
||||
assert r.status_code == 200
|
||||
|
||||
# Replace Old -> New (locks: add new, remove old)
|
||||
r2 = client.post('/build/replace', data={'old': 'Old Card', 'new': 'New Card'})
|
||||
assert r2.status_code == 200
|
||||
body = r2.text
|
||||
assert 'Locked <strong>New Card</strong> and unlocked <strong>Old Card</strong>' in body
|
||||
|
||||
state = _decode_permalink_state(client)
|
||||
locks = {s.lower() for s in state.get('locks', [])}
|
||||
assert 'new card' in locks
|
||||
assert 'old card' not in locks
|
||||
|
||||
# Undo should remove new and re-add old
|
||||
r3 = client.post('/build/replace/undo', data={'old': 'Old Card', 'new': 'New Card'})
|
||||
assert r3.status_code == 200
|
||||
state2 = _decode_permalink_state(client)
|
||||
locks2 = {s.lower() for s in state2.get('locks', [])}
|
||||
assert 'old card' in locks2
|
||||
assert 'new card' not in locks2
|
||||
|
||||
|
||||
def test_lock_from_list_unlock_emits_oob_updates():
|
||||
app_module = importlib.import_module('code.web.app')
|
||||
client = TestClient(app_module.app)
|
||||
|
||||
# Initialize session
|
||||
r = client.get('/build')
|
||||
assert r.status_code == 200
|
||||
|
||||
# Lock a name
|
||||
r1 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '1'})
|
||||
assert r1.status_code == 200
|
||||
|
||||
# Now unlock from the locked list path (from_list=1)
|
||||
r2 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '0', 'from_list': '1'})
|
||||
assert r2.status_code == 200
|
||||
body = r2.text
|
||||
# Should include out-of-band updates so UI can refresh the locks chip/section
|
||||
assert 'hx-swap-oob' in body
|
||||
assert 'id="locks-chip"' in body or "id='locks-chip'" in body
|
1
code/web/__init__.py
Normal file
1
code/web/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Web package marker
|
398
code/web/app.py
Normal file
398
code/web/app.py
Normal file
|
@ -0,0 +1,398 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json as _json
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from typing import Any, Tuple
|
||||
|
||||
# Resolve template/static dirs relative to this file
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||
_STATIC_DIR = _THIS_DIR / "static"
|
||||
|
||||
app = FastAPI(title="MTG Deckbuilder Web UI")
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Mount static if present
|
||||
if _STATIC_DIR.exists():
|
||||
class CacheStatic(StaticFiles):
|
||||
async def get_response(self, path, scope): # type: ignore[override]
|
||||
resp = await super().get_response(path, scope)
|
||||
try:
|
||||
# Add basic cache headers for static assets
|
||||
resp.headers.setdefault("Cache-Control", "public, max-age=604800, immutable")
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
app.mount("/static", CacheStatic(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
# Jinja templates
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
# Global template flags (env-driven)
|
||||
def _as_bool(val: str | None, default: bool = False) -> bool:
|
||||
if val is None:
|
||||
return default
|
||||
return val.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
||||
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
||||
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)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
|
||||
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
|
||||
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
|
||||
DEFAULT_THEME = "system"
|
||||
if _THEME_ENV in {"light", "dark", "system"}:
|
||||
DEFAULT_THEME = _THEME_ENV
|
||||
|
||||
# Expose as Jinja globals so all templates can reference without passing per-view
|
||||
templates.env.globals.update({
|
||||
"show_logs": SHOW_LOGS,
|
||||
"show_setup": SHOW_SETUP,
|
||||
"show_diagnostics": SHOW_DIAGNOSTICS,
|
||||
"virtualize": SHOW_VIRTUALIZE,
|
||||
"enable_themes": ENABLE_THEMES,
|
||||
"enable_pwa": ENABLE_PWA,
|
||||
"enable_presets": ENABLE_PRESETS,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
})
|
||||
|
||||
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
|
||||
_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {}
|
||||
_FRAGMENT_TTL_SECONDS = 60.0
|
||||
|
||||
def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str:
|
||||
"""Render a template fragment with an optional cache key and short TTL.
|
||||
|
||||
Intended for finished/immutable views (e.g., saved deck summaries). On error,
|
||||
falls back to direct rendering without cache interaction.
|
||||
"""
|
||||
try:
|
||||
if cache_key:
|
||||
now = time.time()
|
||||
k = (template_name, str(cache_key))
|
||||
hit = _FRAGMENT_CACHE.get(k)
|
||||
if hit and (now - hit[0]) < _FRAGMENT_TTL_SECONDS:
|
||||
return hit[1]
|
||||
html = templates.get_template(template_name).render(**ctx)
|
||||
_FRAGMENT_CACHE[k] = (now, html)
|
||||
return html
|
||||
return templates.get_template(template_name).render(**ctx)
|
||||
except Exception:
|
||||
return templates.get_template(template_name).render(**ctx)
|
||||
|
||||
templates.env.globals["render_cached"] = render_cached
|
||||
|
||||
# --- Diagnostics: request-id and uptime ---
|
||||
_APP_START_TIME = time.time()
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id_middleware(request: Request, call_next):
|
||||
"""Assign or propagate a request id and attach to response headers."""
|
||||
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
|
||||
request.state.request_id = rid
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as ex:
|
||||
# Log and re-raise so FastAPI exception handlers can format the response.
|
||||
logging.getLogger("web").error(f"Unhandled error [rid={rid}]: {ex}", exc_info=True)
|
||||
raise
|
||||
response.headers["X-Request-ID"] = rid
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
|
||||
|
||||
|
||||
# Simple health check (hardened)
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
try:
|
||||
version = os.getenv("APP_VERSION", "dev")
|
||||
uptime_s = int(time.time() - _APP_START_TIME)
|
||||
return {"status": "ok", "version": version, "uptime_seconds": uptime_s}
|
||||
except Exception:
|
||||
# Avoid throwing from health
|
||||
return {"status": "degraded"}
|
||||
|
||||
# System summary endpoint for diagnostics
|
||||
@app.get("/status/sys")
|
||||
async def status_sys():
|
||||
try:
|
||||
version = os.getenv("APP_VERSION", "dev")
|
||||
uptime_s = int(time.time() - _APP_START_TIME)
|
||||
server_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
return {
|
||||
"version": version,
|
||||
"uptime_seconds": uptime_s,
|
||||
"server_time_utc": server_time,
|
||||
"flags": {
|
||||
"SHOW_LOGS": bool(SHOW_LOGS),
|
||||
"SHOW_SETUP": bool(SHOW_SETUP),
|
||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
||||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
},
|
||||
}
|
||||
except Exception:
|
||||
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
|
||||
|
||||
# Logs tail endpoint (read-only)
|
||||
@app.get("/status/logs")
|
||||
async def status_logs(
|
||||
tail: int = Query(200, ge=1, le=500),
|
||||
q: str | None = None,
|
||||
level: str | None = Query(None, description="Optional level filter: error|warning|info|debug"),
|
||||
):
|
||||
try:
|
||||
if not SHOW_LOGS:
|
||||
# Hide when logs are disabled
|
||||
return JSONResponse({"error": True, "status": 403, "detail": "Logs disabled"}, status_code=403)
|
||||
log_path = Path('logs/deck_builder.log')
|
||||
if not log_path.exists():
|
||||
return JSONResponse({"lines": [], "count": 0})
|
||||
from collections import deque
|
||||
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
|
||||
lines = list(deque(lf, maxlen=tail))
|
||||
if q:
|
||||
ql = q.lower()
|
||||
lines = [ln for ln in lines if ql in ln.lower()]
|
||||
# Optional level filter (simple substring match)
|
||||
if level:
|
||||
lv = level.strip().lower()
|
||||
# accept warn as alias for warning
|
||||
if lv == "warn":
|
||||
lv = "warning"
|
||||
if lv in {"error", "warning", "info", "debug"}:
|
||||
lines = [ln for ln in lines if lv in ln.lower()]
|
||||
return JSONResponse({"lines": lines, "count": len(lines)})
|
||||
except Exception:
|
||||
return JSONResponse({"lines": [], "count": 0})
|
||||
|
||||
# Lightweight setup/tagging status endpoint
|
||||
@app.get("/status/setup")
|
||||
async def setup_status():
|
||||
try:
|
||||
p = Path("csv_files/.setup_status.json")
|
||||
if p.exists():
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = _json.load(f)
|
||||
# Attach a small log tail if available
|
||||
try:
|
||||
log_path = Path('logs/deck_builder.log')
|
||||
if log_path.exists():
|
||||
tail_lines = []
|
||||
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
|
||||
# Read last ~100 lines efficiently
|
||||
from collections import deque
|
||||
tail = deque(lf, maxlen=100)
|
||||
tail_lines = list(tail)
|
||||
# Reduce noise: keep lines related to setup/tagging; fallback to last 30 if too few remain
|
||||
try:
|
||||
lowered = [ln for ln in tail_lines]
|
||||
keywords = ["setup", "tag", "color", "csv", "initial setup", "tagging", "load_dataframe"]
|
||||
filtered = [ln for ln in lowered if any(kw in ln.lower() for kw in keywords)]
|
||||
if len(filtered) >= 5:
|
||||
use_lines = filtered[-60:]
|
||||
else:
|
||||
use_lines = tail_lines[-30:]
|
||||
data["log_tail"] = "".join(use_lines).strip()
|
||||
except Exception:
|
||||
data["log_tail"] = "".join(tail_lines).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse(data)
|
||||
return JSONResponse({"running": False, "phase": "idle"})
|
||||
except Exception:
|
||||
return JSONResponse({"running": False, "phase": "error"})
|
||||
|
||||
# Routers
|
||||
from .routes import build as build_routes # noqa: E402
|
||||
from .routes import configs as config_routes # noqa: E402
|
||||
from .routes import decks as decks_routes # noqa: E402
|
||||
from .routes import setup as setup_routes # noqa: E402
|
||||
from .routes import owned as owned_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)
|
||||
|
||||
# --- Exception handling ---
|
||||
def _wants_html(request: Request) -> bool:
|
||||
try:
|
||||
accept = request.headers.get('accept', '')
|
||||
is_htmx = request.headers.get('hx-request') == 'true'
|
||||
return ("text/html" in accept) and not is_htmx
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
||||
logging.getLogger("web").warning(
|
||||
f"HTTPException [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}"
|
||||
)
|
||||
if _wants_html(request):
|
||||
# Friendly HTML page
|
||||
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
|
||||
try:
|
||||
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
|
||||
except Exception:
|
||||
# Fallback plain text
|
||||
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
|
||||
# JSON structure for HTMX/API
|
||||
return JSONResponse(status_code=exc.status_code, content={
|
||||
"error": True,
|
||||
"status": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"request_id": rid,
|
||||
"path": str(request.url.path),
|
||||
}, headers={"X-Request-ID": rid})
|
||||
|
||||
|
||||
# Also handle Starlette's HTTPException (e.g., 404 route not found)
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
||||
logging.getLogger("web").warning(
|
||||
f"HTTPException* [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}"
|
||||
)
|
||||
if _wants_html(request):
|
||||
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
|
||||
try:
|
||||
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
|
||||
except Exception:
|
||||
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
|
||||
return JSONResponse(status_code=exc.status_code, content={
|
||||
"error": True,
|
||||
"status": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"request_id": rid,
|
||||
"path": str(request.url.path),
|
||||
}, headers={"X-Request-ID": rid})
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
|
||||
logging.getLogger("web").error(
|
||||
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True
|
||||
)
|
||||
if _wants_html(request):
|
||||
try:
|
||||
return templates.TemplateResponse("errors/500.html", {"request": request, "request_id": rid}, status_code=500, headers={"X-Request-ID": rid})
|
||||
except Exception:
|
||||
return PlainTextResponse(f"Internal Server Error\nRequest-ID: {rid}", status_code=500, headers={"X-Request-ID": rid})
|
||||
return JSONResponse(status_code=500, content={
|
||||
"error": True,
|
||||
"status": 500,
|
||||
"detail": "Internal Server Error",
|
||||
"request_id": rid,
|
||||
"path": str(request.url.path),
|
||||
}, headers={"X-Request-ID": rid})
|
||||
|
||||
# Lightweight file download endpoint for exports
|
||||
@app.get("/files")
|
||||
async def get_file(path: str):
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists() or not p.is_file():
|
||||
return PlainTextResponse("File not found", status_code=404)
|
||||
# Only allow returning files within the workspace directory for safety
|
||||
# (best-effort: require relative to current working directory)
|
||||
try:
|
||||
cwd = Path.cwd().resolve()
|
||||
if cwd not in p.resolve().parents and p.resolve() != cwd:
|
||||
# Still allow if under deck_files or config
|
||||
allowed = any(seg in ("deck_files", "config", "logs") for seg in p.parts)
|
||||
if not allowed:
|
||||
return PlainTextResponse("Access denied", status_code=403)
|
||||
except Exception:
|
||||
pass
|
||||
return FileResponse(path)
|
||||
except Exception:
|
||||
return PlainTextResponse("Error serving file", status_code=500)
|
||||
|
||||
# Serve /favicon.ico from static (prefer .ico, fallback to .png)
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
try:
|
||||
ico = _STATIC_DIR / "favicon.ico"
|
||||
png = _STATIC_DIR / "favicon.png"
|
||||
target = ico if ico.exists() else (png if png.exists() else None)
|
||||
if target is None:
|
||||
return PlainTextResponse("Not found", status_code=404)
|
||||
return FileResponse(str(target))
|
||||
except Exception:
|
||||
return PlainTextResponse("Error", status_code=500)
|
||||
|
||||
|
||||
# Simple Logs page (optional, controlled by SHOW_LOGS)
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page(
|
||||
request: Request,
|
||||
tail: int = Query(200, ge=1, le=500),
|
||||
q: str | None = None,
|
||||
level: str | None = Query(None),
|
||||
) -> Response:
|
||||
if not SHOW_LOGS:
|
||||
# Respect feature flag
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
# Reuse status_logs logic
|
||||
data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type]
|
||||
lines: list[str]
|
||||
if isinstance(data, JSONResponse):
|
||||
payload = data.body
|
||||
try:
|
||||
parsed = _json.loads(payload)
|
||||
lines = parsed.get("lines", [])
|
||||
except Exception:
|
||||
lines = []
|
||||
else:
|
||||
lines = []
|
||||
return templates.TemplateResponse(
|
||||
"diagnostics/logs.html",
|
||||
{"request": request, "lines": lines, "tail": tail, "q": q or "", "level": (level or "all")},
|
||||
)
|
||||
|
||||
|
||||
# Error trigger route for demoing HTMX/global error handling (feature-flagged)
|
||||
@app.get("/diagnostics/trigger-error")
|
||||
async def trigger_error(kind: str = Query("http")):
|
||||
if kind == "http":
|
||||
raise HTTPException(status_code=418, detail="Teapot: example error for testing")
|
||||
raise RuntimeError("Example unhandled error for testing")
|
||||
|
||||
|
||||
@app.get("/diagnostics", response_class=HTMLResponse)
|
||||
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
async def diagnostics_perf(request: Request) -> HTMLResponse:
|
||||
"""Synthetic scroll performance page (diagnostics only)."""
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/perf.html", {"request": request})
|
1
code/web/routes/__init__.py
Normal file
1
code/web/routes/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Routes package marker
|
1681
code/web/routes/build.py
Normal file
1681
code/web/routes/build.py
Normal file
File diff suppressed because it is too large
Load diff
218
code/web/routes/configs.py
Normal file
218
code/web/routes/configs.py
Normal file
|
@ -0,0 +1,218 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, Form, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/configs")
|
||||
|
||||
|
||||
def _config_dir() -> Path:
|
||||
# Prefer explicit env var if provided, else default to ./config
|
||||
p = os.getenv("DECK_CONFIG")
|
||||
if p:
|
||||
# If env points to a file, use its parent dir; else treat as dir
|
||||
pp = Path(p)
|
||||
return (pp.parent if pp.suffix else pp).resolve()
|
||||
return (Path.cwd() / "config").resolve()
|
||||
|
||||
|
||||
def _list_configs() -> list[dict]:
|
||||
d = _config_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
items: list[dict] = []
|
||||
for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
meta["commander"] = data.get("commander")
|
||||
tags = [t for t in [data.get("primary_tag"), data.get("secondary_tag"), data.get("tertiary_tag")] if t]
|
||||
meta["tags"] = tags
|
||||
meta["bracket_level"] = data.get("bracket_level")
|
||||
except Exception:
|
||||
pass
|
||||
items.append(meta)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def configs_index(request: Request) -> HTMLResponse:
|
||||
items = _list_configs()
|
||||
# Load example deck.json from the config directory, if present
|
||||
example_json = None
|
||||
example_name = "deck.json"
|
||||
try:
|
||||
example_path = _config_dir() / example_name
|
||||
if example_path.exists() and example_path.is_file():
|
||||
example_json = example_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
example_json = None
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": items, "example_json": example_json, "example_name": example_name},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/view", response_class=HTMLResponse)
|
||||
async def configs_view(request: Request, name: str) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
# Safety: ensure the resolved path is within config dir
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/view.html",
|
||||
{"request": request, "path": str(p), "name": p.name, "data": data},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_class=HTMLResponse)
|
||||
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
try:
|
||||
if base not in p.parents and p != base:
|
||||
raise ValueError("Access denied")
|
||||
except Exception:
|
||||
pass
|
||||
if not (p.exists() and p.is_file() and p.suffix.lower() == ".json"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": "Config not found."},
|
||||
)
|
||||
try:
|
||||
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Failed to read JSON: {e}"},
|
||||
)
|
||||
|
||||
commander = cfg.get("commander", "")
|
||||
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
|
||||
bracket = int(cfg.get("bracket_level") or 0)
|
||||
ideals = cfg.get("ideal_counts", {}) or {}
|
||||
# Optional combine mode for tags (AND/OR); support a few aliases
|
||||
try:
|
||||
tag_mode = (str(cfg.get("tag_mode") or cfg.get("combine_mode") or cfg.get("mode") or "AND").upper())
|
||||
if tag_mode not in ("AND", "OR"):
|
||||
tag_mode = "AND"
|
||||
except Exception:
|
||||
tag_mode = "AND"
|
||||
|
||||
# Optional owned-only for headless runs via JSON flag or form override
|
||||
owned_flag = False
|
||||
try:
|
||||
uo = cfg.get("use_owned_only")
|
||||
if isinstance(uo, bool):
|
||||
owned_flag = uo
|
||||
elif isinstance(uo, str):
|
||||
owned_flag = uo.strip().lower() in ("1","true","yes","on")
|
||||
except Exception:
|
||||
owned_flag = False
|
||||
|
||||
# Form override takes precedence if provided
|
||||
if use_owned_only is not None:
|
||||
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
|
||||
|
||||
owned_names = owned_store.get_names() if owned_flag else None
|
||||
|
||||
# Run build headlessly with orchestrator
|
||||
res = orch.run_build(
|
||||
commander=commander,
|
||||
tags=tags,
|
||||
bracket=bracket,
|
||||
ideals=ideals,
|
||||
tag_mode=tag_mode,
|
||||
use_owned_only=owned_flag,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": False,
|
||||
"error": res.get("error") or "Build failed",
|
||||
"log": res.get("log", ""),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"tag_mode": tag_mode,
|
||||
"use_owned_only": owned_flag,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"ok": True,
|
||||
"log": res.get("log", ""),
|
||||
"csv_path": res.get("csv_path"),
|
||||
"txt_path": res.get("txt_path"),
|
||||
"summary": res.get("summary"),
|
||||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"tag_mode": tag_mode,
|
||||
"use_owned_only": owned_flag,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def configs_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||
# Optional helper: allow uploading a JSON config
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
# Minimal validation
|
||||
if not data.get("commander"):
|
||||
raise ValueError("Missing 'commander'")
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "error": f"Invalid JSON: {e}"},
|
||||
)
|
||||
# Save to config dir with original filename (or unique)
|
||||
d = _config_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
fname = file.filename or "config.json"
|
||||
out = d / fname
|
||||
i = 1
|
||||
while out.exists():
|
||||
stem = out.stem
|
||||
out = d / f"{stem}_{i}.json"
|
||||
i += 1
|
||||
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
return templates.TemplateResponse(
|
||||
"configs/index.html",
|
||||
{"request": request, "items": _list_configs(), "notice": f"Uploaded {out.name}"},
|
||||
)
|
390
code/web/routes/decks.py
Normal file
390
code/web/routes/decks.py
Normal file
|
@ -0,0 +1,390 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import os
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
router = APIRouter(prefix="/decks")
|
||||
|
||||
|
||||
def _deck_dir() -> Path:
|
||||
# Prefer explicit env var if provided, else default to ./deck_files
|
||||
p = os.getenv("DECK_EXPORTS")
|
||||
if p:
|
||||
return Path(p).resolve()
|
||||
return (Path.cwd() / "deck_files").resolve()
|
||||
|
||||
|
||||
def _list_decks() -> list[dict]:
|
||||
d = _deck_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
items: list[dict] = []
|
||||
# Prefer CSV entries and pair with matching TXT if present
|
||||
for p in sorted(d.glob("*.csv"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
meta = {"name": p.name, "path": str(p), "mtime": p.stat().st_mtime}
|
||||
stem = p.stem
|
||||
txt = p.with_suffix('.txt')
|
||||
if txt.exists():
|
||||
meta["txt_name"] = txt.name
|
||||
meta["txt_path"] = str(txt)
|
||||
# Prefer sidecar summary meta if present
|
||||
sidecar = p.with_suffix('.summary.json')
|
||||
if sidecar.exists():
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
||||
meta["commander"] = _m.get('commander') or meta.get("commander")
|
||||
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
||||
if _m.get('name'):
|
||||
meta["display"] = _m.get('name')
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
||||
if not meta.get("commander"):
|
||||
parts = stem.split('_')
|
||||
if len(parts) >= 3:
|
||||
meta["commander"] = parts[0]
|
||||
meta["tags"] = parts[1:-1]
|
||||
else:
|
||||
meta["commander"] = stem
|
||||
meta["tags"] = []
|
||||
items.append(meta)
|
||||
return items
|
||||
|
||||
|
||||
def _safe_within(base: Path, target: Path) -> bool:
|
||||
try:
|
||||
base_r = base.resolve()
|
||||
targ_r = target.resolve()
|
||||
return (base_r == targ_r) or (base_r in targ_r.parents)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, int], Dict[str, List[dict]]]:
|
||||
"""Parse CSV export to reconstruct minimal summary pieces.
|
||||
|
||||
Returns: (meta, type_counts, curve_counts, type_cards)
|
||||
meta: { 'commander': str, 'colors': [..] }
|
||||
"""
|
||||
headers = []
|
||||
type_counts: Dict[str, int] = {}
|
||||
type_cards: Dict[str, List[dict]] = {}
|
||||
curve_bins = ['0','1','2','3','4','5','6+']
|
||||
curve_counts: Dict[str, int] = {b: 0 for b in curve_bins}
|
||||
curve_cards: Dict[str, List[dict]] = {b: [] for b in curve_bins}
|
||||
meta: dict = {"commander": "", "colors": []}
|
||||
commander_seen = False
|
||||
# Infer commander from filename stem (pattern Commander_Themes_YYYYMMDD)
|
||||
stem_parts = csv_path.stem.split('_')
|
||||
inferred_commander = stem_parts[0] if stem_parts else ''
|
||||
|
||||
def classify_mv(raw) -> str:
|
||||
try:
|
||||
v = float(raw)
|
||||
except Exception:
|
||||
v = 0.0
|
||||
return '6+' if v >= 6 else str(int(v))
|
||||
|
||||
try:
|
||||
with csv_path.open('r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader, [])
|
||||
# Expected columns include: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, ..., Tags, Text, Owned
|
||||
name_idx = headers.index('Name') if 'Name' in headers else 0
|
||||
count_idx = headers.index('Count') if 'Count' in headers else 1
|
||||
type_idx = headers.index('Type') if 'Type' in headers else 2
|
||||
mv_idx = headers.index('ManaValue') if 'ManaValue' in headers else (headers.index('Mana Value') if 'Mana Value' in headers else -1)
|
||||
role_idx = headers.index('Role') if 'Role' in headers else -1
|
||||
tags_idx = headers.index('Tags') if 'Tags' in headers else -1
|
||||
colors_idx = headers.index('Colors') if 'Colors' in headers else -1
|
||||
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
name = row[name_idx]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cnt = int(float(row[count_idx])) if row[count_idx] else 1
|
||||
except Exception:
|
||||
cnt = 1
|
||||
type_line = row[type_idx] if type_idx >= 0 and type_idx < len(row) else ''
|
||||
role = (row[role_idx] if role_idx >= 0 and role_idx < len(row) else '')
|
||||
tags = (row[tags_idx] if tags_idx >= 0 and tags_idx < len(row) else '')
|
||||
tags_list = [t.strip() for t in tags.split(';') if t.strip()]
|
||||
|
||||
# Commander detection: prefer filename inference; else best-effort via type line containing 'Commander'
|
||||
is_commander = (inferred_commander and name == inferred_commander)
|
||||
if not is_commander:
|
||||
is_commander = isinstance(type_line, str) and ('commander' in type_line.lower())
|
||||
if is_commander and not commander_seen:
|
||||
meta['commander'] = name
|
||||
commander_seen = True
|
||||
|
||||
# Map type_line to broad category
|
||||
tl = (type_line or '').lower()
|
||||
if 'battle' in tl:
|
||||
cat = 'Battle'
|
||||
elif 'planeswalker' in tl:
|
||||
cat = 'Planeswalker'
|
||||
elif 'creature' in tl:
|
||||
cat = 'Creature'
|
||||
elif 'instant' in tl:
|
||||
cat = 'Instant'
|
||||
elif 'sorcery' in tl:
|
||||
cat = 'Sorcery'
|
||||
elif 'artifact' in tl:
|
||||
cat = 'Artifact'
|
||||
elif 'enchantment' in tl:
|
||||
cat = 'Enchantment'
|
||||
elif 'land' in tl:
|
||||
cat = 'Land'
|
||||
else:
|
||||
cat = 'Other'
|
||||
|
||||
# Type counts/cards (exclude commander entry from distribution)
|
||||
if not is_commander:
|
||||
type_counts[cat] = type_counts.get(cat, 0) + cnt
|
||||
type_cards.setdefault(cat, []).append({
|
||||
'name': name,
|
||||
'count': cnt,
|
||||
'role': role,
|
||||
'tags': tags_list,
|
||||
})
|
||||
|
||||
# Curve
|
||||
if mv_idx >= 0 and mv_idx < len(row):
|
||||
bucket = classify_mv(row[mv_idx])
|
||||
if bucket not in curve_counts:
|
||||
bucket = '6+'
|
||||
curve_counts[bucket] += cnt
|
||||
curve_cards[bucket].append({'name': name, 'count': cnt})
|
||||
|
||||
# Colors (from Colors col for commander/overall)
|
||||
if is_commander and colors_idx >= 0 and colors_idx < len(row):
|
||||
cid = row[colors_idx] or ''
|
||||
if isinstance(cid, str):
|
||||
meta['colors'] = list(cid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Precedence ordering
|
||||
precedence_order = [
|
||||
'Battle', 'Planeswalker', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land', 'Other'
|
||||
]
|
||||
prec_index = {k: i for i, k in enumerate(precedence_order)}
|
||||
type_order = sorted(type_counts.keys(), key=lambda k: prec_index.get(k, 999))
|
||||
|
||||
summary = {
|
||||
'type_breakdown': {
|
||||
'counts': type_counts,
|
||||
'order': type_order,
|
||||
'cards': type_cards,
|
||||
'total': sum(type_counts.values()),
|
||||
},
|
||||
'pip_distribution': {
|
||||
# Not recoverable from CSV without mana symbols; leave zeros
|
||||
'counts': {c: 0 for c in ('W','U','B','R','G')},
|
||||
'weights': {c: 0 for c in ('W','U','B','R','G')},
|
||||
},
|
||||
'mana_generation': {
|
||||
# Not recoverable from CSV alone
|
||||
'W': 0, 'U': 0, 'B': 0, 'R': 0, 'G': 0, 'total_sources': 0,
|
||||
},
|
||||
'mana_curve': {
|
||||
**curve_counts,
|
||||
'total_spells': sum(curve_counts.values()),
|
||||
'cards': curve_cards,
|
||||
},
|
||||
'colors': meta.get('colors', []),
|
||||
}
|
||||
return summary, type_counts, curve_counts, type_cards
|
||||
|
||||
|
||||
def _read_deck_counts(csv_path: Path) -> Dict[str, int]:
|
||||
"""Read a CSV deck export and return a mapping of card name -> total count.
|
||||
|
||||
Falls back to zero on parse issues; ignores header case and missing columns.
|
||||
"""
|
||||
counts: Dict[str, int] = {}
|
||||
try:
|
||||
with csv_path.open('r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader, [])
|
||||
name_idx = headers.index('Name') if 'Name' in headers else 0
|
||||
count_idx = headers.index('Count') if 'Count' in headers else 1
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
name = row[name_idx]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cnt = int(float(row[count_idx])) if row[count_idx] else 1
|
||||
except Exception:
|
||||
cnt = 1
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
continue
|
||||
counts[name] = counts.get(name, 0) + cnt
|
||||
except Exception:
|
||||
pass
|
||||
return counts
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def decks_index(request: Request) -> HTMLResponse:
|
||||
items = _list_decks()
|
||||
return templates.TemplateResponse("decks/index.html", {"request": request, "items": items})
|
||||
|
||||
|
||||
@router.get("/view", response_class=HTMLResponse)
|
||||
async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||
base = _deck_dir()
|
||||
p = (base / name).resolve()
|
||||
if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"):
|
||||
return templates.TemplateResponse("decks/index.html", {"request": request, "items": _list_decks(), "error": "Deck not found."})
|
||||
|
||||
# Try to load sidecar summary JSON first
|
||||
summary = None
|
||||
commander_name = ''
|
||||
tags: List[str] = []
|
||||
sidecar = p.with_suffix('.summary.json')
|
||||
if sidecar.exists():
|
||||
try:
|
||||
import json as _json
|
||||
payload = _json.loads(sidecar.read_text(encoding='utf-8'))
|
||||
if isinstance(payload, dict):
|
||||
summary = payload.get('summary')
|
||||
meta = payload.get('meta', {})
|
||||
if isinstance(meta, dict):
|
||||
commander_name = meta.get('commander') or ''
|
||||
_tags = meta.get('tags') or []
|
||||
if isinstance(_tags, list):
|
||||
tags = [str(t) for t in _tags]
|
||||
display_name = meta.get('name') or ''
|
||||
except Exception:
|
||||
summary = None
|
||||
display_name = ''
|
||||
if not summary:
|
||||
# Reconstruct minimal summary from CSV
|
||||
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
||||
display_name = ''
|
||||
stem = p.stem
|
||||
txt_path = p.with_suffix('.txt')
|
||||
# If missing still, infer from filename stem
|
||||
if not commander_name:
|
||||
parts = stem.split('_')
|
||||
commander_name = parts[0] if parts else ''
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"name": p.name,
|
||||
"csv_path": str(p),
|
||||
"txt_path": str(txt_path) if txt_path.exists() else None,
|
||||
"summary": summary,
|
||||
"commander": commander_name,
|
||||
"tags": tags,
|
||||
"display_name": display_name,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
}
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
|
||||
|
||||
@router.get("/compare", response_class=HTMLResponse)
|
||||
async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[str] = None) -> HTMLResponse:
|
||||
"""Compare two finished deck CSVs and show diffs.
|
||||
|
||||
Query params:
|
||||
- A: filename of first deck (e.g., Alena_..._20250827.csv)
|
||||
- B: filename of second deck
|
||||
"""
|
||||
base = _deck_dir()
|
||||
items = _list_decks()
|
||||
# Build select options with friendly display labels
|
||||
options: List[Dict[str, str]] = []
|
||||
for it in items:
|
||||
label = it.get("display") or it.get("commander") or it.get("name")
|
||||
# Include mtime for "Latest two" selection refinement
|
||||
mt = it.get("mtime", 0)
|
||||
try:
|
||||
mt_val = str(int(mt))
|
||||
except Exception:
|
||||
mt_val = "0"
|
||||
options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type]
|
||||
|
||||
diffs = None
|
||||
metaA: Dict[str, str] = {}
|
||||
metaB: Dict[str, str] = {}
|
||||
if A and B:
|
||||
pA = (base / A)
|
||||
pB = (base / B)
|
||||
if _safe_within(base, pA) and _safe_within(base, pB) and pA.exists() and pB.exists():
|
||||
ca = _read_deck_counts(pA)
|
||||
cb = _read_deck_counts(pB)
|
||||
setA = set(ca.keys())
|
||||
setB = set(cb.keys())
|
||||
onlyA = sorted(list(setA - setB))
|
||||
onlyB = sorted(list(setB - setA))
|
||||
changed: List[Tuple[str, int, int]] = []
|
||||
for n in sorted(setA & setB):
|
||||
if ca.get(n, 0) != cb.get(n, 0):
|
||||
changed.append((n, ca.get(n, 0), cb.get(n, 0)))
|
||||
# Side meta (commander/name/tags) if available
|
||||
def _meta_for(path: Path) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {"filename": path.name}
|
||||
sc = path.with_suffix('.summary.json')
|
||||
try:
|
||||
if sc.exists():
|
||||
import json as _json
|
||||
payload = _json.loads(sc.read_text(encoding='utf-8'))
|
||||
if isinstance(payload, dict):
|
||||
m = payload.get('meta', {}) or {}
|
||||
out["display"] = (m.get('name') or '')
|
||||
out["commander"] = (m.get('commander') or '')
|
||||
out["tags"] = ', '.join(m.get('tags') or [])
|
||||
except Exception:
|
||||
pass
|
||||
if not out.get("commander"):
|
||||
parts = path.stem.split('_')
|
||||
if parts:
|
||||
out["commander"] = parts[0]
|
||||
return out
|
||||
metaA = _meta_for(pA)
|
||||
metaB = _meta_for(pB)
|
||||
diffs = {
|
||||
"onlyA": onlyA,
|
||||
"onlyB": onlyB,
|
||||
"changed": changed,
|
||||
"A": A,
|
||||
"B": B,
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"decks/compare.html",
|
||||
{
|
||||
"request": request,
|
||||
"options": options,
|
||||
"A": A or "",
|
||||
"B": B or "",
|
||||
"diffs": diffs,
|
||||
"metaA": metaA,
|
||||
"metaB": metaB,
|
||||
},
|
||||
)
|
11
code/web/routes/home.py
Normal file
11
code/web/routes/home.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from ..app import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("home.html", {"request": request})
|
282
code/web/routes/owned.py
Normal file
282
code/web/routes/owned.py
Normal file
|
@ -0,0 +1,282 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile, File
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from ..app import templates
|
||||
from ..services import owned_store as store
|
||||
# Session helpers are not required for owned routes
|
||||
|
||||
|
||||
router = APIRouter(prefix="/owned")
|
||||
|
||||
|
||||
def _canon_color_code(seq: list[str] | tuple[str, ...]) -> str:
|
||||
"""Canonicalize a color identity sequence to a stable code (WUBRG order, no 'C' unless only color)."""
|
||||
order = {'W':0,'U':1,'B':2,'R':3,'G':4,'C':5}
|
||||
uniq: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for c in (seq or []):
|
||||
uc = (c or '').upper()
|
||||
if uc in order and uc not in seen:
|
||||
seen.add(uc)
|
||||
uniq.append(uc)
|
||||
uniq.sort(key=lambda x: order[x])
|
||||
code = ''.join([c for c in uniq if c != 'C'])
|
||||
return code or ('C' if 'C' in seen else '')
|
||||
|
||||
|
||||
def _color_combo_label(code: str) -> str:
|
||||
"""Return friendly label for a 2/3/4-color combo code; empty if unknown.
|
||||
|
||||
Uses standard names: Guilds, Shards/Wedges, and Nephilim-style for 4-color.
|
||||
"""
|
||||
two_map = {
|
||||
'WU':'Azorius','UB':'Dimir','BR':'Rakdos','RG':'Gruul','WG':'Selesnya',
|
||||
'WB':'Orzhov','UR':'Izzet','BG':'Golgari','WR':'Boros','UG':'Simic',
|
||||
}
|
||||
three_map = {
|
||||
'WUB':'Esper','UBR':'Grixis','BRG':'Jund','WRG':'Naya','WUG':'Bant',
|
||||
'WBR':'Mardu','WUR':'Jeskai','UBG':'Sultai','URG':'Temur','WBG':'Abzan',
|
||||
}
|
||||
four_map = {
|
||||
'WUBR': 'Yore-Tiller', # no G
|
||||
'WUBG': 'Witch-Maw', # no R
|
||||
'WURG': 'Ink-Treader', # no B
|
||||
'WBRG': 'Dune-Brood', # no U
|
||||
'UBRG': 'Glint-Eye', # no W
|
||||
}
|
||||
if len(code) == 2:
|
||||
return two_map.get(code, '')
|
||||
if len(code) == 3:
|
||||
return three_map.get(code, '')
|
||||
if len(code) == 4:
|
||||
return four_map.get(code, '')
|
||||
return ''
|
||||
|
||||
|
||||
def _build_color_combos(names_sorted: list[str], colors_by_name: dict[str, list[str]]) -> list[tuple[str, str]]:
|
||||
"""Compute present color combos and return [(code, display)], ordered by length then code."""
|
||||
combo_set: set[str] = set()
|
||||
for n in names_sorted:
|
||||
cols = (colors_by_name.get(n) or [])
|
||||
code = _canon_color_code(cols)
|
||||
if len(code) >= 2:
|
||||
combo_set.add(code)
|
||||
combos: list[tuple[str, str]] = []
|
||||
for code in sorted(combo_set, key=lambda s: (len(s), s)):
|
||||
label = _color_combo_label(code)
|
||||
display = f"{label} ({code})" if label else code
|
||||
combos.append((code, display))
|
||||
return combos
|
||||
|
||||
|
||||
def _build_owned_context(request: Request, notice: str | None = None, error: str | None = None) -> dict:
|
||||
"""Build the template context for the Owned Library page, including
|
||||
enrichment from csv_files and filter option lists.
|
||||
"""
|
||||
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
|
||||
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
added_at_map = store.get_added_at_map()
|
||||
# Default sort by name (case-insensitive)
|
||||
names_sorted = sorted(names, key=lambda s: s.lower())
|
||||
# Build filter option sets
|
||||
all_types = sorted({type_by_name.get(n) for n in names_sorted if type_by_name.get(n)}, key=lambda s: s.lower())
|
||||
all_tags = sorted({t for n in names_sorted for t in (tags_by_name.get(n) or [])}, key=lambda s: s.lower())
|
||||
all_colors = ['W','U','B','R','G','C']
|
||||
# Build color combos displayed in the filter
|
||||
combos = _build_color_combos(names_sorted, colors_by_name)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"names": names_sorted,
|
||||
"count": len(names_sorted),
|
||||
"tags_by_name": tags_by_name,
|
||||
"type_by_name": type_by_name,
|
||||
"colors_by_name": colors_by_name,
|
||||
"all_types": all_types,
|
||||
"all_tags": all_tags,
|
||||
"all_colors": all_colors,
|
||||
"color_combos": combos,
|
||||
"added_at_map": added_at_map,
|
||||
}
|
||||
if notice:
|
||||
ctx["notice"] = notice
|
||||
if error:
|
||||
ctx["error"] = error
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def owned_index(request: Request) -> HTMLResponse:
|
||||
ctx = _build_owned_context(request)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
async def owned_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
|
||||
try:
|
||||
content = await file.read()
|
||||
fname = (file.filename or "").lower()
|
||||
if fname.endswith(".csv"):
|
||||
names = store.parse_csv_bytes(content)
|
||||
else:
|
||||
names = store.parse_txt_bytes(content)
|
||||
# Add and enrich immediately so the page doesn't need to parse CSVs
|
||||
added, total = store.add_and_enrich(names)
|
||||
notice = f"Added {added} new name(s). Total: {total}."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Upload failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/clear", response_class=HTMLResponse)
|
||||
async def owned_clear(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
store.clear()
|
||||
ctx = _build_owned_context(request, notice="Library cleared.")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Clear failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/remove", response_class=HTMLResponse)
|
||||
async def owned_remove(request: Request) -> HTMLResponse:
|
||||
"""Remove a set of names provided as JSON or form data under 'names'."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
# Try JSON first
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
# Fallback to form field 'names' as comma-separated
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
removed, total = store.remove_names(names)
|
||||
notice = f"Removed {removed} name(s). Total: {total}."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Remove failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
# Bulk user-tag endpoints removed by request.
|
||||
|
||||
|
||||
"""
|
||||
Note: Per request, all user tag add/remove endpoints have been removed.
|
||||
"""
|
||||
|
||||
|
||||
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def owned_export_txt() -> Response:
|
||||
"""Download the owned library as a simple TXT (one name per line)."""
|
||||
names, _, _, _ = store.get_enriched()
|
||||
# Stable case-insensitive sort
|
||||
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
|
||||
return Response(
|
||||
content=lines + ("\n" if lines else ""),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_cards.txt"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export.csv")
|
||||
async def owned_export_csv() -> Response:
|
||||
"""Download the owned library with enrichment as CSV (Name,Type,Colors,Tags)."""
|
||||
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
# Prepare CSV content
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Name", "Type", "Colors", "Tags"])
|
||||
for n in sorted((names or []), key=lambda s: s.lower()):
|
||||
tline = type_by_name.get(n, "")
|
||||
cols = ''.join(colors_by_name.get(n, []) or [])
|
||||
tags = '|'.join(tags_by_name.get(n, []) or [])
|
||||
writer.writerow([n, tline, cols, tags])
|
||||
content = buf.getvalue()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-visible")
|
||||
async def owned_export_visible_txt(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) as TXT."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Stable case-insensitive sort
|
||||
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
|
||||
return Response(
|
||||
content=lines + ("\n" if lines else ""),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.txt"},
|
||||
)
|
||||
except Exception:
|
||||
# On error return empty file
|
||||
return Response(content="", media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
@router.post("/export-visible.csv")
|
||||
async def owned_export_visible_csv(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) with enrichment as CSV."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Build CSV using current enrichment
|
||||
all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
import csv
|
||||
from io import StringIO
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Name", "Type", "Colors", "Tags"])
|
||||
for n in sorted((names or []), key=lambda s: s.lower()):
|
||||
tline = type_by_name.get(n, "")
|
||||
cols = ''.join(colors_by_name.get(n, []) or [])
|
||||
tags = '|'.join(tags_by_name.get(n, []) or [])
|
||||
writer.writerow([n, tline, cols, tags])
|
||||
content = buf.getvalue()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.csv"},
|
||||
)
|
||||
except Exception:
|
||||
return Response(content="", media_type="text/csv; charset=utf-8")
|
105
code/web/routes/setup.py
Normal file
105
code/web/routes/setup.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import Body
|
||||
from pathlib import Path
|
||||
import json as _json
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from ..app import templates
|
||||
from ..services.orchestrator import _ensure_setup_ready # type: ignore
|
||||
|
||||
router = APIRouter(prefix="/setup")
|
||||
|
||||
|
||||
def _kickoff_setup_async(force: bool = False):
|
||||
def runner():
|
||||
try:
|
||||
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=runner, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
@router.get("/running", response_class=HTMLResponse)
|
||||
async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override]
|
||||
# Optionally start the setup/tagging in the background if requested
|
||||
try:
|
||||
if start and int(start) != 0:
|
||||
# honor optional force flag from query
|
||||
f = False
|
||||
try:
|
||||
if force is not None:
|
||||
f = bool(force)
|
||||
else:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
f = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
f = False
|
||||
_kickoff_setup_async(force=f)
|
||||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse("setup/running.html", {"request": request, "next_url": next})
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def setup_start(request: Request, force: bool = Body(False)): # accept JSON body {"force": true}
|
||||
try:
|
||||
# Allow query string override as well (?force=1)
|
||||
try:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
pass
|
||||
# Write immediate status so UI reflects the start
|
||||
try:
|
||||
p = Path("csv_files")
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||
_json.dump(status, f)
|
||||
except Exception:
|
||||
pass
|
||||
_kickoff_setup_async(force=bool(force))
|
||||
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/start")
|
||||
async def setup_start_get(request: Request):
|
||||
"""GET alias to start setup/tagging via query string (?force=1).
|
||||
|
||||
Useful as a fallback from clients that cannot POST JSON.
|
||||
"""
|
||||
try:
|
||||
# Determine force from query params
|
||||
force = False
|
||||
try:
|
||||
q_force = request.query_params.get('force')
|
||||
if q_force is not None:
|
||||
force = q_force.strip().lower() in {"1", "true", "yes", "on"}
|
||||
except Exception:
|
||||
pass
|
||||
# Write immediate status so UI reflects the start
|
||||
try:
|
||||
p = Path("csv_files")
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
status = {"running": True, "phase": "setup", "message": "Starting setup/tagging...", "color": None}
|
||||
with (p / ".setup_status.json").open('w', encoding='utf-8') as f:
|
||||
_json.dump(status, f)
|
||||
except Exception:
|
||||
pass
|
||||
_kickoff_setup_async(force=bool(force))
|
||||
return JSONResponse({"ok": True, "started": True, "force": bool(force)}, status_code=202)
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def setup_index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("setup/index.html", {"request": request})
|
1
code/web/services/__init__.py
Normal file
1
code/web/services/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Services package marker
|
1408
code/web/services/orchestrator.py
Normal file
1408
code/web/services/orchestrator.py
Normal file
File diff suppressed because it is too large
Load diff
493
code/web/services/owned_store.py
Normal file
493
code/web/services/owned_store.py
Normal file
|
@ -0,0 +1,493 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Tuple, Dict
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def _owned_dir() -> Path:
|
||||
"""Resolve the owned cards directory (shared with CLI) for persistence.
|
||||
|
||||
Precedence:
|
||||
- OWNED_CARDS_DIR env var
|
||||
- CARD_LIBRARY_DIR env var (back-compat)
|
||||
- ./owned_cards (if exists)
|
||||
- ./card_library (if exists)
|
||||
- default ./owned_cards
|
||||
"""
|
||||
env_dir = os.getenv("OWNED_CARDS_DIR") or os.getenv("CARD_LIBRARY_DIR")
|
||||
if env_dir:
|
||||
return Path(env_dir).resolve()
|
||||
for name in ("owned_cards", "card_library"):
|
||||
p = Path(name)
|
||||
if p.exists() and p.is_dir():
|
||||
return p.resolve()
|
||||
return Path("owned_cards").resolve()
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
d = _owned_dir()
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return (d / ".web_owned_db.json").resolve()
|
||||
|
||||
|
||||
def _load_raw() -> dict:
|
||||
p = _db_path()
|
||||
if p.exists():
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
# Back-compat defaults
|
||||
if "names" not in data or not isinstance(data.get("names"), list):
|
||||
data["names"] = []
|
||||
if "meta" not in data or not isinstance(data.get("meta"), dict):
|
||||
data["meta"] = {}
|
||||
return data
|
||||
except Exception:
|
||||
return {"names": [], "meta": {}}
|
||||
return {"names": [], "meta": {}}
|
||||
|
||||
|
||||
def _save_raw(data: dict) -> None:
|
||||
p = _db_path()
|
||||
try:
|
||||
with p.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_names() -> List[str]:
|
||||
data = _load_raw()
|
||||
names = data.get("names") or []
|
||||
if not isinstance(names, list):
|
||||
return []
|
||||
# Normalize and dedupe while preserving stable ordering
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for n in names:
|
||||
s = str(n).strip()
|
||||
if not s:
|
||||
continue
|
||||
key = s.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
_save_raw({"names": [], "meta": {}})
|
||||
|
||||
|
||||
def add_names(names: Iterable[str]) -> Tuple[int, int]:
|
||||
"""Add a batch of names; returns (added_count, total_after)."""
|
||||
data = _load_raw()
|
||||
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
|
||||
cur_set = {n.lower() for n in cur}
|
||||
added = 0
|
||||
for raw in names:
|
||||
try:
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
key = s.lower()
|
||||
if key in cur_set:
|
||||
continue
|
||||
cur.append(s)
|
||||
cur_set.add(key)
|
||||
added += 1
|
||||
except Exception:
|
||||
continue
|
||||
data["names"] = cur
|
||||
if "meta" not in data or not isinstance(data.get("meta"), dict):
|
||||
data["meta"] = {}
|
||||
meta = data["meta"]
|
||||
now = int(time.time())
|
||||
# Ensure newly added names have an added_at
|
||||
for s in cur:
|
||||
info = meta.get(s)
|
||||
if not info:
|
||||
meta[s] = {"added_at": now}
|
||||
else:
|
||||
if "added_at" not in info:
|
||||
info["added_at"] = now
|
||||
_save_raw(data)
|
||||
return added, len(cur)
|
||||
|
||||
|
||||
def _enrich_from_csvs(target_names: Iterable[str]) -> Dict[str, Dict[str, object]]:
|
||||
"""Return metadata for target names by scanning csv_files/*_cards.csv.
|
||||
Output: { Name: { 'tags': [..], 'type': str|None, 'colors': [..] } }
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json as _json
|
||||
import csv as _csv
|
||||
|
||||
base = Path('csv_files')
|
||||
meta: Dict[str, Dict[str, object]] = {}
|
||||
want = {str(n).strip().lower() for n in target_names if str(n).strip()}
|
||||
if not (base.exists() and want):
|
||||
return meta
|
||||
csv_files = [p for p in base.glob('*_cards.csv') if p.name.lower() not in ('cards.csv', 'commander_cards.csv')]
|
||||
|
||||
def _norm(s: str) -> str: return str(s or '').strip().lower()
|
||||
for path in csv_files:
|
||||
try:
|
||||
with path.open('r', encoding='utf-8', errors='ignore') as f:
|
||||
reader = _csv.DictReader(f)
|
||||
headers = [h for h in (reader.fieldnames or [])]
|
||||
name_key = None
|
||||
tags_key = None
|
||||
type_key = None
|
||||
colors_key = None
|
||||
for h in headers:
|
||||
hn = _norm(h)
|
||||
if hn in ('name', 'card', 'cardname', 'card_name'):
|
||||
name_key = h
|
||||
if hn in ('tags', 'theme_tags', 'themetags', 'themetagsjson') or hn == 'themetags' or hn == 'themetagsjson':
|
||||
tags_key = h
|
||||
if hn in ('type', 'type_line', 'typeline'):
|
||||
type_key = h
|
||||
if hn in ('colors', 'coloridentity', 'color_identity', 'color'):
|
||||
colors_key = h
|
||||
if not tags_key:
|
||||
for h in headers:
|
||||
if h.strip() in ('ThemeTags', 'themeTags'):
|
||||
tags_key = h
|
||||
break
|
||||
if not colors_key:
|
||||
for h in headers:
|
||||
if h.strip() in ('ColorIdentity', 'colorIdentity'):
|
||||
colors_key = h
|
||||
break
|
||||
if not name_key:
|
||||
continue
|
||||
for row in reader:
|
||||
try:
|
||||
nm = str(row.get(name_key) or '').strip()
|
||||
if not nm:
|
||||
continue
|
||||
low = nm.lower()
|
||||
if low not in want:
|
||||
continue
|
||||
entry = meta.setdefault(nm, {"tags": [], "type": None, "colors": []})
|
||||
# Tags
|
||||
if tags_key:
|
||||
raw = (row.get(tags_key) or '').strip()
|
||||
vals: List[str] = []
|
||||
if raw:
|
||||
if raw.startswith('['):
|
||||
try:
|
||||
arr = _json.loads(raw)
|
||||
if isinstance(arr, list):
|
||||
vals = [str(x).strip() for x in arr if str(x).strip()]
|
||||
except Exception:
|
||||
vals = []
|
||||
if not vals:
|
||||
parts = [p.strip() for p in raw.replace(';', ',').split(',')]
|
||||
vals = [p for p in parts if p]
|
||||
if vals:
|
||||
existing = entry.get('tags') or []
|
||||
seen = {str(t).lower() for t in existing}
|
||||
for t in vals:
|
||||
if str(t).lower() not in seen:
|
||||
existing.append(str(t))
|
||||
seen.add(str(t).lower())
|
||||
entry['tags'] = existing
|
||||
# Type
|
||||
if type_key and not entry.get('type'):
|
||||
t_raw = str(row.get(type_key) or '').strip()
|
||||
if t_raw:
|
||||
tline = t_raw.split('—')[0].strip() if '—' in t_raw else t_raw
|
||||
prim = None
|
||||
for cand in ['Creature','Instant','Sorcery','Artifact','Enchantment','Planeswalker','Land','Battle']:
|
||||
if cand.lower() in tline.lower():
|
||||
prim = cand
|
||||
break
|
||||
if not prim and tline:
|
||||
prim = tline.split()[0]
|
||||
if prim:
|
||||
entry['type'] = prim
|
||||
# Colors
|
||||
if colors_key and not entry.get('colors'):
|
||||
c_raw = str(row.get(colors_key) or '').strip()
|
||||
cols: List[str] = []
|
||||
if c_raw:
|
||||
if c_raw.startswith('['):
|
||||
try:
|
||||
arr = _json.loads(c_raw)
|
||||
if isinstance(arr, list):
|
||||
cols = [str(x).strip().upper() for x in arr if str(x).strip()]
|
||||
except Exception:
|
||||
cols = []
|
||||
if not cols:
|
||||
parts = [p.strip().upper() for p in c_raw.replace(';', ',').replace('[','').replace(']','').replace("'",'').split(',') if p.strip()]
|
||||
if parts:
|
||||
cols = parts
|
||||
if not cols:
|
||||
for ch in c_raw:
|
||||
if ch.upper() in ('W','U','B','R','G','C'):
|
||||
cols.append(ch.upper())
|
||||
if cols:
|
||||
seen_c = set()
|
||||
uniq = []
|
||||
for c in cols:
|
||||
if c not in seen_c:
|
||||
uniq.append(c)
|
||||
seen_c.add(c)
|
||||
entry['colors'] = uniq
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return meta
|
||||
|
||||
|
||||
def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]:
|
||||
"""Add names and enrich their metadata from CSVs in one pass.
|
||||
Returns (added_count, total_after).
|
||||
"""
|
||||
data = _load_raw()
|
||||
current_names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
|
||||
cur_set = {n.lower() for n in current_names}
|
||||
new_names: List[str] = []
|
||||
for raw in names:
|
||||
try:
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
key = s.lower()
|
||||
if key in cur_set:
|
||||
continue
|
||||
current_names.append(s)
|
||||
cur_set.add(key)
|
||||
new_names.append(s)
|
||||
except Exception:
|
||||
continue
|
||||
# Enrich
|
||||
meta = data.get("meta") or {}
|
||||
now = int(time.time())
|
||||
if new_names:
|
||||
enriched = _enrich_from_csvs(new_names)
|
||||
for nm, info in enriched.items():
|
||||
meta[nm] = info
|
||||
# Stamp added_at for new names if missing
|
||||
for nm in new_names:
|
||||
entry = meta.setdefault(nm, {})
|
||||
if "added_at" not in entry:
|
||||
entry["added_at"] = now
|
||||
data["names"] = current_names
|
||||
data["meta"] = meta
|
||||
_save_raw(data)
|
||||
return len(new_names), len(current_names)
|
||||
|
||||
|
||||
def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dict[str, List[str]]]:
|
||||
"""Return names and metadata dicts (tags_by_name, type_by_name, colors_by_name).
|
||||
If metadata missing, returns empty for those entries.
|
||||
"""
|
||||
data = _load_raw()
|
||||
names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
|
||||
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
|
||||
tags_by_name: Dict[str, List[str]] = {}
|
||||
type_by_name: Dict[str, str] = {}
|
||||
colors_by_name: Dict[str, List[str]] = {}
|
||||
for n in names:
|
||||
info = meta.get(n) or {}
|
||||
tags = (info.get('tags') or [])
|
||||
# user-defined tags are no longer supported; no merge
|
||||
typ = info.get('type') or None
|
||||
cols = info.get('colors') or []
|
||||
if tags:
|
||||
tags_by_name[n] = [str(x) for x in tags if str(x)]
|
||||
if typ:
|
||||
type_by_name[n] = str(typ)
|
||||
if cols:
|
||||
colors_by_name[n] = [str(x).upper() for x in cols if str(x)]
|
||||
return names, tags_by_name, type_by_name, colors_by_name
|
||||
|
||||
|
||||
# add_user_tag/remove_user_tag removed; user-defined tags are not persisted anymore
|
||||
|
||||
|
||||
def get_added_at_map() -> Dict[str, int]:
|
||||
"""Return a mapping of name -> added_at unix timestamp (if known)."""
|
||||
data = _load_raw()
|
||||
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
|
||||
out: Dict[str, int] = {}
|
||||
for n, info in meta.items():
|
||||
try:
|
||||
ts = info.get("added_at")
|
||||
if isinstance(ts, (int, float)):
|
||||
out[n] = int(ts)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def remove_names(names: Iterable[str]) -> Tuple[int, int]:
|
||||
"""Remove a batch of names; returns (removed_count, total_after)."""
|
||||
target = {str(n).strip().lower() for n in (names or []) if str(n).strip()}
|
||||
if not target:
|
||||
return 0, len(get_names())
|
||||
data = _load_raw()
|
||||
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
|
||||
before = len(cur)
|
||||
cur_kept: List[str] = []
|
||||
for s in cur:
|
||||
if s.lower() in target:
|
||||
continue
|
||||
cur_kept.append(s)
|
||||
removed = before - len(cur_kept)
|
||||
data["names"] = cur_kept
|
||||
meta = data.get("meta") or {}
|
||||
# Drop meta entries for removed names
|
||||
for s in list(meta.keys()):
|
||||
try:
|
||||
if s.lower() in target:
|
||||
meta.pop(s, None)
|
||||
except Exception:
|
||||
continue
|
||||
data["meta"] = meta
|
||||
_save_raw(data)
|
||||
return removed, len(cur_kept)
|
||||
|
||||
|
||||
def get_user_tags_map() -> Dict[str, list[str]]:
|
||||
"""Deprecated: user-defined tags have been removed. Always returns empty mapping."""
|
||||
return {}
|
||||
|
||||
|
||||
def parse_txt_bytes(content: bytes) -> List[str]:
|
||||
out: List[str] = []
|
||||
try:
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
text = content.decode(errors="ignore")
|
||||
for line in text.splitlines():
|
||||
s = (line or "").strip()
|
||||
if not s or s.startswith("#") or s.startswith("//"):
|
||||
continue
|
||||
parts = s.split()
|
||||
if len(parts) >= 2 and (parts[0].isdigit() or (parts[0].lower().endswith('x') and parts[0][:-1].isdigit())):
|
||||
s = ' '.join(parts[1:])
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def parse_csv_bytes(content: bytes) -> List[str]:
|
||||
names: List[str] = []
|
||||
try:
|
||||
import csv
|
||||
from io import StringIO
|
||||
import re
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
f = StringIO(text)
|
||||
try:
|
||||
reader = csv.DictReader(f)
|
||||
headers = [h for h in (reader.fieldnames or []) if isinstance(h, str)]
|
||||
# Normalize headers: lowercase and remove non-letters (spaces, underscores, dashes)
|
||||
def norm(h: str) -> str:
|
||||
return re.sub(r"[^a-z]", "", (h or "").lower())
|
||||
|
||||
# Map normalized -> original header
|
||||
norm_map = {norm(h): h for h in headers}
|
||||
|
||||
# Preferred keys (exact normalized match)
|
||||
preferred = ["name", "cardname"]
|
||||
key = None
|
||||
for k in preferred:
|
||||
if k in norm_map:
|
||||
key = norm_map[k]
|
||||
break
|
||||
# Fallback: allow plain 'card' but avoid 'cardnumber', 'cardid', etc.
|
||||
if key is None:
|
||||
if "card" in norm_map and all(x not in norm_map for x in ("cardnumber", "cardno", "cardid", "collectornumber", "collector", "multiverseid")):
|
||||
key = norm_map["card"]
|
||||
# Another fallback: try common variants if not strictly normalized
|
||||
if key is None:
|
||||
for h in headers:
|
||||
h_clean = (h or "").strip().lower()
|
||||
if h_clean in ("name", "card name", "card_name", "cardname"):
|
||||
key = h
|
||||
break
|
||||
|
||||
if key:
|
||||
for row in reader:
|
||||
val = str(row.get(key) or '').strip()
|
||||
if not val:
|
||||
continue
|
||||
names.append(val)
|
||||
else:
|
||||
f.seek(0)
|
||||
reader2 = csv.reader(f)
|
||||
rows = list(reader2)
|
||||
if not rows:
|
||||
pass
|
||||
else:
|
||||
# Try to detect a likely name column from the first row
|
||||
header = rows[0]
|
||||
name_col = 0
|
||||
if header:
|
||||
# Look for header cells resembling name
|
||||
for idx, cell in enumerate(header):
|
||||
c = str(cell or '').strip()
|
||||
cn = norm(c)
|
||||
if cn in ("name", "cardname"):
|
||||
name_col = idx
|
||||
break
|
||||
else:
|
||||
# As a fallback, if any cell lower is exactly 'card', take it
|
||||
for idx, cell in enumerate(header):
|
||||
c = str(cell or '').strip().lower()
|
||||
if c == 'card':
|
||||
name_col = idx
|
||||
break
|
||||
# Iterate rows, skip header-like first row when it matches
|
||||
for i, row in enumerate(rows):
|
||||
if not row:
|
||||
continue
|
||||
if i == 0:
|
||||
first = str(row[name_col] if len(row) > name_col else '').strip()
|
||||
fn = norm(first)
|
||||
if fn in ("name", "cardname") or first.lower() in ("name", "card name", "card", "card_name"):
|
||||
continue # skip header
|
||||
val = str(row[name_col] if len(row) > name_col else '').strip()
|
||||
if not val:
|
||||
continue
|
||||
# Skip rows that look like header or counts
|
||||
low = val.lower()
|
||||
if low in ("name", "card name", "card", "card_name"):
|
||||
continue
|
||||
names.append(val)
|
||||
except Exception:
|
||||
# Fallback: one name per line
|
||||
f.seek(0)
|
||||
for line in f:
|
||||
s = (line or '').strip()
|
||||
if s and s.lower() not in ('name', 'card', 'card name'):
|
||||
names.append(s)
|
||||
except Exception:
|
||||
pass
|
||||
# Normalize, dedupe while preserving order
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for n in names:
|
||||
s = str(n).strip()
|
||||
if not s:
|
||||
continue
|
||||
k = s.lower()
|
||||
if k in seen:
|
||||
continue
|
||||
seen.add(k)
|
||||
out.append(s)
|
||||
return out
|
48
code/web/services/tasks.py
Normal file
48
code/web/services/tasks.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Extremely simple in-memory session/task store for MVP
|
||||
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
||||
_TTL_SECONDS = 60 * 60 * 8 # 8 hours
|
||||
|
||||
|
||||
def new_sid() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def touch_session(sid: str) -> Dict[str, Any]:
|
||||
now = time.time()
|
||||
s = _SESSIONS.get(sid)
|
||||
if not s:
|
||||
s = {"created": now, "updated": now}
|
||||
_SESSIONS[sid] = s
|
||||
else:
|
||||
s["updated"] = now
|
||||
return s
|
||||
|
||||
|
||||
def get_session(sid: Optional[str]) -> Dict[str, Any]:
|
||||
if not sid:
|
||||
sid = new_sid()
|
||||
return touch_session(sid)
|
||||
|
||||
|
||||
def set_session_value(sid: str, key: str, value: Any) -> None:
|
||||
touch_session(sid)[key] = value
|
||||
|
||||
|
||||
def get_session_value(sid: str, key: str, default: Any = None) -> Any:
|
||||
return touch_session(sid).get(key, default)
|
||||
|
||||
|
||||
def cleanup_expired() -> None:
|
||||
now = time.time()
|
||||
expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS]
|
||||
for sid in expired:
|
||||
try:
|
||||
del _SESSIONS[sid]
|
||||
except Exception:
|
||||
pass
|
666
code/web/static/app.js
Normal file
666
code/web/static/app.js
Normal file
|
@ -0,0 +1,666 @@
|
|||
/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
|
||||
(function(){
|
||||
// Design tokens fallback (in case CSS variables missing in older browsers)
|
||||
// No-op here since styles.css defines variables; kept for future JS reads.
|
||||
|
||||
// State persistence helpers (localStorage + URL hash)
|
||||
var state = {
|
||||
get: function(key, def){
|
||||
try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
|
||||
},
|
||||
set: function(key, val){
|
||||
try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
|
||||
},
|
||||
inHash: function(obj){
|
||||
// Merge obj into location.hash as query-like params
|
||||
try {
|
||||
var params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
|
||||
Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); });
|
||||
location.hash = params.toString();
|
||||
} catch(e){}
|
||||
},
|
||||
readHash: function(){
|
||||
try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
|
||||
}
|
||||
};
|
||||
window.__mtgState = state;
|
||||
|
||||
// Toast system
|
||||
var toastHost;
|
||||
function ensureToastHost(){
|
||||
if (!toastHost){
|
||||
toastHost = document.createElement('div');
|
||||
toastHost.className = 'toast-host';
|
||||
document.body.appendChild(toastHost);
|
||||
}
|
||||
return toastHost;
|
||||
}
|
||||
function toast(msg, type, opts){
|
||||
ensureToastHost();
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast' + (type ? ' '+type : '');
|
||||
t.setAttribute('role','status');
|
||||
t.setAttribute('aria-live','polite');
|
||||
t.textContent = '';
|
||||
if (typeof msg === 'string') { t.textContent = msg; }
|
||||
else if (msg && msg.nodeType === 1) { t.appendChild(msg); }
|
||||
toastHost.appendChild(t);
|
||||
var delay = (opts && opts.duration) || 2600;
|
||||
setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
|
||||
return t;
|
||||
}
|
||||
window.toast = toast;
|
||||
function toastHTML(html, type, opts){
|
||||
var container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
return toast(container, type, opts);
|
||||
}
|
||||
window.toastHTML = toastHTML;
|
||||
|
||||
// Global HTMX error handling => toast
|
||||
document.addEventListener('htmx:responseError', function(e){
|
||||
var detail = e.detail || {}; var xhr = detail.xhr || {};
|
||||
var rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || '';
|
||||
var payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })();
|
||||
var status = payload.status || xhr.status || '';
|
||||
var msg = payload.detail || payload.message || 'Action failed';
|
||||
var path = payload.path || (e && e.detail && e.detail.path) || '';
|
||||
var html = ''+
|
||||
'<div style="display:flex; align-items:center; gap:.5rem">'+
|
||||
'<span style="font-weight:600">'+String(msg)+'</span>'+ (status? ' <span class="muted">('+status+')</span>' : '')+
|
||||
(rid ? '<button class="btn small" style="margin-left:auto" type="button" data-copy-error>Copy details</button>' : '')+
|
||||
'</div>'+
|
||||
(rid ? '<div class="muted" style="font-size:11px; margin-top:2px">Request-ID: <code>'+rid+'</code></div>' : '');
|
||||
var t = toastHTML(html, 'error', { duration: 7000 });
|
||||
// Wire Copy
|
||||
var btn = t.querySelector('[data-copy-error]');
|
||||
if (btn){
|
||||
btn.addEventListener('click', function(){
|
||||
var lines = [
|
||||
'Error: '+String(msg),
|
||||
'Status: '+String(status),
|
||||
'Path: '+String(path || (xhr.responseURL||'')),
|
||||
'Request-ID: '+String(rid)
|
||||
];
|
||||
try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ }
|
||||
});
|
||||
}
|
||||
// Optional inline banner if a surface is available
|
||||
try {
|
||||
var target = e && e.target;
|
||||
var surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]');
|
||||
if (surface){
|
||||
var banner = document.createElement('div');
|
||||
banner.className = 'inline-error-banner';
|
||||
banner.innerHTML = '<strong>'+String(msg)+'</strong>' + (rid? ' <span class="muted">(Request-ID: '+rid+')</span>' : '');
|
||||
surface.prepend(banner);
|
||||
setTimeout(function(){ banner.remove(); }, 8000);
|
||||
}
|
||||
} catch(_){ }
|
||||
});
|
||||
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
|
||||
|
||||
// Keyboard shortcuts
|
||||
var keymap = {
|
||||
' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); },
|
||||
'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); },
|
||||
'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); },
|
||||
'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); },
|
||||
};
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
|
||||
var k = e.key.toLowerCase();
|
||||
// If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
|
||||
try {
|
||||
var active = document.activeElement;
|
||||
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
|
||||
return;
|
||||
}
|
||||
} catch(_) { /* noop */ }
|
||||
if (keymap[k]){ e.preventDefault(); keymap[k](); }
|
||||
});
|
||||
|
||||
// Focus ring visibility for keyboard nav
|
||||
function addFocusVisible(){
|
||||
var hadKeyboardEvent = false;
|
||||
function onKeyDown(){ hadKeyboardEvent = true; }
|
||||
function onPointer(){ hadKeyboardEvent = false; }
|
||||
function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); }
|
||||
function onBlur(e){ e.target.classList.remove('focus-visible'); }
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('mousedown', onPointer, true);
|
||||
window.addEventListener('pointerdown', onPointer, true);
|
||||
window.addEventListener('touchstart', onPointer, true);
|
||||
document.addEventListener('focusin', onFocus);
|
||||
document.addEventListener('focusout', onBlur);
|
||||
}
|
||||
addFocusVisible();
|
||||
|
||||
// Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
|
||||
function showSkeletons(container){
|
||||
(container || document).querySelectorAll('[data-skeleton]')
|
||||
.forEach(function(el){ el.classList.add('is-loading'); });
|
||||
}
|
||||
function hideSkeletons(container){
|
||||
(container || document).querySelectorAll('[data-skeleton]')
|
||||
.forEach(function(el){ el.classList.remove('is-loading'); });
|
||||
}
|
||||
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
|
||||
document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
|
||||
|
||||
// Example: persist "show skipped" toggle if present
|
||||
document.addEventListener('change', function(e){
|
||||
var el = e.target;
|
||||
if (el && el.matches('[data-pref]')){
|
||||
var key = el.getAttribute('data-pref');
|
||||
var val = (el.type === 'checkbox') ? !!el.checked : el.value;
|
||||
state.set(key, val);
|
||||
state.inHash((function(o){ o[key] = val; return o; })({}));
|
||||
}
|
||||
});
|
||||
// On load, initialize any data-pref elements
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll('[data-pref]').forEach(function(el){
|
||||
var key = el.getAttribute('data-pref');
|
||||
var saved = state.get(key, undefined);
|
||||
if (typeof saved !== 'undefined'){
|
||||
if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved;
|
||||
}
|
||||
});
|
||||
hydrateProgress(document);
|
||||
syncShowSkipped(document);
|
||||
initCardFilters(document);
|
||||
initVirtualization(document);
|
||||
});
|
||||
|
||||
// Hydrate progress bars with width based on data-pct
|
||||
function hydrateProgress(root){
|
||||
(root || document).querySelectorAll('.progress[data-pct]')
|
||||
.forEach(function(p){
|
||||
var pct = parseInt(p.getAttribute('data-pct') || '0', 10);
|
||||
if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
|
||||
var bar = p.querySelector('.bar'); if (!bar) return;
|
||||
// Animate width for a bit of delight
|
||||
requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
|
||||
});
|
||||
}
|
||||
// Keep hidden inputs for show_skipped in sync with the sticky checkbox
|
||||
function syncShowSkipped(root){
|
||||
var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
|
||||
if (!cb) return;
|
||||
var val = cb.checked ? '1' : '0';
|
||||
(root || document).querySelectorAll('section form').forEach(function(f){
|
||||
var h = f.querySelector('input[name="show_skipped"]');
|
||||
if (h) h.value = val;
|
||||
});
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', function(e){
|
||||
hydrateProgress(e.target);
|
||||
syncShowSkipped(e.target);
|
||||
initCardFilters(e.target);
|
||||
initVirtualization(e.target);
|
||||
});
|
||||
|
||||
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
|
||||
function scrollCardIntoView(name){
|
||||
if (!name) return;
|
||||
try{
|
||||
var section = document.querySelector('section');
|
||||
var grid = section && section.querySelector('.card-grid');
|
||||
if (!grid) return;
|
||||
// If virtualized, force a render around the approximate match by searching stored children
|
||||
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if (!target) {
|
||||
// Trigger a render update and try again
|
||||
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
|
||||
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
}
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
target.focus && target.focus();
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
window.scrollCardIntoView = scrollCardIntoView;
|
||||
|
||||
// --- Card grid filters, reasons, and collapsible groups ---
|
||||
function initCardFilters(root){
|
||||
var section = (root || document).querySelector('section');
|
||||
if (!section) return;
|
||||
var toolbar = section.querySelector('.cards-toolbar');
|
||||
if (!toolbar) return; // nothing to do
|
||||
var q = toolbar.querySelector('input[name="filter_query"]');
|
||||
var ownedSel = toolbar.querySelector('select[name="filter_owned"]');
|
||||
var showReasons = toolbar.querySelector('input[name="show_reasons"]');
|
||||
var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
|
||||
var resultsEl = toolbar.querySelector('[data-results]');
|
||||
var emptyEl = section.querySelector('[data-empty]');
|
||||
var sortSel = toolbar.querySelector('select[name="filter_sort"]');
|
||||
var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
|
||||
var chipNot = toolbar.querySelector('[data-chip-owned="not"]');
|
||||
var chipAll = toolbar.querySelector('[data-chip-owned="all"]');
|
||||
var chipClear = toolbar.querySelector('[data-chip-clear]');
|
||||
|
||||
function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
|
||||
// Read URL hash on first init to hydrate controls
|
||||
try {
|
||||
var params = window.__mtgState.readHash();
|
||||
if (params){
|
||||
var hv = params.get('q'); if (q && hv !== null) q.value = hv;
|
||||
hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
|
||||
hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
|
||||
hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
|
||||
hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
|
||||
}
|
||||
} catch(_){}
|
||||
function apply(){
|
||||
var query = (getVal(q)+ '').toLowerCase().trim();
|
||||
var ownedMode = (getVal(ownedSel) || 'all');
|
||||
var showR = !!getVal(showReasons);
|
||||
var collapse = !!getVal(collapseGroups);
|
||||
var sortMode = (getVal(sortSel) || 'az');
|
||||
// Toggle reasons visibility via section class
|
||||
section.classList.toggle('hide-reasons', !showR);
|
||||
// Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
|
||||
section.querySelectorAll('.group').forEach(function(wrapper){
|
||||
var grid = wrapper.querySelector('.group-grid'); if (!grid) return;
|
||||
var key = wrapper.getAttribute('data-group-key');
|
||||
if (collapse){
|
||||
grid.setAttribute('data-collapsed','1');
|
||||
} else {
|
||||
// restore stored
|
||||
if (key){
|
||||
var stored = state.get('cards:group:'+key, null);
|
||||
if (stored === true){ grid.setAttribute('data-collapsed','1'); }
|
||||
else { grid.removeAttribute('data-collapsed'); }
|
||||
} else {
|
||||
grid.removeAttribute('data-collapsed');
|
||||
}
|
||||
}
|
||||
});
|
||||
// Filter tiles
|
||||
var tiles = section.querySelectorAll('.card-grid .card-tile');
|
||||
var visible = 0;
|
||||
tiles.forEach(function(tile){
|
||||
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
|
||||
var role = (tile.getAttribute('data-role')||'').toLowerCase();
|
||||
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
|
||||
var owned = tile.getAttribute('data-owned') === '1';
|
||||
var text = name + ' ' + role + ' ' + tags;
|
||||
var qOk = !query || text.indexOf(query) !== -1;
|
||||
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
|
||||
var show = qOk && oOk;
|
||||
tile.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
// Sort within each grid
|
||||
function keyFor(tile){
|
||||
var name = (tile.getAttribute('data-card-name')||'');
|
||||
var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
|
||||
var gc = tile.classList.contains('game-changer') ? 1 : 0;
|
||||
return { name: name.toLowerCase(), owned: owned, gc: gc };
|
||||
}
|
||||
section.querySelectorAll('.card-grid').forEach(function(grid){
|
||||
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
|
||||
arr.sort(function(a,b){
|
||||
var ka = keyFor(a), kb = keyFor(b);
|
||||
if (sortMode === 'owned'){
|
||||
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
|
||||
if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
|
||||
return ka.name.localeCompare(kb.name);
|
||||
} else if (sortMode === 'gc'){
|
||||
if (kb.gc !== ka.gc) return kb.gc - ka.gc;
|
||||
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
|
||||
return ka.name.localeCompare(kb.name);
|
||||
}
|
||||
// default A–Z
|
||||
return ka.name.localeCompare(kb.name);
|
||||
});
|
||||
arr.forEach(function(el){ grid.appendChild(el); });
|
||||
});
|
||||
// Update group counts based on visible tiles within each group
|
||||
section.querySelectorAll('.group').forEach(function(wrapper){
|
||||
var grid = wrapper.querySelector('.group-grid');
|
||||
var count = 0;
|
||||
if (grid){
|
||||
grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
|
||||
}
|
||||
var cEl = wrapper.querySelector('[data-count]');
|
||||
if (cEl) cEl.textContent = count;
|
||||
});
|
||||
if (resultsEl) resultsEl.textContent = String(visible);
|
||||
if (emptyEl) emptyEl.hidden = (visible !== 0);
|
||||
// Persist prefs
|
||||
if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
|
||||
if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
|
||||
if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
|
||||
if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
|
||||
if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
|
||||
// Update URL hash for shareability
|
||||
try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
|
||||
}
|
||||
// Wire events
|
||||
if (q) q.addEventListener('input', apply);
|
||||
if (ownedSel) ownedSel.addEventListener('change', apply);
|
||||
if (showReasons) showReasons.addEventListener('change', apply);
|
||||
if (collapseGroups) collapseGroups.addEventListener('change', apply);
|
||||
if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
|
||||
if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
|
||||
if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
|
||||
if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
|
||||
// Individual group toggles
|
||||
section.querySelectorAll('.group-header .toggle').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
var wrapper = btn.closest('.group');
|
||||
var grid = wrapper && wrapper.querySelector('.group-grid');
|
||||
if (!grid) return;
|
||||
var key = wrapper.getAttribute('data-group-key');
|
||||
var willCollapse = !grid.getAttribute('data-collapsed');
|
||||
if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
|
||||
if (key){ state.set('cards:group:'+key, !!willCollapse); }
|
||||
// ARIA
|
||||
btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
|
||||
});
|
||||
});
|
||||
// Per-card reason toggle: delegate clicks on .btn-why
|
||||
section.addEventListener('click', function(e){
|
||||
var t = e.target;
|
||||
if (!t || !t.classList || !t.classList.contains('btn-why')) return;
|
||||
e.preventDefault();
|
||||
var tile = t.closest('.card-tile');
|
||||
if (!tile) return;
|
||||
var globalHidden = section.classList.contains('hide-reasons');
|
||||
if (globalHidden){
|
||||
// Force-show overrides global hidden
|
||||
var on = tile.classList.toggle('force-show');
|
||||
if (on) tile.classList.remove('force-hide');
|
||||
t.textContent = on ? 'Hide why' : 'Why?';
|
||||
} else {
|
||||
// Hide this tile only
|
||||
var off = tile.classList.toggle('force-hide');
|
||||
if (off) tile.classList.remove('force-show');
|
||||
t.textContent = off ? 'Show why' : 'Hide why';
|
||||
}
|
||||
});
|
||||
// Initial apply on hydrate
|
||||
apply();
|
||||
|
||||
// Keyboard helpers: '/' focuses query, Esc clears
|
||||
function onKey(e){
|
||||
// avoid when typing in inputs
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key === '/'){
|
||||
if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
|
||||
} else if (e.key === 'Escape'){
|
||||
if (q && q.value){ q.value=''; apply(); }
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
|
||||
function initVirtualization(root){
|
||||
try{
|
||||
var body = document.body || document.documentElement;
|
||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||
// Global diagnostics aggregator
|
||||
var GLOBAL = (function(){
|
||||
if (!DIAG) return null;
|
||||
if (window.__virtGlobal) return window.__virtGlobal;
|
||||
var store = { grids: [], summaryEl: null };
|
||||
function ensure(){
|
||||
if (!store.summaryEl){
|
||||
var el = document.createElement('div');
|
||||
el.id = 'virt-global-diag';
|
||||
el.style.position = 'fixed';
|
||||
el.style.right = '8px';
|
||||
el.style.bottom = '8px';
|
||||
el.style.background = 'rgba(17,24,39,.85)';
|
||||
el.style.border = '1px solid var(--border)';
|
||||
el.style.padding = '.25rem .5rem';
|
||||
el.style.borderRadius = '6px';
|
||||
el.style.fontSize = '12px';
|
||||
el.style.color = '#cbd5e1';
|
||||
el.style.zIndex = '50';
|
||||
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
||||
el.style.cursor = 'default';
|
||||
// Hidden by default; toggle with 'v'
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
store.summaryEl = el;
|
||||
}
|
||||
return store.summaryEl;
|
||||
}
|
||||
function update(){
|
||||
var el = ensure(); if (!el) return;
|
||||
var g = store.grids;
|
||||
var total = 0, visible = 0, lastMs = 0;
|
||||
for (var i=0;i<g.length;i++){
|
||||
total += g[i].total||0;
|
||||
visible += (g[i].end||0) - (g[i].start||0);
|
||||
lastMs = Math.max(lastMs, g[i].lastMs||0);
|
||||
}
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
|
||||
}
|
||||
function register(gridId, ref){
|
||||
store.grids.push({ id: gridId, ref: ref });
|
||||
update();
|
||||
return {
|
||||
set: function(stats){
|
||||
for (var i=0;i<store.grids.length;i++){
|
||||
if (store.grids[i].id === gridId){
|
||||
store.grids[i] = Object.assign({ id: gridId, ref: ref }, stats);
|
||||
break;
|
||||
}
|
||||
}
|
||||
update();
|
||||
},
|
||||
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
|
||||
};
|
||||
}
|
||||
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
|
||||
return window.__virtGlobal;
|
||||
})();
|
||||
// Support card grids and other scroll containers (e.g., #owned-box)
|
||||
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
|
||||
if (!grids.length) return;
|
||||
grids.forEach(function(grid){
|
||||
if (grid.__virtBound) return;
|
||||
grid.__virtBound = true;
|
||||
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
|
||||
var container = grid;
|
||||
container.style.position = container.style.position || 'relative';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
// Ensure wrapper itself is a grid to preserve multi-column layout inside
|
||||
// when the container (e.g., .card-grid) is virtualized.
|
||||
wrapper.style.display = 'grid';
|
||||
// Move children into a fragment store (for owned, children live under UL)
|
||||
var source = container;
|
||||
// If this is the owned box, use the UL inside as the source list
|
||||
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
|
||||
if (ownedGrid) { source = ownedGrid; }
|
||||
var all = Array.prototype.slice.call(source.children);
|
||||
var store = document.createElement('div');
|
||||
store.style.display = 'none';
|
||||
all.forEach(function(n){ store.appendChild(n); });
|
||||
var padTop = document.createElement('div');
|
||||
var padBottom = document.createElement('div');
|
||||
padTop.style.height = '0px'; padBottom.style.height = '0px';
|
||||
// For owned, keep the UL but render into it; otherwise append wrapper to container
|
||||
if (ownedGrid){
|
||||
ownedGrid.innerHTML = '';
|
||||
ownedGrid.appendChild(padTop);
|
||||
ownedGrid.appendChild(wrapper);
|
||||
ownedGrid.appendChild(padBottom);
|
||||
ownedGrid.appendChild(store);
|
||||
} else {
|
||||
container.appendChild(wrapper);
|
||||
container.appendChild(padBottom);
|
||||
container.appendChild(store);
|
||||
}
|
||||
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
|
||||
var perRow = 1;
|
||||
// Optional diagnostics overlay
|
||||
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
|
||||
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
||||
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
||||
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
|
||||
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
|
||||
function ensureDiag(){
|
||||
if (!DIAG) return null;
|
||||
if (diagBox) return diagBox;
|
||||
diagBox = document.createElement('div');
|
||||
diagBox.className = 'virt-diag';
|
||||
diagBox.style.position = 'sticky';
|
||||
diagBox.style.top = '0';
|
||||
diagBox.style.zIndex = '5';
|
||||
diagBox.style.background = 'rgba(17,24,39,.85)';
|
||||
diagBox.style.border = '1px solid var(--border)';
|
||||
diagBox.style.padding = '.25rem .5rem';
|
||||
diagBox.style.borderRadius = '6px';
|
||||
diagBox.style.fontSize = '12px';
|
||||
diagBox.style.margin = '0 0 .35rem 0';
|
||||
diagBox.style.color = '#cbd5e1';
|
||||
diagBox.style.display = 'none'; // hidden until toggled
|
||||
// Controls
|
||||
var controls = document.createElement('div');
|
||||
controls.style.display = 'flex';
|
||||
controls.style.gap = '.35rem';
|
||||
controls.style.alignItems = 'center';
|
||||
controls.style.marginBottom = '.25rem';
|
||||
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
|
||||
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
|
||||
btnCopy.addEventListener('click', function(){ try{ var payload = {
|
||||
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
|
||||
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
|
||||
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
|
||||
});
|
||||
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
|
||||
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
|
||||
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
|
||||
diagBox.appendChild(controls);
|
||||
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
||||
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
||||
host.insertBefore(diagBox, host.firstChild);
|
||||
return diagBox;
|
||||
}
|
||||
function measure(){
|
||||
try {
|
||||
measureCount++;
|
||||
// create a temp tile to measure if none
|
||||
var probe = store.firstElementChild || all[0];
|
||||
if (probe){
|
||||
var fake = probe.cloneNode(true);
|
||||
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
|
||||
(ownedGrid || container).appendChild(fake);
|
||||
var rect = fake.getBoundingClientRect();
|
||||
rowH = Math.max(120, Math.ceil(rect.height) + 16);
|
||||
(ownedGrid || container).removeChild(fake);
|
||||
}
|
||||
// Estimate perRow via computed styles of grid
|
||||
var style = window.getComputedStyle(ownedGrid || container);
|
||||
var cols = style.getPropertyValue('grid-template-columns');
|
||||
// Mirror grid settings onto the wrapper so its children still flow in columns
|
||||
try {
|
||||
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
|
||||
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
||||
if (gap && gap.trim()) wrapper.style.gap = gap;
|
||||
// Inherit justify/align if present
|
||||
var ji = style.getPropertyValue('justify-items');
|
||||
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
||||
var ai = style.getPropertyValue('align-items');
|
||||
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
||||
} catch(_) {}
|
||||
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
|
||||
} catch(_){}
|
||||
}
|
||||
measure();
|
||||
var total = all.length;
|
||||
var start = 0, end = 0;
|
||||
function render(){
|
||||
var t0 = DIAG ? performance.now() : 0;
|
||||
var scroller = container;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var scrollTop = scroller.scrollTop;
|
||||
// If container isn’t scrollable, use window scroll offset
|
||||
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
|
||||
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
|
||||
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
|
||||
var newStart = rowStart * perRow;
|
||||
var newEnd = Math.min(total, rowEnd * perRow);
|
||||
if (newStart === start && newEnd === end) return; // no change
|
||||
start = newStart; end = newEnd;
|
||||
// Padding
|
||||
var beforeRows = Math.floor(start / perRow);
|
||||
var afterRows = Math.ceil((total - end) / perRow);
|
||||
padTop.style.height = (beforeRows * rowH) + 'px';
|
||||
padBottom.style.height = (afterRows * rowH) + 'px';
|
||||
// Render visible children
|
||||
wrapper.innerHTML = '';
|
||||
for (var i = start; i < end; i++) {
|
||||
var node = all[i];
|
||||
if (node) wrapper.appendChild(node);
|
||||
}
|
||||
if (DIAG){
|
||||
var box = ensureDiag();
|
||||
if (box){
|
||||
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
|
||||
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
|
||||
var textEl = box.querySelector('.virt-diag-text');
|
||||
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
|
||||
textEl.textContent = msg;
|
||||
// Health hint
|
||||
var bad = (dt > 33) || (vis > 300);
|
||||
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
||||
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
|
||||
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
|
||||
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
function onScroll(){ render(); }
|
||||
function onResize(){ measure(); render(); }
|
||||
container.addEventListener('scroll', onScroll);
|
||||
window.addEventListener('resize', onResize);
|
||||
// Initial size; ensure container is scrollable for our logic
|
||||
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
|
||||
container.style.overflow = container.style.overflow || 'auto';
|
||||
render();
|
||||
// Re-render after filters resort or HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
|
||||
// Keyboard toggle for overlays: 'v'
|
||||
if (DIAG && !window.__virtHotkeyBound){
|
||||
window.__virtHotkeyBound = true;
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key && e.key.toLowerCase() === 'v'){
|
||||
e.preventDefault();
|
||||
// Toggle all virt-diag boxes and the global summary
|
||||
var shown = null;
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
|
||||
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
}
|
||||
});
|
||||
}catch(_){ }
|
||||
}
|
||||
|
||||
// LQIP blur/fade-in for thumbnails marked with data-lqip
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
try{
|
||||
document.querySelectorAll('img[data-lqip]')
|
||||
.forEach(function(img){
|
||||
img.classList.add('lqip');
|
||||
img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
|
||||
});
|
||||
}catch(_){ }
|
||||
});
|
||||
})();
|
BIN
code/web/static/favicon-small.png
Normal file
BIN
code/web/static/favicon-small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
code/web/static/favicon.png
Normal file
BIN
code/web/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.1 KiB |
13
code/web/static/manifest.webmanifest
Normal file
13
code/web/static/manifest.webmanifest
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "MTG Deckbuilder",
|
||||
"short_name": "Deckbuilder",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f10",
|
||||
"theme_color": "#0f0f10",
|
||||
"icons": [
|
||||
{ "src": "/static/favicon.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/favicon.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
311
code/web/static/styles.css
Normal file
311
code/web/static/styles.css
Normal file
|
@ -0,0 +1,311 @@
|
|||
/* Base */
|
||||
:root{
|
||||
/* MTG color palette (approx from provided values) */
|
||||
--banner-h: 52px;
|
||||
--sidebar-w: 260px;
|
||||
--green-main: rgb(0,115,62);
|
||||
--green-light: rgb(196,211,202);
|
||||
--blue-main: rgb(14,104,171);
|
||||
--blue-light: rgb(179,206,234);
|
||||
--red-main: rgb(211,32,42);
|
||||
--red-light: rgb(235,159,130);
|
||||
--white-main: rgb(249,250,244);
|
||||
--white-light: rgb(248,231,185);
|
||||
--black-main: rgb(21,11,0);
|
||||
--black-light: rgb(166,159,157);
|
||||
--bg: #0f0f10;
|
||||
--panel: #1a1b1e;
|
||||
--text: #e8e8e8;
|
||||
--muted: #b6b8bd;
|
||||
--border: #2a2b2f;
|
||||
--ring: #60a5fa; /* focus ring */
|
||||
--ok: #16a34a; /* success */
|
||||
--warn: #f59e0b; /* warning */
|
||||
--err: #ef4444; /* error */
|
||||
/* Surface overrides for specific regions (default to panel) */
|
||||
--surface-banner: var(--panel);
|
||||
--surface-banner-text: var(--text);
|
||||
--surface-sidebar: var(--panel);
|
||||
--surface-sidebar-text: var(--text);
|
||||
}
|
||||
|
||||
/* Light blend between Slate and Parchment (leans gray) */
|
||||
[data-theme="light-blend"]{
|
||||
--bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */
|
||||
--panel: #ffffff; /* crisp panels for readability */
|
||||
--text: #0b0d12;
|
||||
--muted: #6b655d; /* slightly warm muted */
|
||||
--border: #d6d1c7; /* neutral warm-gray border */
|
||||
/* Slightly darker banner/sidebar for separation */
|
||||
--surface-banner: #1a1b1e;
|
||||
--surface-sidebar: #1a1b1e;
|
||||
--surface-banner-text: #e8e8e8;
|
||||
--surface-sidebar-text: #e8e8e8;
|
||||
}
|
||||
|
||||
[data-theme="dark"]{
|
||||
--bg: #0f0f10;
|
||||
--panel: #1a1b1e;
|
||||
--text: #e8e8e8;
|
||||
--muted: #b6b8bd;
|
||||
--border: #2a2b2f;
|
||||
}
|
||||
[data-theme="high-contrast"]{
|
||||
--bg: #000;
|
||||
--panel: #000;
|
||||
--text: #fff;
|
||||
--muted: #e5e7eb;
|
||||
--border: #fff;
|
||||
--ring: #ff0;
|
||||
}
|
||||
[data-theme="cb-friendly"]{
|
||||
/* Tweak accents for color-blind friendliness */
|
||||
--green-main: #2e7d32; /* darker green */
|
||||
--red-main: #c62828; /* deeper red */
|
||||
--blue-main: #1565c0; /* balanced blue */
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body {
|
||||
font-family: system-ui, Arial, sans-serif;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
/* Honor HTML hidden attribute across the app */
|
||||
[hidden] { display: none !important; }
|
||||
/* Accessible focus ring for keyboard navigation */
|
||||
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
/* Top banner */
|
||||
.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); }
|
||||
.top-banner{ min-height: var(--banner-h); }
|
||||
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
|
||||
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
|
||||
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.banner-status.busy{ color:#fbbf24; }
|
||||
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
|
||||
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
|
||||
|
||||
/* Layout */
|
||||
.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; }
|
||||
.sidebar{
|
||||
background: var(--surface-sidebar);
|
||||
color: var(--surface-sidebar-text);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: var(--banner-h);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
width: var(--sidebar-w);
|
||||
z-index: 9; /* below the banner (z=10) */
|
||||
box-shadow: 2px 0 10px rgba(0,0,0,.18);
|
||||
}
|
||||
.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; }
|
||||
|
||||
.brand h1{ display:none; }
|
||||
.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; }
|
||||
.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; }
|
||||
.dot.green{ background: var(--green-main); }
|
||||
.dot.blue{ background: var(--blue-main); }
|
||||
.dot.red{ background: var(--red-main); }
|
||||
.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); }
|
||||
.dot.black{ background: var(--black-light); }
|
||||
|
||||
.nav{ display:flex; flex-direction:column; gap:.35rem; }
|
||||
.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; }
|
||||
.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); }
|
||||
|
||||
/* Simple two-column layout for inspect panel */
|
||||
.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; }
|
||||
.two-col .grow { min-width: 0; }
|
||||
.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); }
|
||||
@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Left-rail variant puts the image first */
|
||||
.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; }
|
||||
.card-preview.card-sm{ max-width:200px; }
|
||||
|
||||
/* Buttons, inputs */
|
||||
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
|
||||
button:hover{ filter:brightness(1.05); }
|
||||
/* Anchor-style buttons */
|
||||
.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
|
||||
.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; }
|
||||
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); }
|
||||
|
||||
/* Toasts */
|
||||
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
|
||||
.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
|
||||
.toast.hide{ opacity:0; transform: translateY(6px); }
|
||||
.toast.success{ border-color: rgba(22,163,74,.4); }
|
||||
.toast.error{ border-color: rgba(239,68,68,.45); }
|
||||
.toast.warn{ border-color: rgba(245,158,11,.45); }
|
||||
|
||||
/* Skeletons */
|
||||
[data-skeleton]{ position: relative; }
|
||||
[data-skeleton].is-loading > *{ opacity: 0; }
|
||||
[data-skeleton]::after{
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.1s linear infinite;
|
||||
display: none;
|
||||
}
|
||||
[data-skeleton].is-loading::after{ display:block; }
|
||||
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
|
||||
|
||||
/* Banner */
|
||||
.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
|
||||
.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
|
||||
.banner .subtitle{ color: var(--muted); font-size:.95rem; }
|
||||
|
||||
/* Home actions */
|
||||
.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; }
|
||||
.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; }
|
||||
.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); }
|
||||
.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; }
|
||||
|
||||
/* Card grid for added cards (responsive, compact tiles) */
|
||||
.card-grid{
|
||||
display:grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */
|
||||
gap: .5rem;
|
||||
margin-top:.5rem;
|
||||
justify-content: start; /* pack as many as possible per row */
|
||||
}
|
||||
.card-tile{
|
||||
width:170px;
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border:1px solid var(--border);
|
||||
border-radius:6px;
|
||||
padding:.25rem .25rem .4rem;
|
||||
text-align:center;
|
||||
}
|
||||
.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
|
||||
.card-tile.locked{
|
||||
/* Subtle yellow/goldish-white accent for locked cards */
|
||||
border-color: #f5e6a8; /* soft parchment gold */
|
||||
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
|
||||
}
|
||||
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
|
||||
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
|
||||
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
||||
|
||||
/* Shared ownership badge for card tiles and stacked images */
|
||||
.owned-badge{
|
||||
position:absolute;
|
||||
top:6px;
|
||||
left:6px;
|
||||
background:rgba(17,24,39,.9);
|
||||
color:#e5e7eb;
|
||||
border:1px solid var(--border);
|
||||
border-radius:12px;
|
||||
font-size:12px;
|
||||
line-height:18px;
|
||||
height:18px;
|
||||
min-width:18px;
|
||||
padding:0 6px;
|
||||
text-align:center;
|
||||
pointer-events:none;
|
||||
z-index:2;
|
||||
}
|
||||
|
||||
/* Step 1 candidate grid (200px-wide scaled images) */
|
||||
.candidate-grid{
|
||||
display:grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap:.75rem;
|
||||
}
|
||||
.candidate-tile{
|
||||
background: var(--panel);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
padding:.4rem;
|
||||
}
|
||||
.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; }
|
||||
.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; }
|
||||
.candidate-tile .meta{ text-align:center; margin-top:.35rem; }
|
||||
.candidate-tile .name{ font-weight:600; font-size:.95rem; }
|
||||
.candidate-tile .score{ color:var(--muted); font-size:.85rem; }
|
||||
|
||||
/* Deck summary: highlight game changers */
|
||||
.game-changer { color: var(--green-main); }
|
||||
.stack-card.game-changer { outline: 2px solid var(--green-main); }
|
||||
|
||||
/* Image button inside card tiles */
|
||||
.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
|
||||
|
||||
/* Stage Navigator */
|
||||
.stage-nav { margin:.5rem 0 1rem; }
|
||||
.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
|
||||
.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
|
||||
.stage-nav .stage-item.done .stage-link { opacity:.75; }
|
||||
.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
|
||||
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
|
||||
.stage-nav .name { font-size:12px; }
|
||||
|
||||
/* Build controls sticky box tweaks for small screens */
|
||||
@media (max-width: 720px){
|
||||
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||
.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
|
||||
.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
|
||||
|
||||
/* Chips */
|
||||
.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
|
||||
.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
|
||||
|
||||
/* Cards toolbar */
|
||||
.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
|
||||
.cards-toolbar input[type="text"]{ min-width: 220px; }
|
||||
.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
|
||||
.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
|
||||
|
||||
/* Collapse groups and reason toggle */
|
||||
.group{ margin:.5rem 0; }
|
||||
.group-header{ display:flex; align-items:center; gap:.5rem; }
|
||||
.group-header h5{ margin:.4rem 0; }
|
||||
.group-header .count{ color: var(--muted); font-size:12px; }
|
||||
.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
|
||||
.group-grid[data-collapsed]{ display:none; }
|
||||
.hide-reasons .card-tile .reason{ display:none; }
|
||||
.card-tile.force-show .reason{ display:block !important; }
|
||||
.card-tile.force-hide .reason{ display:none !important; }
|
||||
.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
|
||||
.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
|
||||
.chips-inline .chip{ cursor:pointer; user-select:none; }
|
||||
|
||||
/* Inline error banner */
|
||||
.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
|
||||
.inline-error-banner .muted{ color:#fda4af; }
|
||||
|
||||
/* Alternatives panel */
|
||||
.alts ul{ list-style:none; padding:0; margin:0; }
|
||||
.alts li{ display:flex; align-items:center; gap:.4rem; }
|
||||
/* LQIP blur/fade-in for thumbnails */
|
||||
img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; }
|
||||
img.lqip.loaded { filter: blur(0); opacity: 1; }
|
||||
|
||||
/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { scroll-behavior: auto !important; }
|
||||
img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; }
|
||||
}
|
||||
|
||||
/* Virtualization wrapper should mirror grid to keep multi-column flow */
|
||||
.virt-wrapper { display: grid; }
|
10
code/web/static/sw.js
Normal file
10
code/web/static/sw.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Minimal service worker (stub). Controlled by ENABLE_PWA.
|
||||
self.addEventListener('install', event => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
self.addEventListener('fetch', event => {
|
||||
// Pass-through; caching strategy can be added later.
|
||||
});
|
3
code/web/static/vendor/htmx-1.9.12.min.js
vendored
Normal file
3
code/web/static/vendor/htmx-1.9.12.min.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/* Local fallback for HTMX 1.9.12. If the CDN fails, base.html will load this file. */
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t():("function"==typeof define&&define.amd?define(t):t())}(0,function(){/* placeholder minimal shim to avoid runtime errors if CDN blocked; swaps won't work until user refreshes with network */
|
||||
window.htmx=window.htmx||{version:"1.9.12",onLoad:function(){},find:function(){return null},trigger:function(){},config:{},logAll:function(){}};});
|
431
code/web/templates/base.html
Normal file
431
code/web/templates/base.html
Normal file
|
@ -0,0 +1,431 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-theme="{% if default_theme == 'light' %}light-blend{% elif default_theme == 'dark' %}dark{% else %}light-blend{% endif %}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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>
|
||||
(function(){
|
||||
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
|
||||
try{
|
||||
var root = document.documentElement;
|
||||
var KEY = 'mtg:theme';
|
||||
var SERVER_DEFAULT = '{{ default_theme }}';
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
var urlTheme = (params.get('theme') || '').toLowerCase();
|
||||
var stored = localStorage.getItem(KEY);
|
||||
function resolveSystem(){
|
||||
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'dark' : 'light-blend';
|
||||
}
|
||||
function mapTheme(v){
|
||||
var x = (v || 'system').toLowerCase();
|
||||
if (x === 'system') return resolveSystem();
|
||||
if (x === 'light') return 'light-blend';
|
||||
return x;
|
||||
}
|
||||
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
|
||||
root.setAttribute('data-theme', mapTheme(initial));
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||
{% if enable_pwa %}
|
||||
<link rel="manifest" href="/static/manifest.webmanifest" />
|
||||
{% endif %}
|
||||
</head>
|
||||
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||
<header class="top-banner">
|
||||
<div class="top-inner">
|
||||
<h1>MTG Deckbuilder</h1>
|
||||
<div style="display:flex; align-items:center; gap:.5rem">
|
||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
<button type="button" class="btn" title="Open a saved permalink"
|
||||
onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||||
{% if enable_themes %}
|
||||
<label style="margin:0 .5rem; align-items:flex-start; margin-left:auto">
|
||||
<span class="muted" style="font-size:11px">Theme</span>
|
||||
<select id="theme-select" aria-label="Theme selector">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="high-contrast">High contrast</option>
|
||||
<option value="cb-friendly">Color-blind</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" id="theme-reset" class="btn" title="Reset theme preference" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
|
||||
Reset
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="mana-dots" aria-hidden="true">
|
||||
<span class="dot green"></span>
|
||||
<span class="dot blue"></span>
|
||||
<span class="dot red"></span>
|
||||
<span class="dot white"></span>
|
||||
<span class="dot black"></span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/build">Build</a>
|
||||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="content" data-error-surface>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="site-footer" role="contentinfo">
|
||||
Card images and data provided by
|
||||
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
||||
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
||||
</footer>
|
||||
<style>
|
||||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); }
|
||||
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||
.card-meta li { margin:.1rem 0; }
|
||||
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
footer.site-footer { flex-shrink: 0; }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
// Setup/Tagging status poller
|
||||
var statusEl;
|
||||
function ensureStatusEl(){
|
||||
if (!statusEl) statusEl = document.getElementById('banner-status');
|
||||
return statusEl;
|
||||
}
|
||||
function renderSetupStatus(data){
|
||||
var el = ensureStatusEl(); if (!el) return;
|
||||
if (data && data.running) {
|
||||
var msg = (data.message || 'Preparing data...');
|
||||
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||
el.classList.add('busy');
|
||||
} else if (data && data.phase === 'done') {
|
||||
el.innerHTML = '<span class="muted">Setup complete.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
|
||||
} else if (data && data.phase === 'error') {
|
||||
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||
} else {
|
||||
if (!el.innerHTML.trim()) el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
function pollStatus(){
|
||||
try {
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(renderSetupStatus)
|
||||
.catch(function(){ /* noop */ });
|
||||
} catch(e) {}
|
||||
}
|
||||
setInterval(pollStatus, 3000);
|
||||
pollStatus();
|
||||
|
||||
// Health indicator poller
|
||||
var healthDot = document.getElementById('health-dot');
|
||||
function renderHealth(data){
|
||||
if (!healthDot) return;
|
||||
var ok = data && data.status === 'ok';
|
||||
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
|
||||
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
|
||||
}
|
||||
function pollHealth(){
|
||||
try {
|
||||
fetch('/healthz', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(renderHealth)
|
||||
.catch(function(){ renderHealth({ status: 'bad' }); });
|
||||
} catch(e){ renderHealth({ status: 'bad' }); }
|
||||
}
|
||||
setInterval(pollHealth, 5000);
|
||||
pollHealth();
|
||||
|
||||
function ensureCard() {
|
||||
var pop = document.getElementById('card-hover');
|
||||
if (!pop) {
|
||||
pop = document.createElement('div');
|
||||
pop.id = 'card-hover';
|
||||
pop.className = 'card-hover';
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'card-hover-inner';
|
||||
var img = document.createElement('img');
|
||||
img.alt = 'Card preview';
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'card-meta';
|
||||
inner.appendChild(img);
|
||||
inner.appendChild(meta);
|
||||
pop.appendChild(inner);
|
||||
document.body.appendChild(pop);
|
||||
}
|
||||
return pop;
|
||||
}
|
||||
var cardPop = ensureCard();
|
||||
var PREVIEW_VERSIONS = ['normal','large'];
|
||||
function buildCardUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
// Generic Scryfall image URL builder
|
||||
function buildScryfallImageUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
||||
// Global image retry binding for any <img data-card-name>
|
||||
var IMG_FLAG = '__cardImgRetry';
|
||||
function bindCardImageRetry(img, versions){
|
||||
try {
|
||||
if (!img || img[IMG_FLAG]) return;
|
||||
var name = img.getAttribute('data-card-name') || '';
|
||||
if (!name) return;
|
||||
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
|
||||
img.addEventListener('error', function(){
|
||||
var st = img[IMG_FLAG];
|
||||
if (!st) return;
|
||||
if (st.vi < st.versions.length - 1){
|
||||
st.vi += 1;
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
|
||||
} else if (!st.nocache){
|
||||
st.nocache = 1;
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
|
||||
}
|
||||
});
|
||||
// If the initial load already failed before binding, try the next immediately
|
||||
if (img.complete && img.naturalWidth === 0){
|
||||
// If src corresponds to the first version, move to next; else, just force a reload
|
||||
var st = img[IMG_FLAG];
|
||||
var current = img.src || '';
|
||||
var first = buildScryfallImageUrl(name, st.versions[0], false);
|
||||
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
|
||||
st.vi = Math.min(1, st.versions.length - 1);
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
|
||||
} else {
|
||||
// Re-trigger current request (may succeed if transient)
|
||||
img.src = current;
|
||||
}
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
function bindAllCardImageRetries(){
|
||||
document.querySelectorAll('img[data-card-name]').forEach(function(img){
|
||||
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
|
||||
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
|
||||
bindCardImageRetry(img, versions);
|
||||
});
|
||||
}
|
||||
|
||||
function positionCard(e) {
|
||||
var x = e.clientX + 16, y = e.clientY + 16;
|
||||
cardPop.style.display = 'block';
|
||||
cardPop.style.left = x + 'px';
|
||||
cardPop.style.top = y + 'px';
|
||||
var rect = cardPop.getBoundingClientRect();
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
|
||||
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
|
||||
}
|
||||
function attachCardHover() {
|
||||
document.querySelectorAll('[data-card-name]').forEach(function(el) {
|
||||
if (el.__cardHoverBound) return; // avoid duplicate bindings
|
||||
el.__cardHoverBound = true;
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
var img = cardPop.querySelector('img');
|
||||
var meta = cardPop.querySelector('.card-meta');
|
||||
var name = el.getAttribute('data-card-name') || '';
|
||||
var vi = 0; // always start at 'normal' on hover
|
||||
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
|
||||
// Bind a one-off error handler per enter to try fallbacks
|
||||
var triedNoCache = false;
|
||||
function onErr(){
|
||||
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
|
||||
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
|
||||
else { img.removeEventListener('error', onErr); }
|
||||
}
|
||||
img.addEventListener('error', onErr, { once:false });
|
||||
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
|
||||
var role = el.getAttribute('data-role') || '';
|
||||
var rawTags = el.getAttribute('data-tags') || '';
|
||||
// Clean and split tags into an array; remove brackets and quotes
|
||||
var tags = rawTags
|
||||
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
|
||||
.split(/\s*,\s*/)
|
||||
.filter(function(t){ return t && t.trim(); });
|
||||
if (role || (tags && tags.length)) {
|
||||
var html = '';
|
||||
if (role) {
|
||||
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
||||
}
|
||||
if (tags && tags.length) {
|
||||
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'<') + '</li>'; }).join('') + '</ul></div>';
|
||||
}
|
||||
meta.innerHTML = html;
|
||||
meta.style.display = '';
|
||||
} else {
|
||||
meta.style.display = 'none';
|
||||
meta.innerHTML = '';
|
||||
}
|
||||
positionCard(e);
|
||||
});
|
||||
el.addEventListener('mousemove', positionCard);
|
||||
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
|
||||
});
|
||||
}
|
||||
attachCardHover();
|
||||
bindAllCardImageRetries();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/app.js?v=20250826-4"></script>
|
||||
{% if enable_themes %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
var sel = document.getElementById('theme-select');
|
||||
var resetBtn = document.getElementById('theme-reset');
|
||||
var root = document.documentElement;
|
||||
var KEY = 'mtg:theme';
|
||||
var SERVER_DEFAULT = '{{ default_theme }}';
|
||||
function mapLight(v){ return v === 'light' ? 'light-blend' : v; }
|
||||
function resolveSystem(){
|
||||
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'dark' : 'light-blend';
|
||||
}
|
||||
function normalizeUiValue(v){
|
||||
var x = (v||'system').toLowerCase();
|
||||
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
|
||||
return x;
|
||||
}
|
||||
function apply(val){
|
||||
var v = (val || 'system').toLowerCase();
|
||||
if (v === 'system') v = resolveSystem();
|
||||
v = mapLight(v);
|
||||
root.setAttribute('data-theme', v);
|
||||
}
|
||||
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
var urlTheme = (params.get('theme') || '').toLowerCase();
|
||||
if (urlTheme) {
|
||||
// Persist the UI value, not the mapped CSS token
|
||||
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
|
||||
// Clean the URL so reloads don't keep overriding
|
||||
try { var u = new URL(window.location.href); u.searchParams.delete('theme'); window.history.replaceState({}, document.title, u.toString()); } catch(_){ }
|
||||
}
|
||||
// Determine initial selection: URL -> localStorage -> server default -> system
|
||||
var stored = localStorage.getItem(KEY);
|
||||
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
|
||||
apply(initial);
|
||||
if (sel){
|
||||
sel.value = normalizeUiValue(initial);
|
||||
sel.addEventListener('change', function(){
|
||||
var v = sel.value || 'system';
|
||||
localStorage.setItem(KEY, v);
|
||||
apply(v);
|
||||
});
|
||||
}
|
||||
if (resetBtn){
|
||||
resetBtn.addEventListener('click', function(){
|
||||
try{ localStorage.removeItem(KEY); }catch(_){ }
|
||||
var v = SERVER_DEFAULT || 'system';
|
||||
apply(v);
|
||||
if (sel) sel.value = normalizeUiValue(v);
|
||||
});
|
||||
}
|
||||
// React to system changes when set to system
|
||||
if (window.matchMedia){
|
||||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener && mq.addEventListener('change', function(){
|
||||
var cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
|
||||
if (cur === 'system') apply('system');
|
||||
});
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if not enable_themes %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
// Apply THEME env even when the selector is disabled. Resolve 'system' to OS preference.
|
||||
var root = document.documentElement;
|
||||
var SERVER_DEFAULT = '{{ default_theme }}';
|
||||
function resolveSystem(){
|
||||
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'dark' : 'light-blend';
|
||||
}
|
||||
var v = (SERVER_DEFAULT || 'system').toLowerCase();
|
||||
if (v === 'system') v = resolveSystem();
|
||||
if (v === 'light') v = 'light-blend';
|
||||
root.setAttribute('data-theme', v);
|
||||
// Track OS changes when using system
|
||||
if ((SERVER_DEFAULT||'system').toLowerCase() === 'system' && window.matchMedia){
|
||||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener && mq.addEventListener('change', function(){ root.setAttribute('data-theme', resolveSystem()); });
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if enable_pwa %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
if ('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('/static/sw.js').then(function(reg){
|
||||
window.__pwaStatus = { registered: true, scope: reg.scope };
|
||||
}).catch(function(){ window.__pwaStatus = { registered: false }; });
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
// Show pending toast after full page reloads when actions replace the whole document
|
||||
(function(){
|
||||
try{
|
||||
var raw = sessionStorage.getItem('mtg:toastAfterReload');
|
||||
if (raw){
|
||||
sessionStorage.removeItem('mtg:toastAfterReload');
|
||||
var data = JSON.parse(raw);
|
||||
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
1
code/web/templates/build/_banner_subtitle.html
Normal file
1
code/web/templates/build/_banner_subtitle.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div id="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
|
18
code/web/templates/build/_new_deck_candidates.html
Normal file
18
code/web/templates/build/_new_deck_candidates.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% if candidates and candidates|length %}
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
|
||||
{% for name, score, colors in candidates %}
|
||||
<li>
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
|
||||
hx-get="/build/new/inspect?name={{ name|urlencode }}"
|
||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% if query %}
|
||||
<div class="muted">No matches for “{{ query }}”.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
149
code/web/templates/build/_new_deck_modal.html
Normal file
149
code/web/templates/build/_new_deck_modal.html
Normal file
|
@ -0,0 +1,149 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
|
||||
<div class="modal-header">
|
||||
<h3 id="newDeckTitle">Build a New Deck</h3>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="error" role="alert" style="margin:.35rem 0 .5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form hx-post="/build/new" hx-target="#wizard" hx-swap="innerHTML" hx-on="htmx:afterRequest: (function(evt){ try{ if(evt && evt.detail && evt.detail.elt === this){ var m=this.closest('.modal'); if(m){ m.remove(); } } }catch(_){} }).call(this, event)" autocomplete="off">
|
||||
<fieldset>
|
||||
<legend>Basics</legend>
|
||||
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span class="muted">Optional name (used for file names)</span>
|
||||
<input type="text" name="name" placeholder="e.g., Inti Discard Tempo" autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span>Commander</span>
|
||||
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
|
||||
hx-get="/build/new/candidates" hx-trigger="input changed delay:150ms" hx-target="#newdeck-candidates" hx-sync="this:replace" />
|
||||
</label>
|
||||
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
|
||||
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>
|
||||
</div>
|
||||
<div id="newdeck-commander-slot" class="muted" style="max-width:230px;">
|
||||
<em style="font-size:12px;">Pick a commander to preview here.</em>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Themes</legend>
|
||||
<div id="newdeck-tags-slot" class="muted">
|
||||
<em>Select a commander to see theme recommendations and choices.</em>
|
||||
<input type="hidden" name="primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div style="margin-top:.5rem;">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
{% for b in brackets %}
|
||||
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Advanced options (ideals)</summary>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
|
||||
{% for key, label in labels.items() %}
|
||||
<label>{{ label }}
|
||||
<input type="number" name="{{ key }}" value="{{ defaults[key] }}" min="0" />
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<button type="submit" class="btn-continue">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
// Prevent Enter in text inputs from submitting the form
|
||||
try {
|
||||
var form = modal ? modal.querySelector('form') : document.querySelector('.modal form');
|
||||
if (form){
|
||||
// Prevent Enter in name field from submitting
|
||||
var nameEl = form.querySelector('input[name="name"]');
|
||||
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
|
||||
// In commander field, Enter picks the first candidate (if any) without closing the modal
|
||||
var cmdEl = form.querySelector('input[name=\"commander\"]');
|
||||
if (cmdEl){
|
||||
function handleEnterNav(e){
|
||||
// Enter selects the highlighted (or first) suggestion
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var btns = list ? Array.prototype.slice.call(list.querySelectorAll('button.candidate-btn')) : [];
|
||||
var getActiveIndex = function(){ return btns.findIndex(function(b){ return b.classList.contains('active'); }); };
|
||||
if (!btns.length) return; // nothing to do, but we've already prevented default
|
||||
// Skip if a request is in-flight to avoid fighting with swap timing
|
||||
try{ if (cmdEl.matches('.htmx-request') || list.matches('.htmx-request')) return; }catch(_){ }
|
||||
if (e.key === 'Enter'){
|
||||
var idx = getActiveIndex();
|
||||
var target = btns[(idx >= 0 ? idx : 0)];
|
||||
if (target) { target.click(); }
|
||||
}
|
||||
}
|
||||
// Capture keydown early to prevent submit on Enter (arrows left to default behavior)
|
||||
cmdEl.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter'){
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (hasBtns){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEnterNav(e);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
// Defensive: also block Enter on keyup (in case a browser tries to submit on keyup)
|
||||
cmdEl.addEventListener('keyup', function(e){ if (e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); } });
|
||||
// Global fallback: capture keydown at the document level so Enter never slips through when the commander input is focused
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (document.activeElement !== cmdEl) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (!hasBtns) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEnterNav(e);
|
||||
}catch(_){ }
|
||||
}, true);
|
||||
// Reset candidate highlight when the list updates
|
||||
document.body.addEventListener('htmx:afterSwap', function(ev){
|
||||
try {
|
||||
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
|
||||
if (!tgt) return;
|
||||
if (tgt.id === 'newdeck-candidates'){
|
||||
var first = tgt.querySelector('button.candidate-btn');
|
||||
if (first){
|
||||
// Clear any lingering active classes, then set the first as active for immediate Enter selection
|
||||
tgt.querySelectorAll('button.candidate-btn').forEach(function(b){ b.classList.remove('active'); b.setAttribute('aria-selected','false'); });
|
||||
first.classList.add('active');
|
||||
first.setAttribute('aria-selected','true');
|
||||
try{ cmdEl.setAttribute('aria-activedescendant', first.id || ''); }catch(_){ }
|
||||
}
|
||||
}
|
||||
} catch(_){}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
// Close on Escape
|
||||
function closeModal(){ try{ var m = document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){} }
|
||||
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
})();
|
||||
</script>
|
105
code/web/templates/build/_new_deck_tags.html
Normal file
105
code/web/templates/build/_new_deck_tags.html
Normal file
|
@ -0,0 +1,105 @@
|
|||
{% set pname = commander.name %}
|
||||
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
|
||||
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
|
||||
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ pname|urlencode }}&format=image&version=normal" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
|
||||
<script>
|
||||
try {
|
||||
var nm = document.querySelector('input[name="name"]');
|
||||
var value = document.querySelector('#newdeck-commander-slot [data-card-name]')?.getAttribute('data-card-name') || '{{ pname|e }}';
|
||||
if (nm && (!nm.value || !nm.value.trim())) { nm.value = value; }
|
||||
} catch(_) {}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if tags and tags|length %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
|
||||
<span class="muted" style="font-size:12px;">Combine</span>
|
||||
<div role="group" aria-label="Combine mode">
|
||||
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
|
||||
<input type="radio" name="combine_mode_radio" value="AND" checked /> AND
|
||||
</label>
|
||||
<label title="OR treats your themes as a union (broader pool, fills easier).">
|
||||
<input type="radio" name="combine_mode_radio" value="OR" /> OR
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
|
||||
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
|
||||
</div>
|
||||
{% if recommended and recommended|length %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
</div>
|
||||
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No theme tags available for this commander.</p>
|
||||
{% endif %}
|
||||
<!-- hidden inputs that the main modal form will submit -->
|
||||
<input type="hidden" name="primary_tag" id="modal_primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" id="modal_secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" id="modal_tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" id="modal_tag_mode" value="AND" />
|
||||
|
||||
<div id="modal-selected-themes" class="muted" style="font-size:12px; margin-top:.5rem;">
|
||||
<em>No themes selected yet.</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var list = document.getElementById('modal-tag-list');
|
||||
var reco = document.getElementById('modal-tag-reco');
|
||||
var selAll = document.getElementById('modal-reco-select-all');
|
||||
var resetBtn = document.getElementById('modal-reset-tags');
|
||||
var p = document.getElementById('modal_primary_tag');
|
||||
var s = document.getElementById('modal_secondary_tag');
|
||||
var t = document.getElementById('modal_tertiary_tag');
|
||||
var mode = document.getElementById('modal_tag_mode');
|
||||
var countEl = document.getElementById('modal-tag-count');
|
||||
var selSummary = document.getElementById('modal-selected-themes');
|
||||
if (!list) return;
|
||||
|
||||
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
|
||||
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
|
||||
function toggle(tag){ var cur=getSel(); var i=cur.indexOf(tag); if(i>=0){cur.splice(i,1);} else { if(cur.length>=3){cur=cur.slice(1);} cur.push(tag);} setSel(cur); }
|
||||
function updateUI(){
|
||||
try{ if(countEl) countEl.textContent = getSel().length + ' / 3 selected'; }catch(_){ }
|
||||
try{
|
||||
if(selSummary){
|
||||
var sel = getSel();
|
||||
if(!sel.length){ selSummary.innerHTML = '<em>No themes selected yet.</em>'; }
|
||||
else {
|
||||
var parts = [];
|
||||
sel.forEach(function(tag, idx){ parts.push((idx+1) + '. ' + tag); });
|
||||
selSummary.textContent = 'Selected: ' + parts.join(' · ');
|
||||
}
|
||||
}
|
||||
}catch(_){ }
|
||||
function apply(container){ if(!container) return; var chips = container.querySelectorAll('button.chip'); chips.forEach(function(btn){ var tag=btn.dataset.tag||''; var active=getSel().indexOf(tag)>=0; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active?'true':'false'); }); }
|
||||
apply(list); apply(reco);
|
||||
}
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
|
||||
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });
|
||||
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
|
||||
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
|
||||
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
|
||||
updateUI();
|
||||
})();
|
||||
</script>
|
25
code/web/templates/build/_stage_navigator.html
Normal file
25
code/web/templates/build/_stage_navigator.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{# Build Stage Navigator: shows steps and allows jumping via HTMX #}
|
||||
{% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %}
|
||||
{% set index = step_index if step_index is defined else i if i is defined else 1 %}
|
||||
{% set total = step_total if step_total is defined else n if n is defined else 5 %}
|
||||
<nav class="stage-nav" aria-label="Build stages">
|
||||
<ol>
|
||||
{% for idx in range(1, total+1) %}
|
||||
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}
|
||||
{% set is_cur = (idx == index) %}
|
||||
{% set is_done = (idx < index) %}
|
||||
<li class="stage-item{% if is_cur %} current{% endif %}{% if is_done %} done{% endif %}">
|
||||
<button
|
||||
class="stage-link"
|
||||
{% if is_cur %}aria-current="step"{% endif %}
|
||||
hx-get="/build/step{{ idx }}"
|
||||
hx-target="#wizard"
|
||||
hx-swap="innerHTML"
|
||||
title="Go to {{ name }}">
|
||||
<span class="idx">{{ idx }}</span>
|
||||
<span class="name">{{ name }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
396
code/web/templates/build/_step1.html
Normal file
396
code/web/templates/build/_step1.html
Normal file
|
@ -0,0 +1,396 @@
|
|||
<section>
|
||||
{% set step_index = 1 %}{% set step_total = 5 %}
|
||||
<h3>Step 1: Choose a Commander</h3>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
|
||||
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
|
||||
<label for="cmdr-search">Search by name</label>
|
||||
<span class="input-wrap">
|
||||
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" aria-describedby="cmdr-help" aria-controls="candidate-grid" placeholder="Type a commander name…" />
|
||||
<button id="cmdr-clear" type="button" class="clear-btn" title="Clear search" aria-label="Clear search" hidden>×</button>
|
||||
</span>
|
||||
<input id="active-name" type="hidden" name="active" value="{{ active or '' }}" />
|
||||
<button type="submit">Search</button>
|
||||
<label style="margin-left:.5rem; font-weight:normal;">
|
||||
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
|
||||
</label>
|
||||
<span id="search-spinner" class="spinner" aria-hidden="true" hidden style="display:none;"></span>
|
||||
</form>
|
||||
<div id="cmdr-help" class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
|
||||
Tip: Press Enter to select the highlighted result, or use arrow keys to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
|
||||
</div>
|
||||
<div id="selection-live" class="sr-only" aria-live="polite" role="status"></div>
|
||||
<div id="results-live" class="sr-only" aria-live="polite" role="status"></div>
|
||||
<div id="kbd-hint" class="hint" hidden>
|
||||
<span class="hint-text">Use
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span> to navigate, <kbd>Enter</kbd> to select
|
||||
</span>
|
||||
<button type="button" class="hint-close" title="Dismiss keyboard hint" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<h4 style="display:flex; align-items:center; gap:.5rem;">
|
||||
Top matches
|
||||
<small class="muted" aria-live="polite">{% if count is defined %}{{ count }} result{% if count != 1 %}s{% endif %}{% else %}{{ (candidates|length) if candidates else 0 }} results{% endif %}</small>
|
||||
</h4>
|
||||
<div class="candidate-grid" id="candidate-grid" role="list">
|
||||
{% for name, score, colors in candidates %}
|
||||
<div class="candidate-tile{% if active and active == name %} active{% endif %}" data-card-name="{{ name }}" role="listitem" aria-selected="{% if active and active == name %}true{% else %}false{% endif %}">
|
||||
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
|
||||
alt="{{ name }}" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</form>
|
||||
<div class="meta">
|
||||
<div class="name"><span class="name-text">{{ name }}</span></div>
|
||||
<div class="score">
|
||||
match {{ score }}%
|
||||
{% if colors %}
|
||||
<span class="colors" style="margin-left:.25rem;">
|
||||
{% for c in colors %}
|
||||
<span class="chip chip-{{ c|lower }}" title="{{ c }}">{{ c }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form hx-post="/build/step1/inspect" hx-target="#wizard" hx-swap="innerHTML" style="margin-top:.25rem;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button type="submit">Inspect</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
|
||||
<div id="candidate-grid" class="muted" style="margin-top:.5rem;" aria-live="polite">
|
||||
No results for “{{ query }}”. Try a shorter name or a different spelling.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if inspect and inspect.ok %}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
|
||||
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ selected }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<h4>Theme Tags</h4>
|
||||
{% if tags and tags|length > 0 %}
|
||||
<ul>
|
||||
{% for t in tags %}
|
||||
<li>{{ t }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No theme tags found for this commander.</p>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem;">
|
||||
<form style="display:inline" hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ selected }}" />
|
||||
<button class="btn-continue" data-action="continue">Use this commander</button>
|
||||
</form>
|
||||
<form style="display:inline" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="query" value="" />
|
||||
<button class="btn-back" data-action="back">Back to search</button>
|
||||
</form>
|
||||
<form action="/build" method="get" style="display:inline; margin-left:.5rem;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
<div hx-get="/build/banner?step=Choose%20Commander&i=1&n=5" hx-trigger="load"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif inspect and not inspect.ok %}
|
||||
<div style="color:#a00">{{ inspect.error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div style="color:#a00">{{ error }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('cmdr-search');
|
||||
var form = document.getElementById('cmdr-search-form');
|
||||
var grid = document.getElementById('candidate-grid');
|
||||
var spinner = document.getElementById('search-spinner');
|
||||
var activeField = document.getElementById('active-name');
|
||||
var selLive = document.getElementById('selection-live');
|
||||
var resultsLive = document.getElementById('results-live');
|
||||
var hint = document.getElementById('kbd-hint');
|
||||
var defaultPlaceholder = (input && input.placeholder) ? input.placeholder : 'Type a commander name…';
|
||||
var clearBtn = document.getElementById('cmdr-clear');
|
||||
var initialDescribedBy = (input && input.getAttribute('aria-describedby')) || '';
|
||||
// Persist auto-select preference
|
||||
try {
|
||||
var autoCb = document.querySelector('input[name="auto"][type="checkbox"]');
|
||||
if (autoCb) {
|
||||
var saved = localStorage.getItem('step1-auto');
|
||||
if (saved === '1' || saved === '0') autoCb.checked = (saved === '1');
|
||||
autoCb.addEventListener('change', function(){ localStorage.setItem('step1-auto', autoCb.checked ? '1' : '0'); });
|
||||
}
|
||||
} catch(_){ }
|
||||
if (!input || !form) return;
|
||||
// Show keyboard hint only when candidates exist and user hasn't dismissed it
|
||||
function showHintIfNeeded() {
|
||||
try {
|
||||
if (!hint) return;
|
||||
var dismissed = localStorage.getItem('step1-hint-dismissed') === '1';
|
||||
var hasTiles = !!(document.getElementById('candidate-grid') && document.getElementById('candidate-grid').querySelector('.candidate-tile'));
|
||||
var shouldShow = !(dismissed || !hasTiles);
|
||||
hint.hidden = !shouldShow;
|
||||
// Link hint to input a11y description only when visible
|
||||
if (input) {
|
||||
var base = initialDescribedBy.trim();
|
||||
var parts = base ? base.split(/\s+/) : [];
|
||||
var idx = parts.indexOf('kbd-hint');
|
||||
if (shouldShow) {
|
||||
if (idx === -1) parts.push('kbd-hint');
|
||||
} else {
|
||||
if (idx !== -1) parts.splice(idx, 1);
|
||||
}
|
||||
if (parts.length) input.setAttribute('aria-describedby', parts.join(' '));
|
||||
else input.removeAttribute('aria-describedby');
|
||||
}
|
||||
} catch(_) { /* noop */ }
|
||||
}
|
||||
showHintIfNeeded();
|
||||
// Close button for hint
|
||||
try {
|
||||
var closeBtn = hint ? hint.querySelector('.hint-close') : null;
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(){
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
}
|
||||
} catch(_){ }
|
||||
// Debounce live search
|
||||
var t = null;
|
||||
function submit(){
|
||||
if (!form) return;
|
||||
// Trigger the HTMX post without clicking submit
|
||||
if (window.htmx) { window.htmx.trigger(form, 'submit'); }
|
||||
else { form.submit(); }
|
||||
}
|
||||
input.addEventListener('input', function(){
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 250);
|
||||
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
|
||||
});
|
||||
// Initialize clear visibility
|
||||
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
try { clearBtn.hidden = true; } catch(_){ }
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 0);
|
||||
try { input.focus(); } catch(_){}
|
||||
});
|
||||
// Focus the search box on load if nothing else is focused
|
||||
try {
|
||||
var ae = document.activeElement;
|
||||
if (input && (!ae || ae === document.body)) { input.focus(); input.select && input.select(); }
|
||||
} catch(_){}
|
||||
// Quick focus: press "/" to focus the search input (unless already typing)
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== '/') return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
if (input) { input.focus(); try { input.select(); } catch(_){} }
|
||||
});
|
||||
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
|
||||
document.addEventListener('keydown', function(e){
|
||||
// Dismiss hint on first keyboard navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
if (!grid || !grid.children || grid.children.length === 0) return;
|
||||
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
|
||||
// Ensure something is selected by default
|
||||
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
|
||||
if (idx < 0 && tiles.length > 0) {
|
||||
tiles[0].classList.add('active');
|
||||
try { if (activeField) activeField.value = tiles[0].dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[0].dataset.cardName || ''); } catch(_){}
|
||||
idx = 0;
|
||||
}
|
||||
|
||||
// Determine columns via first row's offsetTop count
|
||||
var cols = 1;
|
||||
if (tiles.length > 1) {
|
||||
var firstTop = tiles[0].offsetTop;
|
||||
cols = tiles.findIndex(function(el, i){ return i>0 && el.offsetTop !== firstTop; });
|
||||
if (cols === -1 || cols === 0) cols = tiles.length; // single row fallback
|
||||
}
|
||||
|
||||
function setActive(newIdx) {
|
||||
// Clamp to bounds; wrapping handled by callers
|
||||
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
|
||||
tiles.forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
|
||||
tiles[newIdx].classList.add('active');
|
||||
tiles[newIdx].setAttribute('aria-selected', 'true');
|
||||
try { if (activeField) activeField.value = tiles[newIdx].dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[newIdx].dataset.cardName || ''); } catch(_){}
|
||||
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
return newIdx;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var total = tiles.length;
|
||||
var rows = Math.ceil(total / cols);
|
||||
var row = Math.floor(idx / cols);
|
||||
var col = idx % cols;
|
||||
var newRow = row + 1;
|
||||
if (newRow >= rows) newRow = 0; // wrap to first row
|
||||
var newIdx = newRow * cols + col;
|
||||
if (newIdx >= total) newIdx = total - 1; // clamp to last tile if last row shorter
|
||||
idx = setActive(newIdx);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var totalU = tiles.length;
|
||||
var rowsU = Math.ceil(totalU / cols);
|
||||
var rowU = Math.floor(idx / cols);
|
||||
var colU = idx % cols;
|
||||
var newRowU = rowU - 1;
|
||||
if (newRowU < 0) newRowU = rowsU - 1; // wrap to last row
|
||||
var newIdxU = newRowU * cols + colU;
|
||||
if (newIdxU >= totalU) newIdxU = totalU - 1;
|
||||
idx = setActive(newIdxU);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
var totalR = tiles.length;
|
||||
idx = setActive((idx + 1) % totalR);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var totalL = tiles.length;
|
||||
idx = setActive((idx - 1 + totalL) % totalL);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var active = tiles[idx];
|
||||
if (!active) return;
|
||||
var formSel = active.querySelector('form[hx-post="/build/step1/confirm"]');
|
||||
if (formSel) {
|
||||
if (window.htmx) { window.htmx.trigger(formSel, 'submit'); }
|
||||
else if (formSel.submit) { formSel.submit(); }
|
||||
else {
|
||||
var btn = active.querySelector('button');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// ESC clears the search field and triggers a refresh
|
||||
if (input && input.value) {
|
||||
input.value = '';
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Persist current active on click selection movement too
|
||||
if (grid) {
|
||||
grid.addEventListener('click', function(e){
|
||||
// Dismiss hint on interaction
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
var tile = e.target.closest('.candidate-tile');
|
||||
if (!tile) return;
|
||||
grid.querySelectorAll('.candidate-tile').forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
|
||||
tile.classList.add('active');
|
||||
tile.setAttribute('aria-selected', 'true');
|
||||
try { if (activeField) activeField.value = tile.dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tile.dataset.cardName || ''); } catch(_){}
|
||||
});
|
||||
}
|
||||
// Highlight matched text
|
||||
try {
|
||||
var q = (input.value || '').trim().toLowerCase();
|
||||
if (q && grid) {
|
||||
grid.querySelectorAll('.name-text').forEach(function(el){
|
||||
var txt = el.textContent || '';
|
||||
var low = txt.toLowerCase();
|
||||
var i = low.indexOf(q);
|
||||
if (i >= 0) {
|
||||
el.innerHTML = txt.substring(0, i) + '<mark>' + txt.substring(i, i+q.length) + '</mark>' + txt.substring(i+q.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(_){}
|
||||
// HTMX spinner binding for this form — only show if no results are currently displayed
|
||||
if (window.htmx && form) {
|
||||
form.addEventListener('htmx:beforeRequest', function(){
|
||||
var hasTiles = false;
|
||||
try { hasTiles = !!(grid && grid.querySelector('.candidate-tile')); } catch(_){}
|
||||
if (spinner) spinner.hidden = hasTiles ? true : false;
|
||||
if (!hasTiles && input) input.placeholder = 'Searching…';
|
||||
try { form.setAttribute('aria-busy', 'true'); } catch(_){ }
|
||||
if (resultsLive) resultsLive.textContent = 'Searching…';
|
||||
});
|
||||
form.addEventListener('htmx:afterSwap', function(){
|
||||
if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder;
|
||||
// After swap, if there are no candidate tiles, clear active selection and live text
|
||||
try {
|
||||
var grid2 = document.getElementById('candidate-grid');
|
||||
var hasAny = !!(grid2 && grid2.querySelector('.candidate-tile'));
|
||||
if (!hasAny) {
|
||||
if (activeField) activeField.value = '';
|
||||
if (selLive) selLive.textContent = '';
|
||||
}
|
||||
// Re-evaluate hint visibility post-swap
|
||||
showHintIfNeeded();
|
||||
// Announce results count
|
||||
try {
|
||||
var qNow = (input && input.value) ? input.value.trim() : '';
|
||||
var cnt = 0;
|
||||
if (grid2) cnt = grid2.querySelectorAll('.candidate-tile').length;
|
||||
if (resultsLive) {
|
||||
if (cnt > 0) resultsLive.textContent = cnt + (cnt === 1 ? ' result' : ' results');
|
||||
else if (qNow) resultsLive.textContent = 'No results for "' + qNow + '"';
|
||||
else resultsLive.textContent = '';
|
||||
}
|
||||
} catch(_){ }
|
||||
try { form.removeAttribute('aria-busy'); } catch(_){ }
|
||||
} catch(_){ }
|
||||
});
|
||||
form.addEventListener('htmx:responseError', function(){ if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder; });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.candidate-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:.75rem; }
|
||||
.candidate-tile { border:1px solid var(--border); border-radius:8px; background:#0f1115; padding:.5rem; }
|
||||
.candidate-tile.active { outline:2px solid #3b82f6; }
|
||||
.img-btn { display:block; width:100%; background:transparent; border:0; padding:0; cursor:pointer; }
|
||||
.img-btn img { width:100%; height:auto; border-radius:6px; }
|
||||
.chip { display:inline-block; padding:0 .35rem; border-radius:999px; font-size:.75rem; line-height:1.4; border:1px solid var(--border); background:#151821; margin-left:.15rem; }
|
||||
.chip-w { background:#fdf4d6; color:#6b4f00; border-color:#e9d8a6; }
|
||||
.chip-u { background:#dbeafe; color:#1e40af; border-color:#93c5fd; }
|
||||
.chip-b { background:#e5e7eb; color:#111827; border-color:#9ca3af; }
|
||||
.chip-g { background:#dcfce7; color:#065f46; border-color:#86efac; }
|
||||
.chip-r { background:#fee2e2; color:#991b1b; border-color:#fecaca; }
|
||||
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
.candidate-tile { cursor: pointer; }
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.spinner { display:inline-block; width:16px; height:16px; border:2px solid #93c5fd; border-top-color: transparent; border-radius:50%; animation: spin 0.8s linear infinite; vertical-align:middle; margin-left:.4rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
/* Ensure hidden attribute always hides spinner within this fragment */
|
||||
.spinner[hidden] { display: none !important; }
|
||||
.hint { display:flex; align-items:center; gap:.5rem; background:#0b1220; border:1px solid var(--border); color:#cbd5e1; padding:.4rem .6rem; border-radius:8px; margin:.4rem 0 .6rem; }
|
||||
.hint .hint-close { background:transparent; border:0; color:#9aa4b2; font-size:1rem; line-height:1; cursor:pointer; }
|
||||
.hint .keys kbd { background:#1f2937; color:#e5e7eb; padding:.1rem .3rem; border-radius:4px; margin:0 .1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:.85em; }
|
||||
.input-wrap { position: relative; display:inline-flex; align-items:center; }
|
||||
.clear-btn { position:absolute; right:.35rem; background:transparent; color:#9aa4b2; border:0; cursor:pointer; font-size:1.1rem; line-height:1; padding:.1rem .2rem; }
|
||||
.clear-btn:hover { color:#cbd5e1; }
|
||||
</style>
|
320
code/web/templates/build/_step2.html
Normal file
320
code/web/templates/build/_step2.html
Normal file
|
@ -0,0 +1,320 @@
|
|||
<section>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander.name }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
{% if error %}
|
||||
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Theme Tags</legend>
|
||||
{% if tags %}
|
||||
<input type="hidden" name="primary_tag" id="primary_tag" value="{{ primary_tag or '' }}" />
|
||||
<input type="hidden" name="secondary_tag" id="secondary_tag" value="{{ secondary_tag or '' }}" />
|
||||
<input type="hidden" name="tertiary_tag" id="tertiary_tag" value="{{ tertiary_tag or '' }}" />
|
||||
<input type="hidden" name="tag_mode" id="tag_mode" value="{{ tag_mode or 'AND' }}" />
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
|
||||
<span class="muted" style="font-size:12px;">Combine</span>
|
||||
<div role="group" aria-label="Combine mode" aria-describedby="combine-help-tip">
|
||||
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
|
||||
<input type="radio" name="combine_mode_radio" value="AND" {% if (tag_mode or 'AND') == 'AND' %}checked{% endif %} /> AND
|
||||
</label>
|
||||
<label title="OR treats your themes as a union (broader pool, fills easier).">
|
||||
<input type="radio" name="combine_mode_radio" value="OR" {% if tag_mode == 'OR' %}checked{% endif %} /> OR
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
|
||||
<span id="tag-count" class="muted" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
|
||||
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
|
||||
{% if recommended and recommended|length %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
|
||||
</div>
|
||||
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
|
||||
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
|
||||
<ul style="margin:.25rem 0; padding-left:1.1rem;">
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
|
||||
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No theme tags available for this commander.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Budget/Power Bracket</legend>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
{% for b in brackets %}
|
||||
<label style="display:flex; gap:.5rem; align-items:flex-start;">
|
||||
<input type="radio" name="bracket" value="{{ b.level }}" {% if (selected_bracket is defined and selected_bracket == b.level) or (selected_bracket is not defined and loop.first) %}checked{% endif %} />
|
||||
<span><strong>{{ b.name }}</strong> — <small>{{ b.desc }}</small></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.35rem; font-size:.9em;">
|
||||
Note: This guides deck creation and relaxes/raises constraints, but it is not a guarantee the final deck strictly fits that bracket.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit" class="btn-continue" data-action="continue">Continue to Ideals</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:.5rem;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var chipHost = document.getElementById('tag-chip-list');
|
||||
var recoHost = document.getElementById('tag-reco-list');
|
||||
var selAll = document.getElementById('reco-select-all');
|
||||
var resetBtn = document.getElementById('reset-tags');
|
||||
var primary = document.getElementById('primary_tag');
|
||||
var secondary = document.getElementById('secondary_tag');
|
||||
var tertiary = document.getElementById('tertiary_tag');
|
||||
var tagMode = document.getElementById('tag_mode');
|
||||
var countEl = document.getElementById('tag-count');
|
||||
var orderEl = document.getElementById('tag-order');
|
||||
var commander = '{{ commander.name|e }}';
|
||||
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
|
||||
if (!chipHost) return;
|
||||
|
||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||
|
||||
function getSelected(){
|
||||
var arr = [];
|
||||
if (primary && primary.value) arr.push(primary.value);
|
||||
if (secondary && secondary.value) arr.push(secondary.value);
|
||||
if (tertiary && tertiary.value) arr.push(tertiary.value);
|
||||
return arr;
|
||||
}
|
||||
function setSelected(arr){
|
||||
arr = Array.from(new Set(arr || [])).filter(Boolean).slice(0,3);
|
||||
if (primary) primary.value = arr[0] || '';
|
||||
if (secondary) secondary.value = arr[1] || '';
|
||||
if (tertiary) tertiary.value = arr[2] || '';
|
||||
updateCount();
|
||||
persist();
|
||||
updateOrderUI();
|
||||
}
|
||||
function toggleTag(t){
|
||||
var cur = getSelected();
|
||||
var idx = cur.indexOf(t);
|
||||
if (idx >= 0) { cur.splice(idx, 1); }
|
||||
else {
|
||||
if (cur.length >= 3) { cur = cur.slice(1); }
|
||||
cur.push(t);
|
||||
}
|
||||
setSelected(cur);
|
||||
updateChipsState();
|
||||
}
|
||||
function updateCount(){
|
||||
try { if (countEl) countEl.textContent = getSelected().length + ' / 3 selected'; } catch(_){}
|
||||
}
|
||||
function persist(){
|
||||
try {
|
||||
localStorage.setItem(storageKey('tags'), JSON.stringify(getSelected()));
|
||||
if (tagMode) localStorage.setItem(storageKey('mode'), tagMode.value || 'AND');
|
||||
} catch(_){}
|
||||
}
|
||||
function loadPersisted(){
|
||||
try {
|
||||
// If this page load follows a fresh commander confirmation, wipe persisted values.
|
||||
if (clearPersisted){
|
||||
try {
|
||||
localStorage.removeItem(storageKey('tags'));
|
||||
localStorage.removeItem(storageKey('mode'));
|
||||
} catch(_){ }
|
||||
}
|
||||
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
|
||||
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
|
||||
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
|
||||
if (tagMode) { tagMode.value = (savedMode === 'OR' ? 'OR' : 'AND'); }
|
||||
// sync radios
|
||||
syncModeRadios();
|
||||
} catch(_){}
|
||||
}
|
||||
function syncModeRadios(){
|
||||
try {
|
||||
var radios = document.querySelectorAll('input[name="combine_mode_radio"]');
|
||||
Array.prototype.forEach.call(radios, function(r){ r.checked = (r.value === (tagMode && tagMode.value || 'AND')); });
|
||||
} catch(_){}
|
||||
}
|
||||
function updateChipsState(){
|
||||
var sel = getSelected();
|
||||
function applyToContainer(container){
|
||||
if (!container) return;
|
||||
var chips = Array.prototype.slice.call(container.querySelectorAll('button.chip'));
|
||||
chips.forEach(function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
var active = sel.indexOf(t) >= 0;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
// update numeric badge for order
|
||||
var old = btn.querySelector('sup.tag-order');
|
||||
if (old) { try { old.remove(); } catch(_){} }
|
||||
if (active){
|
||||
var idx = sel.indexOf(t);
|
||||
if (idx >= 0){
|
||||
var sup = document.createElement('sup');
|
||||
sup.className = 'tag-order';
|
||||
sup.style.marginLeft = '.25rem';
|
||||
sup.style.opacity = '.75';
|
||||
sup.textContent = String(idx + 1);
|
||||
btn.appendChild(sup);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
applyToContainer(chipHost);
|
||||
applyToContainer(recoHost);
|
||||
updateCount();
|
||||
updateOrderUI();
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
function updateOrderUI(){
|
||||
if (!orderEl) return;
|
||||
var sel = getSelected();
|
||||
if (!sel.length){ orderEl.textContent = ''; return; }
|
||||
try {
|
||||
var parts = sel.map(function(t, i){ return (i+1) + '. ' + t; });
|
||||
orderEl.textContent = 'Selected order: ' + parts.join(' • ');
|
||||
} catch(_){ orderEl.textContent = ''; }
|
||||
}
|
||||
|
||||
// bind mode radios
|
||||
Array.prototype.forEach.call(document.querySelectorAll('input[name="combine_mode_radio"]'), function(r){
|
||||
r.addEventListener('change', function(){ if (tagMode) { tagMode.value = r.value; persist(); } });
|
||||
});
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
|
||||
|
||||
// attach handlers to existing chips
|
||||
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
btn.addEventListener('keydown', function(e){
|
||||
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
|
||||
var ix = chips.indexOf(e.currentTarget);
|
||||
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
|
||||
if (next) { try { next.focus(); } catch(_){ } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// attach handlers to recommended chips and select-all
|
||||
if (recoHost){
|
||||
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
});
|
||||
if (selAll){
|
||||
selAll.addEventListener('click', function(){
|
||||
try {
|
||||
var sel = getSelected();
|
||||
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
|
||||
var combined = sel.slice();
|
||||
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
|
||||
combined = combined.slice(-3); // keep last 3
|
||||
setSelected(combined);
|
||||
updateChipsState();
|
||||
updateSelectAllState();
|
||||
} catch(_){ }
|
||||
});
|
||||
}
|
||||
// Why recommended panel toggle
|
||||
var whyBtn = document.getElementById('reco-why');
|
||||
var whyPanel = document.getElementById('reco-why-panel');
|
||||
function setWhy(open){
|
||||
if (!whyBtn || !whyPanel) return;
|
||||
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
whyPanel.style.display = open ? 'block' : 'none';
|
||||
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
||||
}
|
||||
if (whyBtn && whyPanel){
|
||||
whyBtn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
setWhy(!isOpen);
|
||||
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
try {
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
if (!isOpen) return;
|
||||
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
|
||||
setWhy(false);
|
||||
} catch(_){}
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape'){ setWhy(false); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectAllState(){
|
||||
try {
|
||||
if (!selAll) return;
|
||||
var sel = getSelected();
|
||||
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
|
||||
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
|
||||
var atCap = sel.length >= 3;
|
||||
var noNew = unselected.length === 0;
|
||||
var disable = atCap || noNew;
|
||||
selAll.disabled = disable;
|
||||
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
|
||||
if (disable){
|
||||
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
|
||||
} else {
|
||||
selAll.title = 'Add recommended up to 3';
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
// initial: set from template-selected values, then maybe load persisted if none
|
||||
updateChipsState();
|
||||
loadPersisted();
|
||||
updateChipsState();
|
||||
})();
|
||||
</script>
|
44
code/web/templates/build/_step3.html
Normal file
44
code/web/templates/build/_step3.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
<section>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
|
||||
|
||||
{% if error %}
|
||||
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<fieldset>
|
||||
<legend>Card Type Targets</legend>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem;">
|
||||
{% for key, label in labels.items() %}
|
||||
<label>
|
||||
{{ label }}
|
||||
<input type="number" name="{{ key }}" min="0" value="{{ (values or defaults)[key] }}" />
|
||||
<small class="muted">Default: {{ defaults[key] }}</small>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<button type="submit" class="btn-continue" data-action="continue">Continue to Review</button>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-top:.5rem;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
45
code/web/templates/build/_step4.html
Normal file
45
code/web/templates/build/_step4.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<section>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
{% if locks_restored and locks_restored > 0 %}
|
||||
<div class="muted" style="margin:.35rem 0;">
|
||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h4>Chosen Ideals</h4>
|
||||
<ul>
|
||||
{% for key, label in labels.items() %}
|
||||
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form hx-post="/build/toggle-owned-review" hx-target="#wizard" hx-swap="innerHTML" style="margin:.5rem 0; display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" {% if owned_only %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Prefer owned cards (allow unowned fallback)
|
||||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
|
||||
</form>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
365
code/web/templates/build/_step5.html
Normal file
365
code/web/templates/build/_step5.html
Normal file
|
@ -0,0 +1,365 @@
|
|||
<section>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
{% if i and n %}
|
||||
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
|
||||
{% endif %}
|
||||
{% set deck_count = (total_cards if total_cards is not none else 0) %}
|
||||
<span class="chip"><span class="dot" style="background: var(--green-main);"></span> Deck {{ deck_count }}/100</span>
|
||||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||
{% endif %}
|
||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
|
||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||||
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||||
</div>
|
||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||
{% set pct_int = pct_clamped|int %}
|
||||
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %}" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" style="margin:.25rem 0 1rem 0;" data-pct="{{ pct_int }}">
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
|
||||
{% if status %}
|
||||
<div style="margin-top:1rem;">
|
||||
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
{% for lk in locked_cards %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
|
||||
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
|
||||
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
|
||||
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
|
||||
<input type="hidden" name="name" value="{{ lk.name }}" />
|
||||
<input type="hidden" name="locked" value="0" />
|
||||
<input type="hidden" name="from_list" value="1" />
|
||||
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last action chip (oob-updated) -->
|
||||
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
<div class="cards-toolbar">
|
||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||
<select name="filter_owned" data-pref="cards:owned">
|
||||
<option value="all">All</option>
|
||||
<option value="owned">Owned</option>
|
||||
<option value="not">Not owned</option>
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;">
|
||||
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;">
|
||||
<input type="checkbox" name="collapse_groups" data-pref="cards:collapse" /> Collapse groups
|
||||
</label>
|
||||
<select name="filter_sort" data-pref="cards:sort" aria-label="Sort">
|
||||
<option value="az">A–Z</option>
|
||||
<option value="owned">Owned first</option>
|
||||
<option value="gc">Game-changers first</option>
|
||||
</select>
|
||||
<span class="sep"></span>
|
||||
<span class="hint">Visible: <strong data-results>0</strong></span>
|
||||
<span class="sep"></span>
|
||||
<div class="chips-inline">
|
||||
<span class="chip" data-chip-owned="all">All</span>
|
||||
<span class="chip" data-chip-owned="owned">Owned</span>
|
||||
<span class="chip" data-chip-owned="not">Not owned</span>
|
||||
<span class="chip" data-chip-clear>Clear</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<span class="sep"></span>
|
||||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
|
||||
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
|
||||
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
|
||||
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
|
||||
Replace stage picks
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
|
||||
</form>
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
|
||||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||||
Show skipped stages
|
||||
</label>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
|
||||
{% if added_cards is not none %}
|
||||
{% if history is defined and history %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Stage timeline</summary>
|
||||
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
{% for h in history %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem;">
|
||||
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
|
||||
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="to" value="{{ h.i }}" />
|
||||
<button type="submit" class="btn">Go</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
||||
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✔</span> Owned</span>
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✖</span> Not owned</span>
|
||||
</div>
|
||||
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
{% set group_idx = loop.index0 %}
|
||||
{% set role = g.grouper %}
|
||||
{% if role %}
|
||||
{% set heading = 'Theme: ' + role.title() %}
|
||||
{% else %}
|
||||
{% set heading = 'Additional Picks' %}
|
||||
{% endif %}
|
||||
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
|
||||
<div class="group-header">
|
||||
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||||
</div>
|
||||
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in g.list %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in added_cards %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Click a card to lock or unlock it. Locked cards are kept across reruns and won’t be replaced unless you unlock them.</div>
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||
No cards match your filters.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_logs and log %}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary>Show logs</summary>
|
||||
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- controls now above -->
|
||||
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
{% if summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
// Sync tile class and image-button toggle after lock button swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
try{
|
||||
const tgt = ev.target;
|
||||
if(!tgt) return;
|
||||
// Only act for lock-box updates
|
||||
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
||||
const tile = tgt.closest('.card-tile');
|
||||
if(!tile) return;
|
||||
const lockBtn = tgt.querySelector('.btn-lock');
|
||||
if(lockBtn){
|
||||
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
||||
tile.classList.toggle('locked', isLocked);
|
||||
const imgBtn = tile.querySelector('.img-btn');
|
||||
if(imgBtn){
|
||||
try{
|
||||
const valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
||||
const cur = JSON.parse(valsAttr.replace(/"/g, '"'));
|
||||
const next = isLocked ? '0' : '1';
|
||||
// Keep name stable; fallback to tile data attribute
|
||||
const nm = cur.name || tile.getAttribute('data-card-name') || '';
|
||||
imgBtn.setAttribute('hx-vals', JSON.stringify({ name: nm, locked: next }));
|
||||
imgBtn.title = 'Click to ' + (isLocked ? 'unlock' : 'lock') + ' this card';
|
||||
try { imgBtn.setAttribute('aria-pressed', isLocked ? 'true' : 'false'); } catch(_){ }
|
||||
}catch(_){/* noop */}
|
||||
}
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
// Allow dismissing/auto-clearing the last-action chip
|
||||
document.addEventListener('click', function(ev){
|
||||
try{
|
||||
var t = ev.target;
|
||||
if (!t) return;
|
||||
if (t.matches && t.matches('#last-action .chip')){
|
||||
var c = document.getElementById('last-action');
|
||||
if (c) c.innerHTML = '';
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
||||
|
||||
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
// Ignore when typing in inputs/selects
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
|
||||
if (!tile) return;
|
||||
if (e.key === 'l' || e.key === 'L') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
|
||||
if (lockFormBtn) { lockFormBtn.click(); }
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
|
||||
if (altBtn) { altBtn.click(); }
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
</script>
|
13
code/web/templates/build/index.html
Normal file
13
code/web/templates/build/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div style="margin:.25rem 0 1rem 0;">
|
||||
<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>
|
||||
</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>
|
||||
{% endblock %}
|
93
code/web/templates/configs/index.html
Normal file
93
code/web/templates/configs/index.html
Normal file
|
@ -0,0 +1,93 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Build from JSON</h2>
|
||||
<div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;">
|
||||
<p class="muted" style="max-width: 70ch; margin:0;">
|
||||
Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.
|
||||
</p>
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
|
||||
</div>
|
||||
<pre style="margin-top:.35rem; background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% if notice %}<div class="notice">{{ notice }}</div>{% endif %}
|
||||
<div class="config-actions" style="margin-bottom:1rem; display:flex; gap:12px; align-items:center;">
|
||||
<form hx-post="/configs/upload" hx-target="#config-list" hx-swap="outerHTML" enctype="multipart/form-data">
|
||||
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload JSON</button>
|
||||
<input id="upload-json" type="file" name="file" accept="application/json" style="display:none" onchange="this.form.requestSubmit();">
|
||||
</form>
|
||||
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:#0f1115; color:#e5e7eb;" />
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// Drag and drop upload support
|
||||
var form = document.querySelector('.config-actions form[enctype="multipart/form-data"]');
|
||||
var fileInput = document.getElementById('upload-json');
|
||||
if (form && fileInput) {
|
||||
form.addEventListener('dragover', function(e){ e.preventDefault(); form.style.outline = '2px dashed #334155'; });
|
||||
form.addEventListener('dragleave', function(){ form.style.outline = ''; });
|
||||
form.addEventListener('drop', function(e){
|
||||
e.preventDefault(); form.style.outline = '';
|
||||
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Client-side filter of config list
|
||||
var filter = document.getElementById('config-filter');
|
||||
var list = document.getElementById('config-list');
|
||||
function applyFilter() {
|
||||
if (!list) return;
|
||||
var q = (filter.value || '').toLowerCase().trim();
|
||||
var items = list.querySelectorAll('li');
|
||||
items.forEach(function(li){
|
||||
var txt = (li.textContent || '').toLowerCase();
|
||||
li.style.display = (!q || txt.indexOf(q) !== -1) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
if (filter) {
|
||||
filter.addEventListener('input', applyFilter);
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', function(e){ if (e && e.target && e.target.id === 'config-list') applyFilter(); });
|
||||
})();
|
||||
</script>
|
||||
<div id="config-list">
|
||||
{% if not items %}
|
||||
<p>No configs found in /config. Export a run config from a build, or upload one here.</p>
|
||||
{% else %}
|
||||
<ul class="file-list" style="list-style: none; margin: 0; padding: 0;">
|
||||
{% for it in items %}
|
||||
<li>
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<strong>
|
||||
{% if it.commander %}
|
||||
<span data-card-name="{{ it.commander }}" data-tags="{{ (it.tags|join(', ')) if it.tags else '' }}">{{ it.commander }}</span>
|
||||
{% else %}
|
||||
{{ it.name }}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{% if it.tags %}<span style="color:#64748b;">[{{ ', '.join(it.tags) }}]</span>{% endif %}
|
||||
{% if it.bracket_level is not none %}<span class="badge">Bracket {{ it.bracket_level }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="actions" style="display:flex; gap:8px;">
|
||||
<form method="get" action="/configs/view" style="display:inline;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit" class="btn">View</button>
|
||||
</form>
|
||||
<form method="post" action="/configs/run" style="display:inline;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
56
code/web/templates/configs/run_result.html
Normal file
56
code/web/templates/configs/run_result.html
Normal file
|
@ -0,0 +1,56 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Build from JSON: {{ cfg_name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
|
||||
{% if commander %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}{% if use_owned_only %} · Owned-only{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
{% if commander %}
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if ok and csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if ok and txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="get" action="/configs" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Build from JSON</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
{% if not ok %}
|
||||
<div class="error">Build failed: {{ error }}</div>
|
||||
{% else %}
|
||||
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
|
||||
|
||||
|
||||
{% if summary %}
|
||||
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_logs %}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary>Show logs</summary>
|
||||
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
24
code/web/templates/configs/run_summary.html
Normal file
24
code/web/templates/configs/run_summary.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Run from Config: {{ cfg_name }}</h2>
|
||||
{% if not ok %}
|
||||
<div class="error">Build failed: {{ error }}</div>
|
||||
{% else %}
|
||||
<div class="notice">Build completed.</div>
|
||||
<div style="margin:.5rem 0;">
|
||||
{% if csv_path %}<a class="btn" href="/files?path={{ csv_path | urlencode }}">Download CSV</a>{% endif %}
|
||||
{% if txt_path %}<a class="btn" href="/files?path={{ txt_path | urlencode }}">Download TXT</a>{% endif %}
|
||||
</div>
|
||||
{% if summary %}
|
||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
{# Reuse the same inner sections as the build summary page by including its markup #}
|
||||
{% set __summary = summary %}
|
||||
{% set summary = __summary %}
|
||||
{% include "build/_step5.html" ignore missing %}
|
||||
{% else %}
|
||||
<p class="muted">No summary data available.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p style="margin-top:1rem;"><a href="/configs">Back to Configs</a></p>
|
||||
{% endblock %}
|
27
code/web/templates/configs/view.html
Normal file
27
code/web/templates/configs/view.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Build from JSON: {{ name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">Review the configuration details below, then run a non-interactive build to produce deck exports and a summary.</p>
|
||||
<details open>
|
||||
<summary>Overview</summary>
|
||||
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
|
||||
<div>Commander</div><div>{{ data.commander }}</div>
|
||||
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
|
||||
<div>Combine Mode</div><div>{{ (data.tag_mode or data.combine_mode or 'AND') | upper }}</div>
|
||||
<div>Bracket</div><div>{{ data.bracket_level }}</div>
|
||||
</div>
|
||||
</details>
|
||||
<details style="margin-top:1rem;" open>
|
||||
<summary>Ideal Counts</summary>
|
||||
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
<form method="post" action="/configs/run" style="margin-top:1rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<button type="submit">Run Headless</button>
|
||||
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
|
||||
</form>
|
||||
{% endblock %}
|
226
code/web/templates/decks/compare.html
Normal file
226
code/web/templates/decks/compare.html
Normal file
|
@ -0,0 +1,226 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Compare Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Compare Decks</h2>
|
||||
<p class="muted">Pick two finished decks to compare. You can get here from Finished Decks or deck view pages.</p>
|
||||
|
||||
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label>Deck A
|
||||
<select name="A" required>
|
||||
<option value="">Choose…</option>
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Deck B
|
||||
<select name="B" required>
|
||||
<option value="">Choose…</option>
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Compare</button>
|
||||
<button type="button" id="cmp-swap" class="btn" title="Swap A and B" style="margin-left:.25rem;">Swap A/B</button>
|
||||
<button type="button" id="cmp-latest" class="btn" title="Pick the latest two decks">Latest two</button>
|
||||
</form>
|
||||
|
||||
{% if diffs %}
|
||||
<div class="panel" style="margin-top:.75rem;">
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:center;">
|
||||
<div>
|
||||
<strong>A:</strong> {{ metaA.display or metaA.filename }}
|
||||
{% if metaA.commander %}<span class="muted">({{ metaA.commander }})</span>{% endif %}
|
||||
{% if metaA.tags %}<div class="muted">{{ metaA.tags }}</div>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>B:</strong> {{ metaB.display or metaB.filename }}
|
||||
{% if metaB.commander %}<span class="muted">({{ metaB.commander }})</span>{% endif %}
|
||||
{% if metaB.tags %}<div class="muted">{{ metaB.tags }}</div>{% endif %}
|
||||
</div>
|
||||
<div style="margin-left:auto; display:flex; gap:.5rem; align-items:center;">
|
||||
<button type="button" id="cmp-copy" class="btn" title="Copy a plain-text summary of the diffs">Copy summary</button>
|
||||
<button type="button" id="cmp-download" class="btn" title="Download a plain-text summary of the diffs">Download .txt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:.5rem; display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<div class="muted">Totals:
|
||||
<strong id="totA">{{ (diffs.onlyA|length) if diffs.onlyA else 0 }}</strong> only-in-A,
|
||||
<strong id="totB">{{ (diffs.onlyB|length) if diffs.onlyB else 0 }}</strong> only-in-B,
|
||||
<strong id="totC">{{ (diffs.changed|length) if diffs.changed else 0 }}</strong> changed
|
||||
</div>
|
||||
<label class="muted" style="margin-left:auto; display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" id="cmp-changed-only" /> Changed only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="only-panels" style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top:.75rem;">
|
||||
<div class="panel onlyA">
|
||||
<h3 style="margin-top:0;">Only in A</h3>
|
||||
{% if diffs.onlyA and diffs.onlyA|length %}
|
||||
<ul>
|
||||
{% for n in diffs.onlyA %}<li>{{ n }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel onlyB">
|
||||
<h3 style="margin-top:0;">Only in B</h3>
|
||||
{% if diffs.onlyB and diffs.onlyB|length %}
|
||||
<ul>
|
||||
{% for n in diffs.onlyB %}<li>{{ n }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top:1rem;">
|
||||
<h3 style="margin-top:0;">Changed counts</h3>
|
||||
{% if diffs.changed and diffs.changed|length %}
|
||||
<ul class="changed-list">
|
||||
{% for n, a, b in diffs.changed %}
|
||||
{% set delta = b - a %}
|
||||
{% if delta > 0 %}
|
||||
<li class="chg inc" title="Increased in B">▲ {{ n }}: A={{ a }}, B={{ b }} (+{{ delta }})</li>
|
||||
{% elif delta < 0 %}
|
||||
<li class="chg dec" title="Decreased in B">▼ {{ n }}: A={{ a }}, B={{ b }} ({{ delta }})</li>
|
||||
{% else %}
|
||||
<li class="chg">{{ n }}: A={{ a }}, B={{ b }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="muted">None</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script id="cmp-data" type="application/json">{{ {
|
||||
'aLabel': (metaA.display or metaA.filename),
|
||||
'bLabel': (metaB.display or metaB.filename),
|
||||
'onlyA': diffs.onlyA or [],
|
||||
'onlyB': diffs.onlyB or [],
|
||||
'changed': diffs.changed or []
|
||||
} | tojson }}</script>
|
||||
<script>
|
||||
(function(){
|
||||
var copyBtn = document.getElementById('cmp-copy');
|
||||
var dlBtn = document.getElementById('cmp-download');
|
||||
var changedOnly = document.getElementById('cmp-changed-only');
|
||||
var dataEl = document.getElementById('cmp-data');
|
||||
var data = null;
|
||||
try { data = JSON.parse((dataEl && dataEl.textContent) ? dataEl.textContent : 'null'); } catch(e) { data = null; }
|
||||
function buildLines(){
|
||||
var lines = [];
|
||||
lines.push('Compare:');
|
||||
lines.push('A: ' + data.aLabel);
|
||||
lines.push('B: ' + data.bLabel);
|
||||
lines.push('');
|
||||
if (!changedOnly || !changedOnly.checked) {
|
||||
lines.push('Only in A:');
|
||||
if (data.onlyA && data.onlyA.length) { data.onlyA.forEach(function(n){ lines.push('- ' + n); }); }
|
||||
else { lines.push('(none)'); }
|
||||
lines.push('');
|
||||
lines.push('Only in B:');
|
||||
if (data.onlyB && data.onlyB.length) { data.onlyB.forEach(function(n){ lines.push('- ' + n); }); }
|
||||
else { lines.push('(none)'); }
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('Changed counts:');
|
||||
if (data.changed && data.changed.length) {
|
||||
data.changed.forEach(function(row){ lines.push('- ' + row[0] + ': A=' + row[1] + ', B=' + row[2]); });
|
||||
} else { lines.push('(none)'); }
|
||||
return lines;
|
||||
}
|
||||
if (copyBtn) copyBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var txt = buildLines().join('\n');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt); }
|
||||
else {
|
||||
var ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); }catch(_){} document.body.removeChild(ta);
|
||||
}
|
||||
if (window.toast) window.toast('Copied comparison');
|
||||
}catch(_){ }
|
||||
});
|
||||
if (dlBtn) dlBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var txt = buildLines().join('\n');
|
||||
var blob = new Blob([txt], {type:'text/plain'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'compare.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function(){ try{ URL.revokeObjectURL(url); document.body.removeChild(a); }catch(_){ } }, 0);
|
||||
}catch(_){ }
|
||||
});
|
||||
function applyChangedOnlyFlag(){
|
||||
try{
|
||||
var wrap = document.querySelector('.only-panels');
|
||||
if (!wrap || !changedOnly) return;
|
||||
wrap.style.display = changedOnly.checked ? 'none' : 'grid';
|
||||
}catch(_){ }
|
||||
}
|
||||
if (changedOnly) {
|
||||
try {
|
||||
var saved = localStorage.getItem('compare:changedOnly');
|
||||
if (saved === '1') { changedOnly.checked = true; }
|
||||
} catch(_){ }
|
||||
applyChangedOnlyFlag();
|
||||
changedOnly.addEventListener('change', function(){
|
||||
try { localStorage.setItem('compare:changedOnly', this.checked ? '1' : '0'); } catch(_){ }
|
||||
applyChangedOnlyFlag();
|
||||
});
|
||||
}
|
||||
// Swap A/B
|
||||
var swapBtn = document.getElementById('cmp-swap');
|
||||
if (swapBtn) swapBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var f = this.closest('form'); if(!f) return;
|
||||
var a = f.querySelector('select[name="A"]');
|
||||
var b = f.querySelector('select[name="B"]');
|
||||
if(!a || !b) return;
|
||||
var aVal = a.value, bVal = b.value;
|
||||
a.value = bVal; b.value = aVal;
|
||||
f.requestSubmit();
|
||||
}catch(_){ }
|
||||
});
|
||||
// Pick latest two by mtime from options metadata
|
||||
var latestBtn = document.getElementById('cmp-latest');
|
||||
if (latestBtn) latestBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var f = this.closest('form'); if(!f) return;
|
||||
var a = f.querySelector('select[name="A"]');
|
||||
var b = f.querySelector('select[name="B"]');
|
||||
if(!a || !b) return;
|
||||
var opts = Array.from(a.querySelectorAll('option[value]')).filter(function(o){ return o.value; });
|
||||
opts.sort(function(x,y){
|
||||
var mx = parseInt(x.getAttribute('data-mtime') || '0', 10);
|
||||
var my = parseInt(y.getAttribute('data-mtime') || '0', 10);
|
||||
return (my - mx);
|
||||
});
|
||||
if (opts.length >= 2){
|
||||
var first = opts[0].value;
|
||||
var second = opts[1].value;
|
||||
a.value = first; b.value = second;
|
||||
f.requestSubmit();
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.changed-list { list-style: none; padding-left: 0; }
|
||||
.changed-list .chg { padding: 2px 0; }
|
||||
.changed-list .chg.inc { color: #10b981; }
|
||||
.changed-list .chg.dec { color: #ef4444; }
|
||||
.only-panels .onlyA h3 { color: #60a5fa; }
|
||||
.only-panels .onlyB h3 { color: #f59e0b; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endblock %}
|
656
code/web/templates/decks/index.html
Normal file
656
code/web/templates/decks/index.html
Normal file
|
@ -0,0 +1,656 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2 id="decks-heading">Finished Decks</h2>
|
||||
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" aria-controls="deck-list" />
|
||||
<select id="deck-sort" aria-label="Sort decks">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="name-asc">Commander A–Z</option>
|
||||
<option value="name-desc">Commander Z–A</option>
|
||||
</select>
|
||||
<select id="deck-theme" aria-label="Theme">
|
||||
<option value="">All Themes</option>
|
||||
</select>
|
||||
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" id="deck-txt-only" /> TXT only
|
||||
</label>
|
||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
|
||||
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
|
||||
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
|
||||
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
|
||||
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
|
||||
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
||||
<span id="deck-count" class="muted" aria-live="polite"></span>
|
||||
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
|
||||
</div>
|
||||
|
||||
|
||||
{% if items %}
|
||||
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
{% for it in items %}
|
||||
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<div>
|
||||
<div>
|
||||
{% if it.display %}
|
||||
<strong>{{ it.display }}</strong>
|
||||
<div class="muted" style="font-size:12px;">Commander: <span data-card-name="{{ it.commander }}">{{ it.commander }}</span></div>
|
||||
{% else %}
|
||||
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if it.tags and it.tags|length %}
|
||||
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px;">
|
||||
{% if it.mtime is defined %}
|
||||
<span title="Modified">{{ it.mtime | int }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:.35rem; align-items:center;">
|
||||
<label title="Select deck for comparison" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
|
||||
<span class="muted" style="font-size:12px;">Select</span>
|
||||
</label>
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||
</form>
|
||||
{% if it.txt_path %}
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.txt_path }}" />
|
||||
<button type="submit" title="Download TXT" aria-label="Download TXT for {{ it.commander }}">TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="/decks/view" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="deck-empty" class="muted" style="display:none; margin-top:.5rem;">No decks match your filters.</div>
|
||||
<!-- Help modal -->
|
||||
<div id="deck-help-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deck-help-title" hidden>
|
||||
<div class="modal-backdrop" id="deck-help-backdrop"></div>
|
||||
<div class="modal-content" role="document">
|
||||
<div class="modal-header">
|
||||
<h3 id="deck-help-title" style="margin:0;">Keyboard and tips</h3>
|
||||
<button type="button" id="deck-help-close" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul style="margin:.25rem 0 0 1rem;">
|
||||
<li><kbd>/</kbd> focuses the filter</li>
|
||||
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
|
||||
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
|
||||
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and theme</li>
|
||||
<li>Use “TXT only” to show only decks that have a TXT export</li>
|
||||
<li>Share copies a link with your current filters</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No exports yet. Run a build to create one.</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('deck-filter');
|
||||
var sortSel = document.getElementById('deck-sort');
|
||||
var themeSel = document.getElementById('deck-theme');
|
||||
var clearBtn = document.getElementById('deck-clear');
|
||||
var list = document.getElementById('deck-list');
|
||||
var countEl = document.getElementById('deck-count');
|
||||
var shareBtn = document.getElementById('deck-share');
|
||||
var resetAllBtn = document.getElementById('deck-reset-all');
|
||||
var liveEl = document.getElementById('deck-live');
|
||||
var emptyEl = document.getElementById('deck-empty');
|
||||
var helpBtn = document.getElementById('deck-help');
|
||||
var helpModal = document.getElementById('deck-help-modal');
|
||||
var helpClose = document.getElementById('deck-help-close');
|
||||
var helpBackdrop = document.getElementById('deck-help-backdrop');
|
||||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||
var cmpSelBtn = document.getElementById('deck-compare-selected');
|
||||
var cmpLatestBtn = document.getElementById('deck-compare-latest');
|
||||
var openPermalinkBtn = document.getElementById('deck-open-permalink');
|
||||
if (!list) return;
|
||||
|
||||
// Panels and themes discovery from data-tags-pipe
|
||||
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
|
||||
// Selection state for compare
|
||||
var selected = new Set();
|
||||
function updateCompareButtons(){
|
||||
if (!cmpSelBtn) return;
|
||||
var size = selected.size;
|
||||
cmpSelBtn.disabled = (size !== 2);
|
||||
if (cmpSelBtn) cmpSelBtn.title = (size === 2 ? 'Compare the two selected decks' : 'Select exactly two decks to enable');
|
||||
}
|
||||
var themeSet = new Set();
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ t = (t||'').trim(); if (t) themeSet.add(t); });
|
||||
});
|
||||
// Populate theme dropdown
|
||||
if (themeSel) {
|
||||
// Preserve current selection if any
|
||||
var prev = themeSel.value || '';
|
||||
// Reset to default option
|
||||
themeSel.innerHTML = '<option value="">All Themes</option>';
|
||||
Array.from(themeSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var opt = document.createElement('option');
|
||||
opt.value = t; opt.textContent = t; themeSel.appendChild(opt);
|
||||
});
|
||||
if (prev) {
|
||||
// Re-apply previous selection if it exists
|
||||
var has = Array.prototype.some.call(themeSel.options, function(o){ return o.value === prev; });
|
||||
if (has) themeSel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// URL hash <-> state sync helpers
|
||||
function parseHash(){
|
||||
try {
|
||||
var h = (location.hash || '').replace(/^#/, '');
|
||||
if (!h) return null;
|
||||
var qp = new URLSearchParams(h);
|
||||
var q = qp.get('q') || '';
|
||||
var sort = qp.get('sort') || '';
|
||||
var tag = qp.get('tag') || '';
|
||||
var tagsStr = qp.get('tags') || '';
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
if (!tag && tags.length) { tag = tags[0]; }
|
||||
var txt = qp.get('txt');
|
||||
var txtOnly = (txt === '1' || txt === 'true');
|
||||
return { q: q, sort: sort, tag: tag, txt: txtOnly };
|
||||
} catch(_) { return null; }
|
||||
}
|
||||
function updateHashFromState(){
|
||||
try {
|
||||
var q = (input && input.value) ? input.value.trim() : '';
|
||||
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
|
||||
var tag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
var qp = new URLSearchParams();
|
||||
if (q) qp.set('q', q);
|
||||
if (sort && sort !== 'newest') qp.set('sort', sort);
|
||||
if (tag) qp.set('tag', tag);
|
||||
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
|
||||
var newHash = qp.toString();
|
||||
var base = location.pathname + location.search;
|
||||
var current = (location.hash || '').replace(/^#/, '');
|
||||
if (current !== newHash) {
|
||||
history.replaceState(null, '', base + (newHash ? ('#' + newHash) : ''));
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
function applyStateFromHash(){
|
||||
var s = parseHash();
|
||||
if (!s) return false;
|
||||
var changed = false;
|
||||
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
|
||||
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
|
||||
if (typeof s.tag === 'string' && themeSel) {
|
||||
// If the tag isn't present in options, add it for back-compat
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === s.tag; });
|
||||
if (s.tag && !exists) { var opt = document.createElement('option'); opt.value = s.tag; opt.textContent = s.tag; themeSel.appendChild(opt); }
|
||||
themeSel.value = s.tag; changed = true;
|
||||
}
|
||||
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
|
||||
applyAll();
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateCount(){
|
||||
if (!countEl) return;
|
||||
var total = panels.length;
|
||||
var visible = panels.filter(function(p){ return p.style.display !== 'none'; }).length;
|
||||
countEl.textContent = visible + ' of ' + total + ' decks';
|
||||
if (emptyEl) emptyEl.style.display = (visible === 0 ? '' : 'none');
|
||||
try {
|
||||
if (liveEl) {
|
||||
if (visible === 0) liveEl.textContent = 'No decks match your filters';
|
||||
else liveEl.textContent = 'Showing ' + visible + ' of ' + total + ' decks';
|
||||
}
|
||||
} catch(_){ }
|
||||
return { total: total, visible: visible };
|
||||
}
|
||||
|
||||
function applyFilter(){
|
||||
var q = (input && input.value || '').toLowerCase();
|
||||
var selTag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
panels.forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
var textMatch = hay.indexOf(q) >= 0;
|
||||
var tagsPipe = row.dataset.tagsPipe || '';
|
||||
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
|
||||
var tagMatch = selTag ? (tags.indexOf(selTag) !== -1) : true;
|
||||
var txtOk = true;
|
||||
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
|
||||
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function highlightMatches(){
|
||||
var q = (input && input.value || '').trim();
|
||||
var ql = q.toLowerCase();
|
||||
panels.forEach(function(row){
|
||||
var strong = row.querySelector('strong[data-card-name]');
|
||||
if (!strong) return;
|
||||
var raw = strong.getAttribute('data-card-name') || strong.textContent || '';
|
||||
if (!q) { strong.textContent = raw; return; }
|
||||
var low = raw.toLowerCase();
|
||||
var i = low.indexOf(ql);
|
||||
if (i >= 0) {
|
||||
strong.innerHTML = raw.substring(0, i) + '<mark>' + raw.substring(i, i+q.length) + '</mark>' + raw.substring(i+q.length);
|
||||
} else {
|
||||
strong.textContent = raw;
|
||||
}
|
||||
// Also highlight in Themes: ... line if present
|
||||
try {
|
||||
var themeEl = Array.prototype.slice.call(row.querySelectorAll('.muted')).find(function(el){
|
||||
var t = (el.textContent || '').trim().toLowerCase();
|
||||
return t.startsWith('themes:');
|
||||
});
|
||||
if (themeEl) {
|
||||
if (!themeEl.dataset.raw) { themeEl.dataset.raw = themeEl.textContent || ''; }
|
||||
var base = themeEl.dataset.raw;
|
||||
if (!q) { themeEl.textContent = base; }
|
||||
else {
|
||||
var prefix = 'Themes: ';
|
||||
var rest = base.startsWith(prefix) ? base.substring(prefix.length) : base;
|
||||
var li = rest.toLowerCase().indexOf(ql);
|
||||
if (li >= 0) {
|
||||
themeEl.innerHTML = prefix + rest.substring(0, li) + '<mark>' + rest.substring(li, li+q.length) + '</mark>' + rest.substring(li+q.length);
|
||||
} else {
|
||||
themeEl.textContent = base;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
});
|
||||
}
|
||||
|
||||
function applySort(){
|
||||
var mode = (sortSel && sortSel.value) || 'newest';
|
||||
var rows = panels.slice();
|
||||
rows.sort(function(a,b){
|
||||
if (mode === 'newest' || mode === 'oldest'){
|
||||
var am = parseFloat(a.dataset.mtime || '0');
|
||||
var bm = parseFloat(b.dataset.mtime || '0');
|
||||
return (mode === 'newest') ? (bm - am) : (am - bm);
|
||||
} else if (mode === 'name-asc' || mode === 'name-desc'){
|
||||
var ac = (a.dataset.commander || '').toLowerCase();
|
||||
var bc = (b.dataset.commander || '').toLowerCase();
|
||||
var cmp = ac.localeCompare(bc);
|
||||
return (mode === 'name-asc') ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// Re-append in new order
|
||||
rows.forEach(function(r){ list.appendChild(r); });
|
||||
refreshPanels();
|
||||
}
|
||||
|
||||
function applyAll(){
|
||||
applyFilter();
|
||||
applySort();
|
||||
highlightMatches();
|
||||
var counts = updateCount();
|
||||
// If focus is on a hidden panel, move to first visible
|
||||
try {
|
||||
var active = document.activeElement;
|
||||
if (active && list.contains(active)) {
|
||||
var p = active.closest('.panel');
|
||||
if (p && p.style.display === 'none') {
|
||||
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
|
||||
if (firstVis) firstVis.focus();
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
// Persist state
|
||||
try {
|
||||
if (input) localStorage.setItem('decks-filter', input.value || '');
|
||||
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
|
||||
if (themeSel) localStorage.setItem('decks-theme', themeSel.value || '');
|
||||
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
|
||||
} catch(_){ }
|
||||
// Update URL hash for shareable state
|
||||
updateHashFromState();
|
||||
}
|
||||
|
||||
// Wire up compare selection checkboxes
|
||||
function attachSelectHandlers(){
|
||||
try {
|
||||
var cbs = Array.prototype.slice.call(list.querySelectorAll('input.deck-select'));
|
||||
cbs.forEach(function(cb){
|
||||
// Initialize checked state based on current selection
|
||||
var row = cb.closest('.panel');
|
||||
var name = row ? (row.dataset.name || '') : '';
|
||||
cb.checked = selected.has(name);
|
||||
// Apply visual state on init
|
||||
if (row) row.classList.toggle('selected', cb.checked);
|
||||
cb.addEventListener('change', function(){
|
||||
if (!name) return;
|
||||
if (cb.checked) { selected.add(name); }
|
||||
else { selected.delete(name); }
|
||||
// Toggle selection highlight
|
||||
if (row) row.classList.toggle('selected', cb.checked);
|
||||
updateCompareButtons();
|
||||
});
|
||||
});
|
||||
updateCompareButtons();
|
||||
} catch(_){}
|
||||
}
|
||||
attachSelectHandlers();
|
||||
|
||||
// Debounce helper
|
||||
function debounce(fn, delay){
|
||||
var timer = null;
|
||||
return function(){
|
||||
var ctx = this, args = arguments;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){ fn.apply(ctx, args); }, delay);
|
||||
};
|
||||
}
|
||||
|
||||
var debouncedApply = debounce(applyAll, 150);
|
||||
if (input) input.addEventListener('input', debouncedApply);
|
||||
if (sortSel) sortSel.addEventListener('change', applyAll);
|
||||
if (themeSel) themeSel.addEventListener('change', applyAll);
|
||||
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (input) input.value = '';
|
||||
if (themeSel) themeSel.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
applyAll();
|
||||
});
|
||||
|
||||
// Compare selected action
|
||||
if (cmpSelBtn) cmpSelBtn.addEventListener('click', function(){
|
||||
try {
|
||||
if (selected.size !== 2) return;
|
||||
var arr = Array.from(selected);
|
||||
var url = '/decks/compare?A=' + encodeURIComponent(arr[0]) + '&B=' + encodeURIComponent(arr[1]);
|
||||
window.location.href = url;
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Latest two (by modified time across all decks, not just visible)
|
||||
if (cmpLatestBtn) cmpLatestBtn.addEventListener('click', function(){
|
||||
try {
|
||||
// Gather all panels (including hidden) and sort by data-mtime desc
|
||||
var rows = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
rows.sort(function(a,b){
|
||||
var am = parseFloat(a.dataset.mtime || '0');
|
||||
var bm = parseFloat(b.dataset.mtime || '0');
|
||||
return bm - am;
|
||||
});
|
||||
// Take first two distinct names
|
||||
var pick = [];
|
||||
for (var i=0; i<rows.length && pick.length<2; i++){
|
||||
var nm = rows[i].dataset.name || '';
|
||||
if (nm && pick.indexOf(nm) === -1) pick.push(nm);
|
||||
}
|
||||
if (pick.length === 2){
|
||||
var url = '/decks/compare?A=' + encodeURIComponent(pick[0]) + '&B=' + encodeURIComponent(pick[1]);
|
||||
window.location.href = url;
|
||||
} else {
|
||||
if (window.toast) window.toast('Need at least two decks');
|
||||
}
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Open permalink prompt
|
||||
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var token = prompt('Paste a /build/from?state=... URL or token:');
|
||||
if(!token) return;
|
||||
var m = token.match(/state=([^&]+)/);
|
||||
var t = m ? m[1] : token.trim();
|
||||
if(!t) return;
|
||||
window.location.href = '/build/from?state=' + encodeURIComponent(t);
|
||||
}catch(_){ }
|
||||
});
|
||||
|
||||
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
||||
// Clear UI state
|
||||
try {
|
||||
if (input) input.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
if (themeSel) themeSel.value = '';
|
||||
// Clear persistence
|
||||
localStorage.removeItem('decks-filter');
|
||||
localStorage.removeItem('decks-sort');
|
||||
localStorage.removeItem('decks-theme');
|
||||
localStorage.removeItem('decks-txt');
|
||||
// Clear URL hash
|
||||
var base = location.pathname + location.search;
|
||||
history.replaceState(null, '', base);
|
||||
} catch(_){ }
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and theme reset';
|
||||
});
|
||||
|
||||
if (shareBtn) shareBtn.addEventListener('click', function(){
|
||||
try {
|
||||
// Ensure hash reflects current UI state
|
||||
updateHashFromState();
|
||||
var url = window.location.href;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
var t = document.createElement('input');
|
||||
t.value = url; document.body.appendChild(t); t.select(); try { document.execCommand('copy'); } catch(_){} document.body.removeChild(t);
|
||||
}
|
||||
var prev = shareBtn.textContent;
|
||||
shareBtn.textContent = 'Copied';
|
||||
setTimeout(function(){ shareBtn.textContent = prev; }, 1200);
|
||||
if (liveEl) liveEl.textContent = 'Link copied to clipboard';
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Initial state: prefer URL hash, fall back to localStorage
|
||||
var hadHash = false;
|
||||
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
|
||||
if (hadHash) {
|
||||
if (!applyStateFromHash()) { applyAll(); }
|
||||
} else {
|
||||
// Load persisted state
|
||||
try {
|
||||
var savedFilter = localStorage.getItem('decks-filter') || '';
|
||||
if (input) input.value = savedFilter;
|
||||
var savedSort = localStorage.getItem('decks-sort') || 'newest';
|
||||
if (sortSel) sortSel.value = savedSort;
|
||||
var savedTheme = localStorage.getItem('decks-theme') || '';
|
||||
if (themeSel && savedTheme) {
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === savedTheme; });
|
||||
if (!exists) { var opt = document.createElement('option'); opt.value = savedTheme; opt.textContent = savedTheme; themeSel.appendChild(opt); }
|
||||
themeSel.value = savedTheme;
|
||||
}
|
||||
// Back-compat: if no savedTheme, try first of old saved tags
|
||||
if (themeSel && !savedTheme) {
|
||||
try {
|
||||
var oldTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(oldTags) && oldTags.length > 0) {
|
||||
var ot = oldTags[0];
|
||||
var ex2 = Array.prototype.some.call(themeSel.options, function(o){ return o.value === ot; });
|
||||
if (!ex2) { var o2 = document.createElement('option'); o2.value = ot; o2.textContent = ot; themeSel.appendChild(o2); }
|
||||
themeSel.value = ot;
|
||||
}
|
||||
} catch(_e){}
|
||||
}
|
||||
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
|
||||
} catch(_){ }
|
||||
applyAll();
|
||||
}
|
||||
|
||||
// React to external hash changes
|
||||
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
|
||||
|
||||
// Re-attach selection handlers when list changes order
|
||||
var observer = new MutationObserver(function(){ attachSelectHandlers(); });
|
||||
try { observer.observe(list, { childList: true }); } catch(_){ }
|
||||
|
||||
// Open deck: keyboard and mouse helpers on panels
|
||||
function getPanelUrl(p){
|
||||
try {
|
||||
var name = p.getAttribute('data-name') || '';
|
||||
if (name) return '/decks/view?name=' + encodeURIComponent(name);
|
||||
var form = p.querySelector('form[action="/decks/view"]');
|
||||
if (form) {
|
||||
var nameInput = form.querySelector('input[name="name"]');
|
||||
if (nameInput && nameInput.value) return '/decks/view?name=' + encodeURIComponent(nameInput.value);
|
||||
}
|
||||
} catch(_){ }
|
||||
return '/decks/view';
|
||||
}
|
||||
function openPanel(p, newTab){
|
||||
if (!p) return;
|
||||
if (newTab) { window.open(getPanelUrl(p), '_blank'); return; }
|
||||
var openForm = p.querySelector('form[action="/decks/view"]');
|
||||
if (openForm) {
|
||||
if (window.htmx) { window.htmx.trigger(openForm, 'submit'); }
|
||||
else if (openForm.submit) { openForm.submit(); }
|
||||
} else { window.location.href = getPanelUrl(p); }
|
||||
}
|
||||
list.addEventListener('dblclick', function(e){
|
||||
var p = e.target.closest('.panel');
|
||||
if (!p) return;
|
||||
// Ignore when double-clicking interactive controls
|
||||
if (e.target.closest('button, a, input, select, textarea, label, form')) return;
|
||||
openPanel(p);
|
||||
});
|
||||
list.addEventListener('keydown', function(e){
|
||||
var p = e.target.closest('.panel[tabindex]');
|
||||
if (!p) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
var newTab = !!(e.ctrlKey || e.metaKey || e.shiftKey);
|
||||
openPanel(p, newTab);
|
||||
}
|
||||
});
|
||||
|
||||
// Arrow key navigation between visible panels
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
|
||||
var active = document.activeElement;
|
||||
if (!active || !list.contains(active)) return;
|
||||
var vis = Array.prototype.slice.call(list.querySelectorAll('.panel')).filter(function(p){ return p.style.display !== 'none'; });
|
||||
if (!vis.length) return;
|
||||
var idx = vis.indexOf(active.closest('.panel'));
|
||||
if (idx === -1) return;
|
||||
e.preventDefault();
|
||||
var target = null;
|
||||
if (e.key === 'ArrowDown') target = vis[Math.min(idx + 1, vis.length - 1)];
|
||||
else if (e.key === 'ArrowUp') target = vis[Math.max(idx - 1, 0)];
|
||||
else if (e.key === 'Home') target = vis[0];
|
||||
else if (e.key === 'End') target = vis[vis.length - 1];
|
||||
if (target) { try { target.focus(); } catch(_){ } }
|
||||
});
|
||||
|
||||
// ESC clears filter when focused in the filter input
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape' && input.value) {
|
||||
input.value = '';
|
||||
debouncedApply();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Open first visible deck when pressing Enter in filter
|
||||
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
|
||||
if (firstVis) { e.preventDefault(); openPanel(firstVis, !!(e.ctrlKey||e.metaKey||e.shiftKey)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quick focus: '/' focuses filter when not typing elsewhere
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== '/') return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
if (input) { input.focus(); try { input.select(); } catch(_){} }
|
||||
});
|
||||
|
||||
// Global shortcut: 'R' to reset all (when not typing)
|
||||
document.addEventListener('keydown', function(e){
|
||||
if ((e.key === 'r' || e.key === 'R') && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (resetAllBtn) { e.preventDefault(); resetAllBtn.click(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Help modal wiring
|
||||
(function(){
|
||||
if (!helpBtn || !helpModal) return;
|
||||
var prevFocus = null;
|
||||
function openHelp(){
|
||||
prevFocus = document.activeElement;
|
||||
helpModal.hidden = false;
|
||||
try { document.body.dataset.prevOverflow = document.body.style.overflow || ''; document.body.style.overflow = 'hidden'; } catch(_){ }
|
||||
var close = helpClose || helpModal.querySelector('button');
|
||||
if (close) try { close.focus(); } catch(_){ }
|
||||
}
|
||||
function closeHelp(){
|
||||
helpModal.hidden = true;
|
||||
try { document.body.style.overflow = document.body.dataset.prevOverflow || ''; } catch(_){ }
|
||||
if (prevFocus) try { prevFocus.focus(); } catch(_){ }
|
||||
}
|
||||
helpBtn.addEventListener('click', openHelp);
|
||||
if (helpClose) helpClose.addEventListener('click', closeHelp);
|
||||
if (helpBackdrop) helpBackdrop.addEventListener('click', closeHelp);
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && !helpModal.hidden) { e.preventDefault(); closeHelp(); } });
|
||||
document.addEventListener('keydown', function(e){
|
||||
if ((e.key === '?' || (e.shiftKey && e.key === '/')) && !(e.ctrlKey||e.metaKey||e.altKey)){
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
e.preventDefault();
|
||||
if (helpModal.hidden) openHelp(); else closeHelp();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Enhance mtime display to human-readable date
|
||||
try {
|
||||
panels.forEach(function(p){
|
||||
var m = parseFloat(p.dataset.mtime || '0');
|
||||
if (!m) return;
|
||||
var el = p.querySelector('[title="Modified"]');
|
||||
if (el) {
|
||||
try { el.textContent = new Date(m * 1000).toLocaleString(); } catch(_){}
|
||||
}
|
||||
});
|
||||
} catch(_){ }
|
||||
|
||||
// (copy button removed)
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
|
||||
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
|
||||
#deck-list .panel.selected { box-shadow: 0 0 0 2px #10b981 inset; border-color: #10b981; }
|
||||
</style>
|
||||
{% endblock %}
|
67
code/web/templates/decks/view.html
Normal file
67
code/web/templates/decks/view.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Finished Deck</h2>
|
||||
{% if display_name %}
|
||||
<div><strong>{{ display_name }}</strong></div>
|
||||
{% endif %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
||||
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
|
||||
<div>
|
||||
{% if commander %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
|
||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Finished Decks</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if summary %}
|
||||
{% if owned_set %}
|
||||
{% set ns = namespace(owned=0, total=0) %}
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.cards %}
|
||||
{% for t, clist in tb.cards.items() %}
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set ns.total = ns.total + cnt %}
|
||||
{% if c.name and (c.name|lower in owned_set) %}
|
||||
{% set ns.owned = ns.owned + cnt %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set not_owned = (ns.total - ns.owned) %}
|
||||
{% set pct = ( (ns.owned * 100.0 / (ns.total or 1)) | round(1) ) %}
|
||||
<div class="panel" style="margin-bottom:.75rem;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<div><strong>Ownership</strong></div>
|
||||
<div class="muted">Owned: {{ ns.owned }} • Not owned: {{ not_owned }} • Total: {{ ns.total }} ({{ pct }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
137
code/web/templates/diagnostics/index.html
Normal file
137
code/web/templates/diagnostics/index.html
Normal file
|
@ -0,0 +1,137 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
<div id="themeSummary" style="margin-top:.5rem"></div>
|
||||
<div style="margin-top:.35rem">
|
||||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Performance (local)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap">
|
||||
<div><strong>Scroll FPS:</strong> <span id="perf-fps">–</span></div>
|
||||
<div><strong>Visible tiles:</strong> <span id="perf-visible">–</span></div>
|
||||
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{% if enable_pwa %}
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">PWA status</h3>
|
||||
<div id="pwaStatus" class="muted">Checking…</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||
<h3 style="margin-top:0">Error triggers</h3>
|
||||
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
||||
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
|
||||
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
|
||||
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_logs %}
|
||||
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var el = document.getElementById('sysSummary');
|
||||
function render(data){
|
||||
if (!el) return;
|
||||
try {
|
||||
var v = (data && data.version) || 'dev';
|
||||
var up = (data && data.uptime_seconds) || 0;
|
||||
var st = (data && data.server_time_utc) || '';
|
||||
var flags = (data && data.flags) || {};
|
||||
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
|
||||
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
|
||||
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
|
||||
'<div><strong>Flags:</strong> SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') +', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') +', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') +'</div>';
|
||||
} catch(_){ el.textContent = 'Unavailable'; }
|
||||
}
|
||||
function load(){
|
||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
// Theme status and reset
|
||||
try{
|
||||
var tEl = document.getElementById('themeSummary');
|
||||
var resetBtn = document.getElementById('diag-theme-reset');
|
||||
function renderTheme(){
|
||||
if (!tEl) return;
|
||||
var key = 'mtg:theme';
|
||||
var stored = localStorage.getItem(key);
|
||||
var html = '';
|
||||
var resolved = document.documentElement.getAttribute('data-theme') || '';
|
||||
html += '<div><strong>Resolved theme:</strong> ' + resolved + '</div>';
|
||||
html += '<div><strong>Preference:</strong> ' + (stored ? stored : '(none)') + '</div>';
|
||||
tEl.innerHTML = html;
|
||||
}
|
||||
renderTheme();
|
||||
if (resetBtn){
|
||||
resetBtn.addEventListener('click', function(){
|
||||
try{ localStorage.removeItem('mtg:theme'); }catch(_){ }
|
||||
// Re-apply from server default via base script by simulating system apply
|
||||
try{
|
||||
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var v = prefersDark ? 'dark' : 'light-blend';
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
}catch(_){ }
|
||||
renderTheme();
|
||||
});
|
||||
}
|
||||
}catch(_){ }
|
||||
try{
|
||||
var p = document.getElementById('pwaStatus');
|
||||
if (p){
|
||||
function renderPwa(){
|
||||
try{
|
||||
var st = window.__pwaStatus || {};
|
||||
p.innerHTML = '<div><strong>Registered:</strong> '+ (st.registered? 'Yes':'No') +'</div>' + (st.scope? '<div><strong>Scope:</strong> '+ st.scope +'</div>' : '');
|
||||
}catch(_){ p.textContent = 'Unavailable'; }
|
||||
}
|
||||
setTimeout(renderPwa, 500);
|
||||
}
|
||||
}catch(_){ }
|
||||
// Perf probe: listen to scroll on a card grid if present
|
||||
try{
|
||||
var fpsEl = document.getElementById('perf-fps');
|
||||
var visEl = document.getElementById('perf-visible');
|
||||
var rcEl = document.getElementById('perf-renders');
|
||||
var grid = document.querySelector('.card-grid');
|
||||
var last = performance.now();
|
||||
var frames = 0; var renders = 0;
|
||||
function tick(){
|
||||
frames++;
|
||||
var now = performance.now();
|
||||
if (now - last >= 500){
|
||||
var fps = Math.round((frames * 1000) / (now - last));
|
||||
if (fpsEl) fpsEl.textContent = String(fps);
|
||||
frames = 0; last = now;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
function updateVisible(){
|
||||
try{
|
||||
if (!grid) return;
|
||||
var tiles = grid.querySelectorAll('.card-tile');
|
||||
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
|
||||
if (visEl) visEl.textContent = String(c);
|
||||
}catch(_){ }
|
||||
}
|
||||
if (grid){
|
||||
grid.addEventListener('scroll', updateVisible);
|
||||
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
|
||||
mo.observe(grid, { childList: true, subtree: true, attributes: false });
|
||||
updateVisible();
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
85
code/web/templates/diagnostics/logs.html
Normal file
85
code/web/templates/diagnostics/logs.html
Normal file
|
@ -0,0 +1,85 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Logs</h2>
|
||||
<form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;">
|
||||
<label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label>
|
||||
<label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label>
|
||||
<label>Level
|
||||
<select name="level" id="levelSel">
|
||||
{% set _lvl = (level or 'all') %}
|
||||
<option value="all" {% if _lvl=='all' %}selected{% endif %}>All</option>
|
||||
<option value="error" {% if _lvl=='error' %}selected{% endif %}>Error</option>
|
||||
<option value="warning" {% if _lvl=='warning' %}selected{% endif %}>Warning</option>
|
||||
<option value="info" {% if _lvl=='info' %}selected{% endif %}>Info</option>
|
||||
<option value="debug" {% if _lvl=='debug' %}selected{% endif %}>Debug</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn" type="submit">Refresh</button>
|
||||
<button class="btn" type="button" id="copyLogsBtn" title="Copy visible logs">Copy</button>
|
||||
<label style="margin-left:1rem; display:inline-flex; align-items:center; gap:.35rem">
|
||||
<input type="checkbox" id="autoRefreshLogs" data-pref="logs:auto" /> Auto-refresh
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem">
|
||||
every <input type="number" id="autoRefreshInterval" value="3" min="1" max="30" style="width:60px"> s
|
||||
</label>
|
||||
</form>
|
||||
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
|
||||
<script>
|
||||
(function(){
|
||||
var pre = document.getElementById('logTail');
|
||||
var autoCb = document.getElementById('autoRefreshLogs');
|
||||
var intervalInput = document.getElementById('autoRefreshInterval');
|
||||
// hydrate from saved pref
|
||||
try { var saved = window.__mtgState && window.__mtgState.get('logs:auto', false); if (typeof saved === 'boolean') autoCb.checked = saved; } catch(_){ }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var tailAttr = (pre && pre.getAttribute('data-tail')) || '200';
|
||||
var qAttr = (pre && pre.getAttribute('data-q')) || '';
|
||||
var levelAttr = (pre && pre.getAttribute('data-level')) || 'all';
|
||||
var tail = parseInt(params.get('tail') || tailAttr, 10) || parseInt(tailAttr, 10) || 200;
|
||||
var q = params.get('q') || qAttr;
|
||||
var level = params.get('level') || levelAttr;
|
||||
var timer = null;
|
||||
function fetchLogs(){
|
||||
try {
|
||||
var url = '/status/logs?tail='+encodeURIComponent(String(tail));
|
||||
if (q) url += '&q='+encodeURIComponent(q);
|
||||
if (level && level !== 'all') url += '&level='+encodeURIComponent(level);
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){ if (pre && data && data.lines){ pre.textContent = (data.lines||[]).join(''); pre.scrollTop = pre.scrollHeight; } });
|
||||
} catch(e){}
|
||||
}
|
||||
function start(){
|
||||
stop();
|
||||
var sec = parseInt(intervalInput.value||'3', 10); if (isNaN(sec) || sec < 1) sec = 3; if (sec > 30) sec = 30;
|
||||
timer = setInterval(fetchLogs, sec * 1000);
|
||||
fetchLogs();
|
||||
}
|
||||
function stop(){ if (timer){ clearInterval(timer); timer = null; } }
|
||||
autoCb.addEventListener('change', function(){
|
||||
try { window.__mtgState && window.__mtgState.set('logs:auto', !!autoCb.checked); } catch(_){ }
|
||||
if (autoCb.checked) start(); else stop();
|
||||
});
|
||||
intervalInput.addEventListener('change', function(){ if (autoCb.checked) start(); });
|
||||
if (autoCb.checked) start();
|
||||
var levelSel = document.getElementById('levelSel');
|
||||
if (levelSel){ levelSel.addEventListener('change', function(){ if (autoCb.checked) fetchLogs(); }); }
|
||||
// Copy button
|
||||
var copyBtn = document.getElementById('copyLogsBtn');
|
||||
function copyText(text){
|
||||
try { navigator.clipboard.writeText(text); return true; } catch(_) {
|
||||
try {
|
||||
var ta = document.createElement('textarea'); ta.value = text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); var ok = document.execCommand('copy'); ta.remove(); return ok; } catch(__){ return false; }
|
||||
}
|
||||
}
|
||||
if (copyBtn){
|
||||
copyBtn.addEventListener('click', function(){
|
||||
var ok = copyText(pre ? pre.textContent || '' : '');
|
||||
if (ok){ if (window.toast) window.toast('Copied logs'); copyBtn.textContent = 'Copied'; setTimeout(function(){ copyBtn.textContent='Copy'; }, 1200); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{% endblock %}
|
71
code/web/templates/diagnostics/perf.html
Normal file
71
code/web/templates/diagnostics/perf.html
Normal file
|
@ -0,0 +1,71 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Diagnostics: Synthetic Perf Probe</h2>
|
||||
<p class="muted">Scroll the list; we estimate FPS and count re-renders. This page is only available when diagnostics are enabled.</p>
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin:.5rem 0 1rem 0;">
|
||||
<div><strong>FPS:</strong> <span id="fps">–</span></div>
|
||||
<div><strong>Visible rows:</strong> <span id="rows">–</span></div>
|
||||
<div><strong>Render count:</strong> <span id="renders">0</span></div>
|
||||
</div>
|
||||
|
||||
<div id="probe" style="height:60vh; overflow:auto; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
|
||||
<ul id="list" style="list-style:none; margin:0; padding:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:8px 12px;">
|
||||
{% for i in range(1,1201) %}
|
||||
<li style="padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
||||
<div style="display:flex; align-items:center; gap:.5rem;">
|
||||
<div style="width:64px; height:40px; background:#111; border:1px solid var(--border); border-radius:6px;">
|
||||
<img class="card-thumb" alt="Thumb {{ i }}" loading="lazy" decoding="async" data-lqip
|
||||
src="https://api.scryfall.com/cards/named?fuzzy=Lightning%20Bolt&format=image&version=small"
|
||||
width="64" height="40" style="width:64px; height:40px; object-fit:cover; border-radius:6px;" />
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:.25rem;">
|
||||
<strong>Row {{ i }}</strong>
|
||||
<small class="muted">Synthetic item for performance testing</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var probe = document.getElementById('probe');
|
||||
var list = document.getElementById('list');
|
||||
var fpsEl = document.getElementById('fps');
|
||||
var rowsEl = document.getElementById('rows');
|
||||
var rcEl = document.getElementById('renders');
|
||||
var last = performance.now();
|
||||
var frames = 0; var renders = 0;
|
||||
function raf(){
|
||||
frames++;
|
||||
var now = performance.now();
|
||||
if (now - last >= 500){
|
||||
var fps = Math.round((frames * 1000) / (now - last));
|
||||
if (fpsEl) fpsEl.textContent = String(fps);
|
||||
frames = 0; last = now;
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
function updateVisible(){
|
||||
if (!probe || !list) return;
|
||||
var count = 0;
|
||||
list.querySelectorAll('li').forEach(function(li){
|
||||
// rough: count if within viewport
|
||||
var rect = li.getBoundingClientRect();
|
||||
var pRect = probe.getBoundingClientRect();
|
||||
if (rect.bottom >= pRect.top && rect.top <= pRect.bottom) count++;
|
||||
});
|
||||
if (rowsEl) rowsEl.textContent = String(count);
|
||||
}
|
||||
if (probe){
|
||||
probe.addEventListener('scroll', updateVisible);
|
||||
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
|
||||
mo.observe(list, { childList: true, subtree: true });
|
||||
updateVisible();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
13
code/web/templates/errors/404.html
Normal file
13
code/web/templates/errors/404.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Page not found</h2>
|
||||
<p>The page you requested could not be found.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<pre>Status: {{ status }}
|
||||
Path: {{ request.url.path }}</pre>
|
||||
</details>
|
||||
{% endblock %}
|
13
code/web/templates/errors/4xx.html
Normal file
13
code/web/templates/errors/4xx.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Error {{ status }}</h2>
|
||||
<p>{{ detail }}</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<pre>Status: {{ status }}
|
||||
Path: {{ request.url.path }}</pre>
|
||||
</details>
|
||||
{% endblock %}
|
8
code/web/templates/errors/500.html
Normal file
8
code/web/templates/errors/500.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p>Something went wrong.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
{% endblock %}
|
13
code/web/templates/home.html
Normal file
13
code/web/templates/home.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<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 %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
397
code/web/templates/owned/index.html
Normal file
397
code/web/templates/owned/index.html
Normal file
|
@ -0,0 +1,397 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h3>Owned Cards Library</h3>
|
||||
<p class="muted">Upload .txt or .csv lists. We’ll extract names and keep a de-duplicated library for the web UI.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error" style="margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="notice" style="margin:.5rem 0;">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/owned/upload" method="post" enctype="multipart/form-data" style="margin:.5rem 0 1rem 0;">
|
||||
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload TXT/CSV</button>
|
||||
<input id="upload-owned" type="file" name="file" accept=".txt,.csv" style="display:none" onchange="this.form.requestSubmit();" />
|
||||
</form>
|
||||
|
||||
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.75rem;">
|
||||
<form action="/owned/clear" method="post" style="display:inline;">
|
||||
<button type="submit" {% if count == 0 %}disabled{% endif %}>Clear Library</button>
|
||||
</form>
|
||||
<a href="/owned/export" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export TXT</a>
|
||||
<a href="/owned/export.csv" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export CSV</a>
|
||||
<span class="muted">{{ count }} unique name{{ '' if count == 1 else 's' }} <span id="shown-count" style="margin-left:.25rem;">{% if count %}• {{ count }} shown{% endif %}</span></span>
|
||||
</div>
|
||||
{% if names and names|length %}
|
||||
<div id="bulk-bar" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
<label style="display:flex; align-items:center; gap:.4rem;">
|
||||
<input type="checkbox" id="select-all" /> Select all shown
|
||||
</label>
|
||||
<button type="button" id="btn-remove-selected" class="btn" disabled>Remove selected</button>
|
||||
<button type="button" id="btn-remove-visible" class="btn" disabled>Remove visible</button>
|
||||
<button type="button" id="btn-export-visible" class="btn" disabled>Export visible TXT</button>
|
||||
<button type="button" id="btn-export-visible-csv" class="btn" disabled>Export visible CSV</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if names and names|length %}
|
||||
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
|
||||
<select id="sort-by" data-pref="owned:sort" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="name">Sort: A → Z</option>
|
||||
<option value="type">Sort: Type</option>
|
||||
<option value="color">Sort: Color</option>
|
||||
<option value="tags">Sort: Tags</option>
|
||||
<option value="recent">Sort: Recently added</option>
|
||||
</select>
|
||||
<select id="filter-type" data-pref="owned:type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="">All Types</option>
|
||||
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="filter-tag" data-pref="owned:tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
|
||||
<option value="">All Themes</option>
|
||||
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="filter-color" data-pref="owned:color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="">All Colors</option>
|
||||
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
{% if color_combos and color_combos|length %}
|
||||
<option value="" disabled>──────────</option>
|
||||
{% for code, label in color_combos %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<input id="filter-text" data-pref="owned:q" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
|
||||
<button type="button" id="clear-filters">Clear</button>
|
||||
</div>
|
||||
<div id="active-chips" class="muted" style="display:flex; flex-wrap:wrap; gap:6px; font-size:12px; margin:.25rem 0 .5rem 0;"></div>
|
||||
{% endif %}
|
||||
|
||||
{% if names and names|length %}
|
||||
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
|
||||
{% for n in names %}
|
||||
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
||||
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
|
||||
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
|
||||
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
|
||||
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
|
||||
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||
<div class="owned-vstack">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
||||
sizes="100px" />
|
||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
<div class="mana-group" aria-hidden="true">
|
||||
{% for c in cols %}
|
||||
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No names yet. Upload a file to get started.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var grid = document.getElementById('owned-grid');
|
||||
if (!grid) return;
|
||||
var box = document.getElementById('owned-box');
|
||||
var bulk = document.getElementById('bulk-bar');
|
||||
var selAll = document.getElementById('select-all');
|
||||
var btnRemoveSel = document.getElementById('btn-remove-selected');
|
||||
var btnRemoveVis = document.getElementById('btn-remove-visible');
|
||||
var btnExportVis = document.getElementById('btn-export-visible');
|
||||
var btnExportVisCsv = document.getElementById('btn-export-visible-csv');
|
||||
var fSort = document.getElementById('sort-by');
|
||||
var fType = document.getElementById('filter-type');
|
||||
var fTag = document.getElementById('filter-tag');
|
||||
var fColor = document.getElementById('filter-color');
|
||||
var fText = document.getElementById('filter-text');
|
||||
var btnClear = document.getElementById('clear-filters');
|
||||
var shownCount = document.getElementById('shown-count');
|
||||
var chips = document.getElementById('active-chips');
|
||||
|
||||
|
||||
// State helpers for URL hash and localStorage
|
||||
var state = {
|
||||
get: function(k, d){ try{ var v=localStorage.getItem('mtg:'+k); return v!==null?JSON.parse(v):d; }catch(e){ return d; } },
|
||||
set: function(k, v){ try{ localStorage.setItem('mtg:'+k, JSON.stringify(v)); }catch(e){} },
|
||||
inHash: function(obj){ try{ var p=new URLSearchParams((location.hash||'').replace(/^#/,'')); Object.keys(obj||{}).forEach(function(k){ p.set(k, obj[k]); }); location.hash=p.toString(); }catch(e){} },
|
||||
readHash: function(){ try{ return new URLSearchParams((location.hash||'').replace(/^#/,'')); }catch(e){ return new URLSearchParams(); } }
|
||||
};
|
||||
|
||||
// Helper: build Scryfall image URL with optional cache-busting
|
||||
function buildImageUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
||||
// Resize the container to fill the viewport height
|
||||
function sizeBox(){
|
||||
if (!box) return;
|
||||
try {
|
||||
var rect = box.getBoundingClientRect();
|
||||
var margin = 16; // breathing room at bottom
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
var h = Math.max(240, Math.floor(vh - rect.top - margin));
|
||||
box.style.height = h + 'px';
|
||||
} catch(_){}
|
||||
}
|
||||
function debounce(fn, delay){ var t=null; return function(){ var a=arguments, c=this; if(t) clearTimeout(t); t=setTimeout(function(){ fn.apply(c,a); }, delay); }; }
|
||||
var debouncedSize = debounce(sizeBox, 100);
|
||||
sizeBox();
|
||||
window.addEventListener('resize', debouncedSize);
|
||||
|
||||
function passType(li, val){ if (!val) return true; var t=(li.getAttribute('data-type')||'').toLowerCase(); return t.indexOf(val.toLowerCase())!==-1; }
|
||||
function passTag(li, val){ if (!val) return true; var ts=(li.getAttribute('data-tags')||''); if (!ts) return false; var parts=ts.split('|'); return parts.some(function(x){return x.toLowerCase()===val.toLowerCase();}); }
|
||||
function canonCode(raw){
|
||||
var s = (raw||'').toUpperCase();
|
||||
var order = ['W','U','B','R','G'];
|
||||
var out = [];
|
||||
for (var i=0;i<order.length;i++){
|
||||
var ch = order[i];
|
||||
if (s.indexOf(ch) !== -1) out.push(ch);
|
||||
}
|
||||
if (out.length === 0){
|
||||
// Treat empty or explicit C as colorless
|
||||
if (s.indexOf('C') !== -1 || s === '') return 'C';
|
||||
return '';
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function passColor(li, val){
|
||||
if (!val) return true;
|
||||
var cs=(li.getAttribute('data-colors')||'');
|
||||
var vcode = canonCode(val);
|
||||
var ccode = canonCode(cs);
|
||||
if (!vcode) return true; // if somehow invalid selection
|
||||
return ccode === vcode;
|
||||
}
|
||||
function passText(li, val){ if (!val) return true; var txt=(li.textContent||'').toLowerCase(); return txt.indexOf(val.toLowerCase())!==-1; }
|
||||
|
||||
function updateShownCount(){
|
||||
if (!shownCount) return;
|
||||
var total = 0;
|
||||
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display !== 'none') total++; });
|
||||
shownCount.textContent = (total > 0 ? '• ' + total + ' shown' : '');
|
||||
// Enable/disable bulk buttons
|
||||
var anyVisible = total > 0;
|
||||
[btnRemoveVis, btnExportVis, btnExportVisCsv].forEach(function(b){ if (b) b.disabled = !anyVisible; });
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
function apply(){
|
||||
var vt = fType ? fType.value : '';
|
||||
var vtag = fTag ? fTag.value : '';
|
||||
var vc = fColor ? fColor.value : '';
|
||||
var vx = fText ? fText.value.trim() : '';
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
var ok = passType(li, vt) && passTag(li, vtag) && passColor(li, vc) && passText(li, vx);
|
||||
li.style.display = ok ? '' : 'none';
|
||||
});
|
||||
resort();
|
||||
updateShownCount();
|
||||
renderChips();
|
||||
// Persist
|
||||
if (fSort && fSort.hasAttribute('data-pref')) state.set(fSort.getAttribute('data-pref'), fSort.value);
|
||||
if (fType && fType.hasAttribute('data-pref')) state.set(fType.getAttribute('data-pref'), fType.value);
|
||||
if (fTag && fTag.hasAttribute('data-pref')) state.set(fTag.getAttribute('data-pref'), fTag.value);
|
||||
if (fColor && fColor.hasAttribute('data-pref')) state.set(fColor.getAttribute('data-pref'), fColor.value);
|
||||
if (fText && fText.hasAttribute('data-pref')) state.set(fText.getAttribute('data-pref'), fText.value);
|
||||
// Update URL hash
|
||||
try{ state.inHash({ o_sort: (fSort?fSort.value:''), o_type: vt, o_tag: vtag, o_color: vc, o_q: vx }); }catch(_){ }
|
||||
}
|
||||
function renderChips(){
|
||||
if (!chips) return;
|
||||
var items = [];
|
||||
if (fType && fType.value) items.push({ k:'Type', v: fType.value, clear: function(){ fType.value=''; apply(); } });
|
||||
if (fTag && fTag.value) items.push({ k:'Theme', v: fTag.value, clear: function(){ fTag.value=''; apply(); } });
|
||||
if (fColor && fColor.value) items.push({ k:'Colors', v: fColor.value, clear: function(){ fColor.value=''; apply(); } });
|
||||
if (fText && fText.value) items.push({ k:'Search', v: fText.value, clear: function(){ fText.value=''; apply(); } });
|
||||
chips.innerHTML = '';
|
||||
if (!items.length){ chips.style.display='none'; return; }
|
||||
chips.style.display='flex';
|
||||
items.forEach(function(it){
|
||||
var span = document.createElement('span');
|
||||
span.style.border = '1px solid var(--border)';
|
||||
span.style.borderRadius = '16px';
|
||||
span.style.padding = '2px 8px';
|
||||
span.style.background = '#0f1115';
|
||||
span.textContent = it.k+': '+it.v+' ×';
|
||||
span.style.cursor = 'pointer';
|
||||
span.title = 'Clear '+it.k;
|
||||
span.addEventListener('click', function(){ it.clear(); });
|
||||
chips.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk user-tag add/remove controls removed by request; inline chip removal remains supported.
|
||||
|
||||
function resort(){
|
||||
if (!fSort) return;
|
||||
var mode = fSort.value || 'name';
|
||||
var lis = Array.prototype.slice.call(grid.children);
|
||||
// Only consider visible items, but keep hidden in place after visible ones to avoid DOM thrash
|
||||
var visible = lis.filter(function(li){ return li.style.display !== 'none'; });
|
||||
var hidden = lis.filter(function(li){ return li.style.display === 'none'; });
|
||||
function byName(a,b){ return (a.textContent||'').toLowerCase().localeCompare((b.textContent||'').toLowerCase()); }
|
||||
function byType(a,b){ return (a.getAttribute('data-type')||'').toLowerCase().localeCompare((b.getAttribute('data-type')||'').toLowerCase()); }
|
||||
function byColor(a,b){ return (a.getAttribute('data-colors')||'').localeCompare((b.getAttribute('data-colors')||'')); }
|
||||
function byTags(a,b){ var ac=(a.getAttribute('data-tags')||'').split('|').filter(Boolean).length; var bc=(b.getAttribute('data-tags')||'').split('|').filter(Boolean).length; return ac-bc || byName(a,b); }
|
||||
function byRecent(a,b){ var ta=parseInt(a.getAttribute('data-added')||'0',10); var tb=parseInt(b.getAttribute('data-added')||'0',10); return (tb-ta) || byName(a,b); }
|
||||
var cmp = byName;
|
||||
if (mode === 'type') cmp = byType;
|
||||
else if (mode === 'color') cmp = byColor;
|
||||
else if (mode === 'tags') cmp = byTags;
|
||||
else if (mode === 'recent') cmp = byRecent;
|
||||
visible.sort(cmp);
|
||||
// Re-append in new order
|
||||
var frag = document.createDocumentFragment();
|
||||
visible.forEach(function(li){ frag.appendChild(li); });
|
||||
hidden.forEach(function(li){ frag.appendChild(li); });
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
|
||||
function getVisibleNames(){
|
||||
var out=[];
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
if (li.style.display === 'none') return;
|
||||
var span = li.querySelector('[data-card-name]');
|
||||
if (span) out.push(span.getAttribute('data-card-name'));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function getSelectedNames(){
|
||||
var out=[];
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
var cb = li.querySelector('input.sel');
|
||||
if (cb && cb.checked){ var span = li.querySelector('[data-card-name]'); if (span) out.push(span.getAttribute('data-card-name')); }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function updateSelectedState(){
|
||||
if (!bulk) return;
|
||||
var selected = getSelectedNames();
|
||||
if (btnRemoveSel) btnRemoveSel.disabled = selected.length === 0;
|
||||
if (selAll){
|
||||
// Reflect if all visible are selected
|
||||
var vis = getVisibleNames();
|
||||
selAll.checked = (vis.length>0 && selected.length === vis.length);
|
||||
selAll.indeterminate = (selected.length>0 && selected.length < vis.length);
|
||||
}
|
||||
// Toggle selected class for visual feedback
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
var cb = li.querySelector('input.sel');
|
||||
li.classList.toggle('is-selected', !!(cb && cb.checked));
|
||||
});
|
||||
}
|
||||
|
||||
if (selAll){
|
||||
selAll.addEventListener('change', function(){
|
||||
var on = !!selAll.checked;
|
||||
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display==='none') return; var cb=li.querySelector('input.sel'); if (cb) cb.checked = on; });
|
||||
updateSelectedState();
|
||||
});
|
||||
}
|
||||
grid.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) updateSelectedState(); });
|
||||
// Keyboard: allow Enter/Space on the row to toggle selection
|
||||
grid.addEventListener('keydown', function(e){
|
||||
if (!(e.key === 'Enter' || e.key === ' ')) return;
|
||||
var row = e.target && e.target.closest && e.target.closest('label.owned-row');
|
||||
if (!row) return;
|
||||
e.preventDefault();
|
||||
var cb = row.querySelector('input.sel');
|
||||
if (cb){ cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles:true })); }
|
||||
});
|
||||
|
||||
function postJSON(url, body){ return fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{}) }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); }); }
|
||||
function formPost(url, names){
|
||||
var fd = new FormData(); fd.append('names', names.join(','));
|
||||
return fetch(url, { method:'POST', body: fd }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); });
|
||||
}
|
||||
function confirmRemove(count){
|
||||
return window.confirm('Remove '+count+' item'+(count===1?'':'s')+' from Owned? This cannot be undone.');
|
||||
}
|
||||
if (btnRemoveVis) btnRemoveVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' visible name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
|
||||
if (btnRemoveSel) btnRemoveSel.addEventListener('click', function(){ var names=getSelectedNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' selected name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
|
||||
if (btnExportVis) btnExportVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible', { names: names }).then(function(txt){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([txt],{type:'text/plain'})); a.download='owned_visible.txt'; a.click(); }); });
|
||||
if (btnExportVisCsv) btnExportVisCsv.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible.csv', { names: names }).then(function(csv){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='owned_visible.csv'; a.click(); }); });
|
||||
|
||||
// Keyboard helpers: '/' focus search, Esc clear filters
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key === '/') { if (fText){ e.preventDefault(); fText.focus(); fText.select && fText.select(); } }
|
||||
else if (e.key === 'Escape'){ if (fText && fText.value){ fText.value=''; apply(); } }
|
||||
});
|
||||
|
||||
// Hydrate from URL hash/localStorage
|
||||
try{
|
||||
var params = state.readHash();
|
||||
var hv;
|
||||
if (fSort){ hv = params.get('o_sort'); if (!hv) hv = state.get(fSort.getAttribute('data-pref'), 'name'); if (hv) fSort.value = hv; }
|
||||
if (fType){ hv = params.get('o_type'); if (!hv) hv = state.get(fType.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fType.value = hv; }
|
||||
if (fTag){ hv = params.get('o_tag'); if (!hv) hv = state.get(fTag.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fTag.value = hv; }
|
||||
if (fColor){ hv = params.get('o_color'); if (!hv) hv = state.get(fColor.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fColor.value = hv; }
|
||||
if (fText){ hv = params.get('o_q'); if (!hv && hv !== '') hv = state.get(fText.getAttribute('data-pref'), ''); if (typeof hv === 'string') fText.value = hv; }
|
||||
}catch(_){ }
|
||||
|
||||
if (fSort) fSort.addEventListener('change', function(){ resort(); apply(); });
|
||||
if (fType) fType.addEventListener('change', apply);
|
||||
if (fTag) fTag.addEventListener('change', apply);
|
||||
if (fColor) fColor.addEventListener('change', apply);
|
||||
if (fText) fText.addEventListener('input', apply);
|
||||
if (btnClear) btnClear.addEventListener('click', function(){
|
||||
if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value='';
|
||||
apply();
|
||||
});
|
||||
// Initial state
|
||||
apply();
|
||||
|
||||
// Thumbnail retry now handled by global binder in base.html
|
||||
|
||||
// User tag chip UI removed by request.
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.mana{ display:inline-block; width:14px; height:14px; border-radius:50%; box-sizing:border-box; }
|
||||
.mana-W{ background:#f9fafb; border:1px solid #d1d5db; }
|
||||
.mana-U{ background:#3b82f6; }
|
||||
.mana-B{ background:#111827; }
|
||||
.mana-R{ background:#ef4444; }
|
||||
.mana-G{ background:#10b981; }
|
||||
.mana-C{ background:#9ca3af; border:1px solid #6b7280; }
|
||||
/* Subtle scrollbar styling for the owned list box */
|
||||
#owned-box{ scrollbar-width: thin; scrollbar-color: rgba(148,163,184,.35) transparent; }
|
||||
#owned-box:hover{ scrollbar-color: rgba(148,163,184,.6) transparent; }
|
||||
#owned-box::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
#owned-box::-webkit-scrollbar-track{ background: transparent; }
|
||||
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
|
||||
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
|
||||
/* Owned item layout */
|
||||
#owned-grid{ justify-items:center; }
|
||||
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:200px; margin:0 auto; }
|
||||
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
|
||||
.card-thumb{ display:block; width:100px; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
|
||||
/* Highlight only the thumbnail when selected */
|
||||
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
|
||||
.mana-group{ display:flex; gap:4px; justify-content:center; }
|
||||
.card-name{ display:block; }
|
||||
</style>
|
||||
{% endblock %}
|
576
code/web/templates/partials/deck_summary.html
Normal file
576
code/web/templates/partials/deck_summary.html
Normal file
|
@ -0,0 +1,576 @@
|
|||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Legend:</span>
|
||||
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
|
||||
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✔</span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✖</span>Not owned</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||
<section style="margin-top:.5rem;">
|
||||
<h5>Card Types</h5>
|
||||
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||
<span class="muted">View:</span>
|
||||
<div class="seg" role="tablist" aria-label="Type view">
|
||||
<button type="button" class="seg-btn" data-view="list" aria-selected="true">List</button>
|
||||
<button type="button" class="seg-btn" data-view="thumbs">Thumbnails</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.counts %}
|
||||
<style>
|
||||
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.seg-btn { background:#12161c; color:#e5e7eb; border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
|
||||
.seg-btn[aria-selected="true"] { background:#1f2937; }
|
||||
.typeview { margin-top:.25rem; }
|
||||
.typeview.hidden { display:none; }
|
||||
.stack-wrap { --card-w: 160px; --card-h: 224px; --cols: 9; --overlap: .5; overflow: visible; padding: 6px 0 calc(var(--card-h) * (1 - var(--overlap))) 0; }
|
||||
.stack-grid { display: grid; grid-template-columns: repeat(var(--cols), var(--card-w)); grid-auto-rows: calc(var(--card-h) * var(--overlap)); column-gap: 10px; }
|
||||
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
|
||||
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
|
||||
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
|
||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
{% if clist %}
|
||||
<style>
|
||||
.list-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0; }
|
||||
@media (max-width: 1199px) {
|
||||
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
}
|
||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
{% for c in clist %}
|
||||
<div class="list-row {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<span class="count">{{ cnt }}</span>
|
||||
<span class="times">x</span>
|
||||
<span class="name" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">{{ c.name }}</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="typeview-thumbs" class="typeview hidden">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
{% if clist %}
|
||||
<div class="stack-wrap">
|
||||
<div class="stack-grid">
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 1200px) 160px, 240px" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No type data available.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
|
||||
var thumbsBtn = document.querySelector('.seg-btn[data-view="thumbs"]');
|
||||
var listView = document.getElementById('typeview-list');
|
||||
var thumbsView = document.getElementById('typeview-thumbs');
|
||||
|
||||
function recalcThumbCols() {
|
||||
if (thumbsView.classList.contains('hidden')) return;
|
||||
var wraps = thumbsView.querySelectorAll('.stack-wrap');
|
||||
wraps.forEach(function(sw){
|
||||
var grid = sw.querySelector('.stack-grid');
|
||||
if (!grid) return;
|
||||
var gridStyles = window.getComputedStyle(grid);
|
||||
var gap = parseFloat(gridStyles.columnGap) || 10;
|
||||
var swStyles = window.getComputedStyle(sw);
|
||||
var cardW = parseFloat(swStyles.getPropertyValue('--card-w')) || 160;
|
||||
var width = sw.clientWidth;
|
||||
if (!width || width < cardW) {
|
||||
sw.style.setProperty('--cols', '1');
|
||||
return;
|
||||
}
|
||||
var cols = Math.max(1, Math.floor((width + gap) / (cardW + gap)));
|
||||
sw.style.setProperty('--cols', String(cols));
|
||||
});
|
||||
}
|
||||
|
||||
function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, ms); }; }
|
||||
var debouncedRecalc = debounce(recalcThumbCols, 100);
|
||||
window.addEventListener('resize', debouncedRecalc);
|
||||
document.addEventListener('htmx:afterSwap', debouncedRecalc);
|
||||
|
||||
function applyMode(mode){
|
||||
var isList = (mode !== 'thumbs');
|
||||
listView.classList.toggle('hidden', !isList);
|
||||
thumbsView.classList.toggle('hidden', isList);
|
||||
if (listBtn) listBtn.setAttribute('aria-selected', isList ? 'true' : 'false');
|
||||
if (thumbsBtn) thumbsBtn.setAttribute('aria-selected', isList ? 'false' : 'true');
|
||||
try { localStorage.setItem('summaryTypeView', mode); } catch(e) {}
|
||||
if (!isList) recalcThumbCols();
|
||||
}
|
||||
|
||||
if (listBtn && thumbsBtn) {
|
||||
listBtn.addEventListener('click', function(){ applyMode('list'); });
|
||||
thumbsBtn.addEventListener('click', function(){ applyMode('thumbs'); });
|
||||
}
|
||||
var initial = 'list';
|
||||
try { initial = localStorage.getItem('summaryTypeView') || 'list'; } catch(e) {}
|
||||
applyMode(initial);
|
||||
if (initial === 'thumbs') recalcThumbCols();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Overview</h5>
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||
<!-- Pips Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pc = pd['cards'] if 'cards' in pd else None %}
|
||||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No pip data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sources Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
|
||||
<div class="muted" style="font-weight:600;">Mana Sources</div>
|
||||
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
|
||||
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
|
||||
</label>
|
||||
</div>
|
||||
{% set mg = summary.mana_generation %}
|
||||
{% if mg %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
{# If colorless sources exist, append 'C' to colors for display #}
|
||||
{% if 'C' in mg and (mg.get('C', 0) > 0) and ('C' not in colors) %}
|
||||
{% set colors = colors + ['C'] %}
|
||||
{% endif %}
|
||||
{% set ns = namespace(max_src=0) %}
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
{% set mgc = mg['cards'] if 'cards' in mg else None %}
|
||||
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Curve Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Test Hand</h5>
|
||||
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
||||
<button type="button" id="btn-new-hand">New Hand</button>
|
||||
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
</div>
|
||||
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
|
||||
<div class="stack-grid" id="test-hand-grid"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var GC_SET = (function(){
|
||||
try {
|
||||
var els = document.querySelectorAll('#typeview-list .game-changer [data-card-name], #typeview-thumbs .game-changer [data-card-name]');
|
||||
var s = new Set();
|
||||
els.forEach(function(el){ var n = el.getAttribute('data-card-name'); if(n) s.add(n); });
|
||||
return s;
|
||||
} catch(e) { return new Set(); }
|
||||
})();
|
||||
var BASE_BASICS = ["Plains","Island","Swamp","Mountain","Forest","Wastes"];
|
||||
function isBasicLand(name){
|
||||
if (!name) return false;
|
||||
if (BASE_BASICS.indexOf(name) >= 0) return true;
|
||||
if (name.startsWith('Snow-Covered ')) {
|
||||
var base = name.substring('Snow-Covered '.length);
|
||||
return BASE_BASICS.indexOf(base) >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function collectDeck(){
|
||||
var deck = [];
|
||||
document.querySelectorAll('#typeview-list span[data-card-name]').forEach(function(el){
|
||||
var name = el.getAttribute('data-card-name');
|
||||
var cnt = parseInt(el.getAttribute('data-count') || '1', 10);
|
||||
if (name) deck.push({ name: name, count: (isFinite(cnt) && cnt>0 ? cnt : 1) });
|
||||
});
|
||||
return deck;
|
||||
}
|
||||
function buildPool(deck){
|
||||
var pool = [];
|
||||
deck.forEach(function(it){
|
||||
var n = Math.max(1, parseInt(it.count || 1, 10));
|
||||
for (var i=0;i<n;i++){ pool.push(it.name); }
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
function drawHand(deck){
|
||||
var pool = buildPool(deck);
|
||||
if (!pool.length) return [];
|
||||
var picked = {};
|
||||
var hand = [];
|
||||
var attempts = 0;
|
||||
while (hand.length < 7 && attempts < 500) {
|
||||
attempts++;
|
||||
var idx = Math.floor(Math.random() * pool.length);
|
||||
var name = pool[idx];
|
||||
if (!name) continue;
|
||||
var allowDup = isBasicLand(name);
|
||||
if (!allowDup && picked[name]) continue;
|
||||
hand.push(name);
|
||||
if (!allowDup) picked[name] = true;
|
||||
pool.splice(idx, 1);
|
||||
if (!pool.length) break;
|
||||
}
|
||||
return hand;
|
||||
}
|
||||
function compress(hand){
|
||||
var map = {};
|
||||
hand.forEach(function(n){ map[n] = (map[n]||0) + 1; });
|
||||
var out = [];
|
||||
Object.keys(map).forEach(function(n){ out.push({name:n, count: map[n]}); });
|
||||
out.sort(function(a,b){ return hand.indexOf(a.name) - hand.indexOf(b.name); });
|
||||
return out;
|
||||
}
|
||||
function render(hand){
|
||||
var grid = document.getElementById('test-hand-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
hand.forEach(function(name){
|
||||
if (!name) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'stack-card';
|
||||
if (GC_SET && GC_SET.has(name)) {
|
||||
div.className += ' game-changer';
|
||||
}
|
||||
div.innerHTML = (
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
||||
);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
|
||||
var btn = document.getElementById('btn-new-hand');
|
||||
if (btn) btn.addEventListener('click', newHand);
|
||||
newHand();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
<style>
|
||||
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||
/* Cross-highlight from charts to cards */
|
||||
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
|
||||
/* For list view, ensure baseline padding so no layout shift on highlight */
|
||||
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
|
||||
/* Ensure stack-card gets visible highlight */
|
||||
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
function ensureTip() {
|
||||
var tip = document.getElementById('chart-tooltip');
|
||||
if (!tip) {
|
||||
tip = document.createElement('div');
|
||||
tip.id = 'chart-tooltip';
|
||||
tip.className = 'chart-tooltip';
|
||||
document.body.appendChild(tip);
|
||||
}
|
||||
return tip;
|
||||
}
|
||||
var tip = ensureTip();
|
||||
var hoverTimer = null;
|
||||
var lastNames = [];
|
||||
var lastType = '';
|
||||
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
|
||||
function position(e) {
|
||||
tip.style.display = 'block';
|
||||
var x = e.clientX + 12, y = e.clientY + 12;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
var rect = tip.getBoundingClientRect();
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
|
||||
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
|
||||
}
|
||||
function buildTip(el) {
|
||||
// Render tooltip with safe DOM and a Copy button for card list
|
||||
tip.innerHTML = '';
|
||||
var t = el.getAttribute('data-type');
|
||||
var header = document.createElement('div');
|
||||
header.style.fontWeight = '600';
|
||||
header.style.marginBottom = '.25rem';
|
||||
var listText = '';
|
||||
if (t === 'pips') {
|
||||
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else if (t === 'sources') {
|
||||
header.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else if (t === 'curve') {
|
||||
header.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else {
|
||||
header.textContent = el.getAttribute('aria-label') || '';
|
||||
}
|
||||
tip.appendChild(header);
|
||||
if (listText) {
|
||||
var pre = document.createElement('pre');
|
||||
pre.style.margin = '0 0 .35rem 0';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = listText;
|
||||
tip.appendChild(pre);
|
||||
var btn = document.createElement('button');
|
||||
btn.textContent = 'Copy';
|
||||
btn.style.fontSize = '12px';
|
||||
btn.style.padding = '.2rem .4rem';
|
||||
btn.style.border = '1px solid var(--border)';
|
||||
btn.style.background = '#12161c';
|
||||
btn.style.color = '#e5e7eb';
|
||||
btn.style.borderRadius = '4px';
|
||||
btn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(listText);
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = listText; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
|
||||
}
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
|
||||
} catch(_) {}
|
||||
});
|
||||
tip.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function normalizeList(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(function(n){
|
||||
if (!n) return '';
|
||||
var s = String(n);
|
||||
// Strip trailing " ×<num>" count suffix if present
|
||||
s = s.replace(/\s×\d+$/,'');
|
||||
return s.trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
function attach() {
|
||||
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
buildTip(el);
|
||||
position(e);
|
||||
// Cross-highlight for mana curve bars -> card items
|
||||
try {
|
||||
if (el.getAttribute('data-type') === 'curve') {
|
||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
lastType = 'curve';
|
||||
highlightNames(lastNames, true);
|
||||
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
|
||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
lastType = el.getAttribute('data-type');
|
||||
highlightNames(lastNames, true);
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
el.addEventListener('mousemove', position);
|
||||
el.addEventListener('mouseleave', function() {
|
||||
clearHoverTimer();
|
||||
hoverTimer = setTimeout(function(){
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
// Keep tooltip open while hovering it
|
||||
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
|
||||
tip.addEventListener('mouseleave', function(){
|
||||
tip.style.display = 'none';
|
||||
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
|
||||
lastNames = []; lastType = '';
|
||||
});
|
||||
// Initialize Show C toggle
|
||||
initShowCToggle();
|
||||
}
|
||||
function initShowCToggle(){
|
||||
var cb = document.getElementById('toggle-show-c');
|
||||
var container = document.querySelector('.sources-bars');
|
||||
if (!cb || !container) return;
|
||||
// Default ON; restore prior state
|
||||
var pref = 'true';
|
||||
try { var v = localStorage.getItem('showColorlessC'); if (v!==null) pref = v; } catch(_) {}
|
||||
cb.checked = (pref !== 'false');
|
||||
function apply(){
|
||||
var on = cb.checked;
|
||||
try { localStorage.setItem('showColorlessC', String(on)); } catch(_) {}
|
||||
container.querySelectorAll('[data-color="C"]').forEach(function(el){
|
||||
el.style.display = on ? '' : 'none';
|
||||
});
|
||||
}
|
||||
cb.addEventListener('change', apply);
|
||||
apply();
|
||||
}
|
||||
function highlightNames(names, on){
|
||||
if (!Array.isArray(names) || names.length === 0) return;
|
||||
// List view spans
|
||||
try {
|
||||
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
if (!n) return;
|
||||
var match = names.indexOf(n) !== -1;
|
||||
// Toggle highlight only on the inline span so it doesn't fill the entire grid cell
|
||||
it.classList.toggle('chart-highlight', !!(on && match));
|
||||
if (!on && !match) it.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// Thumbs view images
|
||||
try {
|
||||
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
if (!n) return;
|
||||
var tile = it.closest('.stack-card') || it;
|
||||
var match = names.indexOf(n) !== -1;
|
||||
tile.classList.toggle('chart-highlight', !!(on && match));
|
||||
if (!on && !match) tile.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// If virtualized lists are enabled, auto-scroll the Step 5 grid to the first match
|
||||
try {
|
||||
if (on && window.scrollCardIntoView && Array.isArray(names) && names.length) {
|
||||
window.scrollCardIntoView(names[0]);
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
162
code/web/templates/setup/index.html
Normal file
162
code/web/templates/setup/index.html
Normal file
|
@ -0,0 +1,162 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Setup / Tagging</h2>
|
||||
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
|
||||
|
||||
<details open style="margin-top:.5rem;">
|
||||
<summary>Current Status</summary>
|
||||
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="setup-status-line" style="margin-top:.25rem;">Checking…</div>
|
||||
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||
</div>
|
||||
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
|
||||
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
|
||||
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
|
||||
<input type="checkbox" id="chk-force" checked /> Force run
|
||||
</label>
|
||||
</form>
|
||||
<form method="get" action="/setup/running?start=1&force=1">
|
||||
<button type="submit">Open Progress Page</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
function update(data){
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var colorEl = document.getElementById('setup-color-line');
|
||||
var logEl = document.getElementById('setup-log-tail');
|
||||
var progEl = document.getElementById('setup-progress-line');
|
||||
var timeEl = document.getElementById('setup-time-line');
|
||||
var bar = document.getElementById('setup-progress-bar');
|
||||
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||
var logWrap = document.getElementById('setup-log-wrap');
|
||||
var logSummary = document.getElementById('setup-log-summary');
|
||||
if (!line) return;
|
||||
if (data && data.running) {
|
||||
line.textContent = (data.message || 'Working…');
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||
}
|
||||
if (typeof data.eta_seconds === 'number') {
|
||||
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||
}
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at) {
|
||||
timeEl.style.display = '';
|
||||
timeEl.textContent = 'Started: ' + data.started_at;
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'Current color: ' + data.color;
|
||||
} else {
|
||||
colorEl.style.display = 'none';
|
||||
}
|
||||
if (data.log_tail) {
|
||||
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||
if (logWrap) logWrap.style.display = '';
|
||||
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||
logEl.textContent = data.log_tail;
|
||||
} else {
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
} else if (data && data.phase === 'done') {
|
||||
line.textContent = 'Setup complete.';
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at || data.finished_at) {
|
||||
timeEl.style.display = '';
|
||||
var t = [];
|
||||
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||
timeEl.textContent = t.join(' • ');
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
} else if (data && data.phase === 'error') {
|
||||
line.textContent = (data.message || 'Setup error.');
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'While working on: ' + data.color;
|
||||
}
|
||||
} else {
|
||||
line.textContent = 'Idle';
|
||||
progEl.style.display = 'none';
|
||||
timeEl.style.display = 'none';
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function poll(){
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(){});
|
||||
}
|
||||
function rapidPoll(times, delay){
|
||||
var i = 0;
|
||||
function tick(){
|
||||
poll();
|
||||
i++;
|
||||
if (i < times) setTimeout(tick, delay);
|
||||
}
|
||||
tick();
|
||||
}
|
||||
window.startSetup = function(){
|
||||
var btn = document.getElementById('btn-start-setup');
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var force = document.getElementById('chk-force') && document.getElementById('chk-force').checked;
|
||||
if (btn) btn.disabled = true;
|
||||
if (line) line.textContent = 'Starting setup/tagging…';
|
||||
// First try POST with JSON body
|
||||
fetch('/setup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) })
|
||||
.then(function(r){ if (!r.ok) throw new Error('POST failed'); return r.json().catch(function(){ return {}; }); })
|
||||
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||
.catch(function(){
|
||||
// Fallback to GET if POST fails (proxy/middleware issues)
|
||||
var url = '/setup/start' + (force ? '?force=1' : '');
|
||||
fetch(url, { method: 'GET', cache: 'no-store' })
|
||||
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||
.catch(function(){});
|
||||
})
|
||||
.finally(function(){ if (btn) btn.disabled = false; });
|
||||
};
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
134
code/web/templates/setup/running.html
Normal file
134
code/web/templates/setup/running.html
Normal file
|
@ -0,0 +1,134 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Preparing Card Database</h2>
|
||||
<p class="muted">Initial setup and tagging may take several minutes on first run.</p>
|
||||
|
||||
<div id="setup-status" style="margin-top:1rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;" data-next-url="{{ next_url or '' }}">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="setup-status-line" style="margin-top:.25rem;">Starting…</div>
|
||||
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||
</div>
|
||||
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form method="get" action="/setup">
|
||||
<button type="submit">Back to Setup</button>
|
||||
</form>
|
||||
{% if next_url %}
|
||||
<form method="get" action="{{ next_url }}">
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var container = document.getElementById('setup-status');
|
||||
var nextUrl = (container && container.dataset.nextUrl) ? container.dataset.nextUrl : null;
|
||||
if (nextUrl === '') nextUrl = null;
|
||||
function update(data){
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var colorEl = document.getElementById('setup-color-line');
|
||||
var logEl = document.getElementById('setup-log-tail');
|
||||
var progEl = document.getElementById('setup-progress-line');
|
||||
var timeEl = document.getElementById('setup-time-line');
|
||||
var bar = document.getElementById('setup-progress-bar');
|
||||
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||
var logWrap = document.getElementById('setup-log-wrap');
|
||||
var logSummary = document.getElementById('setup-log-summary');
|
||||
if (!line) return;
|
||||
if (data && data.running) {
|
||||
line.textContent = (data.message || 'Working…');
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||
}
|
||||
if (typeof data.eta_seconds === 'number') {
|
||||
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||
}
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at) {
|
||||
timeEl.style.display = '';
|
||||
timeEl.textContent = 'Started: ' + data.started_at;
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'Current color: ' + data.color;
|
||||
} else {
|
||||
colorEl.style.display = 'none';
|
||||
}
|
||||
if (data.log_tail) {
|
||||
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||
if (logWrap) logWrap.style.display = '';
|
||||
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||
logEl.textContent = data.log_tail;
|
||||
} else {
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
} else if (data && data.phase === 'done') {
|
||||
line.textContent = 'Setup complete.';
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at || data.finished_at) {
|
||||
timeEl.style.display = '';
|
||||
var t = [];
|
||||
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||
timeEl.textContent = t.join(' • ');
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
if (nextUrl) {
|
||||
setTimeout(function(){ window.location.href = nextUrl; }, 1200);
|
||||
}
|
||||
} else if (data && data.phase === 'error') {
|
||||
line.textContent = (data.message || 'Setup error.');
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'While working on: ' + data.color;
|
||||
}
|
||||
} else {
|
||||
line.textContent = 'Idle';
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function poll(){
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(){});
|
||||
}
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,24 +1,57 @@
|
|||
services:
|
||||
mtg-deckbuilder:
|
||||
# Command line driven build
|
||||
# mtg-deckbuilder:
|
||||
# build: .
|
||||
# container_name: mtg-deckbuilder-main
|
||||
# stdin_open: true # Equivalent to docker run -i
|
||||
# tty: true # Equivalent to docker run -t
|
||||
# volumes:
|
||||
# - ${PWD}/deck_files:/app/deck_files
|
||||
# - ${PWD}/logs:/app/logs
|
||||
# - ${PWD}/csv_files:/app/csv_files
|
||||
# # Optional: mount a config directory for headless JSON and owned cards
|
||||
# - ${PWD}/config:/app/config
|
||||
# - ${PWD}/owned_cards:/app/owned_cards
|
||||
# environment:
|
||||
# - PYTHONUNBUFFERED=1
|
||||
# - TERM=xterm-256color
|
||||
# - DEBIAN_FRONTEND=noninteractive
|
||||
# # Set DECK_MODE=headless to auto-run non-interactive mode on start
|
||||
# # - DECK_MODE=headless
|
||||
# # Optional headless configuration (examples):
|
||||
# # - DECK_CONFIG=/app/config/deck.json
|
||||
# # - DECK_COMMANDER=Pantlaza
|
||||
# # Ensure proper cleanup
|
||||
# restart: "no"
|
||||
|
||||
web:
|
||||
build: .
|
||||
container_name: mtg-deckbuilder-main
|
||||
stdin_open: true # Equivalent to docker run -i
|
||||
tty: true # Equivalent to docker run -t
|
||||
volumes:
|
||||
- ${PWD}/deck_files:/app/deck_files
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${PWD}/csv_files:/app/csv_files
|
||||
# Optional: mount a config directory for headless JSON and owned cards
|
||||
- ${PWD}/config:/app/config
|
||||
- ${PWD}/owned_cards:/app/owned_cards
|
||||
container_name: mtg-deckbuilder-web
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TERM=xterm-256color
|
||||
- DEBIAN_FRONTEND=noninteractive
|
||||
# Set DECK_MODE=headless to auto-run non-interactive mode on start
|
||||
# - DECK_MODE=headless
|
||||
# Optional headless configuration (examples):
|
||||
# - DECK_CONFIG=/app/config/deck.json
|
||||
# - DECK_COMMANDER=Pantlaza
|
||||
# Ensure proper cleanup
|
||||
# Default theme for first-time visitors (no local preference yet): system|light|dark
|
||||
# When set to 'light', it maps to the consolidated Light (Blend) palette in the UI
|
||||
# - ENABLE_THEMES=1
|
||||
# - THEME=dark
|
||||
# Logging and error utilities
|
||||
# - SHOW_LOGS=1
|
||||
# - SHOW_DIAGNOSTICS=1
|
||||
# - ENABLE_PWA=1
|
||||
# Speed up setup/tagging in Web UI via parallel workers
|
||||
- WEB_TAG_PARALLEL=1
|
||||
- WEB_TAG_WORKERS=4
|
||||
# Enable virtualization + lazy image tweaks in Step 5
|
||||
- WEB_VIRTUALIZE=1
|
||||
volumes:
|
||||
- ${PWD}/deck_files:/app/deck_files
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${PWD}/csv_files:/app/csv_files
|
||||
- ${PWD}/config:/app/config
|
||||
- ${PWD}/owned_cards:/app/owned_cards
|
||||
working_dir: /app
|
||||
command: ["bash", "-lc", "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"]
|
||||
restart: "no"
|
||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "mtg-deckbuilder"
|
||||
version = "1.1.2"
|
||||
version = "2.0.1"
|
||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
|
6
pytest.ini
Normal file
6
pytest.ini
Normal file
|
@ -0,0 +1,6 @@
|
|||
[pytest]
|
||||
minversion = 7.0
|
||||
# Disable built-in debugging plugin to avoid importing stdlib 'code' module,
|
||||
# which conflicts with our package named 'code/'.
|
||||
addopts = -q -p no:debugging
|
||||
testpaths = code/tests
|
|
@ -5,4 +5,10 @@ tqdm>=4.66.0
|
|||
# Optional pretty output in reports; app falls back gracefully if missing
|
||||
prettytable>=3.9.0
|
||||
|
||||
# Web UI stack (FastAPI + Jinja + HTMX served via CDN)
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.28.0
|
||||
Jinja2>=3.1.0
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# Development dependencies are in requirements-dev.txt
|
41
run-web-from-dockerhub.bat
Normal file
41
run-web-from-dockerhub.bat
Normal file
|
@ -0,0 +1,41 @@
|
|||
@echo off
|
||||
setlocal ENABLEDELAYEDEXPANSION
|
||||
|
||||
echo MTG Python Deckbuilder - Web UI (Docker Hub)
|
||||
echo ============================================
|
||||
|
||||
REM Create directories if they don't exist
|
||||
if not exist "deck_files" mkdir deck_files
|
||||
if not exist "logs" mkdir logs
|
||||
if not exist "csv_files" mkdir csv_files
|
||||
if not exist "config" mkdir config
|
||||
if not exist "owned_cards" mkdir owned_cards
|
||||
|
||||
REM Flags (override by setting env vars before running)
|
||||
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
|
||||
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
|
||||
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
|
||||
|
||||
echo Starting Web UI on http://localhost:8080
|
||||
echo Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% WEB_VIRTUALIZE=%WEB_VIRTUALIZE% THEME=%THEME% ENABLE_THEMES=%ENABLE_THEMES%
|
||||
|
||||
REM Optional theme flags (set before running):
|
||||
REM set THEME=system|light|dark
|
||||
REM set ENABLE_THEMES=1
|
||||
|
||||
docker run --rm ^
|
||||
-p 8080:8080 ^
|
||||
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% -e WEB_VIRTUALIZE=%WEB_VIRTUALIZE% ^
|
||||
-e THEME=%THEME% -e ENABLE_THEMES=%ENABLE_THEMES% ^
|
||||
-v "%cd%\deck_files:/app/deck_files" ^
|
||||
-v "%cd%\logs:/app/logs" ^
|
||||
-v "%cd%\csv_files:/app/csv_files" ^
|
||||
-v "%cd%\owned_cards:/app/owned_cards" ^
|
||||
-v "%cd%\config:/app/config" ^
|
||||
mwisnowski/mtg-python-deckbuilder:latest ^
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
|
||||
echo.
|
||||
echo Open: http://localhost:8080
|
||||
echo Tip: set SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 before running to hide those pages.
|
||||
endlocal
|
30
run-web-from-dockerhub.sh
Normal file
30
run-web-from-dockerhub.sh
Normal file
|
@ -0,0 +1,30 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "MTG Python Deckbuilder - Web UI (Docker Hub)"
|
||||
echo "==========================================="
|
||||
|
||||
# Create directories if they don't exist
|
||||
mkdir -p deck_files logs csv_files config owned_cards
|
||||
|
||||
# Flags (override by exporting before running)
|
||||
: "${SHOW_LOGS:=1}"
|
||||
: "${SHOW_DIAGNOSTICS:=1}"
|
||||
|
||||
echo "Starting Web UI on http://localhost:8080"
|
||||
echo "Flags: SHOW_LOGS=${SHOW_LOGS} SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS}"
|
||||
|
||||
docker run --rm \
|
||||
-p 8080:8080 \
|
||||
-e SHOW_LOGS=${SHOW_LOGS} -e SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS} \
|
||||
-v "$(pwd)/deck_files:/app/deck_files" \
|
||||
-v "$(pwd)/logs:/app/logs" \
|
||||
-v "$(pwd)/csv_files:/app/csv_files" \
|
||||
-v "$(pwd)/owned_cards:/app/owned_cards" \
|
||||
-v "$(pwd)/config:/app/config" \
|
||||
mwisnowski/mtg-python-deckbuilder:latest \
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
|
||||
echo
|
||||
echo "Open: http://localhost:8080"
|
||||
echo "Tip: export SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 to hide those pages."
|
Loading…
Add table
Add a link
Reference in a new issue