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
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
test.py
|
||||
!requirements.txt
|
||||
__pycache__/
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -13,7 +13,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Web UI performance: optional virtualized grids/lists in Step 5 and Owned (enable with `WEB_VIRTUALIZE=1`).
|
||||
- Virtualization diagnostics overlay (when `SHOW_DIAGNOSTICS=1`); press `v` to toggle per‑grid overlays and a global summary bubble with visible range, totals, render time, and counters.
|
||||
- Image polish: lazy‑loading with responsive `srcset/sizes` and LQIP blur/fade‑in for Step 5 and Owned thumbnails and the commander preview image.
|
||||
- Short‑TTL fragment caching for template partials (e.g., finished deck summaries and config run summaries) to reduce re‑render cost.
|
||||
- Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons
|
||||
- New Deck modal consolidating steps 1–3 with optional Name for exports, Enter-to-select commander, and disabled browser autofill
|
||||
- Locks, Replace flow, Compare builds, and shareable permalinks for finished decks
|
||||
- Compare page: Copy summary action to copy diffs (Only in A/B and Changed counts) to clipboard
|
||||
- Finished Decks multi-select → Compare with fallback to "Latest two"; options carry modified-time for ordering
|
||||
- Permalinks include locks; global "Open Permalink…" entry exposed in header and Finished Decks
|
||||
- Replace flow supports session-local Undo and lock-aware validation
|
||||
- New Deck modal: inline summary of selected themes with order (1, 2, 3)
|
||||
- Theme combine mode (AND/OR) with tooltips and selection-order display in the Web UI
|
||||
- AND-mode creatures pre-pass: select "all selected themes" creatures first, then fill by weighted overlap; staged reasons show matched themes
|
||||
- Scryfall attribution footer in the Web UI
|
||||
|
@ -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
|
||||
- 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
|
||||
- Accessibility: respect OS “reduced motion” by disabling blur/fade transitions and smooth scrolling.
|
||||
- Static asset caching and compression tuned for the web service (cache headers + gzip) to improve load performance.
|
||||
- Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected)
|
||||
- Docker assets and docs updated:
|
||||
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
|
||||
|
@ -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
|
||||
- Minor UI/CSS polish and consolidation across builder/owned pages
|
||||
- Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources
|
||||
- New Deck modal submits directly to build, removing the intermediate review step
|
||||
- Finished Decks banner and lists now prefer the custom Name provided in the modal
|
||||
- Step 5 Replace toggle now includes a tooltip clarifying that reruns will replace picks in that stage when enabled
|
||||
- Locks are enforced on rerun; the Locked section live-updates on unlock (row removal and chip refresh)
|
||||
- Compare page shows ▲/▼ indicators on Changed counts and preserves the "Changed only" toggle across interactions
|
||||
- Bracket selector shows numbered labels (e.g., "Bracket 3: Upgraded") and defaults to bracket 3 on new deck creation
|
||||
- List view highlight polished to wrap only the card name (no overrun of the row)
|
||||
- Total sources calculation updated to include 'C' properly
|
||||
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
|
||||
|
@ -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
|
||||
- Fetch lands are no longer miscounted as mana sources
|
||||
- Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler
|
||||
- Windows PowerShell curl parsing issue documented with guidance in README
|
||||
- Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints
|
||||
- Banned list filtering applied consistently to all color/guild CSV generation paths with exact, case-insensitive matching on name/faceName (e.g., Hullbreacher, Dockside Extortionist, and Lutri are excluded)
|
||||
|
||||
---
|
||||
|
||||
|
|
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.
|
||||
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)
|
||||
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:
|
||||
- `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail.
|
||||
- `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`.
|
||||
- Virtualization overlay: press `v` on pages with virtualized grids to toggle per-grid overlays and a global summary bubble.
|
||||
|
||||
Compose example (web service):
|
||||
```yaml
|
||||
|
@ -125,6 +156,8 @@ GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "upti
|
|||
### Web UI tuning env vars
|
||||
- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off)
|
||||
- WEB_TAG_WORKERS=<N> (process count; set based on CPU/memory)
|
||||
- WEB_VIRTUALIZE=1 (enable virtualization)
|
||||
- SHOW_DIAGNOSTICS=1 (enables diagnostics pages and overlay hotkey `v`)
|
||||
|
||||
## Manual build/run
|
||||
```powershell
|
||||
|
|
|
@ -48,3 +48,7 @@ WORKDIR /app/code
|
|||
|
||||
# Run the application
|
||||
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`).
|
||||
- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
||||
- Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=<N>` (compose default: 4). Falls back to sequential if parallel init fails.
|
||||
- Phase 8 UI upgrade: Unified “New Deck” modal (steps 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
|
||||
- Compare page now includes a Copy summary button to quickly share diffs.
|
||||
- New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking.
|
||||
- Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled.
|
||||
- Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources.
|
||||
- Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback).
|
||||
- Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts.
|
||||
|
@ -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.
|
||||
- Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews.
|
||||
- Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability.
|
||||
- Finished Decks: banner and lists prefer the run’s custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display.
|
||||
- Replace toggle includes a tooltip explaining that reruns will replace that stage’s picks when enabled.
|
||||
- Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck.
|
||||
- Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal.
|
||||
|
||||
### Diagnostics and error handling
|
||||
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
|
||||
|
@ -91,6 +99,7 @@ docker compose up --no-deps web
|
|||
- Finished Decks page uses a dropdown theme filter with shareable state.
|
||||
- Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity.
|
||||
- Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths.
|
||||
- Data integrity: per-color/guild CSVs now consistently respect the Commander banned list using exact, case-insensitive name/faceName matching.
|
||||
|
||||
### Tagging updates
|
||||
- Explore/Map: 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
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-e WEB_VIRTUALIZE=1 ` # optional virtualization
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
|
|
|
@ -343,22 +343,31 @@ class ReportingMixin:
|
|||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
# Collect themes in order
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Build a filename stem from either custom export base or commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
# Collect themes in order
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.csv"
|
||||
fname = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
full_df = getattr(self, '_full_cards_df', None)
|
||||
|
@ -534,21 +543,30 @@ class ReportingMixin:
|
|||
return candidate
|
||||
i += 1
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Prefer custom export base if provided; else fall back to commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.txt"
|
||||
if not filename.lower().endswith('.txt'):
|
||||
filename = filename + '.txt'
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
@ -643,21 +661,30 @@ class ReportingMixin:
|
|||
i += 1
|
||||
|
||||
if filename is None:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
# Prefer a custom export base when present; else commander/themes
|
||||
try:
|
||||
custom_base = getattr(self, 'custom_export_base', None)
|
||||
except Exception:
|
||||
custom_base = None
|
||||
date_part = _dt.date.today().strftime('%Y%m%d')
|
||||
filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json"
|
||||
if isinstance(custom_base, str) and custom_base.strip():
|
||||
stem = f"{_slug(custom_base.strip())}_{date_part}"
|
||||
else:
|
||||
cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or ''
|
||||
cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck'
|
||||
themes: List[str] = []
|
||||
if getattr(self, 'selected_tags', None):
|
||||
themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()]
|
||||
else:
|
||||
for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]:
|
||||
if isinstance(t, str) and t.strip():
|
||||
themes.append(t)
|
||||
theme_parts = [_slug(t) for t in themes if t]
|
||||
if not theme_parts:
|
||||
theme_parts = ['notheme']
|
||||
theme_slug = '_'.join(theme_parts)
|
||||
stem = f"{cmdr_slug}_{theme_slug}_{date_part}"
|
||||
filename = f"{stem}.json"
|
||||
|
||||
path = _unique_path(os.path.join(directory, filename))
|
||||
|
||||
|
|
|
@ -198,24 +198,24 @@ def regenerate_csv_by_color(color: str) -> None:
|
|||
try:
|
||||
if color not in SETUP_COLORS:
|
||||
raise ValueError(f'Invalid color: {color}')
|
||||
|
||||
|
||||
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
|
||||
|
||||
|
||||
logger.info(f'Downloading latest card data for {color} cards')
|
||||
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||
|
||||
|
||||
logger.info('Loading and processing card data')
|
||||
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||
|
||||
|
||||
logger.info(f'Regenerating {color} cards CSV')
|
||||
# Use shared utilities to base-filter once then slice color
|
||||
base_df = filter_dataframe(df, [])
|
||||
# Use shared utilities to base-filter once then slice color, honoring bans
|
||||
base_df = filter_dataframe(df, BANNED_CARDS)
|
||||
base_df[base_df['colorIdentity'] == color_abv].to_csv(
|
||||
f'{CSV_DIRECTORY}/{color}_cards.csv', index=False
|
||||
)
|
||||
|
||||
|
||||
logger.info(f'Successfully regenerated {color} cards database')
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to regenerate {color} cards: {str(e)}')
|
||||
raise
|
||||
|
|
|
@ -36,7 +36,8 @@ from .setup_constants import (
|
|||
COLUMN_ORDER,
|
||||
TAGGED_COLUMN_ORDER,
|
||||
SETUP_COLORS,
|
||||
COLOR_ABRV
|
||||
COLOR_ABRV,
|
||||
BANNED_CARDS,
|
||||
)
|
||||
from exceptions import (
|
||||
MTGJSONDownloadError,
|
||||
|
@ -138,7 +139,8 @@ def save_color_filtered_csvs(df: pd.DataFrame, out_dir: Union[str, Path]) -> Non
|
|||
|
||||
# Base-filter once for efficiency, then per-color filter without redoing base filters
|
||||
try:
|
||||
base_df = filter_dataframe(df, [])
|
||||
# Apply full standard filtering including banned list once, then slice per color
|
||||
base_df = filter_dataframe(df, BANNED_CARDS)
|
||||
except Exception as e:
|
||||
# Wrap any unexpected issues as DataFrameProcessingError
|
||||
raise DataFrameProcessingError(
|
||||
|
@ -207,10 +209,16 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
|||
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||
logger.debug('Removed illegal sets')
|
||||
|
||||
# Remove banned cards
|
||||
for card in banned_cards:
|
||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
||||
logger.debug('Removed banned cards')
|
||||
# Remove banned cards (exact, case-insensitive match on name or faceName)
|
||||
if banned_cards:
|
||||
banned_set = {b.casefold() for b in banned_cards}
|
||||
name_lc = filtered_df['name'].astype(str).str.casefold()
|
||||
face_lc = filtered_df['faceName'].astype(str).str.casefold()
|
||||
mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set))
|
||||
before = len(filtered_df)
|
||||
filtered_df = filtered_df[mask]
|
||||
after = len(filtered_df)
|
||||
logger.debug(f'Removed banned cards: {before - after} filtered out')
|
||||
|
||||
# Remove special card types
|
||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
||||
|
@ -268,7 +276,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr
|
|||
|
||||
# Apply base filtering
|
||||
with tqdm(total=1, desc='Applying base filtering') as pbar:
|
||||
filtered_df = filter_dataframe(df, [])
|
||||
filtered_df = filter_dataframe(df, BANNED_CARDS)
|
||||
pbar.update(1)
|
||||
|
||||
# Filter by color identity
|
||||
|
|
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 logging
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from typing import Any, Tuple
|
||||
|
||||
# Resolve template/static dirs relative to this file
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
|
@ -18,10 +20,20 @@ _TEMPLATES_DIR = _THIS_DIR / "templates"
|
|||
_STATIC_DIR = _THIS_DIR / "static"
|
||||
|
||||
app = FastAPI(title="MTG Deckbuilder Web UI")
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Mount static if present
|
||||
if _STATIC_DIR.exists():
|
||||
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
|
||||
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_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
|
||||
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
||||
|
||||
# Expose as Jinja globals so all templates can reference without passing per-view
|
||||
templates.env.globals.update({
|
||||
"show_logs": SHOW_LOGS,
|
||||
"show_setup": SHOW_SETUP,
|
||||
"show_diagnostics": SHOW_DIAGNOSTICS,
|
||||
"virtualize": SHOW_VIRTUALIZE,
|
||||
})
|
||||
|
||||
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
|
||||
_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {}
|
||||
_FRAGMENT_TTL_SECONDS = 60.0
|
||||
|
||||
def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str:
|
||||
"""Render a template fragment with an optional cache key and short TTL.
|
||||
|
||||
Intended for finished/immutable views (e.g., saved deck summaries). On error,
|
||||
falls back to direct rendering without cache interaction.
|
||||
"""
|
||||
try:
|
||||
if cache_key:
|
||||
now = time.time()
|
||||
k = (template_name, str(cache_key))
|
||||
hit = _FRAGMENT_CACHE.get(k)
|
||||
if hit and (now - hit[0]) < _FRAGMENT_TTL_SECONDS:
|
||||
return hit[1]
|
||||
html = templates.get_template(template_name).render(**ctx)
|
||||
_FRAGMENT_CACHE[k] = (now, html)
|
||||
return html
|
||||
return templates.get_template(template_name).render(**ctx)
|
||||
except Exception:
|
||||
return templates.get_template(template_name).render(**ctx)
|
||||
|
||||
templates.env.globals["render_cached"] = render_cached
|
||||
|
||||
# --- Diagnostics: request-id and uptime ---
|
||||
_APP_START_TIME = time.time()
|
||||
|
||||
|
@ -331,3 +371,11 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
|
|||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
async def diagnostics_perf(request: Request) -> HTMLResponse:
|
||||
"""Synthetic scroll performance page (diagnostics only)."""
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/perf.html", {"request": request})
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||
from pathlib import Path
|
||||
import csv
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
|
@ -47,6 +47,8 @@ def _list_decks() -> list[dict]:
|
|||
_m = payload.get('meta', {}) if isinstance(payload, dict) else {}
|
||||
meta["commander"] = _m.get('commander') or meta.get("commander")
|
||||
meta["tags"] = _m.get('tags') or meta.get("tags") or []
|
||||
if _m.get('name'):
|
||||
meta["display"] = _m.get('name')
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD
|
||||
|
@ -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
|
||||
|
||||
|
||||
def _read_deck_counts(csv_path: Path) -> Dict[str, int]:
|
||||
"""Read a CSV deck export and return a mapping of card name -> total count.
|
||||
|
||||
Falls back to zero on parse issues; ignores header case and missing columns.
|
||||
"""
|
||||
counts: Dict[str, int] = {}
|
||||
try:
|
||||
with csv_path.open('r', encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
headers = next(reader, [])
|
||||
name_idx = headers.index('Name') if 'Name' in headers else 0
|
||||
count_idx = headers.index('Count') if 'Count' in headers else 1
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
name = row[name_idx]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
cnt = int(float(row[count_idx])) if row[count_idx] else 1
|
||||
except Exception:
|
||||
cnt = 1
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
continue
|
||||
counts[name] = counts.get(name, 0) + cnt
|
||||
except Exception:
|
||||
pass
|
||||
return counts
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def decks_index(request: Request) -> HTMLResponse:
|
||||
items = _list_decks()
|
||||
|
@ -243,11 +277,14 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
_tags = meta.get('tags') or []
|
||||
if isinstance(_tags, list):
|
||||
tags = [str(t) for t in _tags]
|
||||
display_name = meta.get('name') or ''
|
||||
except Exception:
|
||||
summary = None
|
||||
display_name = ''
|
||||
if not summary:
|
||||
# Reconstruct minimal summary from CSV
|
||||
summary, _tc, _cc, _tcs = _read_csv_summary(p)
|
||||
display_name = ''
|
||||
stem = p.stem
|
||||
txt_path = p.with_suffix('.txt')
|
||||
# If missing still, infer from filename stem
|
||||
|
@ -263,7 +300,91 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
"summary": summary,
|
||||
"commander": commander_name,
|
||||
"tags": tags,
|
||||
"display_name": display_name,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
}
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
|
||||
|
||||
@router.get("/compare", response_class=HTMLResponse)
|
||||
async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[str] = None) -> HTMLResponse:
|
||||
"""Compare two finished deck CSVs and show diffs.
|
||||
|
||||
Query params:
|
||||
- A: filename of first deck (e.g., Alena_..._20250827.csv)
|
||||
- B: filename of second deck
|
||||
"""
|
||||
base = _deck_dir()
|
||||
items = _list_decks()
|
||||
# Build select options with friendly display labels
|
||||
options: List[Dict[str, str]] = []
|
||||
for it in items:
|
||||
label = it.get("display") or it.get("commander") or it.get("name")
|
||||
# Include mtime for "Latest two" selection refinement
|
||||
mt = it.get("mtime", 0)
|
||||
try:
|
||||
mt_val = str(int(mt))
|
||||
except Exception:
|
||||
mt_val = "0"
|
||||
options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type]
|
||||
|
||||
diffs = None
|
||||
metaA: Dict[str, str] = {}
|
||||
metaB: Dict[str, str] = {}
|
||||
if A and B:
|
||||
pA = (base / A)
|
||||
pB = (base / B)
|
||||
if _safe_within(base, pA) and _safe_within(base, pB) and pA.exists() and pB.exists():
|
||||
ca = _read_deck_counts(pA)
|
||||
cb = _read_deck_counts(pB)
|
||||
setA = set(ca.keys())
|
||||
setB = set(cb.keys())
|
||||
onlyA = sorted(list(setA - setB))
|
||||
onlyB = sorted(list(setB - setA))
|
||||
changed: List[Tuple[str, int, int]] = []
|
||||
for n in sorted(setA & setB):
|
||||
if ca.get(n, 0) != cb.get(n, 0):
|
||||
changed.append((n, ca.get(n, 0), cb.get(n, 0)))
|
||||
# Side meta (commander/name/tags) if available
|
||||
def _meta_for(path: Path) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {"filename": path.name}
|
||||
sc = path.with_suffix('.summary.json')
|
||||
try:
|
||||
if sc.exists():
|
||||
import json as _json
|
||||
payload = _json.loads(sc.read_text(encoding='utf-8'))
|
||||
if isinstance(payload, dict):
|
||||
m = payload.get('meta', {}) or {}
|
||||
out["display"] = (m.get('name') or '')
|
||||
out["commander"] = (m.get('commander') or '')
|
||||
out["tags"] = ', '.join(m.get('tags') or [])
|
||||
except Exception:
|
||||
pass
|
||||
if not out.get("commander"):
|
||||
parts = path.stem.split('_')
|
||||
if parts:
|
||||
out["commander"] = parts[0]
|
||||
return out
|
||||
metaA = _meta_for(pA)
|
||||
metaB = _meta_for(pB)
|
||||
diffs = {
|
||||
"onlyA": onlyA,
|
||||
"onlyB": onlyB,
|
||||
"changed": changed,
|
||||
"A": A,
|
||||
"B": B,
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"decks/compare.html",
|
||||
{
|
||||
"request": request,
|
||||
"options": options,
|
||||
"A": A or "",
|
||||
"B": B or "",
|
||||
"diffs": diffs,
|
||||
"metaA": metaA,
|
||||
"metaB": metaB,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -781,6 +781,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
except Exception as e:
|
||||
out(f"Reporting phase failed: {e}")
|
||||
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'):
|
||||
csv_path = b.export_decklist_csv() # type: ignore[attr-defined]
|
||||
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,
|
||||
"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}
|
||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
@ -898,6 +912,8 @@ def start_build_ctx(
|
|||
use_owned_only: bool | None = None,
|
||||
prefer_owned: bool | None = None,
|
||||
owned_names: List[str] | None = None,
|
||||
locks: List[str] | None = None,
|
||||
custom_export_base: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
@ -974,6 +990,9 @@ def start_build_ctx(
|
|||
"csv_path": None,
|
||||
"txt_path": 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
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
||||
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"]
|
||||
stages: List[Dict[str, Any]] = ctx["stages"]
|
||||
logs: List[str] = ctx["logs"]
|
||||
locks_set: set[str] = set(ctx.get("locks") or [])
|
||||
|
||||
# If all stages done, finalize exports (interactive/manual build)
|
||||
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'):
|
||||
try:
|
||||
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
|
||||
except Exception as 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
|
||||
summary = None
|
||||
try:
|
||||
|
@ -1067,6 +1124,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
"csv": ctx.get("csv_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}
|
||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||
_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"]
|
||||
runner_name = stage["runner_name"]
|
||||
|
||||
# Take snapshot before executing; for rerun, 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):
|
||||
# Take snapshot before executing; for rerun with replace, restore first if we have one
|
||||
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
|
||||
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}")
|
||||
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
|
||||
try:
|
||||
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:
|
||||
added_total = 0
|
||||
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["last_visible_idx"] = i + 1
|
||||
return {
|
||||
|
@ -1196,6 +1298,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
except Exception:
|
||||
total_cards = None
|
||||
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["last_visible_idx"] = i + 1
|
||||
return {
|
||||
|
@ -1210,12 +1321,19 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
|
|||
"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
|
||||
# Continue loop to auto-advance
|
||||
|
||||
# If we reached here, all remaining stages were no-ops; finalize exports
|
||||
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'):
|
||||
try:
|
||||
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"),
|
||||
"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}
|
||||
with open(sidecar, 'w', encoding='utf-8') as f:
|
||||
_json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
|
|
@ -110,6 +110,13 @@
|
|||
document.addEventListener('keydown', function(e){
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
|
||||
var k = e.key.toLowerCase();
|
||||
// If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
|
||||
try {
|
||||
var active = document.activeElement;
|
||||
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
|
||||
return;
|
||||
}
|
||||
} catch(_) { /* noop */ }
|
||||
if (keymap[k]){ e.preventDefault(); keymap[k](); }
|
||||
});
|
||||
|
||||
|
@ -165,6 +172,7 @@
|
|||
hydrateProgress(document);
|
||||
syncShowSkipped(document);
|
||||
initCardFilters(document);
|
||||
initVirtualization(document);
|
||||
});
|
||||
|
||||
// Hydrate progress bars with width based on data-pct
|
||||
|
@ -192,8 +200,31 @@
|
|||
hydrateProgress(e.target);
|
||||
syncShowSkipped(e.target);
|
||||
initCardFilters(e.target);
|
||||
initVirtualization(e.target);
|
||||
});
|
||||
|
||||
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
|
||||
function scrollCardIntoView(name){
|
||||
if (!name) return;
|
||||
try{
|
||||
var section = document.querySelector('section');
|
||||
var grid = section && section.querySelector('.card-grid');
|
||||
if (!grid) return;
|
||||
// If virtualized, force a render around the approximate match by searching stored children
|
||||
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if (!target) {
|
||||
// Trigger a render update and try again
|
||||
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
|
||||
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
}
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
target.focus && target.focus();
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
window.scrollCardIntoView = scrollCardIntoView;
|
||||
|
||||
// --- Card grid filters, reasons, and collapsible groups ---
|
||||
function initCardFilters(root){
|
||||
var section = (root || document).querySelector('section');
|
||||
|
@ -250,7 +281,7 @@
|
|||
}
|
||||
});
|
||||
// Filter tiles
|
||||
var tiles = section.querySelectorAll('.card-grid .card-tile');
|
||||
var tiles = section.querySelectorAll('.card-grid .card-tile');
|
||||
var visible = 0;
|
||||
tiles.forEach(function(tile){
|
||||
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
|
||||
|
@ -272,7 +303,7 @@
|
|||
return { name: name.toLowerCase(), owned: owned, gc: gc };
|
||||
}
|
||||
section.querySelectorAll('.card-grid').forEach(function(grid){
|
||||
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
|
||||
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
|
||||
arr.sort(function(a,b){
|
||||
var ka = keyFor(a), kb = keyFor(b);
|
||||
if (sortMode === 'owned'){
|
||||
|
@ -368,4 +399,268 @@
|
|||
}
|
||||
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;
|
||||
}
|
||||
.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; }
|
||||
.card-tile.locked{
|
||||
/* Subtle yellow/goldish-white accent for locked cards */
|
||||
border-color: #f5e6a8; /* soft parchment gold */
|
||||
box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset;
|
||||
}
|
||||
.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; }
|
||||
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
|
||||
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
||||
|
@ -175,6 +180,9 @@ small, .muted{ color: var(--muted); }
|
|||
.game-changer { color: var(--green-main); }
|
||||
.stack-card.game-changer { outline: 2px solid var(--green-main); }
|
||||
|
||||
/* Image button inside card tiles */
|
||||
.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; }
|
||||
|
||||
/* Stage Navigator */
|
||||
.stage-nav { margin:.5rem 0 1rem; }
|
||||
.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
|
||||
|
@ -221,3 +229,19 @@ small, .muted{ color: var(--muted); }
|
|||
/* Inline error banner */
|
||||
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
|
||||
.inline-error-banner .muted{ color:#fda4af; }
|
||||
|
||||
/* 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>
|
||||
<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" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||
<header class="top-banner">
|
||||
<div class="top-inner">
|
||||
<h1>MTG Deckbuilder</h1>
|
||||
<div style="display:flex; align-items:center; gap:.5rem">
|
||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
<button type="button" class="btn" 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>
|
||||
</header>
|
||||
|
@ -261,7 +266,7 @@
|
|||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/app.js?v=20250826-2"></script>
|
||||
<script src="/static/app.js?v=20250826-4"></script>
|
||||
<script>
|
||||
// Show pending toast after full page reloads when actions replace the whole document
|
||||
(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>
|
||||
{% set step_index = 2 %}{% set step_total = 5 %}
|
||||
<h3>Step 2: Tags & Bracket</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||
|
@ -8,8 +7,7 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
|
@ -95,7 +93,7 @@
|
|||
</form>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -116,6 +114,7 @@
|
|||
var countEl = document.getElementById('tag-count');
|
||||
var orderEl = document.getElementById('tag-order');
|
||||
var commander = '{{ commander.name|e }}';
|
||||
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
|
||||
if (!chipHost) return;
|
||||
|
||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||
|
@ -158,6 +157,13 @@
|
|||
}
|
||||
function loadPersisted(){
|
||||
try {
|
||||
// If this page load follows a fresh commander confirmation, wipe persisted values.
|
||||
if (clearPersisted){
|
||||
try {
|
||||
localStorage.removeItem(storageKey('tags'));
|
||||
localStorage.removeItem(storageKey('mode'));
|
||||
} catch(_){ }
|
||||
}
|
||||
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
|
||||
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
|
||||
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<section>
|
||||
{% set step_index = 3 %}{% set step_total = 5 %}
|
||||
<h3>Step 3: Ideal Counts</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
|
@ -8,8 +7,7 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
|
||||
|
||||
|
@ -37,7 +35,7 @@
|
|||
</div>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<section>
|
||||
{% set step_index = 4 %}{% set step_total = 5 %}
|
||||
<h3>Step 4: Review</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
|
@ -8,8 +7,12 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
{% if locks_restored and locks_restored > 0 %}
|
||||
<div class="muted" style="margin:.35rem 0;">
|
||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h4>Chosen Ideals</h4>
|
||||
<ul>
|
||||
{% for key, label in labels.items() %}
|
||||
|
@ -27,12 +30,13 @@
|
|||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
|
||||
</form>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
<form 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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<section>
|
||||
{% set step_index = 5 %}{% set step_total = 5 %}
|
||||
<h3>Step 5: Build</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
@ -24,8 +25,7 @@
|
|||
{% endif %}
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
@ -39,7 +39,7 @@
|
|||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
{% if i and n %}
|
||||
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
|
||||
{% endif %}
|
||||
|
@ -48,6 +48,10 @@
|
|||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||
{% endif %}
|
||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
|
||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||||
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||||
</div>
|
||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||
|
@ -62,7 +66,31 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
{% for lk in locked_cards %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
|
||||
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
|
||||
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
|
||||
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
|
||||
<input type="hidden" name="name" value="{{ lk.name }}" />
|
||||
<input type="hidden" name="locked" value="0" />
|
||||
<input type="hidden" name="from_list" value="1" />
|
||||
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last action chip (oob-updated) -->
|
||||
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
<div class="cards-toolbar">
|
||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||
<select name="filter_owned" data-pref="cards:owned">
|
||||
|
@ -92,11 +120,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<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' }}" />
|
||||
<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 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' }}" />
|
||||
|
@ -106,6 +134,23 @@
|
|||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<span class="sep"></span>
|
||||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
|
||||
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
|
||||
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
|
||||
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
|
||||
Replace stage picks
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
|
||||
</form>
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
|
||||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||||
|
@ -115,7 +160,24 @@
|
|||
</div>
|
||||
|
||||
{% if added_cards is not none %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if history is defined and history %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Stage timeline</summary>
|
||||
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
{% for h in history %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem;">
|
||||
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
|
||||
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="to" value="{{ h.i }}" />
|
||||
<button type="submit" class="btn">Go</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
||||
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
|
||||
{% endif %}
|
||||
|
@ -127,6 +189,7 @@
|
|||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
{% set group_idx = loop.index0 %}
|
||||
{% set role = g.grouper %}
|
||||
{% if role %}
|
||||
{% set heading = 'Theme: ' + role.title() %}
|
||||
|
@ -139,46 +202,81 @@
|
|||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||||
</div>
|
||||
<div class="card-grid group-grid" data-skeleton>
|
||||
{% for c in g.list %}
|
||||
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in g.list %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card-grid" data-skeleton>
|
||||
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in added_cards %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Click a card to lock or unlock it. Locked cards are kept across reruns and won’t be replaced unless you unlock them.</div>
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||
No cards match your filters.
|
||||
</div>
|
||||
|
@ -201,3 +299,67 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
// Sync tile class and image-button toggle after lock button swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
try{
|
||||
const tgt = ev.target;
|
||||
if(!tgt) return;
|
||||
// Only act for lock-box updates
|
||||
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
||||
const tile = tgt.closest('.card-tile');
|
||||
if(!tile) return;
|
||||
const lockBtn = tgt.querySelector('.btn-lock');
|
||||
if(lockBtn){
|
||||
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
||||
tile.classList.toggle('locked', isLocked);
|
||||
const imgBtn = tile.querySelector('.img-btn');
|
||||
if(imgBtn){
|
||||
try{
|
||||
const valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
||||
const cur = JSON.parse(valsAttr.replace(/"/g, '"'));
|
||||
const next = isLocked ? '0' : '1';
|
||||
// Keep name stable; fallback to tile data attribute
|
||||
const nm = cur.name || tile.getAttribute('data-card-name') || '';
|
||||
imgBtn.setAttribute('hx-vals', JSON.stringify({ name: nm, locked: next }));
|
||||
imgBtn.title = 'Click to ' + (isLocked ? 'unlock' : 'lock') + ' this card';
|
||||
try { imgBtn.setAttribute('aria-pressed', isLocked ? 'true' : 'false'); } catch(_){ }
|
||||
}catch(_){/* noop */}
|
||||
}
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
// Allow dismissing/auto-clearing the last-action chip
|
||||
document.addEventListener('click', function(ev){
|
||||
try{
|
||||
var t = ev.target;
|
||||
if (!t) return;
|
||||
if (t.matches && t.matches('#last-action .chip')){
|
||||
var c = document.getElementById('last-action');
|
||||
if (c) c.innerHTML = '';
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
||||
|
||||
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
// Ignore when typing in inputs/selects
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
|
||||
if (!tile) return;
|
||||
if (e.key === 'l' || e.key === 'L') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
|
||||
if (lockFormBtn) { lockFormBtn.click(); }
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
|
||||
if (altBtn) { altBtn.click(); }
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -2,24 +2,12 @@
|
|||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div style="margin:.25rem 0 1rem 0;">
|
||||
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
|
||||
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
</div>
|
||||
<div id="wizard">
|
||||
{% set step = last_step or 1 %}
|
||||
{% if step == 1 %}
|
||||
<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>
|
||||
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
|
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>
|
||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
|
||||
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
|
||||
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
|
||||
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
|
||||
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
|
||||
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
||||
<span id="deck-count" class="muted" aria-live="polite"></span>
|
||||
|
@ -34,11 +38,16 @@
|
|||
{% if items %}
|
||||
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
{% for it in items %}
|
||||
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
|
||||
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<div>
|
||||
<div>
|
||||
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||
{% if it.display %}
|
||||
<strong>{{ it.display }}</strong>
|
||||
<div class="muted" style="font-size:12px;">Commander: <span data-card-name="{{ it.commander }}">{{ it.commander }}</span></div>
|
||||
{% else %}
|
||||
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if it.tags and it.tags|length %}
|
||||
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||
|
@ -50,6 +59,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:.35rem; align-items:center;">
|
||||
<label title="Select deck for comparison" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
|
||||
<span class="muted" style="font-size:12px;">Select</span>
|
||||
</label>
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||
|
@ -112,11 +125,22 @@
|
|||
var helpClose = document.getElementById('deck-help-close');
|
||||
var helpBackdrop = document.getElementById('deck-help-backdrop');
|
||||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||
var cmpSelBtn = document.getElementById('deck-compare-selected');
|
||||
var cmpLatestBtn = document.getElementById('deck-compare-latest');
|
||||
var openPermalinkBtn = document.getElementById('deck-open-permalink');
|
||||
if (!list) return;
|
||||
|
||||
// Panels and themes discovery from data-tags-pipe
|
||||
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
|
||||
// Selection state for compare
|
||||
var selected = new Set();
|
||||
function updateCompareButtons(){
|
||||
if (!cmpSelBtn) return;
|
||||
var size = selected.size;
|
||||
cmpSelBtn.disabled = (size !== 2);
|
||||
if (cmpSelBtn) cmpSelBtn.title = (size === 2 ? 'Compare the two selected decks' : 'Select exactly two decks to enable');
|
||||
}
|
||||
var themeSet = new Set();
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
|
@ -309,6 +333,31 @@
|
|||
updateHashFromState();
|
||||
}
|
||||
|
||||
// Wire up compare selection checkboxes
|
||||
function attachSelectHandlers(){
|
||||
try {
|
||||
var cbs = Array.prototype.slice.call(list.querySelectorAll('input.deck-select'));
|
||||
cbs.forEach(function(cb){
|
||||
// Initialize checked state based on current selection
|
||||
var row = cb.closest('.panel');
|
||||
var name = row ? (row.dataset.name || '') : '';
|
||||
cb.checked = selected.has(name);
|
||||
// Apply visual state on init
|
||||
if (row) row.classList.toggle('selected', cb.checked);
|
||||
cb.addEventListener('change', function(){
|
||||
if (!name) return;
|
||||
if (cb.checked) { selected.add(name); }
|
||||
else { selected.delete(name); }
|
||||
// Toggle selection highlight
|
||||
if (row) row.classList.toggle('selected', cb.checked);
|
||||
updateCompareButtons();
|
||||
});
|
||||
});
|
||||
updateCompareButtons();
|
||||
} catch(_){}
|
||||
}
|
||||
attachSelectHandlers();
|
||||
|
||||
// Debounce helper
|
||||
function debounce(fn, delay){
|
||||
var timer = null;
|
||||
|
@ -332,6 +381,53 @@
|
|||
applyAll();
|
||||
});
|
||||
|
||||
// Compare selected action
|
||||
if (cmpSelBtn) cmpSelBtn.addEventListener('click', function(){
|
||||
try {
|
||||
if (selected.size !== 2) return;
|
||||
var arr = Array.from(selected);
|
||||
var url = '/decks/compare?A=' + encodeURIComponent(arr[0]) + '&B=' + encodeURIComponent(arr[1]);
|
||||
window.location.href = url;
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Latest two (by modified time across all decks, not just visible)
|
||||
if (cmpLatestBtn) cmpLatestBtn.addEventListener('click', function(){
|
||||
try {
|
||||
// Gather all panels (including hidden) and sort by data-mtime desc
|
||||
var rows = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
rows.sort(function(a,b){
|
||||
var am = parseFloat(a.dataset.mtime || '0');
|
||||
var bm = parseFloat(b.dataset.mtime || '0');
|
||||
return bm - am;
|
||||
});
|
||||
// Take first two distinct names
|
||||
var pick = [];
|
||||
for (var i=0; i<rows.length && pick.length<2; i++){
|
||||
var nm = rows[i].dataset.name || '';
|
||||
if (nm && pick.indexOf(nm) === -1) pick.push(nm);
|
||||
}
|
||||
if (pick.length === 2){
|
||||
var url = '/decks/compare?A=' + encodeURIComponent(pick[0]) + '&B=' + encodeURIComponent(pick[1]);
|
||||
window.location.href = url;
|
||||
} else {
|
||||
if (window.toast) window.toast('Need at least two decks');
|
||||
}
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Open permalink prompt
|
||||
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
|
||||
try{
|
||||
var token = prompt('Paste a /build/from?state=... URL or token:');
|
||||
if(!token) return;
|
||||
var m = token.match(/state=([^&]+)/);
|
||||
var t = m ? m[1] : token.trim();
|
||||
if(!t) return;
|
||||
window.location.href = '/build/from?state=' + encodeURIComponent(t);
|
||||
}catch(_){ }
|
||||
});
|
||||
|
||||
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
||||
// Clear UI state
|
||||
try {
|
||||
|
@ -408,6 +504,10 @@
|
|||
// React to external hash changes
|
||||
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
|
||||
|
||||
// Re-attach selection handlers when list changes order
|
||||
var observer = new MutationObserver(function(){ attachSelectHandlers(); });
|
||||
try { observer.observe(list, { childList: true }); } catch(_){ }
|
||||
|
||||
// Open deck: keyboard and mouse helpers on panels
|
||||
function getPanelUrl(p){
|
||||
try {
|
||||
|
@ -551,5 +651,6 @@
|
|||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
|
||||
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
|
||||
#deck-list .panel.selected { box-shadow: 0 0 0 2px #10b981 inset; border-color: #10b981; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Finished Deck</h2>
|
||||
{% if display_name %}
|
||||
<div><strong>{{ display_name }}</strong></div>
|
||||
{% endif %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
|
||||
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
|
||||
|
||||
|
@ -24,6 +27,7 @@
|
|||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Finished Decks</button>
|
||||
</form>
|
||||
|
@ -54,7 +58,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="muted">No summary available.</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -7,6 +7,15 @@
|
|||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
</div>
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; 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;">
|
||||
<h3 style="margin-top:0">Error triggers</h3>
|
||||
<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'; }
|
||||
}
|
||||
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>
|
||||
{% 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 %}
|
||||
|
||||
{% 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;">
|
||||
{% for n in names %}
|
||||
{% 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">
|
||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||
<div class="owned-vstack">
|
||||
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
||||
sizes="100px" />
|
||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
<div class="mana-group" aria-hidden="true">
|
||||
|
|
|
@ -81,7 +81,9 @@
|
|||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
<img 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="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
</div>
|
||||
|
@ -539,7 +541,7 @@
|
|||
}
|
||||
function highlightNames(names, on){
|
||||
if (!Array.isArray(names) || names.length === 0) return;
|
||||
// List view spans
|
||||
// List view spans
|
||||
try {
|
||||
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
|
@ -550,7 +552,7 @@
|
|||
if (!on && !match) it.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// Thumbs view images
|
||||
// Thumbs view images
|
||||
try {
|
||||
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
|
@ -561,6 +563,12 @@
|
|||
if (!on && !match) tile.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// If virtualized lists are enabled, auto-scroll the Step 5 grid to the first match
|
||||
try {
|
||||
if (on && window.scrollCardIntoView && Array.isArray(names) && names.length) {
|
||||
window.scrollCardIntoView(names[0]);
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
|
|
|
@ -38,6 +38,8 @@ services:
|
|||
# Speed up setup/tagging in Web UI via parallel workers
|
||||
- WEB_TAG_PARALLEL=1
|
||||
- WEB_TAG_WORKERS=4
|
||||
# Enable virtualization + lazy image tweaks in Step 5
|
||||
- WEB_VIRTUALIZE=1
|
||||
volumes:
|
||||
- ${PWD}/deck_files:/app/deck_files
|
||||
- ${PWD}/logs:/app/logs
|
||||
|
|
|
@ -14,13 +14,14 @@ if not exist "owned_cards" mkdir owned_cards
|
|||
REM Flags (override by setting env vars before running)
|
||||
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
|
||||
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
|
||||
if "%WEB_VIRTUALIZE%"=="" set WEB_VIRTUALIZE=0
|
||||
|
||||
echo Starting Web UI on http://localhost:8080
|
||||
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 ^
|
||||
-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%\logs:/app/logs" ^
|
||||
-v "%cd%\csv_files:/app/csv_files" ^
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue