mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)
This commit is contained in:
parent
f8c6b5c07e
commit
721e1884af
41 changed files with 2960 additions and 143 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,6 +4,7 @@
|
||||||
*.txt
|
*.txt
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.venv/
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
test.py
|
test.py
|
||||||
!requirements.txt
|
!requirements.txt
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -13,7 +13,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
- 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
|
- 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
|
- Scryfall attribution footer in the Web UI
|
||||||
|
@ -39,7 +50,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics
|
- `/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
|
- Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists
|
||||||
|
|
||||||
|
Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outline.md`.
|
||||||
|
|
||||||
### Changed
|
### 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)
|
- Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected)
|
||||||
- Docker assets and docs updated:
|
- Docker assets and docs updated:
|
||||||
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
|
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
|
||||||
|
@ -51,6 +66,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Builder Review (Step 4): "Use only owned cards" toggle moved here; Step 5 is status-only with "Edit in Review" for changes
|
- 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
|
- 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
|
- 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)
|
- List view highlight polished to wrap only the card name (no overrun of the row)
|
||||||
- Total sources calculation updated to include 'C' properly
|
- 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)
|
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
|
||||||
|
@ -66,7 +87,9 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export
|
- 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
|
- 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
|
- 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
|
- 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
33
DOCKER.md
33
DOCKER.md
|
@ -34,6 +34,36 @@ Then open http://localhost:8080
|
||||||
|
|
||||||
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
|
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`.
|
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.
|
||||||
|
|
||||||
|
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)
|
### Diagnostics and logs (optional)
|
||||||
Enable internal diagnostics and a read-only logs viewer with environment flags.
|
Enable internal diagnostics and a read-only logs viewer with environment flags.
|
||||||
|
@ -44,6 +74,7 @@ Enable internal diagnostics and a read-only logs viewer with environment flags.
|
||||||
When enabled:
|
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.
|
- `/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`.
|
- `/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):
|
Compose example (web service):
|
||||||
```yaml
|
```yaml
|
||||||
|
@ -125,6 +156,8 @@ GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "upti
|
||||||
### Web UI tuning env vars
|
### Web UI tuning env vars
|
||||||
- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off)
|
- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off)
|
||||||
- WEB_TAG_WORKERS=<N> (process count; set based on CPU/memory)
|
- 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
|
## Manual build/run
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
@ -48,3 +48,7 @@ WORKDIR /app/code
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
||||||
|
# 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.
|
@ -8,6 +8,10 @@
|
||||||
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
|
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
|
||||||
- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
- 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.
|
- 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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
@ -16,6 +20,10 @@
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Diagnostics and error handling
|
||||||
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
|
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
|
||||||
|
@ -91,6 +99,7 @@ docker compose up --no-deps web
|
||||||
- Finished Decks page uses a dropdown theme filter with shareable state.
|
- 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.
|
- 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.
|
- 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
|
### Tagging updates
|
||||||
- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
|
- 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.
|
||||||
|
|
|
@ -46,6 +46,7 @@ Run the browser UI by mapping a port and starting uvicorn:
|
||||||
```powershell
|
```powershell
|
||||||
docker run --rm `
|
docker run --rm `
|
||||||
-p 8080:8080 `
|
-p 8080:8080 `
|
||||||
|
-e WEB_VIRTUALIZE=1 ` # optional virtualization
|
||||||
-v "${PWD}/deck_files:/app/deck_files" `
|
-v "${PWD}/deck_files:/app/deck_files" `
|
||||||
-v "${PWD}/logs:/app/logs" `
|
-v "${PWD}/logs:/app/logs" `
|
||||||
-v "${PWD}/csv_files:/app/csv_files" `
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
|
|
|
@ -343,6 +343,15 @@ class ReportingMixin:
|
||||||
return candidate
|
return candidate
|
||||||
i += 1
|
i += 1
|
||||||
if filename is None:
|
if filename is None:
|
||||||
|
# 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')
|
||||||
|
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 = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||||
# Collect themes in order
|
# Collect themes in order
|
||||||
|
@ -357,8 +366,8 @@ class ReportingMixin:
|
||||||
if not theme_parts:
|
if not theme_parts:
|
||||||
theme_parts = ['notheme']
|
theme_parts = ['notheme']
|
||||||
theme_slug = '_'.join(theme_parts)
|
theme_slug = '_'.join(theme_parts)
|
||||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
|
filename = f"{stem}.csv"
|
||||||
fname = _unique_path(os.path.join(directory, filename))
|
fname = _unique_path(os.path.join(directory, filename))
|
||||||
|
|
||||||
full_df = getattr(self, '_full_cards_df', None)
|
full_df = getattr(self, '_full_cards_df', None)
|
||||||
|
@ -534,6 +543,15 @@ class ReportingMixin:
|
||||||
return candidate
|
return candidate
|
||||||
i += 1
|
i += 1
|
||||||
if filename is None:
|
if filename is None:
|
||||||
|
# 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')
|
||||||
|
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 = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||||
themes: List[str] = []
|
themes: List[str] = []
|
||||||
|
@ -547,8 +565,8 @@ class ReportingMixin:
|
||||||
if not theme_parts:
|
if not theme_parts:
|
||||||
theme_parts = ['notheme']
|
theme_parts = ['notheme']
|
||||||
theme_slug = '_'.join(theme_parts)
|
theme_slug = '_'.join(theme_parts)
|
||||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
|
filename = f"{stem}.txt"
|
||||||
if not filename.lower().endswith('.txt'):
|
if not filename.lower().endswith('.txt'):
|
||||||
filename = filename + '.txt'
|
filename = filename + '.txt'
|
||||||
path = _unique_path(os.path.join(directory, filename))
|
path = _unique_path(os.path.join(directory, filename))
|
||||||
|
@ -643,6 +661,15 @@ class ReportingMixin:
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if filename is None:
|
if filename is None:
|
||||||
|
# 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')
|
||||||
|
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 = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||||
themes: List[str] = []
|
themes: List[str] = []
|
||||||
|
@ -656,8 +683,8 @@ class ReportingMixin:
|
||||||
if not theme_parts:
|
if not theme_parts:
|
||||||
theme_parts = ['notheme']
|
theme_parts = ['notheme']
|
||||||
theme_slug = '_'.join(theme_parts)
|
theme_slug = '_'.join(theme_parts)
|
||||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
|
filename = f"{stem}.json"
|
||||||
|
|
||||||
path = _unique_path(os.path.join(directory, filename))
|
path = _unique_path(os.path.join(directory, filename))
|
||||||
|
|
||||||
|
|
|
@ -208,8 +208,8 @@ def regenerate_csv_by_color(color: str) -> None:
|
||||||
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||||
|
|
||||||
logger.info(f'Regenerating {color} cards CSV')
|
logger.info(f'Regenerating {color} cards CSV')
|
||||||
# Use shared utilities to base-filter once then slice color
|
# Use shared utilities to base-filter once then slice color, honoring bans
|
||||||
base_df = filter_dataframe(df, [])
|
base_df = filter_dataframe(df, BANNED_CARDS)
|
||||||
base_df[base_df['colorIdentity'] == color_abv].to_csv(
|
base_df[base_df['colorIdentity'] == color_abv].to_csv(
|
||||||
f'{CSV_DIRECTORY}/{color}_cards.csv', index=False
|
f'{CSV_DIRECTORY}/{color}_cards.csv', index=False
|
||||||
)
|
)
|
||||||
|
|
|
@ -36,7 +36,8 @@ from .setup_constants import (
|
||||||
COLUMN_ORDER,
|
COLUMN_ORDER,
|
||||||
TAGGED_COLUMN_ORDER,
|
TAGGED_COLUMN_ORDER,
|
||||||
SETUP_COLORS,
|
SETUP_COLORS,
|
||||||
COLOR_ABRV
|
COLOR_ABRV,
|
||||||
|
BANNED_CARDS,
|
||||||
)
|
)
|
||||||
from exceptions import (
|
from exceptions import (
|
||||||
MTGJSONDownloadError,
|
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
|
# Base-filter once for efficiency, then per-color filter without redoing base filters
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
# Wrap any unexpected issues as DataFrameProcessingError
|
# Wrap any unexpected issues as DataFrameProcessingError
|
||||||
raise 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)]
|
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||||
logger.debug('Removed illegal sets')
|
logger.debug('Removed illegal sets')
|
||||||
|
|
||||||
# Remove banned cards
|
# Remove banned cards (exact, case-insensitive match on name or faceName)
|
||||||
for card in banned_cards:
|
if banned_cards:
|
||||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
banned_set = {b.casefold() for b in banned_cards}
|
||||||
logger.debug('Removed 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
|
# Remove special card types
|
||||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
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
|
# Apply base filtering
|
||||||
with tqdm(total=1, desc='Applying base filtering') as pbar:
|
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)
|
pbar.update(1)
|
||||||
|
|
||||||
# Filter by color identity
|
# Filter by color identity
|
||||||
|
|
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
|
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
|
|
@ -11,6 +11,8 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
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
|
# Resolve template/static dirs relative to this file
|
||||||
_THIS_DIR = Path(__file__).resolve().parent
|
_THIS_DIR = Path(__file__).resolve().parent
|
||||||
|
@ -18,10 +20,20 @@ _TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||||
_STATIC_DIR = _THIS_DIR / "static"
|
_STATIC_DIR = _THIS_DIR / "static"
|
||||||
|
|
||||||
app = FastAPI(title="MTG Deckbuilder Web UI")
|
app = FastAPI(title="MTG Deckbuilder Web UI")
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
|
|
||||||
# Mount static if present
|
# Mount static if present
|
||||||
if _STATIC_DIR.exists():
|
if _STATIC_DIR.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
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
|
# Jinja templates
|
||||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||||
|
@ -35,14 +47,42 @@ def _as_bool(val: str | None, default: bool = False) -> bool:
|
||||||
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
||||||
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||||
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
||||||
|
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
||||||
|
|
||||||
# Expose as Jinja globals so all templates can reference without passing per-view
|
# Expose as Jinja globals so all templates can reference without passing per-view
|
||||||
templates.env.globals.update({
|
templates.env.globals.update({
|
||||||
"show_logs": SHOW_LOGS,
|
"show_logs": SHOW_LOGS,
|
||||||
"show_setup": SHOW_SETUP,
|
"show_setup": SHOW_SETUP,
|
||||||
"show_diagnostics": SHOW_DIAGNOSTICS,
|
"show_diagnostics": SHOW_DIAGNOSTICS,
|
||||||
|
"virtualize": SHOW_VIRTUALIZE,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# --- 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 ---
|
# --- Diagnostics: request-id and uptime ---
|
||||||
_APP_START_TIME = time.time()
|
_APP_START_TIME = time.time()
|
||||||
|
|
||||||
|
@ -331,3 +371,11 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||||
if not SHOW_DIAGNOSTICS:
|
if not SHOW_DIAGNOSTICS:
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
|
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})
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
|
||||||
from ..app import templates
|
from ..app import templates
|
||||||
from ..services import owned_store
|
from ..services import owned_store
|
||||||
|
@ -47,6 +47,8 @@ def _list_decks() -> list[dict]:
|
||||||
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
||||||
meta["commander"] = _m.get('commander') or meta.get("commander")
|
meta["commander"] = _m.get('commander') or meta.get("commander")
|
||||||
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
||||||
|
if _m.get('name'):
|
||||||
|
meta["display"] = _m.get('name')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
||||||
|
@ -213,6 +215,38 @@ def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, i
|
||||||
return summary, type_counts, curve_counts, type_cards
|
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)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def decks_index(request: Request) -> HTMLResponse:
|
async def decks_index(request: Request) -> HTMLResponse:
|
||||||
items = _list_decks()
|
items = _list_decks()
|
||||||
|
@ -243,11 +277,14 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
_tags = meta.get('tags') or []
|
_tags = meta.get('tags') or []
|
||||||
if isinstance(_tags, list):
|
if isinstance(_tags, list):
|
||||||
tags = [str(t) for t in _tags]
|
tags = [str(t) for t in _tags]
|
||||||
|
display_name = meta.get('name') or ''
|
||||||
except Exception:
|
except Exception:
|
||||||
summary = None
|
summary = None
|
||||||
|
display_name = ''
|
||||||
if not summary:
|
if not summary:
|
||||||
# Reconstruct minimal summary from CSV
|
# Reconstruct minimal summary from CSV
|
||||||
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
||||||
|
display_name = ''
|
||||||
stem = p.stem
|
stem = p.stem
|
||||||
txt_path = p.with_suffix('.txt')
|
txt_path = p.with_suffix('.txt')
|
||||||
# If missing still, infer from filename stem
|
# If missing still, infer from filename stem
|
||||||
|
@ -263,7 +300,91 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"commander": commander_name,
|
"commander": commander_name,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
"display_name": display_name,
|
||||||
"game_changers": bc.GAME_CHANGERS,
|
"game_changers": bc.GAME_CHANGERS,
|
||||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("decks/view.html", ctx)
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
@ -781,6 +781,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
out(f"Reporting phase failed: {e}")
|
out(f"Reporting phase failed: {e}")
|
||||||
try:
|
try:
|
||||||
|
# If a custom export base is threaded via environment/session in web, we can respect env var
|
||||||
|
try:
|
||||||
|
custom_base = os.getenv('WEB_CUSTOM_EXPORT_BASE')
|
||||||
|
if custom_base:
|
||||||
|
setattr(b, 'custom_export_base', custom_base)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if hasattr(b, 'export_decklist_csv'):
|
if hasattr(b, 'export_decklist_csv'):
|
||||||
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
|
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -819,6 +826,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
||||||
"csv": csv_path,
|
"csv": csv_path,
|
||||||
"txt": txt_path,
|
"txt": txt_path,
|
||||||
}
|
}
|
||||||
|
# Attach custom deck name if provided
|
||||||
|
try:
|
||||||
|
custom_base = getattr(b, 'custom_export_base', None)
|
||||||
|
except Exception:
|
||||||
|
custom_base = None
|
||||||
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
|
meta["name"] = custom_base.strip()
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
@ -898,6 +912,8 @@ def start_build_ctx(
|
||||||
use_owned_only: bool | None = None,
|
use_owned_only: bool | None = None,
|
||||||
prefer_owned: bool | None = None,
|
prefer_owned: bool | None = None,
|
||||||
owned_names: List[str] | None = None,
|
owned_names: List[str] | None = None,
|
||||||
|
locks: List[str] | None = None,
|
||||||
|
custom_export_base: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
logs: List[str] = []
|
logs: List[str] = []
|
||||||
|
|
||||||
|
@ -974,6 +990,9 @@ def start_build_ctx(
|
||||||
"csv_path": None,
|
"csv_path": None,
|
||||||
"txt_path": None,
|
"txt_path": None,
|
||||||
"snapshot": None,
|
"snapshot": None,
|
||||||
|
"history": [], # list of {i, key, label, snapshot}
|
||||||
|
"locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()},
|
||||||
|
"custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None,
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
@ -1021,13 +1040,21 @@ def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
|
||||||
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
|
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
|
||||||
|
|
||||||
|
|
||||||
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False) -> Dict[str, Any]:
|
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False, *, replace: bool = False) -> Dict[str, Any]:
|
||||||
b: DeckBuilder = ctx["builder"]
|
b: DeckBuilder = ctx["builder"]
|
||||||
stages: List[Dict[str, Any]] = ctx["stages"]
|
stages: List[Dict[str, Any]] = ctx["stages"]
|
||||||
logs: List[str] = ctx["logs"]
|
logs: List[str] = ctx["logs"]
|
||||||
|
locks_set: set[str] = set(ctx.get("locks") or [])
|
||||||
|
|
||||||
# If all stages done, finalize exports (interactive/manual build)
|
# If all stages done, finalize exports (interactive/manual build)
|
||||||
if ctx["idx"] >= len(stages):
|
if ctx["idx"] >= len(stages):
|
||||||
|
# Apply custom export base if present in context
|
||||||
|
try:
|
||||||
|
custom_base = ctx.get("custom_export_base")
|
||||||
|
if custom_base:
|
||||||
|
setattr(b, 'custom_export_base', str(custom_base))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
||||||
try:
|
try:
|
||||||
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
|
@ -1045,6 +1072,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logs.append(f"Text export failed: {e}")
|
logs.append(f"Text export failed: {e}")
|
||||||
|
# Final lock enforcement before finishing
|
||||||
|
try:
|
||||||
|
for lname in locks_set:
|
||||||
|
try:
|
||||||
|
# If locked card missing, attempt to add a placeholder entry
|
||||||
|
if lname not in {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}:
|
||||||
|
# Try to find exact name in dataframes
|
||||||
|
target_name = None
|
||||||
|
try:
|
||||||
|
df = getattr(b, '_combined_cards_df', None)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
row = df[df['name'].astype(str).str.lower() == lname]
|
||||||
|
if not row.empty:
|
||||||
|
target_name = str(row.iloc[0]['name'])
|
||||||
|
except Exception:
|
||||||
|
target_name = None
|
||||||
|
if target_name is None:
|
||||||
|
# As fallback, use the locked name as-is (display only)
|
||||||
|
target_name = lname
|
||||||
|
b.card_library[target_name] = {
|
||||||
|
'Count': 1,
|
||||||
|
'Role': 'Locked',
|
||||||
|
'SubRole': '',
|
||||||
|
'AddedBy': 'Lock',
|
||||||
|
'TriggerTag': '',
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Build structured summary for UI
|
# Build structured summary for UI
|
||||||
summary = None
|
summary = None
|
||||||
try:
|
try:
|
||||||
|
@ -1067,6 +1124,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
"csv": ctx.get("csv_path"),
|
"csv": ctx.get("csv_path"),
|
||||||
"txt": ctx.get("txt_path"),
|
"txt": ctx.get("txt_path"),
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
custom_base = getattr(b, 'custom_export_base', None)
|
||||||
|
except Exception:
|
||||||
|
custom_base = None
|
||||||
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
|
meta["name"] = custom_base.strip()
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
@ -1095,8 +1158,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
label = stage["label"]
|
label = stage["label"]
|
||||||
runner_name = stage["runner_name"]
|
runner_name = stage["runner_name"]
|
||||||
|
|
||||||
# Take snapshot before executing; for rerun, restore first if we have one
|
# Take snapshot before executing; for rerun with replace, restore first if we have one
|
||||||
if rerun and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
|
if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1):
|
||||||
_restore_builder(b, ctx["snapshot"]) # restore to pre-stage state
|
_restore_builder(b, ctx["snapshot"]) # restore to pre-stage state
|
||||||
snap_before = _snapshot_builder(b)
|
snap_before = _snapshot_builder(b)
|
||||||
|
|
||||||
|
@ -1112,6 +1175,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
logs.append(f"Runner not available: {runner_name}")
|
logs.append(f"Runner not available: {runner_name}")
|
||||||
delta_log = "\n".join(logs[start_log:])
|
delta_log = "\n".join(logs[start_log:])
|
||||||
|
|
||||||
|
# Enforce locks immediately after the stage runs so they appear in added list
|
||||||
|
try:
|
||||||
|
for lname in locks_set:
|
||||||
|
try:
|
||||||
|
lib_keys_lower = {str(n).strip().lower(): str(n) for n in getattr(b, 'card_library', {}).keys()}
|
||||||
|
if lname not in lib_keys_lower:
|
||||||
|
# Try to resolve canonical name from DF
|
||||||
|
target_name = None
|
||||||
|
try:
|
||||||
|
df = getattr(b, '_combined_cards_df', None)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
row = df[df['name'].astype(str).str.lower() == lname]
|
||||||
|
if not row.empty:
|
||||||
|
target_name = str(row.iloc[0]['name'])
|
||||||
|
except Exception:
|
||||||
|
target_name = None
|
||||||
|
if target_name is None:
|
||||||
|
target_name = lname
|
||||||
|
b.card_library[target_name] = {
|
||||||
|
'Count': 1,
|
||||||
|
'Role': 'Locked',
|
||||||
|
'SubRole': '',
|
||||||
|
'AddedBy': 'Lock',
|
||||||
|
'TriggerTag': '',
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Compute added cards based on snapshot
|
# Compute added cards based on snapshot
|
||||||
try:
|
try:
|
||||||
prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {}
|
prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {}
|
||||||
|
@ -1170,6 +1263,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
except Exception:
|
except Exception:
|
||||||
added_total = 0
|
added_total = 0
|
||||||
ctx["snapshot"] = snap_before # snapshot for rerun
|
ctx["snapshot"] = snap_before # snapshot for rerun
|
||||||
|
try:
|
||||||
|
(ctx.setdefault("history", [])).append({
|
||||||
|
"i": i + 1,
|
||||||
|
"key": stage.get("key"),
|
||||||
|
"label": label,
|
||||||
|
"snapshot": snap_before,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
ctx["idx"] = i + 1
|
ctx["idx"] = i + 1
|
||||||
ctx["last_visible_idx"] = i + 1
|
ctx["last_visible_idx"] = i + 1
|
||||||
return {
|
return {
|
||||||
|
@ -1196,6 +1298,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
except Exception:
|
except Exception:
|
||||||
total_cards = None
|
total_cards = None
|
||||||
ctx["snapshot"] = snap_before
|
ctx["snapshot"] = snap_before
|
||||||
|
try:
|
||||||
|
(ctx.setdefault("history", [])).append({
|
||||||
|
"i": i + 1,
|
||||||
|
"key": stage.get("key"),
|
||||||
|
"label": label,
|
||||||
|
"snapshot": snap_before,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
ctx["idx"] = i + 1
|
ctx["idx"] = i + 1
|
||||||
ctx["last_visible_idx"] = i + 1
|
ctx["last_visible_idx"] = i + 1
|
||||||
return {
|
return {
|
||||||
|
@ -1210,12 +1321,19 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
"added_total": 0,
|
"added_total": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# No cards added and not showing skipped: advance to next
|
# No cards added and not showing skipped: advance to next stage and continue loop
|
||||||
i += 1
|
i += 1
|
||||||
# Continue loop to auto-advance
|
# Continue loop to auto-advance
|
||||||
|
|
||||||
# If we reached here, all remaining stages were no-ops; finalize exports
|
# If we reached here, all remaining stages were no-ops; finalize exports
|
||||||
ctx["idx"] = len(stages)
|
ctx["idx"] = len(stages)
|
||||||
|
# Apply custom export base if present
|
||||||
|
try:
|
||||||
|
custom_base = ctx.get("custom_export_base")
|
||||||
|
if custom_base:
|
||||||
|
setattr(b, 'custom_export_base', str(custom_base))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
|
||||||
try:
|
try:
|
||||||
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||||
|
@ -1255,6 +1373,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
||||||
"csv": ctx.get("csv_path"),
|
"csv": ctx.get("csv_path"),
|
||||||
"txt": ctx.get("txt_path"),
|
"txt": ctx.get("txt_path"),
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
custom_base = getattr(b, 'custom_export_base', None)
|
||||||
|
except Exception:
|
||||||
|
custom_base = None
|
||||||
|
if isinstance(custom_base, str) and custom_base.strip():
|
||||||
|
meta["name"] = custom_base.strip()
|
||||||
payload = {"meta": meta, "summary": summary}
|
payload = {"meta": meta, "summary": summary}
|
||||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
|
@ -110,6 +110,13 @@
|
||||||
document.addEventListener('keydown', function(e){
|
document.addEventListener('keydown', function(e){
|
||||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
|
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
|
||||||
var k = e.key.toLowerCase();
|
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](); }
|
if (keymap[k]){ e.preventDefault(); keymap[k](); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -165,6 +172,7 @@
|
||||||
hydrateProgress(document);
|
hydrateProgress(document);
|
||||||
syncShowSkipped(document);
|
syncShowSkipped(document);
|
||||||
initCardFilters(document);
|
initCardFilters(document);
|
||||||
|
initVirtualization(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hydrate progress bars with width based on data-pct
|
// Hydrate progress bars with width based on data-pct
|
||||||
|
@ -192,8 +200,31 @@
|
||||||
hydrateProgress(e.target);
|
hydrateProgress(e.target);
|
||||||
syncShowSkipped(e.target);
|
syncShowSkipped(e.target);
|
||||||
initCardFilters(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 ---
|
// --- Card grid filters, reasons, and collapsible groups ---
|
||||||
function initCardFilters(root){
|
function initCardFilters(root){
|
||||||
var section = (root || document).querySelector('section');
|
var section = (root || document).querySelector('section');
|
||||||
|
@ -368,4 +399,268 @@
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey);
|
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(_){ }
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -130,6 +130,11 @@ small, .muted{ color: var(--muted); }
|
||||||
text-align:center;
|
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.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 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 .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
|
||||||
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
||||||
|
@ -175,6 +180,9 @@ small, .muted{ color: var(--muted); }
|
||||||
.game-changer { color: var(--green-main); }
|
.game-changer { color: var(--green-main); }
|
||||||
.stack-card.game-changer { outline: 2px solid 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 Navigator */
|
||||||
.stage-nav { margin:.5rem 0 1rem; }
|
.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 ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
|
||||||
|
@ -221,3 +229,19 @@ small, .muted{ color: var(--muted); }
|
||||||
/* Inline error banner */
|
/* Inline error banner */
|
||||||
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
|
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
|
||||||
.inline-error-banner .muted{ color:#fda4af; }
|
.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; }
|
||||||
|
|
|
@ -6,18 +6,23 @@
|
||||||
<title>MTG Deckbuilder</title>
|
<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 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>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
|
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
|
||||||
|
<!-- Performance hints -->
|
||||||
|
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||||
|
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||||
<header class="top-banner">
|
<header class="top-banner">
|
||||||
<div class="top-inner">
|
<div class="top-inner">
|
||||||
<h1>MTG Deckbuilder</h1>
|
<h1>MTG Deckbuilder</h1>
|
||||||
<div style="display:flex; align-items:center; gap:.5rem">
|
<div style="display:flex; align-items:center; gap:.5rem">
|
||||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||||
|
<button type="button" class="btn" style="margin-left:.5rem;" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -261,7 +266,7 @@
|
||||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/app.js?v=20250826-2"></script>
|
<script src="/static/app.js?v=20250826-4"></script>
|
||||||
<script>
|
<script>
|
||||||
// Show pending toast after full page reloads when actions replace the whole document
|
// Show pending toast after full page reloads when actions replace the whole document
|
||||||
(function(){
|
(function(){
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
<div id="banner-status" hx-swap-oob="true">{% if step %}<span class="muted">{{ step }}{% if i is not none and n is not none %} ({{ i }}/{{ n }}){% endif %}</span>: {% endif %}{% if commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} - {{ tags|join(', ') }}{% endif %}</div>
|
<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>
|
|
@ -1,6 +1,5 @@
|
||||||
<section>
|
<section>
|
||||||
{% set step_index = 2 %}{% set step_total = 5 %}
|
{# Step phases removed #}
|
||||||
<h3>Step 2: Tags & Bracket</h3>
|
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||||
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
@ -8,8 +7,7 @@
|
||||||
</a>
|
</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow" data-skeleton>
|
<div class="grow" data-skeleton>
|
||||||
{% include "build/_stage_navigator.html" %}
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
|
|
||||||
|
|
||||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||||
|
@ -95,7 +93,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="margin-top:.5rem;">
|
<div style="margin-top:.5rem;">
|
||||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||||
<button type="submit">Start over</button>
|
<button type="submit">Start over</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -116,6 +114,7 @@
|
||||||
var countEl = document.getElementById('tag-count');
|
var countEl = document.getElementById('tag-count');
|
||||||
var orderEl = document.getElementById('tag-order');
|
var orderEl = document.getElementById('tag-order');
|
||||||
var commander = '{{ commander.name|e }}';
|
var commander = '{{ commander.name|e }}';
|
||||||
|
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
|
||||||
if (!chipHost) return;
|
if (!chipHost) return;
|
||||||
|
|
||||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||||
|
@ -158,6 +157,13 @@
|
||||||
}
|
}
|
||||||
function loadPersisted(){
|
function loadPersisted(){
|
||||||
try {
|
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 savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
|
||||||
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
|
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 ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<section>
|
<section>
|
||||||
{% set step_index = 3 %}{% set step_total = 5 %}
|
{# Step phases removed #}
|
||||||
<h3>Step 3: Ideal Counts</h3>
|
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
@ -8,8 +7,7 @@
|
||||||
</a>
|
</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow" data-skeleton>
|
<div class="grow" data-skeleton>
|
||||||
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
{% include "build/_stage_navigator.html" %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,7 +35,7 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div style="margin-top:.5rem;">
|
<div style="margin-top:.5rem;">
|
||||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||||
<button type="submit">Start over</button>
|
<button type="submit">Start over</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<section>
|
<section>
|
||||||
{% set step_index = 4 %}{% set step_total = 5 %}
|
{# Step phases removed #}
|
||||||
<h3>Step 4: Review</h3>
|
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||||
|
@ -8,8 +7,12 @@
|
||||||
</a>
|
</a>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow" data-skeleton>
|
<div class="grow" data-skeleton>
|
||||||
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
{% include "build/_stage_navigator.html" %}
|
{% 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>
|
<h4>Chosen Ideals</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{% for key, label in labels.items() %}
|
{% for key, label in labels.items() %}
|
||||||
|
@ -27,12 +30,13 @@
|
||||||
</label>
|
</label>
|
||||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||||
</form>
|
</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;">
|
<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;">
|
<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>
|
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||||
<button type="submit">Start over</button>
|
<button type="submit">Start over</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
<section>
|
<section>
|
||||||
{% set step_index = 5 %}{% set step_total = 5 %}
|
{# Step phases removed #}
|
||||||
<h3>Step 5: Build</h3>
|
|
||||||
<div class="two-col two-col-left-rail">
|
<div class="two-col two-col-left-rail">
|
||||||
<aside class="card-preview">
|
<aside class="card-preview">
|
||||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
<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 }}" />
|
<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>
|
</a>
|
||||||
{% if status and status.startswith('Build complete') %}
|
{% if status and status.startswith('Build complete') %}
|
||||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||||
|
@ -24,8 +25,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<div class="grow" data-skeleton>
|
<div class="grow" data-skeleton>
|
||||||
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
|
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||||
{% include "build/_stage_navigator.html" %}
|
|
||||||
|
|
||||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||||
|
@ -48,6 +48,10 @@
|
||||||
{% if added_total is not none %}
|
{% if added_total is not none %}
|
||||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||||
|
@ -62,6 +66,30 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Filters toolbar -->
|
||||||
<div class="cards-toolbar">
|
<div class="cards-toolbar">
|
||||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||||
|
@ -94,9 +122,9 @@
|
||||||
|
|
||||||
<!-- Sticky build controls on mobile -->
|
<!-- 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;">
|
<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('Starting build…'); }catch(_){}">
|
<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' }}" />
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
<button type="submit" class="btn-continue" data-action="continue">Start Build</button>
|
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||||
</form>
|
</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(_){}">
|
<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' }}" />
|
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||||
|
@ -106,6 +134,23 @@
|
||||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
<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>
|
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||||
</form>
|
</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;">
|
<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 %}
|
<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; }" />
|
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; }" />
|
||||||
|
@ -115,6 +160,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if added_cards is not none %}
|
{% 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>
|
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
{% 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>
|
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
|
||||||
|
@ -127,6 +189,7 @@
|
||||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||||
{% set groups = added_cards|groupby('sub_role') %}
|
{% set groups = added_cards|groupby('sub_role') %}
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
|
{% set group_idx = loop.index0 %}
|
||||||
{% set role = g.grouper %}
|
{% set role = g.grouper %}
|
||||||
{% if role %}
|
{% if role %}
|
||||||
{% set heading = 'Theme: ' + role.title() %}
|
{% set heading = 'Theme: ' + role.title() %}
|
||||||
|
@ -139,46 +202,81 @@
|
||||||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||||||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid group-grid" data-skeleton>
|
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||||
{% for c in g.list %}
|
{% for c in g.list %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
<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' }}">
|
||||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
<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' }}"
|
||||||
</a>
|
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="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="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 %}
|
{% if c.reason %}
|
||||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
<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-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>
|
||||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</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 %}
|
{% endif %}
|
||||||
|
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-grid" data-skeleton>
|
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||||
{% for c in added_cards %}
|
{% for c in added_cards %}
|
||||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
<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' }}">
|
||||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
<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' }}"
|
||||||
</a>
|
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="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="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 %}
|
{% if c.reason %}
|
||||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
<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-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>
|
||||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</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 %}
|
{% endif %}
|
||||||
|
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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;">
|
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||||
No cards match your filters.
|
No cards match your filters.
|
||||||
</div>
|
</div>
|
||||||
|
@ -201,3 +299,67 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
|
@ -2,24 +2,12 @@
|
||||||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Build a Deck</h2>
|
<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">
|
<div id="wizard">
|
||||||
{% set step = last_step or 1 %}
|
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||||
{% if step == 1 %}
|
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||||
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
|
||||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
|
|
||||||
{% elif step == 2 %}
|
|
||||||
<div hx-get="/build/step2" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
|
||||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=2&n=5" hx-trigger="load"></div>
|
|
||||||
{% elif step == 3 %}
|
|
||||||
<div hx-get="/build/step3" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
|
||||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=3&n=5" hx-trigger="load"></div>
|
|
||||||
{% elif step == 4 %}
|
|
||||||
<div hx-get="/build/step4" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
|
||||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=4&n=5" hx-trigger="load"></div>
|
|
||||||
{% else %}
|
|
||||||
<div hx-get="/build/step5" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
|
||||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=5&n=5" hx-trigger="load"></div>
|
|
||||||
{% endif %}
|
|
||||||
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
|
|
||||||
|
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
{% include "partials/deck_summary.html" %}
|
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
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 %}
|
|
@ -24,6 +24,10 @@
|
||||||
</label>
|
</label>
|
||||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</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-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>
|
<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-count" class="muted" aria-live="polite"></span>
|
||||||
|
@ -38,7 +42,12 @@
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||||
<div>
|
<div>
|
||||||
<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>
|
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if it.tags and it.tags|length %}
|
{% if it.tags and it.tags|length %}
|
||||||
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||||
|
@ -50,6 +59,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:.35rem; align-items:center;">
|
<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;">
|
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
<input type="hidden" name="path" value="{{ it.path }}" />
|
||||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||||
|
@ -112,11 +125,22 @@
|
||||||
var helpClose = document.getElementById('deck-help-close');
|
var helpClose = document.getElementById('deck-help-close');
|
||||||
var helpBackdrop = document.getElementById('deck-help-backdrop');
|
var helpBackdrop = document.getElementById('deck-help-backdrop');
|
||||||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
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;
|
if (!list) return;
|
||||||
|
|
||||||
// Panels and themes discovery from data-tags-pipe
|
// Panels and themes discovery from data-tags-pipe
|
||||||
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||||
function refreshPanels(){ 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();
|
var themeSet = new Set();
|
||||||
panels.forEach(function(p){
|
panels.forEach(function(p){
|
||||||
var raw = p.dataset.tagsPipe || '';
|
var raw = p.dataset.tagsPipe || '';
|
||||||
|
@ -309,6 +333,31 @@
|
||||||
updateHashFromState();
|
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
|
// Debounce helper
|
||||||
function debounce(fn, delay){
|
function debounce(fn, delay){
|
||||||
var timer = null;
|
var timer = null;
|
||||||
|
@ -332,6 +381,53 @@
|
||||||
applyAll();
|
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(){
|
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
||||||
// Clear UI state
|
// Clear UI state
|
||||||
try {
|
try {
|
||||||
|
@ -408,6 +504,10 @@
|
||||||
// React to external hash changes
|
// React to external hash changes
|
||||||
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
|
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
|
// Open deck: keyboard and mouse helpers on panels
|
||||||
function getPanelUrl(p){
|
function getPanelUrl(p){
|
||||||
try {
|
try {
|
||||||
|
@ -551,5 +651,6 @@
|
||||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
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"] { outline: none; }
|
||||||
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
|
#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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Finished Deck</h2>
|
<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">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 class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||||
|
|
||||||
|
@ -24,6 +27,7 @@
|
||||||
<button type="submit">Download TXT</button>
|
<button type="submit">Download TXT</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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;">
|
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||||
<button type="submit">Back to Finished Decks</button>
|
<button type="submit">Back to Finished Decks</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -54,7 +58,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "partials/deck_summary.html" %}
|
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="muted">No summary available.</div>
|
<div class="muted">No summary available.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -7,6 +7,15 @@
|
||||||
<h3 style="margin-top:0">System summary</h3>
|
<h3 style="margin-top:0">System summary</h3>
|
||||||
<div id="sysSummary" class="muted">Loading…</div>
|
<div id="sysSummary" class="muted">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card" style="background:#0f1115; 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>
|
||||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||||
<h3 style="margin-top:0">Error triggers</h3>
|
<h3 style="margin-top:0">Error triggers</h3>
|
||||||
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
||||||
|
@ -39,6 +48,40 @@
|
||||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
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();
|
load();
|
||||||
|
// 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>
|
</script>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
@ -70,7 +70,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if names and names|length %}
|
{% 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;">
|
<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;">
|
<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 %}
|
{% for n in names %}
|
||||||
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
||||||
|
@ -81,7 +81,9 @@
|
||||||
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
||||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||||
<div class="owned-vstack">
|
<div class="owned-vstack">
|
||||||
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
|
<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>
|
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||||
{% if cols and cols|length %}
|
{% if cols and cols|length %}
|
||||||
<div class="mana-group" aria-hidden="true">
|
<div class="mana-group" aria-hidden="true">
|
||||||
|
|
|
@ -81,7 +81,9 @@
|
||||||
{% set cnt = c.count if c.count else 1 %}
|
{% 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)) %}
|
{% 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 %}">
|
<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 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 '' }}" />
|
<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="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 class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -561,6 +563,12 @@
|
||||||
if (!on && !match) tile.classList.remove('chart-highlight');
|
if (!on && !match) tile.classList.remove('chart-highlight');
|
||||||
});
|
});
|
||||||
} catch(_) {}
|
} 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();
|
attach();
|
||||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||||
|
|
|
@ -38,6 +38,8 @@ services:
|
||||||
# Speed up setup/tagging in Web UI via parallel workers
|
# Speed up setup/tagging in Web UI via parallel workers
|
||||||
- WEB_TAG_PARALLEL=1
|
- WEB_TAG_PARALLEL=1
|
||||||
- WEB_TAG_WORKERS=4
|
- WEB_TAG_WORKERS=4
|
||||||
|
# Enable virtualization + lazy image tweaks in Step 5
|
||||||
|
- WEB_VIRTUALIZE=1
|
||||||
volumes:
|
volumes:
|
||||||
- ${PWD}/deck_files:/app/deck_files
|
- ${PWD}/deck_files:/app/deck_files
|
||||||
- ${PWD}/logs:/app/logs
|
- ${PWD}/logs:/app/logs
|
||||||
|
|
|
@ -14,13 +14,14 @@ if not exist "owned_cards" mkdir owned_cards
|
||||||
REM Flags (override by setting env vars before running)
|
REM Flags (override by setting env vars before running)
|
||||||
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
|
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
|
||||||
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
|
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
|
||||||
|
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
|
||||||
|
|
||||||
echo Starting Web UI on http://localhost:8080
|
echo Starting Web UI on http://localhost:8080
|
||||||
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS%
|
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% WEB_VIRTUALIZE=%WEB_VIRTUALIZE%
|
||||||
|
|
||||||
docker run --rm ^
|
docker run --rm ^
|
||||||
-p 8080:8080 ^
|
-p 8080:8080 ^
|
||||||
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% ^
|
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% -e WEB_VIRTUALIZE=%WEB_VIRTUALIZE% ^
|
||||||
-v "%cd%\deck_files:/app/deck_files" ^
|
-v "%cd%\deck_files:/app/deck_files" ^
|
||||||
-v "%cd%\logs:/app/logs" ^
|
-v "%cd%\logs:/app/logs" ^
|
||||||
-v "%cd%\csv_files:/app/csv_files" ^
|
-v "%cd%\csv_files:/app/csv_files" ^
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue