mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
overhaul: migrated to tailwind css for css management, consolidated custom css, removed inline css, removed unneeded css, and otherwise improved page styling
This commit is contained in:
parent
f1e21873e7
commit
b994978f60
81 changed files with 15784 additions and 2936 deletions
|
|
@ -1,22 +1,18 @@
|
|||
"""Loader for background cards derived from `background_cards.csv`."""
|
||||
"""Loader for background cards derived from all_cards.parquet."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import csv
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Mapping, Tuple
|
||||
from typing import Any, Mapping, Tuple
|
||||
|
||||
from logging_util import get_logger
|
||||
from deck_builder.partner_background_utils import analyze_partner_background
|
||||
from path_util import csv_dir
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
BACKGROUND_FILENAME = "background_cards.csv"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BackgroundCard:
|
||||
|
|
@ -57,7 +53,7 @@ class BackgroundCatalog:
|
|||
def load_background_cards(
|
||||
source_path: str | Path | None = None,
|
||||
) -> BackgroundCatalog:
|
||||
"""Load and cache background card data."""
|
||||
"""Load and cache background card data from all_cards.parquet."""
|
||||
|
||||
resolved = _resolve_background_path(source_path)
|
||||
try:
|
||||
|
|
@ -65,7 +61,7 @@ def load_background_cards(
|
|||
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))
|
||||
size = stat.st_size
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Background CSV not found at {resolved}") from None
|
||||
raise FileNotFoundError(f"Background data not found at {resolved}") from None
|
||||
|
||||
entries, version = _load_background_cards_cached(str(resolved), mtime_ns)
|
||||
etag = f"{size}-{mtime_ns}-{len(entries)}"
|
||||
|
|
@ -88,46 +84,49 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B
|
|||
if not path.exists():
|
||||
return tuple(), "unknown"
|
||||
|
||||
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||
first_line = handle.readline()
|
||||
version = "unknown"
|
||||
if first_line.startswith("#"):
|
||||
version = _parse_version(first_line)
|
||||
else:
|
||||
handle.seek(0)
|
||||
reader = csv.DictReader(handle)
|
||||
if reader.fieldnames is None:
|
||||
return tuple(), version
|
||||
entries = _rows_to_cards(reader)
|
||||
try:
|
||||
import pandas as pd
|
||||
df = pd.read_parquet(path, engine="pyarrow")
|
||||
|
||||
# Filter for background cards
|
||||
if 'isBackground' not in df.columns:
|
||||
LOGGER.warning("isBackground column not found in %s", path)
|
||||
return tuple(), "unknown"
|
||||
|
||||
df_backgrounds = df[df['isBackground']].copy()
|
||||
|
||||
if len(df_backgrounds) == 0:
|
||||
LOGGER.warning("No background cards found in %s", path)
|
||||
return tuple(), "unknown"
|
||||
|
||||
entries = _rows_to_cards(df_backgrounds)
|
||||
version = "parquet"
|
||||
|
||||
except Exception as e:
|
||||
LOGGER.error("Failed to load backgrounds from %s: %s", path, e)
|
||||
return tuple(), "unknown"
|
||||
|
||||
frozen = tuple(entries)
|
||||
return frozen, version
|
||||
|
||||
|
||||
def _resolve_background_path(override: str | Path | None) -> Path:
|
||||
"""Resolve path to all_cards.parquet."""
|
||||
if override:
|
||||
return Path(override).resolve()
|
||||
return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve()
|
||||
# Use card_files/processed/all_cards.parquet
|
||||
return Path("card_files/processed/all_cards.parquet").resolve()
|
||||
|
||||
|
||||
def _parse_version(line: str) -> str:
|
||||
tokens = line.lstrip("# ").strip().split()
|
||||
for token in tokens:
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
if key == "version":
|
||||
return value
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
|
||||
def _rows_to_cards(df) -> list[BackgroundCard]:
|
||||
"""Convert DataFrame rows to BackgroundCard objects."""
|
||||
entries: list[BackgroundCard] = []
|
||||
seen: set[str] = set()
|
||||
for raw in reader:
|
||||
if not raw:
|
||||
|
||||
for _, row in df.iterrows():
|
||||
if row.empty:
|
||||
continue
|
||||
card = _row_to_card(raw)
|
||||
card = _row_to_card(row)
|
||||
if card is None:
|
||||
continue
|
||||
key = card.display_name.lower()
|
||||
|
|
@ -135,20 +134,35 @@ def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]:
|
|||
continue
|
||||
seen.add(key)
|
||||
entries.append(card)
|
||||
|
||||
entries.sort(key=lambda card: card.display_name)
|
||||
return entries
|
||||
|
||||
|
||||
def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
|
||||
name = _clean_str(row.get("name"))
|
||||
face_name = _clean_str(row.get("faceName")) or None
|
||||
def _row_to_card(row) -> BackgroundCard | None:
|
||||
"""Convert a DataFrame row to a BackgroundCard."""
|
||||
# Helper to safely get values from DataFrame row
|
||||
def get_val(key: str):
|
||||
try:
|
||||
if hasattr(row, key):
|
||||
val = getattr(row, key)
|
||||
# Handle pandas NA/None
|
||||
if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__):
|
||||
return None
|
||||
return val
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
name = _clean_str(get_val("name"))
|
||||
face_name = _clean_str(get_val("faceName")) or None
|
||||
display = face_name or name
|
||||
if not display:
|
||||
return None
|
||||
|
||||
type_line = _clean_str(row.get("type"))
|
||||
oracle_text = _clean_multiline(row.get("text"))
|
||||
raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags")))
|
||||
type_line = _clean_str(get_val("type"))
|
||||
oracle_text = _clean_multiline(get_val("text"))
|
||||
raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags")))
|
||||
detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags)
|
||||
if not detection.is_background:
|
||||
return None
|
||||
|
|
@ -158,18 +172,18 @@ def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None:
|
|||
face_name=face_name,
|
||||
display_name=display,
|
||||
slug=_slugify(display),
|
||||
color_identity=_parse_color_list(row.get("colorIdentity")),
|
||||
colors=_parse_color_list(row.get("colors")),
|
||||
mana_cost=_clean_str(row.get("manaCost")),
|
||||
mana_value=_parse_float(row.get("manaValue")),
|
||||
color_identity=_parse_color_list(get_val("colorIdentity")),
|
||||
colors=_parse_color_list(get_val("colors")),
|
||||
mana_cost=_clean_str(get_val("manaCost")),
|
||||
mana_value=_parse_float(get_val("manaValue")),
|
||||
type_line=type_line,
|
||||
oracle_text=oracle_text,
|
||||
keywords=tuple(_split_list(row.get("keywords"))),
|
||||
keywords=tuple(_split_list(get_val("keywords"))),
|
||||
theme_tags=tuple(tag for tag in raw_theme_tags if tag),
|
||||
raw_theme_tags=raw_theme_tags,
|
||||
edhrec_rank=_parse_int(row.get("edhrecRank")),
|
||||
layout=_clean_str(row.get("layout")) or "normal",
|
||||
side=_clean_str(row.get("side")) or None,
|
||||
edhrec_rank=_parse_int(get_val("edhrecRank")),
|
||||
layout=_clean_str(get_val("layout")) or "normal",
|
||||
side=_clean_str(get_val("side")) or None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -189,8 +203,19 @@ def _clean_multiline(value: object) -> str:
|
|||
def _parse_literal_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
|
||||
# Check if it's a numpy array (from Parquet/pandas)
|
||||
is_numpy = False
|
||||
try:
|
||||
import numpy as np
|
||||
is_numpy = isinstance(value, np.ndarray)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Handle lists, tuples, sets, and numpy arrays
|
||||
if isinstance(value, (list, tuple, set)) or is_numpy:
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
|
|
@ -205,6 +230,17 @@ def _parse_literal_list(value: object) -> list[str]:
|
|||
|
||||
|
||||
def _split_list(value: object) -> list[str]:
|
||||
# Check if it's a numpy array (from Parquet/pandas)
|
||||
is_numpy = False
|
||||
try:
|
||||
import numpy as np
|
||||
is_numpy = isinstance(value, np.ndarray)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if isinstance(value, (list, tuple, set)) or is_numpy:
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return []
|
||||
|
|
@ -213,6 +249,18 @@ def _split_list(value: object) -> list[str]:
|
|||
|
||||
|
||||
def _parse_color_list(value: object) -> Tuple[str, ...]:
|
||||
# Check if it's a numpy array (from Parquet/pandas)
|
||||
is_numpy = False
|
||||
try:
|
||||
import numpy as np
|
||||
is_numpy = isinstance(value, np.ndarray)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if isinstance(value, (list, tuple, set)) or is_numpy:
|
||||
parts = [str(item).strip().upper() for item in value if str(item).strip()]
|
||||
return tuple(parts)
|
||||
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return tuple()
|
||||
|
|
|
|||
|
|
@ -62,6 +62,32 @@ def _detect_produces_mana(text: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _extract_colors_from_land_type(type_line: str) -> List[str]:
|
||||
"""Extract mana colors from basic land types in a type line.
|
||||
|
||||
Args:
|
||||
type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains")
|
||||
|
||||
Returns:
|
||||
List of color letters (e.g., ['R'], ['G', 'W'])
|
||||
"""
|
||||
if not isinstance(type_line, str):
|
||||
return []
|
||||
type_lower = type_line.lower()
|
||||
colors = []
|
||||
basic_land_colors = {
|
||||
'plains': 'W',
|
||||
'island': 'U',
|
||||
'swamp': 'B',
|
||||
'mountain': 'R',
|
||||
'forest': 'G',
|
||||
}
|
||||
for land_type, color in basic_land_colors.items():
|
||||
if land_type in type_lower:
|
||||
colors.append(color)
|
||||
return colors
|
||||
|
||||
|
||||
def _resolved_csv_dir(base_dir: str | None = None) -> str:
|
||||
try:
|
||||
if base_dir:
|
||||
|
|
@ -144,7 +170,9 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
|
|||
return {}
|
||||
|
||||
# Select only needed columns
|
||||
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName']
|
||||
# M9: Added backType to detect MDFC lands where land is on back face
|
||||
# M9: Added colorIdentity to extract mana colors for MDFC lands
|
||||
usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity']
|
||||
available_cols = [col for col in usecols if col in df.columns]
|
||||
if not available_cols:
|
||||
return {}
|
||||
|
|
@ -160,7 +188,16 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
|
|||
multi_df['type'] = multi_df['type'].fillna('').astype(str)
|
||||
multi_df['side'] = multi_df['side'].fillna('').astype(str)
|
||||
multi_df['text'] = multi_df['text'].fillna('').astype(str)
|
||||
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
|
||||
# M9: Check both type and backType for land faces
|
||||
if 'backType' in multi_df.columns:
|
||||
multi_df['backType'] = multi_df['backType'].fillna('').astype(str)
|
||||
land_mask = (
|
||||
multi_df['type'].str.contains('land', case=False, na=False) |
|
||||
multi_df['backType'].str.contains('land', case=False, na=False)
|
||||
)
|
||||
land_rows = multi_df[land_mask]
|
||||
else:
|
||||
land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)]
|
||||
if land_rows.empty:
|
||||
return {}
|
||||
mapping: Dict[str, Dict[str, Any]] = {}
|
||||
|
|
@ -169,6 +206,78 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]:
|
|||
seen: set[tuple[str, str, str]] = set()
|
||||
front_is_land = False
|
||||
layout_val = ''
|
||||
|
||||
# M9: Handle merged rows with backType
|
||||
if len(group) == 1 and 'backType' in group.columns:
|
||||
row = group.iloc[0]
|
||||
back_type_val = str(row.get('backType', '') or '')
|
||||
if back_type_val and 'land' in back_type_val.lower():
|
||||
# Construct synthetic faces from merged row
|
||||
front_type = str(row.get('type', '') or '')
|
||||
front_text = str(row.get('text', '') or '')
|
||||
mana_cost_val = str(row.get('manaCost', '') or '')
|
||||
mana_value_raw = row.get('manaValue', '')
|
||||
mana_value_val = None
|
||||
try:
|
||||
if mana_value_raw not in (None, ''):
|
||||
mana_value_val = float(mana_value_raw)
|
||||
if math.isnan(mana_value_val):
|
||||
mana_value_val = None
|
||||
except Exception:
|
||||
mana_value_val = None
|
||||
|
||||
# Front face
|
||||
faces.append({
|
||||
'face': str(row.get('faceName', '') or name),
|
||||
'side': 'a',
|
||||
'type': front_type,
|
||||
'text': front_text,
|
||||
'mana_cost': mana_cost_val,
|
||||
'mana_value': mana_value_val,
|
||||
'produces_mana': _detect_produces_mana(front_text),
|
||||
'is_land': 'land' in front_type.lower(),
|
||||
'layout': str(row.get('layout', '') or ''),
|
||||
})
|
||||
|
||||
# Back face (synthesized)
|
||||
# M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line)
|
||||
color_identity_raw = row.get('colorIdentity', [])
|
||||
if isinstance(color_identity_raw, str):
|
||||
# Handle string format like "['G']" or "G"
|
||||
try:
|
||||
import ast
|
||||
color_identity_raw = ast.literal_eval(color_identity_raw)
|
||||
except Exception:
|
||||
color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()]
|
||||
back_face_colors = list(color_identity_raw) if color_identity_raw else []
|
||||
# Fallback to parsing land type if colorIdentity not available
|
||||
if not back_face_colors:
|
||||
back_face_colors = _extract_colors_from_land_type(back_type_val)
|
||||
|
||||
faces.append({
|
||||
'face': name.split(' // ')[1] if ' // ' in name else 'Back',
|
||||
'side': 'b',
|
||||
'type': back_type_val,
|
||||
'text': '', # Not available in merged row
|
||||
'mana_cost': '',
|
||||
'mana_value': None,
|
||||
'produces_mana': True, # Assume land produces mana
|
||||
'is_land': True,
|
||||
'layout': str(row.get('layout', '') or ''),
|
||||
'colors': back_face_colors, # M9: Color information for mana sources
|
||||
})
|
||||
|
||||
front_is_land = 'land' in front_type.lower()
|
||||
layout_val = str(row.get('layout', '') or '')
|
||||
mapping[name] = {
|
||||
'faces': faces,
|
||||
'front_is_land': front_is_land,
|
||||
'layout': layout_val,
|
||||
'colors': back_face_colors, # M9: Store colors at top level for easy access
|
||||
}
|
||||
continue
|
||||
|
||||
# Original logic for multi-row format
|
||||
for _, row in group.iterrows():
|
||||
side_raw = str(row.get('side', '') or '').strip()
|
||||
side_key = side_raw.lower()
|
||||
|
|
@ -332,8 +441,13 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
if hasattr(row, 'get'):
|
||||
row_type_raw = row.get('type', row.get('type_line', '')) or ''
|
||||
tline_full = str(row_type_raw).lower()
|
||||
# M9: Check backType for MDFC land detection
|
||||
back_type_raw = ''
|
||||
if hasattr(row, 'get'):
|
||||
back_type_raw = row.get('backType', '') or ''
|
||||
back_type = str(back_type_raw).lower()
|
||||
# Land or permanent that could produce mana via text
|
||||
is_land = ('land' in entry_type) or ('land' in tline_full)
|
||||
is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type)
|
||||
base_is_land = is_land
|
||||
text_field_raw = ''
|
||||
if hasattr(row, 'get'):
|
||||
|
|
@ -363,7 +477,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
if face_types or face_texts:
|
||||
is_land = True
|
||||
text_field = text_field_raw.lower().replace('\n', ' ')
|
||||
# Skip obvious non-permanents (rituals etc.)
|
||||
# Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land
|
||||
# M9: If is_land is True (from backType check), we keep it regardless of front face type
|
||||
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
|
||||
continue
|
||||
# Keep only candidates that are lands OR whose text indicates mana production
|
||||
|
|
@ -437,6 +552,12 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
|
|||
colors['_dfc_land'] = True
|
||||
if not (base_is_land or dfc_entry.get('front_is_land')):
|
||||
colors['_dfc_counts_as_extra'] = True
|
||||
# M9: Extract colors from DFC face metadata (back face land colors)
|
||||
dfc_colors = dfc_entry.get('colors', [])
|
||||
if dfc_colors:
|
||||
for color in dfc_colors:
|
||||
if color in colors:
|
||||
colors[color] = 1
|
||||
produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C'))
|
||||
if produces_any_color or colors.get('_dfc_land'):
|
||||
matrix[name] = colors
|
||||
|
|
|
|||
|
|
@ -363,7 +363,14 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]:
|
|||
def _normalize_string_sequence(value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return tuple()
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
# Handle numpy arrays, lists, tuples, sets, and other sequences
|
||||
try:
|
||||
import numpy as np
|
||||
is_numpy = isinstance(value, np.ndarray)
|
||||
except ImportError:
|
||||
is_numpy = False
|
||||
|
||||
if isinstance(value, (list, tuple, set)) or is_numpy:
|
||||
items = list(value)
|
||||
else:
|
||||
text = _safe_str(value)
|
||||
|
|
|
|||
|
|
@ -543,6 +543,9 @@ class ReportingMixin:
|
|||
mf_info = {}
|
||||
faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else []
|
||||
layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None
|
||||
# M9: If no colors found from mana production, try extracting from face metadata
|
||||
if not card_colors and isinstance(mf_info, dict):
|
||||
card_colors = list(mf_info.get('colors', []))
|
||||
dfc_land_lookup[name] = {
|
||||
'adds_extra_land': counts_as_extra,
|
||||
'counts_as_land': not counts_as_extra,
|
||||
|
|
@ -681,13 +684,14 @@ class ReportingMixin:
|
|||
'faces': faces_meta,
|
||||
'layout': layout_val,
|
||||
})
|
||||
if adds_extra:
|
||||
dfc_extra_total += copies
|
||||
# M9: Count ALL MDFC lands for land summary
|
||||
dfc_extra_total += copies
|
||||
total_sources = sum(source_counts.values())
|
||||
traditional_lands = type_counts.get('Land', 0)
|
||||
# M9: dfc_extra_total now contains ALL MDFC lands, not just extras
|
||||
land_summary = {
|
||||
'traditional': traditional_lands,
|
||||
'dfc_lands': dfc_extra_total,
|
||||
'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands
|
||||
'with_dfc': traditional_lands + dfc_extra_total,
|
||||
'dfc_cards': dfc_details,
|
||||
'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total),
|
||||
|
|
|
|||
567
code/file_setup/image_cache.py
Normal file
567
code/file_setup/image_cache.py
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
"""
|
||||
Card image caching system.
|
||||
|
||||
Downloads and manages local cache of Magic: The Gathering card images
|
||||
from Scryfall, with graceful fallback to API when images are missing.
|
||||
|
||||
Features:
|
||||
- Optional caching (disabled by default for open source users)
|
||||
- Uses Scryfall bulk data API (respects rate limits and guidelines)
|
||||
- Downloads from Scryfall CDN (no rate limits on image files)
|
||||
- Progress tracking for long downloads
|
||||
- Resume capability if interrupted
|
||||
- Graceful fallback to API if images missing
|
||||
|
||||
Environment Variables:
|
||||
CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0)
|
||||
|
||||
Image Sizes:
|
||||
- small: 160px width (for list views)
|
||||
- normal: 488px width (for prominent displays, hover previews)
|
||||
|
||||
Directory Structure:
|
||||
card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB)
|
||||
card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB)
|
||||
|
||||
See: https://scryfall.com/docs/api
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scryfall CDN has no rate limits, but we'll be conservative
|
||||
DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec)
|
||||
|
||||
# Image sizes to cache
|
||||
IMAGE_SIZES = ["small", "normal"]
|
||||
|
||||
# Card name sanitization (filesystem-safe)
|
||||
INVALID_CHARS = r'[<>:"/\\|?*]'
|
||||
|
||||
|
||||
def sanitize_filename(card_name: str) -> str:
|
||||
"""
|
||||
Sanitize card name for use as filename.
|
||||
|
||||
Args:
|
||||
card_name: Original card name
|
||||
|
||||
Returns:
|
||||
Filesystem-safe filename
|
||||
"""
|
||||
# Replace invalid characters with underscore
|
||||
safe_name = re.sub(INVALID_CHARS, "_", card_name)
|
||||
# Remove multiple consecutive underscores
|
||||
safe_name = re.sub(r"_+", "_", safe_name)
|
||||
# Trim leading/trailing underscores
|
||||
safe_name = safe_name.strip("_")
|
||||
return safe_name
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Manages local card image cache."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_dir: str = "card_files/images",
|
||||
bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json",
|
||||
):
|
||||
"""
|
||||
Initialize image cache.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for cached images
|
||||
bulk_data_path: Path to Scryfall bulk data JSON
|
||||
"""
|
||||
self.base_dir = Path(base_dir)
|
||||
self.bulk_data_path = Path(bulk_data_path)
|
||||
self.client = ScryfallBulkDataClient()
|
||||
self._last_download_time: float = 0.0
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if image caching is enabled via environment variable."""
|
||||
return os.getenv("CACHE_CARD_IMAGES", "0") == "1"
|
||||
|
||||
def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]:
|
||||
"""
|
||||
Get local path to cached image if it exists.
|
||||
|
||||
Args:
|
||||
card_name: Card name
|
||||
size: Image size ('small' or 'normal')
|
||||
|
||||
Returns:
|
||||
Path to cached image, or None if not cached
|
||||
"""
|
||||
if not self.is_enabled():
|
||||
return None
|
||||
|
||||
safe_name = sanitize_filename(card_name)
|
||||
image_path = self.base_dir / size / f"{safe_name}.jpg"
|
||||
|
||||
if image_path.exists():
|
||||
return image_path
|
||||
return None
|
||||
|
||||
def get_image_url(self, card_name: str, size: str = "normal") -> str:
|
||||
"""
|
||||
Get image URL (local path if cached, Scryfall API otherwise).
|
||||
|
||||
Args:
|
||||
card_name: Card name
|
||||
size: Image size ('small' or 'normal')
|
||||
|
||||
Returns:
|
||||
URL or local path to image
|
||||
"""
|
||||
# Check local cache first
|
||||
local_path = self.get_image_path(card_name, size)
|
||||
if local_path:
|
||||
# Return as static file path for web serving
|
||||
return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg"
|
||||
|
||||
# Fallback to Scryfall API
|
||||
from urllib.parse import quote
|
||||
card_query = quote(card_name)
|
||||
return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}"
|
||||
|
||||
def _rate_limit_wait(self) -> None:
|
||||
"""Wait to respect rate limits between downloads."""
|
||||
elapsed = time.time() - self._last_download_time
|
||||
if elapsed < DOWNLOAD_DELAY:
|
||||
time.sleep(DOWNLOAD_DELAY - elapsed)
|
||||
self._last_download_time = time.time()
|
||||
|
||||
def _download_image(self, image_url: str, output_path: Path) -> bool:
|
||||
"""
|
||||
Download single image from Scryfall CDN.
|
||||
|
||||
Args:
|
||||
image_url: Image URL from bulk data
|
||||
output_path: Local path to save image
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
self._rate_limit_wait()
|
||||
|
||||
try:
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
req = Request(image_url)
|
||||
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
|
||||
|
||||
with urlopen(req, timeout=30) as response:
|
||||
image_data = response.read()
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {image_url}: {e}")
|
||||
# Clean up partial download
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
return False
|
||||
|
||||
def _load_bulk_data(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Load card data from bulk data JSON.
|
||||
|
||||
Returns:
|
||||
List of card objects with image URLs
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If bulk data file doesn't exist
|
||||
json.JSONDecodeError: If file is invalid JSON
|
||||
"""
|
||||
if not self.bulk_data_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Bulk data file not found: {self.bulk_data_path}. "
|
||||
"Run download_bulk_data() first."
|
||||
)
|
||||
|
||||
logger.info(f"Loading bulk data from {self.bulk_data_path}")
|
||||
with open(self.bulk_data_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Filter bulk data to only cards in our all_cards.parquet file.
|
||||
Deduplicates by card name (takes first printing only).
|
||||
|
||||
Args:
|
||||
bulk_cards: Full Scryfall bulk data
|
||||
|
||||
Returns:
|
||||
Filtered list of cards matching our dataset (one per unique name)
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
from code.path_util import get_processed_cards_path
|
||||
|
||||
# Load our card names
|
||||
parquet_path = get_processed_cards_path()
|
||||
df = pd.read_parquet(parquet_path, columns=["name"])
|
||||
our_card_names = set(df["name"].str.lower())
|
||||
|
||||
logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset")
|
||||
|
||||
# Filter and deduplicate - keep only first printing of each card
|
||||
seen_names = set()
|
||||
filtered = []
|
||||
|
||||
for card in bulk_cards:
|
||||
card_name_lower = card.get("name", "").lower()
|
||||
if card_name_lower in our_card_names and card_name_lower not in seen_names:
|
||||
filtered.append(card)
|
||||
seen_names.add(card_name_lower)
|
||||
|
||||
logger.info(f"Filtered to {len(filtered)} unique cards with image data")
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.")
|
||||
return bulk_cards
|
||||
|
||||
def download_bulk_data(self, progress_callback=None) -> None:
|
||||
"""
|
||||
Download latest Scryfall bulk data JSON.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback(bytes_downloaded, total_bytes)
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
logger.info("Downloading Scryfall bulk data...")
|
||||
self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.client.get_bulk_data(
|
||||
output_path=str(self.bulk_data_path),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
logger.info("Bulk data download complete")
|
||||
|
||||
def download_images(
|
||||
self,
|
||||
sizes: Optional[list[str]] = None,
|
||||
progress_callback=None,
|
||||
max_cards: Optional[int] = None,
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Download card images from Scryfall CDN.
|
||||
|
||||
Args:
|
||||
sizes: Image sizes to download (default: ['small', 'normal'])
|
||||
progress_callback: Optional callback(current, total, card_name)
|
||||
max_cards: Maximum cards to download (for testing)
|
||||
|
||||
Returns:
|
||||
Dictionary with download statistics
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If bulk data not available
|
||||
"""
|
||||
if not self.is_enabled():
|
||||
logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)")
|
||||
return {"skipped": 0}
|
||||
|
||||
if sizes is None:
|
||||
sizes = IMAGE_SIZES
|
||||
|
||||
logger.info(f"Starting image download for sizes: {sizes}")
|
||||
|
||||
# Load bulk data and filter to our cards
|
||||
bulk_cards = self._load_bulk_data()
|
||||
cards = self._filter_to_our_cards(bulk_cards)
|
||||
total_cards = len(cards) if max_cards is None else min(max_cards, len(cards))
|
||||
|
||||
stats = {
|
||||
"total": total_cards,
|
||||
"downloaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
for i, card in enumerate(cards[:total_cards]):
|
||||
card_name = card.get("name")
|
||||
if not card_name:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Collect all faces to download (single-faced or multi-faced)
|
||||
faces_to_download = []
|
||||
|
||||
# Check if card has direct image_uris (single-faced card)
|
||||
if card.get("image_uris"):
|
||||
faces_to_download.append({
|
||||
"name": card_name,
|
||||
"image_uris": card["image_uris"],
|
||||
})
|
||||
# Handle double-faced cards (get all faces)
|
||||
elif card.get("card_faces"):
|
||||
for face_idx, face in enumerate(card["card_faces"]):
|
||||
if face.get("image_uris"):
|
||||
# For multi-faced cards, append face name or index
|
||||
face_name = face.get("name", f"{card_name}_face{face_idx}")
|
||||
faces_to_download.append({
|
||||
"name": face_name,
|
||||
"image_uris": face["image_uris"],
|
||||
})
|
||||
|
||||
# Skip if no faces found
|
||||
if not faces_to_download:
|
||||
logger.debug(f"No image URIs for {card_name}")
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Download each face in each requested size
|
||||
for face in faces_to_download:
|
||||
face_name = face["name"]
|
||||
image_uris = face["image_uris"]
|
||||
|
||||
for size in sizes:
|
||||
image_url = image_uris.get(size)
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
# Check if already cached
|
||||
safe_name = sanitize_filename(face_name)
|
||||
output_path = self.base_dir / size / f"{safe_name}.jpg"
|
||||
|
||||
if output_path.exists():
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Download image
|
||||
if self._download_image(image_url, output_path):
|
||||
stats["downloaded"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
# Progress callback
|
||||
if progress_callback:
|
||||
progress_callback(i + 1, total_cards, card_name)
|
||||
|
||||
# Invalidate cached summary since we just downloaded new images
|
||||
self.invalidate_summary_cache()
|
||||
|
||||
logger.info(f"Image download complete: {stats}")
|
||||
return stats
|
||||
|
||||
def cache_statistics(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about cached images.
|
||||
|
||||
Uses a cached summary.json file to avoid scanning thousands of files.
|
||||
Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS,
|
||||
default 7 days, matching the main card data staleness check).
|
||||
|
||||
Returns:
|
||||
Dictionary with cache stats (count, size, etc.)
|
||||
"""
|
||||
stats = {"enabled": self.is_enabled()}
|
||||
|
||||
if not self.is_enabled():
|
||||
return stats
|
||||
|
||||
summary_file = self.base_dir / "summary.json"
|
||||
|
||||
# Get staleness threshold from environment (same as card data check)
|
||||
try:
|
||||
refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
|
||||
except Exception:
|
||||
refresh_days = 7
|
||||
|
||||
if refresh_days <= 0:
|
||||
# Never consider stale
|
||||
refresh_seconds = float('inf')
|
||||
else:
|
||||
refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds
|
||||
|
||||
# Check if summary exists and is recent (less than refresh_seconds old)
|
||||
use_cached = False
|
||||
if summary_file.exists():
|
||||
try:
|
||||
import time
|
||||
file_age = time.time() - summary_file.stat().st_mtime
|
||||
if file_age < refresh_seconds:
|
||||
use_cached = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try to use cached summary
|
||||
if use_cached:
|
||||
try:
|
||||
import json
|
||||
with summary_file.open('r', encoding='utf-8') as f:
|
||||
cached_stats = json.load(f)
|
||||
stats.update(cached_stats)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read cache summary: {e}")
|
||||
|
||||
# Regenerate summary (fast - just count files and estimate size)
|
||||
for size in IMAGE_SIZES:
|
||||
size_dir = self.base_dir / size
|
||||
if size_dir.exists():
|
||||
# Fast count: count .jpg files without statting each one
|
||||
count = sum(1 for _ in size_dir.glob("*.jpg"))
|
||||
|
||||
# Estimate total size based on typical averages to avoid stat() calls
|
||||
# Small images: ~40 KB avg, Normal images: ~100 KB avg
|
||||
avg_size_kb = 40 if size == "small" else 100
|
||||
estimated_size_mb = (count * avg_size_kb) / 1024
|
||||
|
||||
stats[size] = {
|
||||
"count": count,
|
||||
"size_mb": round(estimated_size_mb, 1),
|
||||
}
|
||||
else:
|
||||
stats[size] = {"count": 0, "size_mb": 0.0}
|
||||
|
||||
# Save summary for next time
|
||||
try:
|
||||
import json
|
||||
with summary_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({k: v for k, v in stats.items() if k != "enabled"}, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write cache summary: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def invalidate_summary_cache(self) -> None:
|
||||
"""Delete the cached summary file to force regeneration on next call."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
summary_file = self.base_dir / "summary.json"
|
||||
if summary_file.exists():
|
||||
try:
|
||||
summary_file.unlink()
|
||||
logger.debug("Invalidated cache summary file")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete cache summary: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for image caching."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Card image cache management")
|
||||
parser.add_argument(
|
||||
"--download",
|
||||
action="store_true",
|
||||
help="Download images from Scryfall",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats",
|
||||
action="store_true",
|
||||
help="Show cache statistics",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-cards",
|
||||
type=int,
|
||||
help="Maximum cards to download (for testing)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sizes",
|
||||
nargs="+",
|
||||
default=IMAGE_SIZES,
|
||||
choices=IMAGE_SIZES,
|
||||
help="Image sizes to download",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force re-download of bulk data even if recent",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
cache = ImageCache()
|
||||
|
||||
if args.stats:
|
||||
stats = cache.cache_statistics()
|
||||
print("\nCache Statistics:")
|
||||
print(f" Enabled: {stats['enabled']}")
|
||||
if stats["enabled"]:
|
||||
for size in IMAGE_SIZES:
|
||||
if size in stats:
|
||||
print(
|
||||
f" {size.capitalize()}: {stats[size]['count']} images "
|
||||
f"({stats[size]['size_mb']:.1f} MB)"
|
||||
)
|
||||
|
||||
elif args.download:
|
||||
if not cache.is_enabled():
|
||||
print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.")
|
||||
return
|
||||
|
||||
# Check if bulk data already exists and is recent (within 24 hours)
|
||||
bulk_data_exists = cache.bulk_data_path.exists()
|
||||
bulk_data_age_hours = None
|
||||
|
||||
if bulk_data_exists:
|
||||
import time
|
||||
age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime
|
||||
bulk_data_age_hours = age_seconds / 3600
|
||||
print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)")
|
||||
|
||||
# Download bulk data if missing, old, or forced
|
||||
if not bulk_data_exists or bulk_data_age_hours > 24 or args.force:
|
||||
print("Downloading Scryfall bulk data...")
|
||||
|
||||
def bulk_progress(downloaded, total):
|
||||
if total > 0:
|
||||
pct = (downloaded / total) * 100
|
||||
print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / "
|
||||
f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r")
|
||||
|
||||
cache.download_bulk_data(progress_callback=bulk_progress)
|
||||
print("\nBulk data downloaded successfully")
|
||||
else:
|
||||
print("Bulk data is recent, skipping download (use --force to re-download)")
|
||||
|
||||
# Download images
|
||||
print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...")
|
||||
|
||||
def image_progress(current, total, card_name):
|
||||
pct = (current / total) * 100
|
||||
print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r")
|
||||
|
||||
stats = cache.download_images(
|
||||
sizes=args.sizes,
|
||||
progress_callback=image_progress,
|
||||
max_cards=args.max_cards,
|
||||
)
|
||||
print("\n\nDownload complete:")
|
||||
print(f" Total: {stats['total']}")
|
||||
print(f" Downloaded: {stats['downloaded']}")
|
||||
print(f" Skipped: {stats['skipped']}")
|
||||
print(f" Failed: {stats['failed']}")
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
code/file_setup/scryfall_bulk_data.py
Normal file
169
code/file_setup/scryfall_bulk_data.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""
|
||||
Scryfall Bulk Data API client.
|
||||
|
||||
Fetches bulk data JSON files from Scryfall's bulk data API, which provides
|
||||
all card information including image URLs without hitting rate limits.
|
||||
|
||||
See: https://scryfall.com/docs/api/bulk-data
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data"
|
||||
DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database
|
||||
RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines)
|
||||
|
||||
|
||||
class ScryfallBulkDataClient:
|
||||
"""Client for fetching Scryfall bulk data."""
|
||||
|
||||
def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY):
|
||||
"""
|
||||
Initialize Scryfall bulk data client.
|
||||
|
||||
Args:
|
||||
rate_limit_delay: Seconds to wait between API requests (default 100ms)
|
||||
"""
|
||||
self.rate_limit_delay = rate_limit_delay
|
||||
self._last_request_time: float = 0.0
|
||||
|
||||
def _rate_limit_wait(self) -> None:
|
||||
"""Wait to respect rate limits between API calls."""
|
||||
elapsed = time.time() - self._last_request_time
|
||||
if elapsed < self.rate_limit_delay:
|
||||
time.sleep(self.rate_limit_delay - elapsed)
|
||||
self._last_request_time = time.time()
|
||||
|
||||
def _make_request(self, url: str) -> Any:
|
||||
"""
|
||||
Make HTTP request with rate limiting and error handling.
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
Exception: If request fails after retries
|
||||
"""
|
||||
self._rate_limit_wait()
|
||||
|
||||
try:
|
||||
req = Request(url)
|
||||
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
|
||||
with urlopen(req, timeout=30) as response:
|
||||
import json
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch {url}: {e}")
|
||||
raise
|
||||
|
||||
def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]:
|
||||
"""
|
||||
Get bulk data metadata (download URL, size, last updated).
|
||||
|
||||
Args:
|
||||
bulk_type: Type of bulk data to fetch (default: default_cards)
|
||||
|
||||
Returns:
|
||||
Dictionary with bulk data info including 'download_uri'
|
||||
|
||||
Raises:
|
||||
ValueError: If bulk_type not found
|
||||
Exception: If API request fails
|
||||
"""
|
||||
logger.info(f"Fetching bulk data info for type: {bulk_type}")
|
||||
response = self._make_request(BULK_DATA_API_URL)
|
||||
|
||||
# Find the requested bulk data type
|
||||
for item in response.get("data", []):
|
||||
if item.get("type") == bulk_type:
|
||||
logger.info(
|
||||
f"Found bulk data: {item.get('name')} "
|
||||
f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, "
|
||||
f"updated: {item.get('updated_at', 'unknown')})"
|
||||
)
|
||||
return item
|
||||
|
||||
raise ValueError(f"Bulk data type '{bulk_type}' not found")
|
||||
|
||||
def download_bulk_data(
|
||||
self, download_uri: str, output_path: str, progress_callback=None
|
||||
) -> None:
|
||||
"""
|
||||
Download bulk data JSON file.
|
||||
|
||||
Args:
|
||||
download_uri: Direct download URL from get_bulk_data_info()
|
||||
output_path: Local path to save the JSON file
|
||||
progress_callback: Optional callback(bytes_downloaded, total_bytes)
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
logger.info(f"Downloading bulk data from: {download_uri}")
|
||||
logger.info(f"Saving to: {output_path}")
|
||||
|
||||
# No rate limit on bulk data downloads per Scryfall docs
|
||||
try:
|
||||
req = Request(download_uri)
|
||||
req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)")
|
||||
|
||||
with urlopen(req, timeout=60) as response:
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 1024 * 1024 # 1MB chunks
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download bulk data: {e}")
|
||||
# Clean up partial download
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
raise
|
||||
|
||||
def get_bulk_data(
|
||||
self,
|
||||
bulk_type: str = DEFAULT_BULK_TYPE,
|
||||
output_path: str = "card_files/raw/scryfall_bulk_data.json",
|
||||
progress_callback=None,
|
||||
) -> str:
|
||||
"""
|
||||
Fetch bulk data info and download the JSON file.
|
||||
|
||||
Args:
|
||||
bulk_type: Type of bulk data to fetch
|
||||
output_path: Where to save the JSON file
|
||||
progress_callback: Optional progress callback
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
Exception: If fetch or download fails
|
||||
"""
|
||||
info = self.get_bulk_data_info(bulk_type)
|
||||
download_uri = info["download_uri"]
|
||||
self.download_bulk_data(download_uri, output_path, progress_callback)
|
||||
return output_path
|
||||
|
|
@ -349,6 +349,44 @@ def initial_setup() -> None:
|
|||
logger.info(f" Raw: {raw_path}")
|
||||
logger.info(f" Processed: {processed_path}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Step 3: Optional image caching (if enabled)
|
||||
try:
|
||||
from code.file_setup.image_cache import ImageCache
|
||||
cache = ImageCache()
|
||||
|
||||
if cache.is_enabled():
|
||||
logger.info("=" * 80)
|
||||
logger.info("Card image caching enabled - starting download")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Download bulk data
|
||||
logger.info("Downloading Scryfall bulk data...")
|
||||
cache.download_bulk_data()
|
||||
|
||||
# Download images
|
||||
logger.info("Downloading card images (this may take 1-2 hours)...")
|
||||
|
||||
def progress(current, total, card_name):
|
||||
if current % 100 == 0: # Log every 100 cards
|
||||
pct = (current / total) * 100
|
||||
logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}")
|
||||
|
||||
stats = cache.download_images(progress_callback=progress)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("✓ Image cache complete")
|
||||
logger.info(f" Downloaded: {stats['downloaded']}")
|
||||
logger.info(f" Skipped: {stats['skipped']}")
|
||||
logger.info(f" Failed: {stats['failed']}")
|
||||
logger.info("=" * 80)
|
||||
else:
|
||||
logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)")
|
||||
logger.info("Images will be fetched from Scryfall API on demand")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cache images (continuing anyway): {e}")
|
||||
logger.error("Images will be fetched from Scryfall API on demand")
|
||||
|
||||
|
||||
def regenerate_processed_parquet() -> None:
|
||||
|
|
|
|||
|
|
@ -240,6 +240,13 @@ def merge_multi_face_rows(
|
|||
|
||||
faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()]
|
||||
|
||||
# M9: Capture back face type for MDFC land detection
|
||||
if len(group_sorted) >= 2 and "type" in group_sorted.columns:
|
||||
back_face_row = group_sorted.iloc[1]
|
||||
back_type = str(back_face_row.get("type", "") or "")
|
||||
if back_type:
|
||||
work_df.at[primary_idx, "backType"] = back_type
|
||||
|
||||
drop_indices.extend(group_sorted.index[1:])
|
||||
|
||||
merged_count += 1
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ from .services.theme_catalog_loader import prewarm_common_filters, load_index #
|
|||
from .services.commander_catalog_loader import load_commander_catalog # type: ignore
|
||||
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
|
||||
|
||||
# Logger for app-level logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Resolve template/static dirs relative to this file
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||
|
|
@ -99,6 +102,32 @@ if _STATIC_DIR.exists():
|
|||
# Jinja templates
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
# Add custom Jinja2 filter for card image URLs
|
||||
def card_image_url(card_name: str, size: str = "normal") -> str:
|
||||
"""
|
||||
Generate card image URL (uses local cache if available, falls back to Scryfall).
|
||||
|
||||
For DFC cards (containing ' // '), extracts the front face name.
|
||||
|
||||
Args:
|
||||
card_name: Name of the card (may be "Front // Back" for DFCs)
|
||||
size: Image size ('small' or 'normal')
|
||||
|
||||
Returns:
|
||||
URL for the card image
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Extract front face name for DFCs (thumbnails always show front face)
|
||||
display_name = card_name
|
||||
if ' // ' in card_name:
|
||||
display_name = card_name.split(' // ')[0].strip()
|
||||
|
||||
# Use our API endpoint which handles cache lookup and fallback
|
||||
return f"/api/images/{size}/{quote(display_name)}"
|
||||
|
||||
templates.env.filters["card_image"] = card_image_url
|
||||
|
||||
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
|
||||
# and reorder to the new signature TemplateResponse(request, name, {...}).
|
||||
# Prevents DeprecationWarning noise in tests without touching all call sites.
|
||||
|
|
@ -840,6 +869,12 @@ async def home(request: Request) -> HTMLResponse:
|
|||
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
|
||||
|
||||
|
||||
@app.get("/docs/components", response_class=HTMLResponse)
|
||||
async def components_library(request: Request) -> HTMLResponse:
|
||||
"""M2 Component Library - showcase of standardized UI components"""
|
||||
return templates.TemplateResponse("docs/components.html", {"request": request})
|
||||
|
||||
|
||||
# Simple health check (hardened)
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
|
|
@ -2212,6 +2247,13 @@ async def setup_status():
|
|||
return JSONResponse({"running": False, "phase": "error"})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Card Image Serving Endpoint - MOVED TO /routes/api.py
|
||||
# ============================================================================
|
||||
# Image serving logic has been moved to code/web/routes/api.py
|
||||
# The router is included below via: app.include_router(api_routes.router)
|
||||
|
||||
|
||||
# Routers
|
||||
from .routes import build as build_routes # noqa: E402
|
||||
from .routes import configs as config_routes # noqa: E402
|
||||
|
|
@ -2225,6 +2267,7 @@ from .routes import telemetry as telemetry_routes # noqa: E402
|
|||
from .routes import cards as cards_routes # noqa: E402
|
||||
from .routes import card_browser as card_browser_routes # noqa: E402
|
||||
from .routes import compare as compare_routes # noqa: E402
|
||||
from .routes import api as api_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
|
|
@ -2237,6 +2280,7 @@ app.include_router(telemetry_routes.router)
|
|||
app.include_router(cards_routes.router)
|
||||
app.include_router(card_browser_routes.router)
|
||||
app.include_router(compare_routes.router)
|
||||
app.include_router(api_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
|
|||
299
code/web/routes/api.py
Normal file
299
code/web/routes/api.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""API endpoints for web services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from code.file_setup.image_cache import ImageCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Global image cache instance
|
||||
_image_cache = ImageCache()
|
||||
|
||||
|
||||
@router.get("/images/status")
|
||||
async def get_download_status():
|
||||
"""
|
||||
Get current image download status.
|
||||
|
||||
Returns:
|
||||
JSON response with download status
|
||||
"""
|
||||
import json
|
||||
|
||||
status_file = Path("card_files/images/.download_status.json")
|
||||
|
||||
if not status_file.exists():
|
||||
# Check cache statistics if no download in progress
|
||||
stats = _image_cache.cache_statistics()
|
||||
return JSONResponse({
|
||||
"running": False,
|
||||
"stats": stats
|
||||
})
|
||||
|
||||
try:
|
||||
with status_file.open('r', encoding='utf-8') as f:
|
||||
status = json.load(f)
|
||||
return JSONResponse(status)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read status file: {e}")
|
||||
return JSONResponse({
|
||||
"running": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@router.get("/images/debug")
|
||||
async def get_image_debug():
|
||||
"""
|
||||
Debug endpoint to check image cache configuration.
|
||||
|
||||
Returns:
|
||||
JSON with debug information
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
base_dir = Path(_image_cache.base_dir)
|
||||
|
||||
debug_info = {
|
||||
"cache_enabled": _image_cache.is_enabled(),
|
||||
"env_var": os.getenv("CACHE_CARD_IMAGES", "not set"),
|
||||
"base_dir": str(base_dir),
|
||||
"base_dir_exists": base_dir.exists(),
|
||||
"small_dir": str(base_dir / "small"),
|
||||
"small_dir_exists": (base_dir / "small").exists(),
|
||||
"normal_dir": str(base_dir / "normal"),
|
||||
"normal_dir_exists": (base_dir / "normal").exists(),
|
||||
}
|
||||
|
||||
# Count files if directories exist
|
||||
if (base_dir / "small").exists():
|
||||
debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg")))
|
||||
if (base_dir / "normal").exists():
|
||||
debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg")))
|
||||
|
||||
# Test with a sample card name
|
||||
test_card = "Lightning Bolt"
|
||||
debug_info["test_card"] = test_card
|
||||
test_path_small = _image_cache.get_image_path(test_card, "small")
|
||||
test_path_normal = _image_cache.get_image_path(test_card, "normal")
|
||||
debug_info["test_path_small"] = str(test_path_small) if test_path_small else None
|
||||
debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None
|
||||
debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False
|
||||
debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False
|
||||
|
||||
return JSONResponse(debug_info)
|
||||
|
||||
|
||||
@router.get("/images/{size}/{card_name}")
|
||||
async def get_card_image(size: str, card_name: str, face: str = Query(default="front")):
|
||||
"""
|
||||
Serve card image from cache or redirect to Scryfall API.
|
||||
|
||||
Args:
|
||||
size: Image size ('small' or 'normal')
|
||||
card_name: Name of the card
|
||||
face: Which face to show ('front' or 'back') for DFC cards
|
||||
|
||||
Returns:
|
||||
FileResponse if cached locally, RedirectResponse to Scryfall API otherwise
|
||||
"""
|
||||
# Validate size parameter
|
||||
if size not in ["small", "normal"]:
|
||||
size = "normal"
|
||||
|
||||
# Check if caching is enabled
|
||||
cache_enabled = _image_cache.is_enabled()
|
||||
|
||||
# Check if image exists in cache
|
||||
if cache_enabled:
|
||||
image_path = None
|
||||
|
||||
# For DFC cards, handle front/back faces differently
|
||||
if " // " in card_name:
|
||||
if face == "back":
|
||||
# For back face, ONLY try the back face name
|
||||
back_face = card_name.split(" // ")[1].strip()
|
||||
logger.debug(f"DFC back face requested: {back_face}")
|
||||
image_path = _image_cache.get_image_path(back_face, size)
|
||||
else:
|
||||
# For front face (or unspecified), try front face name
|
||||
front_face = card_name.split(" // ")[0].strip()
|
||||
logger.debug(f"DFC front face requested: {front_face}")
|
||||
image_path = _image_cache.get_image_path(front_face, size)
|
||||
else:
|
||||
# Single-faced card, try exact name
|
||||
image_path = _image_cache.get_image_path(card_name, size)
|
||||
|
||||
if image_path and image_path.exists():
|
||||
logger.info(f"Serving cached image: {card_name} ({size}, {face})")
|
||||
return FileResponse(
|
||||
image_path,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000", # 1 year
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No cached image found for: {card_name} (face: {face})")
|
||||
|
||||
# Fallback to Scryfall API
|
||||
# For back face requests of DFC cards, we need the full card name
|
||||
scryfall_card_name = card_name
|
||||
scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}"
|
||||
|
||||
# If this is a back face request, try to find the full DFC name
|
||||
if face == "back":
|
||||
try:
|
||||
from code.services.all_cards_loader import AllCardsLoader
|
||||
loader = AllCardsLoader()
|
||||
df = loader.load()
|
||||
|
||||
# Look for cards where this face name appears in the card_faces
|
||||
# The card name format is "Front // Back"
|
||||
matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)]
|
||||
if not matching.empty:
|
||||
# Find DFC cards (containing ' // ')
|
||||
dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)]
|
||||
if not dfc_matches.empty:
|
||||
# Use the first matching DFC card's full name
|
||||
full_name = dfc_matches.iloc[0]['name']
|
||||
scryfall_card_name = full_name
|
||||
# Add face parameter to Scryfall request
|
||||
scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back"
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}")
|
||||
|
||||
scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}"
|
||||
return RedirectResponse(scryfall_url)
|
||||
|
||||
|
||||
@router.post("/images/download")
|
||||
async def download_images():
|
||||
"""
|
||||
Start downloading card images in background.
|
||||
|
||||
Returns:
|
||||
JSON response with status
|
||||
"""
|
||||
if not _image_cache.is_enabled():
|
||||
return JSONResponse({
|
||||
"ok": False,
|
||||
"message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable."
|
||||
}, status_code=400)
|
||||
|
||||
# Write initial status
|
||||
try:
|
||||
status_dir = Path("card_files/images")
|
||||
status_dir.mkdir(parents=True, exist_ok=True)
|
||||
status_file = status_dir / ".download_status.json"
|
||||
|
||||
import json
|
||||
with status_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"running": True,
|
||||
"phase": "bulk_data",
|
||||
"message": "Downloading Scryfall bulk data...",
|
||||
"current": 0,
|
||||
"total": 0,
|
||||
"percentage": 0
|
||||
}, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write initial status: {e}")
|
||||
|
||||
# Start download in background thread
|
||||
def _download_task():
|
||||
import json
|
||||
status_file = Path("card_files/images/.download_status.json")
|
||||
|
||||
try:
|
||||
# Download bulk data first
|
||||
logger.info("[IMAGE DOWNLOAD] Starting bulk data download...")
|
||||
|
||||
def bulk_progress(downloaded: int, total: int):
|
||||
"""Progress callback for bulk data download."""
|
||||
try:
|
||||
percentage = int(downloaded / total * 100) if total > 0 else 0
|
||||
with status_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"running": True,
|
||||
"phase": "bulk_data",
|
||||
"message": f"Downloading bulk data: {percentage}%",
|
||||
"current": downloaded,
|
||||
"total": total,
|
||||
"percentage": percentage
|
||||
}, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update bulk progress: {e}")
|
||||
|
||||
_image_cache.download_bulk_data(progress_callback=bulk_progress)
|
||||
|
||||
# Download images
|
||||
logger.info("[IMAGE DOWNLOAD] Starting image downloads...")
|
||||
|
||||
def image_progress(current: int, total: int, card_name: str):
|
||||
"""Progress callback for image downloads."""
|
||||
try:
|
||||
percentage = int(current / total * 100) if total > 0 else 0
|
||||
with status_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"running": True,
|
||||
"phase": "images",
|
||||
"message": f"Downloading images: {card_name}",
|
||||
"current": current,
|
||||
"total": total,
|
||||
"percentage": percentage
|
||||
}, f)
|
||||
|
||||
# Log progress every 100 cards
|
||||
if current % 100 == 0:
|
||||
logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update image progress: {e}")
|
||||
|
||||
stats = _image_cache.download_images(progress_callback=image_progress)
|
||||
|
||||
# Write completion status
|
||||
with status_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"running": False,
|
||||
"phase": "complete",
|
||||
"message": f"Download complete: {stats.get('downloaded', 0)} new images",
|
||||
"stats": stats,
|
||||
"percentage": 100
|
||||
}, f)
|
||||
|
||||
logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True)
|
||||
try:
|
||||
with status_file.open('w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"running": False,
|
||||
"phase": "error",
|
||||
"message": f"Download failed: {str(e)}",
|
||||
"percentage": 0
|
||||
}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=_download_task, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"message": "Image download started in background"
|
||||
}, status_code=202)
|
||||
|
|
@ -25,6 +25,7 @@ from ..services.build_utils import (
|
|||
owned_set as owned_set_helper,
|
||||
builder_present_names,
|
||||
builder_display_map,
|
||||
commander_hover_context,
|
||||
)
|
||||
from ..app import templates
|
||||
from deck_builder import builder_constants as bc
|
||||
|
|
@ -1349,6 +1350,14 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
for key in skip_keys:
|
||||
sess.pop(key, None)
|
||||
|
||||
# M2: Clear commander and form selections for fresh start
|
||||
commander_keys = [
|
||||
"commander", "partner", "background", "commander_mode",
|
||||
"themes", "bracket"
|
||||
]
|
||||
for key in commander_keys:
|
||||
sess.pop(key, None)
|
||||
|
||||
theme_context = _custom_theme_context(request, sess)
|
||||
ctx = {
|
||||
"request": request,
|
||||
|
|
@ -1483,20 +1492,14 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
merged_tags.append(token)
|
||||
ctx["tags"] = merged_tags
|
||||
|
||||
# Deduplicate recommended: remove any that are already in partner_tags
|
||||
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
|
||||
existing_recommended = ctx.get("recommended") or []
|
||||
merged_recommended: list[str] = []
|
||||
rec_seen: set[str] = set()
|
||||
for source in (partner_tags, existing_recommended):
|
||||
for tag in source:
|
||||
token = str(tag).strip()
|
||||
if not token:
|
||||
continue
|
||||
key = token.casefold()
|
||||
if key in rec_seen:
|
||||
continue
|
||||
rec_seen.add(key)
|
||||
merged_recommended.append(token)
|
||||
ctx["recommended"] = merged_recommended
|
||||
deduplicated_recommended = [
|
||||
tag for tag in existing_recommended
|
||||
if str(tag).strip().casefold() not in partner_tags_lower
|
||||
]
|
||||
ctx["recommended"] = deduplicated_recommended
|
||||
|
||||
reason_map = dict(ctx.get("recommended_reasons") or {})
|
||||
for tag in partner_tags:
|
||||
|
|
@ -2907,6 +2910,11 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
if is_gc and (sel_br is None or int(sel_br) < 3):
|
||||
sel_br = 3
|
||||
partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS)
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commander": {"name": commander},
|
||||
|
|
@ -2940,7 +2948,22 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
)
|
||||
partner_tags = context.pop("partner_theme_tags", None)
|
||||
if partner_tags:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
context["tags"] = partner_tags
|
||||
# Deduplicate recommended tags: remove any that are already in partner_tags
|
||||
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
|
||||
original_recommended = context.get("recommended", [])
|
||||
deduplicated_recommended = [
|
||||
tag for tag in original_recommended
|
||||
if str(tag).strip().casefold() not in partner_tags_lower
|
||||
]
|
||||
logger.info(
|
||||
f"Step2: partner_tags={len(partner_tags)}, "
|
||||
f"original_recommended={len(original_recommended)}, "
|
||||
f"deduplicated_recommended={len(deduplicated_recommended)}"
|
||||
)
|
||||
context["recommended"] = deduplicated_recommended
|
||||
resp = templates.TemplateResponse("build/_step2.html", context)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -3266,6 +3289,57 @@ async def build_step3_get(request: Request) -> HTMLResponse:
|
|||
sess["last_step"] = 3
|
||||
defaults = orch.ideal_defaults()
|
||||
values = sess.get("ideals") or defaults
|
||||
|
||||
# Check if any skip flags are enabled to show skeleton automation page
|
||||
skip_flags = {
|
||||
"skip_lands": "land selection",
|
||||
"skip_to_misc": "land selection",
|
||||
"skip_basics": "basic lands",
|
||||
"skip_staples": "staple lands",
|
||||
"skip_kindred": "kindred lands",
|
||||
"skip_fetches": "fetch lands",
|
||||
"skip_duals": "dual lands",
|
||||
"skip_triomes": "triome lands",
|
||||
"skip_all_creatures": "creature selection",
|
||||
"skip_creature_primary": "primary creatures",
|
||||
"skip_creature_secondary": "secondary creatures",
|
||||
"skip_creature_fill": "creature fills",
|
||||
"skip_all_spells": "spell selection",
|
||||
"skip_ramp": "ramp spells",
|
||||
"skip_removal": "removal spells",
|
||||
"skip_wipes": "board wipes",
|
||||
"skip_card_advantage": "card advantage spells",
|
||||
"skip_protection": "protection spells",
|
||||
"skip_spell_fill": "spell fills",
|
||||
}
|
||||
|
||||
active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)]
|
||||
|
||||
if active_skips:
|
||||
# Show skeleton automation page with auto-submit
|
||||
automation_parts = []
|
||||
if any("land" in s for s in active_skips):
|
||||
automation_parts.append("lands")
|
||||
if any("creature" in s for s in active_skips):
|
||||
automation_parts.append("creatures")
|
||||
if any("spell" in s for s in active_skips):
|
||||
automation_parts.append("spells")
|
||||
|
||||
automation_message = f"Applying default values for {', '.join(automation_parts)}..."
|
||||
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step3_skeleton.html",
|
||||
{
|
||||
"request": request,
|
||||
"defaults": defaults,
|
||||
"commander": sess.get("commander"),
|
||||
"automation_message": automation_message,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
# No skips enabled, show normal form
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step3.html",
|
||||
{
|
||||
|
|
@ -3844,6 +3918,16 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe
|
|||
ctx["synergies"] = synergies
|
||||
ctx["summary_ready"] = True
|
||||
ctx["summary_token"] = active_token
|
||||
|
||||
# Add commander hover context for color identity and theme tags
|
||||
hover_meta = commander_hover_context(
|
||||
commander_name=ctx.get("commander"),
|
||||
deck_tags=sess.get("tags"),
|
||||
summary=summary_data,
|
||||
combined=ctx.get("combined_commander"),
|
||||
)
|
||||
ctx.update(hover_meta)
|
||||
|
||||
response = templates.TemplateResponse("partials/deck_summary.html", ctx)
|
||||
response.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -195,7 +195,11 @@ async def download_github():
|
|||
@router.get("/", response_class=HTMLResponse)
|
||||
async def setup_index(request: Request) -> HTMLResponse:
|
||||
import code.settings as settings
|
||||
from code.file_setup.image_cache import ImageCache
|
||||
|
||||
image_cache = ImageCache()
|
||||
return templates.TemplateResponse("setup/index.html", {
|
||||
"request": request,
|
||||
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES
|
||||
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES,
|
||||
"image_cache_enabled": image_cache.is_enabled()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -291,28 +291,6 @@ def _diag_enabled() -> bool:
|
|||
return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@router.get("/picker", response_class=HTMLResponse)
|
||||
async def theme_picker_page(request: Request):
|
||||
"""Render the theme picker shell.
|
||||
|
||||
Dynamic data (list, detail) loads via fragment endpoints. We still inject
|
||||
known archetype list for the filter select so it is populated on initial load.
|
||||
"""
|
||||
archetypes: list[str] = []
|
||||
try:
|
||||
idx = load_index()
|
||||
archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
archetypes = []
|
||||
return _templates.TemplateResponse(
|
||||
"themes/picker.html",
|
||||
{
|
||||
"request": request,
|
||||
"archetypes": archetypes,
|
||||
"theme_picker_diagnostics": _diag_enabled(),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/metrics")
|
||||
async def theme_metrics():
|
||||
if not _diag_enabled():
|
||||
|
|
@ -746,89 +724,9 @@ async def api_theme_preview(
|
|||
return JSONResponse({"ok": True, "preview": payload})
|
||||
|
||||
|
||||
@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse)
|
||||
async def theme_preview_fragment(
|
||||
theme_id: str,
|
||||
limit: int = Query(12, ge=1, le=30),
|
||||
colors: str | None = None,
|
||||
commander: str | None = None,
|
||||
suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"),
|
||||
minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale – used in detail page collapsible preview)"),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Return HTML fragment for theme preview with caching headers.
|
||||
|
||||
Adds ETag and Last-Modified headers (no strong caching – enables conditional GET / 304).
|
||||
ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander).
|
||||
"""
|
||||
try:
|
||||
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
||||
except KeyError:
|
||||
return HTMLResponse("<div class='error'>Theme not found.</div>", status_code=404)
|
||||
# Load example commanders (authoritative list) from catalog detail for legality instead of inferring
|
||||
example_commanders: list[str] = []
|
||||
synergy_commanders: list[str] = []
|
||||
try:
|
||||
idx = load_index()
|
||||
slug = slugify(theme_id)
|
||||
entry = idx.slug_to_entry.get(slug)
|
||||
if entry:
|
||||
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False)
|
||||
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
|
||||
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
|
||||
# De-duplicate any overlap with example commanders while preserving order
|
||||
seen = set(example_commanders)
|
||||
for c in synergy_commanders_raw:
|
||||
if c not in seen:
|
||||
synergy_commanders.append(c)
|
||||
seen.add(c)
|
||||
except Exception:
|
||||
example_commanders = []
|
||||
synergy_commanders = []
|
||||
# Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift)
|
||||
import hashlib
|
||||
import json as _json
|
||||
import time as _time
|
||||
try:
|
||||
idx = load_index()
|
||||
catalog_tag = idx.etag
|
||||
except Exception:
|
||||
catalog_tag = "unknown"
|
||||
hash_src = _json.dumps({
|
||||
"theme": theme_id,
|
||||
"limit": limit,
|
||||
"commander": commander,
|
||||
"sample": payload.get("sample", [])[:3], # small slice for stability & speed
|
||||
"v": 1,
|
||||
}, sort_keys=True).encode("utf-8")
|
||||
etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}"
|
||||
# Conditional request support
|
||||
if request is not None:
|
||||
inm = request.headers.get("if-none-match")
|
||||
if inm and inm == etag:
|
||||
# 304 Not Modified – FastAPI HTMLResponse with empty body & headers
|
||||
resp = HTMLResponse(status_code=304, content="")
|
||||
resp.headers["ETag"] = etag
|
||||
from email.utils import formatdate as _fmtdate
|
||||
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
ctx = {
|
||||
"request": request,
|
||||
"preview": payload,
|
||||
"example_commanders": example_commanders,
|
||||
"synergy_commanders": synergy_commanders,
|
||||
"theme_id": theme_id,
|
||||
"etag": etag,
|
||||
"suppress_curated": suppress_curated,
|
||||
"minimal": minimal,
|
||||
}
|
||||
resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx)
|
||||
resp.headers["ETag"] = etag
|
||||
from email.utils import formatdate as _fmtdate
|
||||
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
return resp
|
||||
|
||||
@router.get("/fragment/list", response_class=HTMLResponse)
|
||||
|
||||
|
||||
# --- Preview Export Endpoints (CSV / JSON) ---
|
||||
|
|
|
|||
|
|
@ -310,13 +310,30 @@ def commander_hover_context(
|
|||
|
||||
raw_color_identity = combined_info.get("color_identity") if combined_info else None
|
||||
commander_color_identity: list[str] = []
|
||||
|
||||
# If we have a combined commander (partner/background), use its color identity
|
||||
if isinstance(raw_color_identity, (list, tuple, set)):
|
||||
for item in raw_color_identity:
|
||||
token = str(item).strip().upper()
|
||||
if token:
|
||||
commander_color_identity.append(token)
|
||||
|
||||
# M7: For non-partner commanders, also check summary.colors for color identity
|
||||
# For regular commanders (no partner/background), look up from commander catalog first
|
||||
if not commander_color_identity and not has_combined and commander_name:
|
||||
try:
|
||||
from .commander_catalog_loader import find_commander_record
|
||||
record = find_commander_record(commander_name)
|
||||
if record and hasattr(record, 'color_identity'):
|
||||
raw_ci = record.color_identity
|
||||
if isinstance(raw_ci, (list, tuple, set)):
|
||||
for item in raw_ci:
|
||||
token = str(item).strip().upper()
|
||||
if token:
|
||||
commander_color_identity.append(token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: check summary.colors if we still don't have color identity
|
||||
if not commander_color_identity and not has_combined and isinstance(summary, dict):
|
||||
summary_colors = summary.get("colors")
|
||||
if isinstance(summary_colors, (list, tuple, set)):
|
||||
|
|
|
|||
375
code/web/static/components.js
Normal file
375
code/web/static/components.js
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
/**
|
||||
* M2 Component Library - JavaScript Utilities
|
||||
*
|
||||
* Core functions for interactive components:
|
||||
* - Card flip button (dual-faced cards)
|
||||
* - Collapsible panels
|
||||
* - Card popups
|
||||
* - Modal management
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// CARD FLIP FUNCTIONALITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Flip a dual-faced card image between front and back faces
|
||||
* @param {HTMLElement} button - The flip button element
|
||||
*/
|
||||
function flipCard(button) {
|
||||
const container = button.closest('.card-thumb-container, .card-popup-image');
|
||||
if (!container) return;
|
||||
|
||||
const img = container.querySelector('img');
|
||||
if (!img) return;
|
||||
|
||||
const cardName = img.dataset.cardName;
|
||||
if (!cardName) return;
|
||||
|
||||
const faces = cardName.split(' // ');
|
||||
if (faces.length < 2) return;
|
||||
|
||||
// Determine current face (default to 0 = front)
|
||||
const currentFace = parseInt(img.dataset.currentFace || '0', 10);
|
||||
const nextFace = currentFace === 0 ? 1 : 0;
|
||||
const faceName = faces[nextFace];
|
||||
|
||||
// Determine image version based on container
|
||||
const isLarge = container.classList.contains('card-thumb-large') ||
|
||||
container.classList.contains('card-popup-image');
|
||||
const version = isLarge ? 'normal' : 'small';
|
||||
|
||||
// Update image source
|
||||
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`;
|
||||
img.alt = `${faceName} image`;
|
||||
img.dataset.currentFace = nextFace.toString();
|
||||
|
||||
// Update button aria-label
|
||||
const otherFace = faces[currentFace];
|
||||
button.setAttribute('aria-label', `Flip to ${otherFace}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all card images to show front face
|
||||
* Useful when navigating between pages or clearing selections
|
||||
*/
|
||||
function resetCardFaces() {
|
||||
document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => {
|
||||
const cardName = img.dataset.cardName;
|
||||
const faces = cardName.split(' // ');
|
||||
if (faces.length > 1) {
|
||||
const frontFace = faces[0];
|
||||
const container = img.closest('.card-thumb-container, .card-popup-image');
|
||||
const isLarge = container && (container.classList.contains('card-thumb-large') ||
|
||||
container.classList.contains('card-popup-image'));
|
||||
const version = isLarge ? 'normal' : 'small';
|
||||
|
||||
img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`;
|
||||
img.alt = `${frontFace} image`;
|
||||
img.dataset.currentFace = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COLLAPSIBLE PANEL FUNCTIONALITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Toggle a collapsible panel's expanded/collapsed state
|
||||
* @param {string} panelId - The ID of the panel element
|
||||
*/
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const button = panel.querySelector('.panel-toggle');
|
||||
const content = panel.querySelector('.panel-collapse-content');
|
||||
if (!button || !content) return;
|
||||
|
||||
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
// Toggle state
|
||||
button.setAttribute('aria-expanded', (!isExpanded).toString());
|
||||
content.style.display = isExpanded ? 'none' : 'block';
|
||||
|
||||
// Toggle classes
|
||||
panel.classList.toggle('panel-expanded', !isExpanded);
|
||||
panel.classList.toggle('panel-collapsed', isExpanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a collapsible panel
|
||||
* @param {string} panelId - The ID of the panel element
|
||||
*/
|
||||
function expandPanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const button = panel.querySelector('.panel-toggle');
|
||||
const content = panel.querySelector('.panel-collapse-content');
|
||||
if (!button || !content) return;
|
||||
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
content.style.display = 'block';
|
||||
panel.classList.add('panel-expanded');
|
||||
panel.classList.remove('panel-collapsed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a collapsible panel
|
||||
* @param {string} panelId - The ID of the panel element
|
||||
*/
|
||||
function collapsePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const button = panel.querySelector('.panel-toggle');
|
||||
const content = panel.querySelector('.panel-collapse-content');
|
||||
if (!button || !content) return;
|
||||
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
content.style.display = 'none';
|
||||
panel.classList.add('panel-collapsed');
|
||||
panel.classList.remove('panel-expanded');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODAL MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Open a modal by ID
|
||||
* @param {string} modalId - The ID of the modal element
|
||||
*/
|
||||
function openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first focusable element in modal
|
||||
const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusable) {
|
||||
setTimeout(() => focusable.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a modal by ID or element
|
||||
* @param {string|HTMLElement} modalOrId - Modal element or ID
|
||||
*/
|
||||
function closeModal(modalOrId) {
|
||||
const modal = typeof modalOrId === 'string'
|
||||
? document.getElementById(modalOrId)
|
||||
: modalOrId;
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
modal.remove();
|
||||
|
||||
// Restore body scroll if no other modals are open
|
||||
if (!document.querySelector('.modal')) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all open modals
|
||||
*/
|
||||
function closeAllModals() {
|
||||
document.querySelectorAll('.modal').forEach(modal => modal.remove());
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CARD POPUP FUNCTIONALITY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Show card details popup on hover or tap
|
||||
* @param {string} cardName - The card name
|
||||
* @param {Object} options - Popup options
|
||||
* @param {string[]} options.tags - Card tags
|
||||
* @param {string[]} options.highlightTags - Tags to highlight
|
||||
* @param {string} options.role - Card role
|
||||
* @param {string} options.layout - Card layout (for flip button)
|
||||
*/
|
||||
function showCardPopup(cardName, options = {}) {
|
||||
// Remove any existing popup
|
||||
closeCardPopup();
|
||||
|
||||
const {
|
||||
tags = [],
|
||||
highlightTags = [],
|
||||
role = '',
|
||||
layout = 'normal'
|
||||
} = options;
|
||||
|
||||
const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout);
|
||||
const baseName = cardName.split(' // ')[0];
|
||||
|
||||
// Create popup HTML
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'card-popup';
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-label', `${cardName} details`);
|
||||
|
||||
let tagsHTML = '';
|
||||
if (tags.length > 0) {
|
||||
tagsHTML = '<div class="card-popup-tags">';
|
||||
tags.forEach(tag => {
|
||||
const isHighlight = highlightTags.includes(tag);
|
||||
tagsHTML += `<span class="card-popup-tag${isHighlight ? ' card-popup-tag-highlight' : ''}">${tag}</span>`;
|
||||
});
|
||||
tagsHTML += '</div>';
|
||||
}
|
||||
|
||||
let roleHTML = '';
|
||||
if (role) {
|
||||
roleHTML = `<div class="card-popup-role">Role: <span>${role}</span></div>`;
|
||||
}
|
||||
|
||||
let flipButtonHTML = '';
|
||||
if (isDFC) {
|
||||
flipButtonHTML = `
|
||||
<button type="button" class="card-flip-btn" onclick="flipCard(this)" aria-label="Flip card">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
popup.innerHTML = `
|
||||
<div class="card-popup-backdrop" onclick="closeCardPopup()"></div>
|
||||
<div class="card-popup-content">
|
||||
<div class="card-popup-image">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(baseName)}&format=image&version=normal"
|
||||
alt="${cardName} image"
|
||||
data-card-name="${cardName}"
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
${flipButtonHTML}
|
||||
</div>
|
||||
<div class="card-popup-info">
|
||||
<h3 class="card-popup-name">${cardName}</h3>
|
||||
${roleHTML}
|
||||
${tagsHTML}
|
||||
</div>
|
||||
<button type="button" class="card-popup-close" onclick="closeCardPopup()" aria-label="Close">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(popup);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus close button
|
||||
const closeBtn = popup.querySelector('.card-popup-close');
|
||||
if (closeBtn) {
|
||||
setTimeout(() => closeBtn.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close card popup
|
||||
* @param {HTMLElement} [element] - Element to search from (optional)
|
||||
*/
|
||||
function closeCardPopup(element) {
|
||||
const popup = element
|
||||
? element.closest('.card-popup')
|
||||
: document.querySelector('.card-popup');
|
||||
|
||||
if (popup) {
|
||||
popup.remove();
|
||||
|
||||
// Restore body scroll if no modals are open
|
||||
if (!document.querySelector('.modal')) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup card thumbnail hover/tap events
|
||||
* Call this after dynamically adding card thumbnails to the DOM
|
||||
*/
|
||||
function setupCardPopups() {
|
||||
document.querySelectorAll('.card-thumb-container[data-card-name]').forEach(container => {
|
||||
const img = container.querySelector('.card-thumb');
|
||||
if (!img) return;
|
||||
|
||||
const cardName = container.dataset.cardName || img.dataset.cardName;
|
||||
if (!cardName) return;
|
||||
|
||||
// Desktop: hover
|
||||
container.addEventListener('mouseenter', function(e) {
|
||||
if (window.innerWidth > 768) {
|
||||
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const role = img.dataset.role || '';
|
||||
const layout = img.dataset.layout || 'normal';
|
||||
|
||||
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: tap
|
||||
container.addEventListener('click', function(e) {
|
||||
if (window.innerWidth <= 768) {
|
||||
e.preventDefault();
|
||||
|
||||
const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const role = img.dataset.role || '';
|
||||
const layout = img.dataset.layout || 'normal';
|
||||
|
||||
showCardPopup(cardName, { tags, highlightTags: [], role, layout });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INITIALIZATION
|
||||
// ============================================
|
||||
|
||||
// Setup event listeners when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Setup card popups on initial load
|
||||
setupCardPopups();
|
||||
|
||||
// Close modals/popups on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeCardPopup();
|
||||
|
||||
// Close topmost modal only
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
if (modals.length > 0) {
|
||||
closeModal(modals[modals.length - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// DOM already loaded
|
||||
setupCardPopups();
|
||||
}
|
||||
|
||||
// Export functions for use in other scripts or inline handlers
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
flipCard,
|
||||
resetCardFaces,
|
||||
togglePanel,
|
||||
expandPanel,
|
||||
collapsePanel,
|
||||
openModal,
|
||||
closeModal,
|
||||
closeAllModals,
|
||||
showCardPopup,
|
||||
closeCardPopup,
|
||||
setupCardPopups
|
||||
};
|
||||
}
|
||||
1208
code/web/static/css_backup_pre_tailwind/styles.css
Normal file
1208
code/web/static/css_backup_pre_tailwind/styles.css
Normal file
File diff suppressed because it is too large
Load diff
643
code/web/static/shared-components.css
Normal file
643
code/web/static/shared-components.css
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
/* Shared Component Styles - Not processed by Tailwind PurgeCSS */
|
||||
|
||||
/* Card-style list items (used in theme catalog, commander browser, etc.) */
|
||||
.theme-list-card {
|
||||
background: var(--panel);
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-list-card:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
/* Filter chips (used in theme catalog, card browser, etc.) */
|
||||
.filter-chip {
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.filter-chip-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Loading skeleton cards (used in theme catalog, deck lists, etc.) */
|
||||
.skeleton-card {
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: sk 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Search suggestion dropdowns (used in theme catalog, card search, etc.) */
|
||||
.search-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
z-index: 25;
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.search-suggestions a {
|
||||
display: block;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.search-suggestions a:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-suggestions a:hover,
|
||||
.search-suggestions a.selected {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.search-suggestions a.selected {
|
||||
border-left: 3px solid var(--ring);
|
||||
padding-left: calc(0.6rem - 3px);
|
||||
}
|
||||
|
||||
/* Card reference links (clickable card names with hover preview) */
|
||||
.card-ref {
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.card-ref:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Modal components (used in new deck modal, settings modals, etc.) */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
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, 0.5);
|
||||
padding: 1rem;
|
||||
max-height: min(92vh, 100%);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Form field components */
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-checkbox-label {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-checkbox-label input[type="checkbox"],
|
||||
.form-checkbox-label input[type="radio"] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Include/Exclude card chips (green/red themed) */
|
||||
.include-chips-container {
|
||||
margin-top: 0.5rem;
|
||||
min-height: 30px;
|
||||
border: 1px solid #4ade80;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
background: rgba(74, 222, 128, 0.05);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.exclude-chips-container {
|
||||
margin-top: 0.5rem;
|
||||
min-height: 30px;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chips-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chips-placeholder {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Card list textarea styling */
|
||||
.include-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
border-left: 3px solid #4ade80;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.include-textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Alternative card buttons - force text wrapping */
|
||||
.alt-option {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
text-align: left !important;
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
line-height: 1.3 !important;
|
||||
padding: 0.5rem 0.7rem !important;
|
||||
}
|
||||
|
||||
.exclude-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
border-left: 3px solid #ef4444;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.exclude-textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Info/warning panels */
|
||||
.info-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.info-panel summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.info-panel-content {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Include/Exclude card list helpers */
|
||||
.include-exclude-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.include-exclude-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card-list-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-list-label small {
|
||||
color: #9ca3af;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-list-label-include {
|
||||
color: #4ade80;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-list-label-exclude {
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-list-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-list-count {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-list-validation {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-list-badges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Button variants for include/exclude controls */
|
||||
.btn-upload-include {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #065f46;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.btn-upload-exclude {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #7f1d1d;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
font-size: 11px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #7f1d1d;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
/* Modal footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer-left {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Chip dot color variants */
|
||||
.dot-green {
|
||||
background: var(--green-main);
|
||||
}
|
||||
|
||||
.dot-blue {
|
||||
background: var(--blue-main);
|
||||
}
|
||||
|
||||
.dot-orange {
|
||||
background: var(--orange-main, #f97316);
|
||||
}
|
||||
|
||||
.dot-red {
|
||||
background: var(--red-main);
|
||||
}
|
||||
|
||||
.dot-purple {
|
||||
background: var(--purple-main, #a855f7);
|
||||
}
|
||||
|
||||
/* Form label with icon */
|
||||
.form-label-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Inline form (for control buttons) */
|
||||
.inline-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Locked cards list */
|
||||
.locked-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.35rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.locked-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lock-box-inline {
|
||||
display: inline;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Build controls sticky section */
|
||||
.build-controls {
|
||||
position: sticky;
|
||||
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: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Alert box */
|
||||
.alert-error {
|
||||
margin-top: 0.5rem;
|
||||
color: #fecaca;
|
||||
background: #7f1d1d;
|
||||
border: 1px solid #991b1b;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Stage timeline list */
|
||||
.timeline-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Card action buttons container */
|
||||
.card-actions-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Ownership badge (small circular indicator) */
|
||||
.ownership-badge {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(17,24,39,.9);
|
||||
color: #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Build log pre formatting */
|
||||
.build-log {
|
||||
margin-top: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
background: #0f1115;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Last action status area (prevents layout shift) */
|
||||
.last-action {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
/* Deck summary section divider */
|
||||
.summary-divider {
|
||||
margin: 1.25rem 0;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* Summary type heading */
|
||||
.summary-type-heading {
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Summary view controls */
|
||||
.summary-view-controls {
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Summary section spacing */
|
||||
.summary-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-section-lg {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Land breakdown note chips */
|
||||
.land-note-chip-expand {
|
||||
background: #0f172a;
|
||||
border-color: #34d399;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.land-note-chip-counts {
|
||||
background: #111827;
|
||||
border-color: #60a5fa;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* Land breakdown list */
|
||||
.land-breakdown-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.35rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.land-breakdown-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.land-breakdown-subs {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.2rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
|
||||
.land-breakdown-sub {
|
||||
font-size: 0.85rem;
|
||||
color: #e5e7eb;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Deck metrics wrap */
|
||||
.deck-metrics-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Combo summary styling */
|
||||
.combo-summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #12161c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Mana analytics row grid */
|
||||
.mana-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Mana panel container */
|
||||
.mana-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
background: #0f1115;
|
||||
}
|
||||
|
||||
/* Mana panel heading */
|
||||
.mana-panel-heading {
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Chart bars container */
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
/* Chart column center-aligned text */
|
||||
.chart-column {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Chart SVG cursor */
|
||||
.chart-svg {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Existing card tile styles (for reference/consolidation) */
|
||||
.card-tile {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card-tile:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
/* Theme detail card styles (for reference/consolidation) */
|
||||
.theme-detail-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
3500
code/web/static/tailwind.css
Normal file
3500
code/web/static/tailwind.css
Normal file
File diff suppressed because it is too large
Load diff
2
code/web/static/ts/.gitkeep
Normal file
2
code/web/static/ts/.gitkeep
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Placeholder for TypeScript source files
|
||||
# TypeScript files will be compiled to code/web/static/js/
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
window.__telemetryEndpoint = '/telemetry/events';
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
|
||||
<link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" />
|
||||
<style>
|
||||
/* Disable all transitions until page is loaded to prevent sidebar flash */
|
||||
.no-transition,
|
||||
|
|
@ -63,18 +64,11 @@
|
|||
<body class="no-transition" 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">
|
||||
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
|
||||
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
|
||||
<div class="flex-row banner-left">
|
||||
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border); flex-shrink: 0;">
|
||||
☰ Menu
|
||||
</button>
|
||||
<h1 style="margin:0;">MTG Deckbuilder</h1>
|
||||
</div>
|
||||
<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" id="btn-open-permalink" 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>
|
||||
{# Theme controls moved to sidebar #}
|
||||
<h1 style="margin:0; white-space: nowrap;">MTG Deckbuilder</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -128,115 +122,7 @@
|
|||
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
||||
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
||||
</footer>
|
||||
<style>
|
||||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); }
|
||||
.card-hover .dual {
|
||||
display:flex; gap:12px; align-items:flex-start;
|
||||
}
|
||||
.card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||
.card-meta li { margin:.1rem 0; }
|
||||
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
|
||||
/* Global theme badge styles (moved from picker for reuse on standalone pages) */
|
||||
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
|
||||
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.badge-fallback { background:#7f1d1d; color:#fff; }
|
||||
.badge-quality-draft { background:#4338ca; color:#fff; }
|
||||
.badge-quality-reviewed { background:#065f46; color:#fff; }
|
||||
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
|
||||
.badge-pop-vc { background:#065f46; color:#fff; }
|
||||
.badge-pop-c { background:#047857; color:#fff; }
|
||||
.badge-pop-u { background:#0369a1; color:#fff; }
|
||||
.badge-pop-n { background:#92400e; color:#fff; }
|
||||
.badge-pop-r { background:#7f1d1d; color:#fff; }
|
||||
.badge-curated { background:#4f46e5; color:#fff; }
|
||||
.badge-enforced { background:#334155; color:#fff; }
|
||||
.badge-inferred { background:#57534e; color:#fff; }
|
||||
.theme-detail-card { background:var(--panel); padding:1rem 1.1rem; border:1px solid var(--border); border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,.25); }
|
||||
.theme-detail-card h3 { margin-top:0; margin-bottom:.4rem; }
|
||||
.theme-detail-card .desc { margin-top:0; font-size:13px; line-height:1.45; }
|
||||
.theme-detail-card h4 { margin-bottom:.35rem; margin-top:.85rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.85; }
|
||||
.breadcrumb { font-size:12px; margin-bottom:.4rem; }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
footer.site-footer { flex-shrink: 0; }
|
||||
/* Hide hover preview on narrow screens to avoid covering content */
|
||||
@media (max-width: 900px){
|
||||
.card-hover{ display: none !important; }
|
||||
}
|
||||
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
|
||||
.card-hover .ov-chip { display:inline-block; background:#38bdf8; color:#102746; border:1px solid #0f3a57; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; font-weight:600; }
|
||||
/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */
|
||||
.card-hover .dual.two-faced img { width:320px; }
|
||||
.card-hover .dual.two-faced { gap:8px; }
|
||||
/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */
|
||||
.card-hover .dual.combo img { width:300px; }
|
||||
@media (max-width: 1100px){
|
||||
.card-hover .dual.two-faced img { width:280px; }
|
||||
.card-hover .dual.combo img { width:260px; }
|
||||
}
|
||||
/* Unified hover-card-panel styling parity */
|
||||
#hover-card-panel.is-payoff { border-color: var(--accent, #38bdf8); box-shadow:0 6px 24px rgba(0,0,0,.65), 0 0 0 1px var(--accent, #38bdf8) inset; }
|
||||
#hover-card-panel.is-payoff .hcp-img { border-color: var(--accent, #38bdf8); }
|
||||
/* Inline theme/tag list styling (unifies legacy second panel) */
|
||||
/* Two-column hover layout */
|
||||
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
|
||||
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
|
||||
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
|
||||
#hover-card-panel.hcp-simple { width:auto !important; max-width:min(360px, 90vw) !important; padding:12px !important; height:auto !important; max-height:none !important; overflow:hidden !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-body { display:flex; flex-direction:column; gap:12px; align-items:center; }
|
||||
#hover-card-panel.hcp-simple .hcp-right { display:none !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-img { max-width:100%; }
|
||||
/* Tag list as multi-column list instead of pill chips for readability */
|
||||
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
|
||||
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
|
||||
#hover-card-panel .hcp-taglist li.overlap { font-weight:600; color:var(--accent,#38bdf8); }
|
||||
#hover-card-panel .hcp-taglist li.overlap::before { content:'•'; color:var(--accent,#38bdf8); position:absolute; left:-10px; }
|
||||
#hover-card-panel .hcp-overlaps { font-size:10px; line-height:1.25; margin-top:2px; }
|
||||
#hover-card-panel .hcp-ov-chip { display:inline-flex; align-items:center; background:var(--accent,#38bdf8); color:#102746; border:1px solid rgba(10,54,82,.6); border-radius:9999px; padding:3px 10px; font-size:13px; margin-right:6px; margin-top:4px; font-weight:500; letter-spacing:.02em; }
|
||||
/* Hide modal-specific close button outside modal host */
|
||||
#preview-close-btn { display:none; }
|
||||
#theme-preview-modal #preview-close-btn { display:inline-flex; }
|
||||
/* Overlay flip toggle for double-faced cards */
|
||||
.dfc-host { position:relative; }
|
||||
.dfc-toggle { position:absolute; top:6px; left:6px; z-index:5; background:rgba(15,23,42,.82); color:#fff; border:1px solid #475569; border-radius:50%; width:36px; height:36px; padding:0; font-size:16px; cursor:pointer; line-height:1; display:flex; align-items:center; justify-content:center; opacity:.92; backdrop-filter: blur(3px); }
|
||||
.dfc-toggle:hover, .dfc-toggle:focus { opacity:1; box-shadow:0 0 0 2px rgba(56,189,248,.35); outline:none; }
|
||||
.dfc-toggle:active { transform: translateY(1px); }
|
||||
.dfc-toggle .icon { font-size:12px; }
|
||||
.dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); }
|
||||
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
|
||||
.dfc-toggle[aria-pressed='true'] { box-shadow:0 0 0 2px var(--accent, #38bdf8); }
|
||||
.list-row .dfc-toggle { position:static; width:auto; height:auto; border-radius:6px; padding:2px 8px; font-size:12px; opacity:1; backdrop-filter:none; margin-left:4px; }
|
||||
.list-row .dfc-toggle .icon { font-size:12px; }
|
||||
.list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); }
|
||||
.list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); }
|
||||
#hover-card-panel.mobile { left:50% !important; top:50% !important; bottom:auto !important; transform:translate(-50%, -50%); width:min(94vw, 460px) !important; max-height:88vh; overflow-y:auto; padding:20px 22px; pointer-events:auto !important; }
|
||||
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:20px; }
|
||||
#hover-card-panel.mobile .hcp-img { width:100%; max-width:min(90vw, 420px) !important; margin:0 auto; }
|
||||
#hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
|
||||
#hover-card-panel.mobile .hcp-meta { font-size:13px; text-align:left; }
|
||||
#hover-card-panel.mobile .hcp-overlaps { display:flex; flex-wrap:wrap; gap:6px; width:100%; }
|
||||
#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { margin:0; }
|
||||
#hover-card-panel.mobile .hcp-taglist { columns:1; display:flex; flex-wrap:wrap; gap:6px; margin:4px 0 2px; max-height:none; overflow:visible; padding:0; }
|
||||
#hover-card-panel.mobile .hcp-taglist li { background:rgba(37,99,235,.18); border-radius:9999px; padding:4px 10px; display:inline-flex; align-items:center; }
|
||||
#hover-card-panel.mobile .hcp-taglist li.overlap { background:rgba(37,99,235,.28); color:#dbeafe; }
|
||||
#hover-card-panel.mobile .hcp-taglist li.overlap::before { display:none; }
|
||||
#hover-card-panel.mobile .hcp-reasons { max-height:220px; width:100%; }
|
||||
#hover-card-panel.mobile .hcp-tags { word-break:normal; white-space:normal; text-align:left; width:100%; font-size:12px; opacity:.7; }
|
||||
#hover-card-panel .hcp-close { appearance:none; border:none; background:transparent; color:#9ca3af; font-size:18px; line-height:1; padding:2px 4px; cursor:pointer; border-radius:6px; display:none; }
|
||||
#hover-card-panel .hcp-close:focus { outline:2px solid rgba(59,130,246,.6); outline-offset:2px; }
|
||||
#hover-card-panel.mobile .hcp-close { display:inline-flex; }
|
||||
/* Fade transition for hover panel image */
|
||||
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||||
</style>
|
||||
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
|
||||
<style>
|
||||
.nav a.active { font-weight:600; position:relative; }
|
||||
.nav a.active::after { content:''; position:absolute; left:0; bottom:2px; width:100%; height:2px; background:var(--accent, #38bdf8); border-radius:2px; }
|
||||
|
|
@ -358,25 +244,6 @@
|
|||
setInterval(pollStatus, 10000);
|
||||
pollStatus();
|
||||
|
||||
// Health indicator poller
|
||||
var healthDot = document.getElementById('health-dot');
|
||||
function renderHealth(data){
|
||||
if (!healthDot) return;
|
||||
var ok = data && data.status === 'ok';
|
||||
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
|
||||
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
|
||||
}
|
||||
function pollHealth(){
|
||||
try {
|
||||
fetch('/healthz', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(renderHealth)
|
||||
.catch(function(){ renderHealth({ status: 'bad' }); });
|
||||
} catch(e){ renderHealth({ status: 'bad' }); }
|
||||
}
|
||||
setInterval(pollHealth, 5000);
|
||||
pollHealth();
|
||||
|
||||
function ensureCard() {
|
||||
// Legacy large image hover kept for fallback; disabled in favor of unified hover-card-panel
|
||||
if (window.__disableLegacyCardHover) return document.getElementById('card-hover') || document.createElement('div');
|
||||
|
|
@ -416,17 +283,17 @@
|
|||
function buildCardUrl(name, version, nocache, face){
|
||||
name = normalizeCardName(name);
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (face === 'back') url += '&face=back';
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
var url = '/api/images/' + (version||'normal') + '/' + q;
|
||||
if (face === 'back') url += '?face=back';
|
||||
if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
// Generic Scryfall image URL builder
|
||||
// Generic card image URL builder
|
||||
function buildScryfallImageUrl(name, version, nocache){
|
||||
name = normalizeCardName(name);
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
var url = '/api/images/' + (version||'normal') + '/' + q;
|
||||
if (nocache) url += '?t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
||||
|
|
@ -624,9 +491,21 @@
|
|||
}
|
||||
function hasTwoFaces(card){
|
||||
if(!card) return false;
|
||||
|
||||
// Check if card has a layout attribute - this is the source of truth
|
||||
var layout = card.getAttribute('data-layout') || '';
|
||||
if(layout) {
|
||||
// Only these layouts are actual flippable double-faced cards
|
||||
var flippableLayouts = ['modal_dfc', 'transform', 'reversible_card', 'flip', 'meld'];
|
||||
return flippableLayouts.indexOf(layout) > -1;
|
||||
}
|
||||
|
||||
// Fallback: If no layout data, check if name has // (backwards compatibility)
|
||||
// This shouldn't happen if templates properly pass data-layout
|
||||
var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name'));
|
||||
return name.indexOf('//') > -1;
|
||||
}
|
||||
window.__dfcHasTwoFaces = hasTwoFaces; // Expose globally for popup hover panel
|
||||
function keyFor(card){
|
||||
var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, 'data-original-name') || '').toLowerCase();
|
||||
return LS_PREFIX + nm;
|
||||
|
|
@ -669,7 +548,9 @@
|
|||
var face = card.getAttribute(FACE_ATTR) || 'front';
|
||||
var btn = document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.className='dfc-toggle';
|
||||
// Mobile: flip in popup only (flex below md). Desktop: flip in thumbnails only (hidden at md+)
|
||||
var inPopup = card.closest && card.closest('#hover-card-panel');
|
||||
btn.className = inPopup ? 'dfc-toggle flex md:hidden' : 'dfc-toggle hidden md:flex';
|
||||
btn.setAttribute('aria-pressed','false');
|
||||
btn.setAttribute('tabindex','0');
|
||||
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
|
||||
|
|
@ -692,6 +573,7 @@
|
|||
card.insertBefore(btn, card.firstChild);
|
||||
}
|
||||
}
|
||||
window.__dfcEnsureButton = ensureButton; // Expose for hover panel use
|
||||
function flip(card, btn){
|
||||
var now = Date.now();
|
||||
if(now - lastFlip < DEBOUNCE_MS) return;
|
||||
|
|
@ -746,6 +628,7 @@
|
|||
} catch(_) {}
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/components.js?v=20250121-1"></script>
|
||||
<script src="/static/app.js?v=20250826-4"></script>
|
||||
{% if enable_themes %}
|
||||
<script>
|
||||
|
|
@ -1210,13 +1093,25 @@
|
|||
var chosenFace = card.getAttribute('data-current-face') || 'front';
|
||||
lastCard = card;
|
||||
function renderHoverFace(face){
|
||||
var desiredVersion='large';
|
||||
var faceParam = (face==='back') ? '&face=back' : '';
|
||||
var desiredVersion='normal'; // Use 'normal' since we only cache small/normal
|
||||
var currentKey = nm+':'+face+':'+desiredVersion;
|
||||
var prevFace = imgEl.getAttribute('data-face');
|
||||
var faceChanged = prevFace && prevFace !== face;
|
||||
if(imgEl.getAttribute('data-current')!== currentKey){
|
||||
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
|
||||
// For DFC cards, extract the specific face name for cache lookup
|
||||
// but also send face parameter for Scryfall fallback
|
||||
var faceName = nm;
|
||||
var isDFC = nm.indexOf('//')>-1;
|
||||
if(isDFC){
|
||||
var faces = nm.split('//');
|
||||
faceName = (face==='back') ? faces[1].trim() : faces[0].trim();
|
||||
}
|
||||
// Use cache-aware API endpoint with the specific face name
|
||||
// Add face parameter for DFC back faces to help Scryfall fallback
|
||||
var src='/api/images/'+desiredVersion+'/'+encodeURIComponent(faceName);
|
||||
if(isDFC && face==='back'){
|
||||
src += '?face=back';
|
||||
}
|
||||
if(faceChanged){ imgEl.style.opacity = 0; }
|
||||
prefetch(src);
|
||||
imgEl.src = src;
|
||||
|
|
@ -1228,12 +1123,50 @@
|
|||
imgEl.__errBound = true;
|
||||
imgEl.addEventListener('error', function(){
|
||||
var cur = imgEl.getAttribute('src')||'';
|
||||
if(cur.indexOf('version=large')>-1){ imgEl.src = cur.replace('version=large','version=normal'); }
|
||||
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
|
||||
// Fallback from normal to small if image fails to load
|
||||
if(cur.indexOf('/api/images/normal/')>-1){
|
||||
imgEl.src = cur.replace('/api/images/normal/','/api/images/small/');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
renderHoverFace(chosenFace);
|
||||
|
||||
// Add DFC flip button to popup panel ONLY on mobile
|
||||
var checkFlip = window.__dfcHasTwoFaces || function(){ return false; };
|
||||
if(hasFlip && imgEl && checkFlip(card) && isMobileMode()){
|
||||
var imgWrap = imgEl.parentElement; // .hcp-img-wrap
|
||||
if(imgWrap && !imgWrap.querySelector('.dfc-toggle')){
|
||||
// Create a custom flip button that flips the ORIGINAL card (lastCard)
|
||||
// This ensures the popup refreshes with updated tags/themes
|
||||
var flipBtn = document.createElement('button');
|
||||
flipBtn.type = 'button';
|
||||
flipBtn.className = 'dfc-toggle'; // No responsive classes needed - only created on mobile
|
||||
flipBtn.setAttribute('aria-pressed', 'false');
|
||||
flipBtn.setAttribute('tabindex', '0');
|
||||
flipBtn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
||||
|
||||
// Flip the ORIGINAL card element, not the popup wrapper
|
||||
flipBtn.addEventListener('click', function(ev){
|
||||
ev.stopPropagation();
|
||||
if(window.__dfcFlipCard && lastCard){
|
||||
window.__dfcFlipCard(lastCard); // This will trigger popup refresh
|
||||
}
|
||||
});
|
||||
flipBtn.addEventListener('keydown', function(ev){
|
||||
if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){
|
||||
ev.preventDefault();
|
||||
if(window.__dfcFlipCard && lastCard){
|
||||
window.__dfcFlipCard(lastCard);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
imgWrap.classList.add('dfc-host');
|
||||
imgWrap.appendChild(flipBtn);
|
||||
}
|
||||
}
|
||||
|
||||
window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null;
|
||||
if(evt){ window.__lastPointerEvent = evt; }
|
||||
if(isMobileMode()){
|
||||
|
|
@ -1269,8 +1202,19 @@
|
|||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
// For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile)
|
||||
if(el.closest && el.closest('.card-tile')){
|
||||
var imgBtn = el.closest('.img-btn');
|
||||
if(imgBtn) return imgBtn.closest('.card-tile');
|
||||
// If directly on the image
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))){
|
||||
return el.closest('.card-tile');
|
||||
}
|
||||
// Don't trigger on other parts of the tile (buttons, text, etc.)
|
||||
return null;
|
||||
}
|
||||
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
|
||||
if(container) return container;
|
||||
// Image-based detection (any card image carrying data-card-name)
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
{# Single card tile for grid display #}
|
||||
<div class="card-browser-tile card-tile" data-card-name="{{ card.name }}" data-tags="{{ card.themeTags_parsed|join(', ') if card.themeTags_parsed else '' }}">
|
||||
<div class="card-browser-tile card-tile"
|
||||
data-card-name="{{ card.name }}"
|
||||
data-tags="{{ card.themeTags_parsed|join(', ') if card.themeTags_parsed else '' }}"
|
||||
{% if card.layout %}data-layout="{{ card.layout }}"{% endif %}>
|
||||
{# Card image (uses hover system for preview) #}
|
||||
<div class="card-browser-tile-image">
|
||||
<img
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="{{ card.name }}"
|
||||
src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
|
||||
src="{{ card.name|card_image('normal') }}"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
|
||||
data-card-name="{{ card.name }}"
|
||||
{% if card.layout %}data-layout="{{ card.layout }}"{% endif %}
|
||||
/>
|
||||
{# Fallback for missing images #}
|
||||
<div style="display:none; width:100%; height:100%; align-items:center; justify-content:center; background:#1a1d24; color:#9ca3af; font-size:14px; padding:1rem; text-align:center; position:absolute; top:0; left:0;">
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@
|
|||
<div class="similar-card-tile card-tile" data-card-name="{{ card.name }}">
|
||||
<!-- Card Image (uses hover system for preview) -->
|
||||
<div class="similar-card-image">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
|
||||
<img src="{{ card.name|card_image('normal') }}"
|
||||
alt="{{ card.name }}"
|
||||
loading="lazy"
|
||||
data-card-name="{{ card.name }}"
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@
|
|||
<div class="card-detail-header">
|
||||
<!-- Card Image (no hover on detail page) -->
|
||||
<div class="card-image-large">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ card.name|urlencode }}&format=image&version=normal"
|
||||
<img src="{{ card.name|card_image('normal') }}"
|
||||
alt="{{ card.name }}"
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
|
|
|
|||
|
|
@ -32,13 +32,108 @@
|
|||
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
|
||||
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
|
||||
{% if it.owned %}data-owned="1"{% endif %}
|
||||
data-tags="{{ tags|join(', ') }}" hx-post="/build/replace"
|
||||
data-tags="{{ tags|join(', ') }}"
|
||||
hx-post="/build/replace"
|
||||
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
|
||||
hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">
|
||||
Replace with {{ it.name }}
|
||||
hx-target="closest .alts"
|
||||
hx-swap="outerHTML"
|
||||
title="Lock this alternative and unlock the current pick">
|
||||
{{ it.name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
// Mobile: tap to preview, tap "Use as Replacement" button in popup to replace
|
||||
(function(){
|
||||
var altPanel = document.currentScript.previousElementSibling;
|
||||
if(!altPanel) return;
|
||||
|
||||
// Track which button triggered the popup
|
||||
var pendingReplacement = null;
|
||||
|
||||
// Better mobile detection (matches base.html logic)
|
||||
function isMobileMode(){
|
||||
var coarseQuery = window.matchMedia('(pointer: coarse)');
|
||||
return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
// Intercept htmx request before it's sent
|
||||
altPanel.addEventListener('htmx:configRequest', function(e){
|
||||
var btn = e.detail.elt;
|
||||
if(!btn || !btn.classList.contains('alt-option')) return;
|
||||
|
||||
if(isMobileMode() && !btn.dataset.mobileConfirmed){
|
||||
// First tap on mobile: cancel the request, show preview instead
|
||||
e.preventDefault();
|
||||
pendingReplacement = btn;
|
||||
|
||||
// Show card preview and inject replacement button
|
||||
if(window.__hoverShowCard){
|
||||
window.__hoverShowCard(btn);
|
||||
|
||||
// Inject "Use as Replacement" button into popup
|
||||
setTimeout(function(){
|
||||
if(!isMobileMode()) return; // Double-check we're still in mobile mode
|
||||
|
||||
var hoverPanel = document.getElementById('hover-card-panel');
|
||||
if(hoverPanel && !hoverPanel.querySelector('.mobile-replace-btn')){
|
||||
var imgWrap = hoverPanel.querySelector('.hcp-img-wrap');
|
||||
if(imgWrap){
|
||||
var replaceBtn = document.createElement('button');
|
||||
replaceBtn.type = 'button';
|
||||
replaceBtn.className = 'btn mobile-replace-btn';
|
||||
replaceBtn.textContent = 'Use as Replacement';
|
||||
replaceBtn.style.cssText = 'width:100%;padding:0.75rem;font-size:15px;margin-top:8px;pointer-events:auto;position:relative;z-index:10000;';
|
||||
|
||||
var handleClick = function(ev){
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if(!pendingReplacement) return;
|
||||
|
||||
pendingReplacement.dataset.mobileConfirmed = '1';
|
||||
pendingReplacement.click();
|
||||
|
||||
// Close the popup after a short delay
|
||||
setTimeout(function(){
|
||||
var closeBtn = hoverPanel.querySelector('.hcp-close');
|
||||
if(closeBtn) closeBtn.click();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
replaceBtn.onclick = handleClick;
|
||||
replaceBtn.addEventListener('click', handleClick);
|
||||
replaceBtn.addEventListener('touchend', handleClick);
|
||||
|
||||
imgWrap.appendChild(replaceBtn);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Desktop or mobile with confirmation: allow request to proceed
|
||||
if(btn.dataset.mobileConfirmed){
|
||||
btn.removeAttribute('data-mobileConfirmed');
|
||||
pendingReplacement = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset pending replacement when popup closes
|
||||
document.addEventListener('click', function(e){
|
||||
if(e.target.closest('.hcp-close')){
|
||||
pendingReplacement = null;
|
||||
// Remove mobile replace button when closing
|
||||
var hoverPanel = document.getElementById('hover-card-panel');
|
||||
if(hoverPanel){
|
||||
var replaceBtn = hoverPanel.querySelector('.mobile-replace-btn');
|
||||
if(replaceBtn) replaceBtn.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@
|
|||
{% set sev = (f.severity or 'FAIL')|upper %}
|
||||
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}" {% if sev == 'FAIL' %}style="border-color: var(--red-main);"{% elif sev == 'WARN' %}style="border-color: var(--orange-main);"{% endif %}>
|
||||
<a href="https://scryfall.com/search?q={{ f.name|urlencode }}" target="_blank" rel="noopener" class="img-btn" title="{{ f.name }}">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=large 672w"
|
||||
<img class="card-thumb" src="{{ f.name|card_image('normal') }}" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="{{ f.name|card_image('small') }} 160w, {{ f.name|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if f.owned else 'Not owned' }}" aria-label="{{ 'Owned' if f.owned else 'Not owned' }}">{% if f.owned %}✔{% else %}✖{% endif %}</div>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
|
||||
<div class="modal-backdrop" style="position:fixed; 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; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
|
||||
<div class="modal modal-overlay" id="new-deck-modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
<div class="error my-2" role="alert">{{ 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 class="basics-grid grid grid-cols-[2fr_1fr] gap-4 items-start">
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<label class="form-label">
|
||||
<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;">
|
||||
<label class="form-label">
|
||||
<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"
|
||||
|
|
@ -24,11 +24,11 @@
|
|||
data-hx-debounce="220" data-hx-debounce-events="input"
|
||||
data-hx-debounce-flush="blur" />
|
||||
</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>
|
||||
<small class="muted block mt-1">Start typing to see matches, then select one to load themes.</small>
|
||||
<div id="newdeck-candidates" class="muted text-xs min-h-[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 id="newdeck-commander-slot" class="muted max-w-[230px]">
|
||||
<em class="text-xs">Pick a commander to preview here.</em>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -45,11 +45,11 @@
|
|||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
<div id="newdeck-multicopy-slot" class="muted mt-2 min-h-[1rem]"></div>
|
||||
{% if enable_custom_themes %}
|
||||
{% include "build/_new_deck_additional_themes.html" %}
|
||||
{% endif %}
|
||||
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
|
||||
<div class="mt-2" id="newdeck-bracket-slot">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
{% for b in brackets %}
|
||||
|
|
@ -63,47 +63,47 @@
|
|||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Preferences</legend>
|
||||
<div style="text-align: left;">
|
||||
<div style="margin-bottom: 1rem; display:flex; flex-direction:column; gap:0.75rem;">
|
||||
<label for="pref-combos-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_combos %}checked{% endif %} />
|
||||
<div class="text-left">
|
||||
<div class="mb-4 flex flex-col gap-3">
|
||||
<label for="pref-combos-chk" class="form-checkbox-label" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" {% if form and form.prefer_combos %}checked{% endif %} />
|
||||
<span>Prioritize combos</span>
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top: 0.5rem; margin-left: 1.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 8px; display: none;">
|
||||
<div style="display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;">
|
||||
<div id="pref-combos-config" class="mt-2 ml-6 p-2 border border-[var(--border)] rounded-lg hidden">
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<label>
|
||||
<span>How many combos?</span>
|
||||
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" style="width: 6rem; margin-left: 0.5rem;" />
|
||||
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" class="w-24 ml-2" />
|
||||
</label>
|
||||
<div>
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 0.25rem;">Balance of early vs late-game</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.5rem;">
|
||||
<div class="muted text-xs mb-1">Balance of early vs late-game</div>
|
||||
<label class="inline-flex items-center gap-1 mr-2">
|
||||
<input type="radio" name="combo_balance" value="early" {% if form and form.combo_balance == 'early' %}checked{% endif %} /> Early
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.5rem;">
|
||||
<label class="inline-flex items-center gap-1 mr-2">
|
||||
<input type="radio" name="combo_balance" value="late" {% if form and form.combo_balance == 'late' %}checked{% endif %} /> Late
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<label class="inline-flex items-center gap-1">
|
||||
<input type="radio" name="combo_balance" value="mix" {% if not form or (form and (not form.combo_balance or form.combo_balance == 'mix')) %}checked{% endif %} /> Mix
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="pref-mc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.enable_multicopy %}checked{% endif %} />
|
||||
<label for="pref-mc-chk" class="form-checkbox-label" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" {% if form and form.enable_multicopy %}checked{% endif %} />
|
||||
<span>Enable Multi-Copy package</span>
|
||||
</label>
|
||||
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.75rem;">
|
||||
<label for="use-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
|
||||
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.use_owned_only %}checked{% endif %} />
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
<label for="use-owned-chk" class="form-checkbox-label" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
|
||||
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" {% if form and form.use_owned_only %}checked{% endif %} />
|
||||
<span>Use only owned cards</span>
|
||||
</label>
|
||||
<label for="prefer-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
|
||||
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_owned %}checked{% endif %} />
|
||||
<label for="prefer-owned-chk" class="form-checkbox-label" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
|
||||
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" {% if form and form.prefer_owned %}checked{% endif %} />
|
||||
<span>Prefer owned cards (allow unowned fallback)</span>
|
||||
</label>
|
||||
<label for="swap-mdfc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
|
||||
<label for="swap-mdfc-chk" class="form-checkbox-label" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
|
||||
<span>Swap basics for MDFC lands</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -114,101 +114,97 @@
|
|||
{% if allow_must_haves %}
|
||||
<fieldset>
|
||||
<legend>Include/Exclude Cards</legend>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:1rem; margin-top:.5rem;" class="include-exclude-grid">
|
||||
<div class="include-exclude-grid">
|
||||
<!-- Include Cards Column (Left, Green) -->
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span style="color: #4ade80; font-weight: 500;">✓ Must Include Cards</span>
|
||||
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Cards that must appear in your deck</small>
|
||||
<label class="card-list-label">
|
||||
<span class="card-list-label-include">✓ Must Include Cards</span>
|
||||
<small class="muted block text-xs mt-1">Cards that must appear in your deck</small>
|
||||
</label>
|
||||
<textarea name="include_cards" id="include_cards_textarea"
|
||||
placeholder="Lightning Bolt Counterspell Swords to Plowshares"
|
||||
style="width:100%; min-height:60px; resize:vertical; font-family:monospace; font-size:12px; border-left: 3px solid #4ade80;"
|
||||
class="include-textarea"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false">{{ form.include_cards if form and form.include_cards else '' }}</textarea>
|
||||
<!-- Include Cards Chips Container -->
|
||||
<div id="include_chips_container" style="margin-top:.5rem; min-height:30px; border:1px solid #4ade80; border-radius:6px; padding:.5rem; background:rgba(74, 222, 128, 0.05); display:flex; flex-wrap:wrap; gap:.25rem; align-items:flex-start;">
|
||||
<div id="include_chips" style="display:flex; flex-wrap:wrap; gap:.25rem; flex:1;"></div>
|
||||
<div style="color:#6b7280; font-size:11px; font-style:italic;" id="include_chips_placeholder">Enter card names above to see them as removable tags</div>
|
||||
<div id="include_chips_container" class="include-chips-container">
|
||||
<div id="include_chips" class="chips-inner"></div>
|
||||
<div class="chips-placeholder" id="include_chips_placeholder">Enter card names above to see them as removable tags</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin-top:.5rem; font-size:12px;">
|
||||
<label for="include_file_upload" class="btn" style="cursor:pointer; font-size:11px; padding:.25rem .5rem; background:#065f46; border-color:#059669;">
|
||||
<div class="card-list-controls">
|
||||
<label for="include_file_upload" class="btn btn-upload-include">
|
||||
📄 Upload .txt
|
||||
</label>
|
||||
<input type="file" id="include_file_upload" accept=".txt" style="display:none;"
|
||||
<input type="file" id="include_file_upload" accept=".txt" class="hidden"
|
||||
onchange="handleIncludeFileUpload(this)" />
|
||||
<button type="button" onclick="clearIncludes()" class="btn" style="font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
|
||||
<button type="button" onclick="clearIncludes()" class="btn btn-clear">
|
||||
🗑 Clear All
|
||||
</button>
|
||||
<div id="include_count" class="muted" style="font-size:11px;">0/10</div>
|
||||
<div id="include_badges" style="display:flex; gap:.25rem; font-size:10px;"></div>
|
||||
<div id="include_count" class="muted card-list-count">0/10</div>
|
||||
<div id="include_badges" class="card-list-badges"></div>
|
||||
</div>
|
||||
<div id="include_validation" style="margin-top:.5rem; font-size:12px;"></div>
|
||||
<div id="include_validation" class="card-list-validation"></div>
|
||||
</div>
|
||||
<!-- Exclude Cards Column (Right, Red) -->
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span style="color: #ef4444; font-weight: 500;">✗ Must Exclude Cards</span>
|
||||
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Cards to avoid in your deck</small>
|
||||
<label class="card-list-label">
|
||||
<span class="card-list-label-exclude">✗ Must Exclude Cards</span>
|
||||
<small class="muted block text-xs mt-1">Cards to avoid in your deck</small>
|
||||
</label>
|
||||
<textarea name="exclude_cards" id="exclude_cards_textarea"
|
||||
placeholder="Sol Ring Rhystic Study Smothering Tithe"
|
||||
style="width:100%; min-height:60px; resize:vertical; font-family:monospace; font-size:12px; border-left: 3px solid #ef4444;"
|
||||
class="exclude-textarea"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false">{{ form.exclude_cards if form and form.exclude_cards else '' }}</textarea>
|
||||
<!-- Exclude Cards Chips Container -->
|
||||
<div id="exclude_chips_container" style="margin-top:.5rem; min-height:30px; border:1px solid #ef4444; border-radius:6px; padding:.5rem; background:rgba(239, 68, 68, 0.05); display:flex; flex-wrap:wrap; gap:.25rem; align-items:flex-start;">
|
||||
<div id="exclude_chips" style="display:flex; flex-wrap:wrap; gap:.25rem; flex:1;"></div>
|
||||
<div style="color:#6b7280; font-size:11px; font-style:italic;" id="exclude_chips_placeholder">Enter card names above to see them as removable tags</div>
|
||||
<div id="exclude_chips_container" class="exclude-chips-container">
|
||||
<div id="exclude_chips" class="chips-inner"></div>
|
||||
<div class="chips-placeholder" id="exclude_chips_placeholder">Enter card names above to see them as removable tags</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin-top:.5rem; font-size:12px;">
|
||||
<label for="exclude_file_upload" class="btn" style="cursor:pointer; font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
|
||||
<div class="card-list-controls">
|
||||
<label for="exclude_file_upload" class="btn btn-upload-exclude">
|
||||
📄 Upload .txt
|
||||
</label>
|
||||
<input type="file" id="exclude_file_upload" accept=".txt" style="display:none;"
|
||||
<input type="file" id="exclude_file_upload" accept=".txt" class="hidden"
|
||||
onchange="handleExcludeFileUpload(this)" />
|
||||
<button type="button" onclick="clearExcludes()" class="btn" style="font-size:11px; padding:.25rem .5rem; background:#7f1d1d; border-color:#dc2626;">
|
||||
<button type="button" onclick="clearExcludes()" class="btn btn-clear">
|
||||
🗑 Clear All
|
||||
</button>
|
||||
<div id="exclude_count" class="muted" style="font-size:11px;">0/15</div>
|
||||
<div id="exclude_badges" style="display:flex; gap:.25rem; font-size:10px;"></div>
|
||||
<div id="exclude_count" class="muted card-list-count">0/15</div>
|
||||
<div id="exclude_badges" class="card-list-badges"></div>
|
||||
</div>
|
||||
<div id="exclude_validation" style="margin-top:.5rem; font-size:12px;"></div>
|
||||
<div id="exclude_validation" class="card-list-validation"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:.75rem; padding:.5rem; background:rgba(59, 130, 246, 0.1); border:1px solid rgba(59, 130, 246, 0.3); border-radius:6px;">
|
||||
<div class="info-panel">
|
||||
<details>
|
||||
<summary style="cursor:pointer; font-size:12px; color:#60a5fa;">Advanced Options</summary>
|
||||
<div style="margin-top:.5rem; font-size:12px; line-height:1.5;">
|
||||
<div style="margin-bottom:.5rem;">
|
||||
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
|
||||
<input type="radio" name="enforcement_mode" value="warn" {% if not form or (form and (not form.enforcement_mode or form.enforcement_mode == 'warn')) %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
|
||||
<span>Warn mode</span>
|
||||
<small class="muted" style="margin-left:.25rem;">(proceed if cards missing)</small>
|
||||
<summary>Advanced Options</summary>
|
||||
<div class="info-panel-content">
|
||||
<div class="mb-2">
|
||||
<label class="form-checkbox-label">
|
||||
<input type="radio" name="enforcement_mode" value="warn" {% if not form or (form and (not form.enforcement_mode or form.enforcement_mode == 'warn')) %}checked{% endif %} />
|
||||
<span>Warn mode <small class="muted ml-1">(proceed if cards missing)</small></span>
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
|
||||
<input type="radio" name="enforcement_mode" value="strict" {% if form and form.enforcement_mode == 'strict' %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
|
||||
<span>Strict mode</span>
|
||||
<small class="muted" style="margin-left:.25rem;">(abort if cards missing)</small>
|
||||
<label class="form-checkbox-label">
|
||||
<input type="radio" name="enforcement_mode" value="strict" {% if form and form.enforcement_mode == 'strict' %}checked{% endif %} />
|
||||
<span>Strict mode <small class="muted ml-1">(abort if cards missing)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
|
||||
<input type="checkbox" name="allow_illegal" {% if form and form.allow_illegal %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
|
||||
<label class="form-checkbox-label">
|
||||
<input type="checkbox" name="allow_illegal" {% if form and form.allow_illegal %}checked{% endif %} />
|
||||
<span>Allow illegal cards</span>
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; margin-right:1rem; cursor:pointer; line-height:1;">
|
||||
<input type="checkbox" name="fuzzy_matching" {% if not form or (form and (form.fuzzy_matching is none or form.fuzzy_matching)) %}checked{% endif %} style="margin:0 4px 0 0; flex-shrink:0;" />
|
||||
<span>Fuzzy name matching</span>
|
||||
</label>
|
||||
<!-- Fuzzy matching always enabled - hidden field -->
|
||||
<input type="hidden" name="fuzzy_matching" value="1" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<small class="muted" style="display:block; margin-top:.5rem; font-size:11px; text-align:center;">
|
||||
Enter one card name per line. Cards are validated against the database with smart name matching.
|
||||
<small class="muted block mt-2 text-xs text-center">
|
||||
Enter one card name per line. Cards are validated with fuzzy name matching.
|
||||
</small>
|
||||
</fieldset>
|
||||
{% if not show_must_have_buttons %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.75rem;">
|
||||
<div class="muted text-xs mt-3">
|
||||
Step 5 quick-add buttons are hidden (<code>SHOW_MUST_HAVE_BUTTONS=0</code>), but you can still seed must include/exclude lists here.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -217,22 +213,22 @@
|
|||
{% if enable_batch_build %}
|
||||
<fieldset>
|
||||
<legend>Build Options</legend>
|
||||
<div style="display:flex; flex-direction:column; gap:0.75rem;">
|
||||
<label style="display:block;">
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="block">
|
||||
<span>Number of decks to build</span>
|
||||
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Run the same configuration multiple times to see variance in results</small>
|
||||
<small class="muted block text-xs mt-1">Run the same configuration multiple times to see variance in results</small>
|
||||
</label>
|
||||
{% if ideals_ui_mode == 'slider' %}
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="flex:1;"
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" class="flex-1"
|
||||
oninput="document.getElementById('build_count_value').textContent = this.value; updateBuildCountLabel(this.value); updateButtonState(this.value);" />
|
||||
<span id="build_count_value" style="min-width:2.5rem; text-align:center; font-weight:500; font-size:1.1em;">{{ form.build_count if form and form.build_count else 1 }}</span>
|
||||
<span id="build_count_value" class="min-w-[2.5rem] text-center font-medium text-lg">{{ form.build_count if form and form.build_count else 1 }}</span>
|
||||
</div>
|
||||
<small id="build_count_label" class="muted" style="font-size:11px; text-align:center;">Build 1 deck (normal build)</small>
|
||||
<small id="build_count_label" class="muted text-xs text-center">Build 1 deck (normal build)</small>
|
||||
{% else %}
|
||||
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="width:6rem;"
|
||||
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" class="w-24"
|
||||
oninput="updateButtonState(this.value);" />
|
||||
<small class="muted" style="font-size:11px;">Enter 1 for normal build, 2-10 to compare multiple results</small>
|
||||
<small class="muted text-xs">Enter 1 for normal build, 2-10 to compare multiple results</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -240,9 +236,9 @@
|
|||
{# Hidden input to always send build_count=1 when feature disabled #}
|
||||
<input type="hidden" name="build_count" value="1" />
|
||||
{% endif %}
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<div style="display:flex; gap:.5rem;">
|
||||
<div class="modal-footer-left">
|
||||
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
|
||||
<button type="submit" class="btn-continue" id="create-btn">Create</button>
|
||||
</div>
|
||||
|
|
@ -794,7 +790,8 @@
|
|||
formData.append('commander', commander);
|
||||
formData.append('enforcement_mode', enforcementMode ? enforcementMode.value : 'warn');
|
||||
formData.append('allow_illegal', allowIllegal ? allowIllegal.checked : false);
|
||||
formData.append('fuzzy_matching', fuzzyMatching ? fuzzyMatching.checked : true);
|
||||
// For hidden input, use .value instead of .checked (hidden inputs don't have checked property)
|
||||
formData.append('fuzzy_matching', fuzzyMatching ? (fuzzyMatching.value || 'true') : 'true');
|
||||
|
||||
console.log('Making fetch request to /build/validate/include_exclude');
|
||||
fetch('/build/validate/include_exclude', {
|
||||
|
|
@ -1536,11 +1533,32 @@
|
|||
|
||||
<style>
|
||||
/* Modal responsive tweaks (scoped) */
|
||||
@media (max-width: 720px){
|
||||
@media (max-width: 600px){
|
||||
.modal .basics-grid{ grid-template-columns: 1fr !important; }
|
||||
#newdeck-commander-slot{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
|
||||
.modal .modal-content{ width: min(95vw, 560px) !important; }
|
||||
}
|
||||
/* Keep grid layout on tablet and desktop */
|
||||
@media (min-width: 601px){
|
||||
.modal .basics-grid{
|
||||
display: grid !important;
|
||||
grid-template-columns: 2fr 1fr !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Remove any existing modal to prevent duplicates
|
||||
(function() {
|
||||
// Find all modals with the same ID
|
||||
var allModals = document.querySelectorAll('#new-deck-modal');
|
||||
// If there's more than one, remove all but the last (newest) one
|
||||
if (allModals.length > 1) {
|
||||
for (var i = 0; i < allModals.length - 1; i++) {
|
||||
allModals[i].remove();
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -15,15 +15,15 @@
|
|||
<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;" />
|
||||
<img src="{{ pname|card_image('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>
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px; word-wrap:break-word; overflow-wrap:break-word;">{{ pname }}</div>
|
||||
{% if partner_preview_payload %}
|
||||
{% set partner_secondary_name = partner_preview_payload.secondary_name %}
|
||||
{% set partner_image_url = partner_preview_payload.secondary_image_url or partner_preview_payload.image_url %}
|
||||
{% if not partner_image_url and partner_secondary_name %}
|
||||
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_secondary_name|urlencode ~ '&format=image&version=normal' %}
|
||||
{% set partner_image_url = partner_secondary_name|card_image('normal') %}
|
||||
{% endif %}
|
||||
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
|
||||
{% if not partner_href and partner_secondary_name %}
|
||||
|
|
@ -224,36 +224,83 @@
|
|||
});
|
||||
}
|
||||
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
|
||||
function updatePartnerRecommendations(tags){
|
||||
if (!reco) return;
|
||||
Array.from(reco.querySelectorAll('button.partner-suggestion')).forEach(function(btn){ btn.remove(); });
|
||||
var unique = [];
|
||||
|
||||
function updatePartnerTags(partnerTags){
|
||||
if (!list || !reco) return;
|
||||
|
||||
// Remove old partner-added chips from available list
|
||||
Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); });
|
||||
|
||||
// Deduplicate: remove partner tags from recommended section to avoid showing them twice
|
||||
if (partnerTags && partnerTags.length > 0) {
|
||||
var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
|
||||
Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
|
||||
var tag = btn.dataset.tag || '';
|
||||
if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing tags from the available list (original server-rendered ones)
|
||||
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
|
||||
return {
|
||||
element: b,
|
||||
tag: (b.dataset.tag || '').trim(),
|
||||
tagLower: (b.dataset.tag || '').trim().toLowerCase()
|
||||
};
|
||||
});
|
||||
|
||||
// Build combined list: existing + new partner tags
|
||||
var combined = [];
|
||||
var seen = new Set();
|
||||
(Array.isArray(tags) ? tags : []).forEach(function(tag){
|
||||
|
||||
// Add existing tags first
|
||||
existingTags.forEach(function(item){
|
||||
if (!item.tag || seen.has(item.tagLower)) return;
|
||||
seen.add(item.tagLower);
|
||||
combined.push({ tag: item.tag, element: item.element, isPartner: false });
|
||||
});
|
||||
|
||||
// Add new partner tags
|
||||
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
|
||||
var value = String(tag || '').trim();
|
||||
if (!value) return;
|
||||
var key = value.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
unique.push(value);
|
||||
combined.push({ tag: value, element: null, isPartner: true });
|
||||
});
|
||||
var insertBefore = selAll && selAll.parentElement === reco ? selAll : null;
|
||||
unique.forEach(function(tag){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-reco partner-suggestion';
|
||||
btn.dataset.tag = tag;
|
||||
btn.title = 'Synergizes with selected partner pairing';
|
||||
btn.textContent = '★ ' + tag;
|
||||
if (insertBefore){ reco.insertBefore(btn, insertBefore); }
|
||||
else { reco.appendChild(btn); }
|
||||
|
||||
// Sort alphabetically
|
||||
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
|
||||
|
||||
// Re-render the list in sorted order
|
||||
list.innerHTML = '';
|
||||
combined.forEach(function(item){
|
||||
if (item.element) {
|
||||
// Re-append existing element
|
||||
list.appendChild(item.element);
|
||||
} else {
|
||||
// Create new partner-added chip
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip partner-added';
|
||||
btn.dataset.tag = item.tag;
|
||||
btn.title = 'From combined partner themes';
|
||||
btn.textContent = item.tag;
|
||||
list.appendChild(btn);
|
||||
}
|
||||
});
|
||||
var hasAny = reco.querySelectorAll('button.chip-reco').length > 0;
|
||||
|
||||
// Update visibility of recommended section
|
||||
var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0;
|
||||
if (recoBlock){
|
||||
recoBlock.style.display = hasAny ? '' : 'none';
|
||||
recoBlock.setAttribute('data-has-reco', hasAny ? '1' : '0');
|
||||
recoBlock.style.display = hasAnyReco ? '' : 'none';
|
||||
recoBlock.setAttribute('data-has-reco', hasAnyReco ? '1' : '0');
|
||||
}
|
||||
if (selAll){ selAll.style.display = hasAny ? '' : 'none'; }
|
||||
if (selAll){ selAll.style.display = hasAnyReco ? '' : 'none'; }
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
|
|
@ -264,11 +311,11 @@
|
|||
if (!tags.length && detail.payload && Array.isArray(detail.payload.theme_tags)){
|
||||
tags = detail.payload.theme_tags;
|
||||
}
|
||||
updatePartnerRecommendations(tags);
|
||||
updatePartnerTags(tags);
|
||||
});
|
||||
|
||||
var initialPartnerTags = readPartnerPreviewTags();
|
||||
updatePartnerRecommendations(initialPartnerTags);
|
||||
updatePartnerTags(initialPartnerTags);
|
||||
updateUI();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
{% if partner_preview %}
|
||||
{% set preview_image = partner_preview.secondary_image_url or partner_preview.image_url %}
|
||||
{% if not preview_image and partner_preview.secondary_name %}
|
||||
{% set preview_image = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_preview.secondary_name|urlencode ~ '&format=image&version=normal' %}
|
||||
{% set preview_image = partner_preview.secondary_name|card_image('normal') %}
|
||||
{% endif %}
|
||||
{% set preview_href = partner_preview.secondary_scryfall_url or partner_preview.scryfall_url %}
|
||||
{% if not preview_href and partner_preview.secondary_name %}
|
||||
|
|
@ -463,7 +463,7 @@
|
|||
};
|
||||
function buildCardImageUrl(name){
|
||||
if (!name) return '';
|
||||
return 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal';
|
||||
return '/api/images/normal/' + encodeURIComponent(name);
|
||||
}
|
||||
function buildScryfallUrl(name){
|
||||
if (!name) return '';
|
||||
|
|
@ -528,7 +528,9 @@
|
|||
var colorLabel = payload.color_label || '';
|
||||
var secondaryName = payload.secondary_name || payload.name || '';
|
||||
var primary = payload.primary_name || primaryName;
|
||||
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags : [];
|
||||
// Ensure theme_tags is always an array, even if it comes as a string or other type
|
||||
var themes = Array.isArray(payload.theme_tags) ? payload.theme_tags :
|
||||
(typeof payload.theme_tags === 'string' ? payload.theme_tags.split(',').map(function(t){ return t.trim(); }).filter(Boolean) : []);
|
||||
var imageUrl = payload.secondary_image_url || payload.image_url || '';
|
||||
if (!imageUrl && secondaryName){
|
||||
imageUrl = buildCardImageUrl(secondaryName);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
|
||||
<img src="{{ name|card_image('normal') }}" data-card-name="{{ name }}"
|
||||
alt="{{ name }}" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set sel_base = (selected.split(' - Synergy (')[0] if ' - Synergy (' in selected else selected) %}
|
||||
<a href="https://scryfall.com/search?q={{ sel_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ sel_base|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ sel_base }}" />
|
||||
<img src="{{ sel_base|card_image('normal') }}" alt="{{ selected }} card image" data-card-name="{{ sel_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.name.split(' - Synergy (')[0] if ' - Synergy (' in commander.name else commander.name) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
{% if partner_preview_payload %}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
{% set partner_name_base = partner_secondary_name %}
|
||||
{% endif %}
|
||||
{% if not partner_image_url and partner_name_base %}
|
||||
{% set partner_image_url = 'https://api.scryfall.com/cards/named?fuzzy=' ~ partner_name_base|urlencode ~ '&format=image&version=normal' %}
|
||||
{% set partner_image_url = partner_name_base|card_image('normal') %}
|
||||
{% endif %}
|
||||
{% set partner_href = partner_preview_payload.secondary_scryfall_url or partner_preview_payload.scryfall_url %}
|
||||
{% if not partner_href and partner_name_base %}
|
||||
|
|
@ -35,14 +35,14 @@
|
|||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
|
||||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
{% if partner_name_base %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
<img src="{{ partner_name_base|card_image('normal') }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
width="320"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
|
||||
loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
|
||||
srcset="{{ partner_name_base|card_image('small') }} 160w, {{ partner_name_base|card_image('normal') }} 488w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
{% else %}
|
||||
<img src="{{ partner_image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{# Ensure synergy annotation suffix is stripped for Scryfall query and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
32
code/web/templates/build/_step3_skeleton.html
Normal file
32
code/web/templates/build/_step3_skeleton.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<section>
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<div style="text-align:center; padding:3rem 1rem;">
|
||||
<div class="spinner" style="margin:0 auto 1rem; width:48px; height:48px; border:4px solid rgba(0,0,0,0.1); border-top-color:#007bff; border-radius:50%; animation:spin 0.8s linear infinite;"></div>
|
||||
<h3 style="margin:0 0 0.5rem;">Automating choices...</h3>
|
||||
<p class="muted" style="margin:0;">{{ automation_message }}</p>
|
||||
</div>
|
||||
|
||||
{# Hidden form that auto-submits with defaults #}
|
||||
<form id="auto-step3-form" hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML" hx-trigger="load delay:100ms" style="display:none;">
|
||||
{% for key, value in defaults.items() %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||
{% endfor %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image"
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image"
|
||||
width="320"
|
||||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
|
|
@ -45,10 +45,10 @@
|
|||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
||||
loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=large 672w"
|
||||
srcset="{{ commander_base|card_image('small') }} 160w, {{ commander_base|card_image('normal') }} 488w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">
|
||||
<div class="muted mt-1">
|
||||
Commander: <span data-card-name="{{ commander }}"
|
||||
data-original-name="{{ commander }}"
|
||||
data-role="{{ commander_role_label or 'Commander' }}"
|
||||
|
|
@ -79,51 +79,51 @@
|
|||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
|
||||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||||
{% if partner_name_base %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
<img src="{{ partner_name_base|card_image('normal') }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||||
width="320"
|
||||
data-card-name="{{ partner_name_base }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
|
||||
loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ partner_name_base|urlencode }}&format=image&version=large 672w"
|
||||
srcset="{{ partner_name_base|card_image('small') }} 160w, {{ partner_name_base|card_image('normal') }} 488w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
{% else %}
|
||||
<img src="{{ combined.secondary_image_url or combined.image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
|
||||
{% endif %}
|
||||
{% if partner_href %}</a>{% endif %}
|
||||
</div>
|
||||
<div class="muted partner-label" style="margin-top:.35rem;">
|
||||
<div class="muted partner-label mt-1.5">
|
||||
{{ partner_role_label }}:
|
||||
<span data-card-name="{{ partner_secondary_name }}"
|
||||
data-original-name="{{ partner_secondary_name }}"
|
||||
data-role="{{ partner_role_label }}"
|
||||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
|
||||
</div>
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
<div class="muted partner-meta text-xs mt-1">
|
||||
Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
|
||||
</div>
|
||||
{% if combined.color_label %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
<div class="muted partner-meta text-xs mt-1">
|
||||
Colors: {{ combined.color_label }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if partner_theme_tags %}
|
||||
<div class="muted partner-meta" style="font-size:12px; margin-top:.25rem;">
|
||||
<div class="muted partner-meta text-xs mt-1">
|
||||
Theme emphasis: {{ partner_theme_tags|join(', ') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
<div class="mt-3 flex gap-1.5 flex-wrap">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<form action="/files" method="get" target="_blank" class="inline m-0">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<form action="/files" method="get" target="_blank" class="inline m-0">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
|
|
@ -150,64 +150,64 @@
|
|||
{% endif %}
|
||||
</p>
|
||||
{% if show_color_identity %}
|
||||
<div class="muted" style="display:flex; align-items:center; gap:.35rem; margin:-.35rem 0 .5rem 0;">
|
||||
<div class="muted flex items-center gap-1.5 -my-1.5 mb-2">
|
||||
{{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }}
|
||||
<span>{{ color_label }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p>Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<div class="my-1.5 text-muted flex gap-2 items-center flex-wrap">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" class="bg-gray-700 text-gray-200 border-0 rounded-md px-2 py-1 cursor-pointer text-xs" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||||
<span class="ml-auto"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
<div class="flex items-center gap-2 flex-wrap my-1 mb-2">
|
||||
{% if i and n %}
|
||||
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
|
||||
{% endif %}
|
||||
{% set deck_count = (total_cards if total_cards is not none else 0) %}
|
||||
<span class="chip"><span class="dot" style="background: var(--green-main);"></span> Deck {{ deck_count }}/100</span>
|
||||
<span class="chip"><span class="dot dot-green"></span> Deck {{ deck_count }}/100</span>
|
||||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||
<span class="chip"><span class="dot dot-blue"></span> Added {{ added_total }}</span>
|
||||
{% endif %}
|
||||
{% if prefer_combos %}
|
||||
<span class="chip" title="Combos plan"><span class="dot" style="background: var(--orange-main);"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
|
||||
<span class="chip" title="Combos plan"><span class="dot dot-orange"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
|
||||
{% endif %}
|
||||
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
|
||||
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
|
||||
<span class="chip" title="Trimmed overflow from this stage"><span class="dot dot-red"></span> Clamped {{ clamped_overflow }}</span>
|
||||
{% endif %}
|
||||
{% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %}
|
||||
<span class="chip" title="Multi-Copy package summary"><span class="dot" style="background: var(--purple-main);"></span> {{ mc_summary }}</span>
|
||||
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</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"
|
||||
<button type="button" class="btn ml-auto" title="Copy permalink"
|
||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||||
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||||
</div>
|
||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||
{% set pct_int = pct_clamped|int %}
|
||||
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %}" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" style="margin:.25rem 0 1rem 0;" data-pct="{{ pct_int }}">
|
||||
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %} my-1 mb-4" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" data-pct="{{ pct_int }}">
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
|
||||
{% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %}
|
||||
<div class="muted" style="margin:.35rem 0 .25rem 0;">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
|
||||
<div class="muted my-1">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status %}
|
||||
<div style="margin-top:1rem;">
|
||||
<div class="mt-4">
|
||||
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if gated and (not status or not status.startswith('Build complete')) %}
|
||||
<div class="alert" style="margin-top:.5rem; color:#fecaca; background:#7f1d1d; border:1px solid #991b1b; padding:.5rem .75rem; border-radius:8px;">
|
||||
<div class="alert-error">
|
||||
Compliance gating active — resolve violations above (replace or remove cards) to continue.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -220,15 +220,15 @@
|
|||
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<details id="locked-section" class="mt-2">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
<ul id="locked-list" class="locked-list">
|
||||
{% for lk in locked_cards %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<li class="locked-item">
|
||||
<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 %}
|
||||
<div class="lock-box" style="display:inline; margin-left:auto;">
|
||||
<div class="lock-box-inline">
|
||||
{{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
{% endif %}
|
||||
|
||||
<!-- Last action chip (oob-updated) -->
|
||||
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
|
||||
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
<div class="cards-toolbar">
|
||||
|
|
@ -248,10 +248,10 @@
|
|||
<option value="owned">Owned</option>
|
||||
<option value="not">Not owned</option>
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;">
|
||||
<label class="form-label-icon">
|
||||
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:.35rem;">
|
||||
<label class="form-label-icon">
|
||||
<input type="checkbox" name="collapse_groups" data-pref="cards:collapse" /> Collapse groups
|
||||
</label>
|
||||
<select name="filter_sort" data-pref="cards:sort" aria-label="Sort">
|
||||
|
|
@ -271,37 +271,37 @@
|
|||
</div>
|
||||
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls" style="position:sticky; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<div class="build-controls">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}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;">
|
||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
|
||||
<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.">
|
||||
<label class="muted form-label-icon" 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;">
|
||||
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||||
<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;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<label class="muted form-label-icon ml-2">
|
||||
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
|
||||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||||
Show skipped stages
|
||||
|
|
@ -311,14 +311,14 @@
|
|||
|
||||
{% if added_cards is not none %}
|
||||
{% if history is defined and history %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<details class="mt-2">
|
||||
<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;">
|
||||
<div class="muted text-xs my-1">Jump back to a previous stage, then you can continue forward again.</div>
|
||||
<ul class="timeline-list">
|
||||
{% for h in history %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem;">
|
||||
<li class="timeline-item">
|
||||
<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;">
|
||||
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" class="inline-form m-0">
|
||||
<input type="hidden" name="to" value="{{ h.i }}" />
|
||||
<button type="submit" class="btn">Go</button>
|
||||
</form>
|
||||
|
|
@ -327,13 +327,13 @@
|
|||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
<h4 class="mt-4">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>
|
||||
<div class="muted my-2">No cards added in this stage.</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✔</span> Owned</span>
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✖</span> Not owned</span>
|
||||
<div class="muted text-xs my-1 flex gap-3 items-center flex-wrap">
|
||||
<span><span class="ownership-badge">✔</span> Owned</span>
|
||||
<span><span class="ownership-badge">✖</span> Not owned</span>
|
||||
</div>
|
||||
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
|
|
@ -348,7 +348,7 @@
|
|||
{% endif %}
|
||||
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
|
||||
<div class="group-header">
|
||||
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||
<h5 class="my-2">{{ heading }}</h5>
|
||||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||||
</div>
|
||||
|
|
@ -360,13 +360,13 @@
|
|||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<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"
|
||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% 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;">
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
|
|
@ -377,7 +377,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<div class="card-actions-center">
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
|
|
@ -385,13 +385,13 @@
|
|||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div class="flex justify-center mt-1">
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts mt-1"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -406,13 +406,13 @@
|
|||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<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"
|
||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% 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;">
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
|
|
@ -423,7 +423,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<div class="card-actions-center">
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
|
|
@ -431,31 +431,31 @@
|
|||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div class="flex justify-center mt-1">
|
||||
<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"
|
||||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
<div id="alts-{{ loop.index0 }}" class="alts mt-1"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if allow_must_haves and show_must_have_buttons %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. “Must include” will try to pull the card back in on future reruns, while “Must exclude” blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
|
||||
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. "Must include" will try to pull the card back in on future reruns, while "Must exclude" blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
|
||||
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
|
||||
{% endif %}
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted mt-2">
|
||||
No cards match your filters.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_logs and log %}
|
||||
<details style="margin-top:1rem;">
|
||||
<details class="mt-4">
|
||||
<summary>Show logs</summary>
|
||||
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||
<pre class="build-log">{{ log }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -469,7 +469,7 @@
|
|||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||||
hx-trigger="load once, step5:refresh from:body"
|
||||
hx-swap="outerHTML">
|
||||
<div class="muted" style="margin-top:1rem;">
|
||||
<div class="muted mt-4">
|
||||
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button %}
|
||||
|
||||
{% block content %}
|
||||
<section class="commander-page">
|
||||
<header class="commander-hero">
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="page" value="{{ page }}" />
|
||||
<button type="submit" class="btn filter-submit">Apply</button>
|
||||
{{ button('Apply', variant='primary', type='submit', classes='filter-submit') }}
|
||||
</form>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander_base }}" />
|
||||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image" width="320" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal"
|
||||
<img src="{{ commander_base|card_image('normal') }}"
|
||||
alt="{{ commander }} card image"
|
||||
data-card-name="{{ commander_base }}"
|
||||
data-original-name="{{ commander }}"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<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"
|
||||
src="{{ 'Lightning Bolt'|card_image('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;">
|
||||
|
|
|
|||
386
code/web/templates/docs/components.html
Normal file
386
code/web/templates/docs/components.html
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button, icon_button, close_button, button_group, tag_button %}
|
||||
{% from 'partials/_modals.html' import simple_modal, confirm_dialog, alert_modal %}
|
||||
{% from 'partials/_forms.html' import text_input, textarea, select, checkbox, radio_group, number_input, file_input %}
|
||||
{% from 'partials/_card_display.html' import card_thumb, card_flip_button, card_grid %}
|
||||
{% from 'partials/_panels.html' import panel, simple_panel, info_panel, stat_panel, collapsible_panel, empty_state_panel, loading_panel %}
|
||||
|
||||
{% block title %}Component Library - MTG Deckbuilder{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-inner max-w-content" style="padding: 2rem 1rem;">
|
||||
|
||||
<div class="banner">
|
||||
<h1>Component Library</h1>
|
||||
<div class="subtitle">M2 standardized UI components for MTG Deckbuilder</div>
|
||||
</div>
|
||||
|
||||
<!-- Table of Contents -->
|
||||
{{ simple_panel(
|
||||
title='Table of Contents',
|
||||
content='
|
||||
<ul style="list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
||||
<li><a href="#buttons">Buttons</a></li>
|
||||
<li><a href="#modals">Modals</a></li>
|
||||
<li><a href="#forms">Forms</a></li>
|
||||
<li><a href="#cards">Card Display</a></li>
|
||||
<li><a href="#panels">Panels</a></li>
|
||||
</ul>
|
||||
',
|
||||
variant='alt'
|
||||
) }}
|
||||
|
||||
<!-- BUTTONS -->
|
||||
<section id="buttons" class="section-spacing">
|
||||
{% call panel(title='Buttons', padding='lg') %}
|
||||
{% block body %}
|
||||
|
||||
<h4 style="margin-top: 0;">Button Variants</h4>
|
||||
<div class="btn-group content-spacing-lg">
|
||||
{{ button('Primary Button', variant='primary') }}
|
||||
{{ button('Secondary Button', variant='secondary') }}
|
||||
{{ button('Ghost Button', variant='ghost') }}
|
||||
{{ button('Danger Button', variant='danger') }}
|
||||
</div>
|
||||
|
||||
<h4>Button Sizes</h4>
|
||||
<div class="btn-group" style="margin-bottom: 2rem;">
|
||||
{{ button('Small', size='sm') }}
|
||||
{{ button('Medium (Default)', size='md') }}
|
||||
{{ button('Large', size='lg') }}
|
||||
</div>
|
||||
|
||||
<h4>Icon Buttons</h4>
|
||||
<div class="btn-group" style="margin-bottom: 2rem;">
|
||||
{{ icon_button('×', aria_label='Close', size='sm') }}
|
||||
{{ icon_button('☰', aria_label='Menu', size='md') }}
|
||||
{{ icon_button('⚙', aria_label='Settings', size='lg') }}
|
||||
</div>
|
||||
|
||||
<h4>Tag Buttons</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 2rem;">
|
||||
{{ tag_button('Ramp') }}
|
||||
{{ tag_button('Removal', selected=True) }}
|
||||
{{ tag_button('Card Draw', removable=True, on_remove='alert("Tag removed")') }}
|
||||
{{ tag_button('Counterspells') }}
|
||||
</div>
|
||||
|
||||
<h4>Button Groups</h4>
|
||||
{{ button_group([
|
||||
{'text': 'Back', 'variant': 'secondary'},
|
||||
{'text': 'Cancel', 'variant': 'ghost'},
|
||||
{'text': 'Save', 'variant': 'primary', 'type': 'submit'}
|
||||
], alignment='right') }}
|
||||
|
||||
<h4 style="margin-top: 2rem;">Link Buttons</h4>
|
||||
<div class="btn-group">
|
||||
{{ button('Go Home', href='/', variant='ghost') }}
|
||||
{{ button('Build Deck', href='/build', variant='primary') }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- MODALS -->
|
||||
<section id="modals" style="margin-top: 2rem;">
|
||||
{% call panel(title='Modals', padding='lg') %}
|
||||
{% block body %}
|
||||
|
||||
<p style="margin-top: 0; color: var(--muted);">Click buttons to see modal examples</p>
|
||||
|
||||
<div class="btn-group" style="margin-bottom: 1rem;">
|
||||
{{ button('Simple Modal', onclick='showSimpleModalExample()') }}
|
||||
{{ button('Confirm Dialog', onclick='showConfirmExample()') }}
|
||||
{{ button('Alert (Success)', onclick='showAlertExample("success")') }}
|
||||
{{ button('Alert (Error)', onclick='showAlertExample("error")') }}
|
||||
</div>
|
||||
|
||||
<h4>Modal Sizes</h4>
|
||||
<div class="btn-group">
|
||||
{{ button('Small (480px)', onclick='showSizedModal("sm")') }}
|
||||
{{ button('Medium (620px)', onclick='showSizedModal("md")') }}
|
||||
{{ button('Large (720px)', onclick='showSizedModal("lg")') }}
|
||||
{{ button('XLarge (960px)', onclick='showSizedModal("xl")') }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- FORMS -->
|
||||
<section id="forms" style="margin-top: 2rem;">
|
||||
{% call panel(title='Form Components', padding='lg') %}
|
||||
{% block body %}
|
||||
|
||||
<form onsubmit="event.preventDefault(); alert('Form submitted!');" style="max-width: 600px;">
|
||||
|
||||
{{ text_input('username', label='Username', placeholder='Enter username', required=True) }}
|
||||
|
||||
{{ text_input('email', label='Email', type='email', placeholder='you@example.com', help_text='We\'ll never share your email') }}
|
||||
|
||||
{{ textarea('notes', label='Notes', placeholder='Enter additional notes...', rows=4) }}
|
||||
|
||||
{{ select('color', label='Color Identity', options=[
|
||||
{'value': 'W', 'text': 'White'},
|
||||
{'value': 'U', 'text': 'Blue'},
|
||||
{'value': 'B', 'text': 'Black'},
|
||||
{'value': 'R', 'text': 'Red'},
|
||||
{'value': 'G', 'text': 'Green'}
|
||||
], required=True) }}
|
||||
|
||||
{{ number_input('quantity', label='Quantity', min=1, max=10, value=1) }}
|
||||
|
||||
{{ checkbox('owned_only', label='Show only owned cards', checked=True) }}
|
||||
|
||||
{{ radio_group('theme', label='Preferred Theme', options=[
|
||||
{'value': 'system', 'text': 'System', 'checked': True},
|
||||
{'value': 'light', 'text': 'Light'},
|
||||
{'value': 'dark', 'text': 'Dark'}
|
||||
]) }}
|
||||
|
||||
{{ file_input('deck_file', label='Upload Deck File', accept='.csv,.txt,.json') }}
|
||||
|
||||
<div class="btn-group" style="margin-top: 2rem;">
|
||||
{{ button('Cancel', variant='secondary', type='button') }}
|
||||
{{ button('Submit', variant='primary', type='submit') }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- CARD DISPLAY -->
|
||||
<section id="cards" style="margin-top: 2rem;">
|
||||
{% call panel(title='Card Display Components', padding='lg') %}
|
||||
{% block body %}
|
||||
|
||||
<h4 style="margin-top: 0;">Card Thumbnail Sizes</h4>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; margin-bottom: 2rem;">
|
||||
{{ card_thumb('Sol Ring', size='small', show_name=True) }}
|
||||
{{ card_thumb('Lightning Bolt', size='medium', show_name=True) }}
|
||||
{{ card_thumb('Rampant Growth', size='large', show_name=True) }}
|
||||
</div>
|
||||
|
||||
<h4>Dual-Faced Card with Flip Button</h4>
|
||||
<div style="margin-bottom: 2rem;">
|
||||
{{ card_thumb('Delver of Secrets // Insectile Aberration', size='large', layout='transform', show_flip=True, show_name=True) }}
|
||||
</div>
|
||||
|
||||
<h4>Card Grid</h4>
|
||||
<p style="color: var(--muted); font-size: 0.875rem;">Hover over cards to see popup (desktop) or tap (mobile)</p>
|
||||
{{ card_grid([
|
||||
{'name': 'Sol Ring', 'role': 'ramp', 'tags': ['Ramp', 'Artifact']},
|
||||
{'name': 'Lightning Bolt', 'role': 'removal', 'tags': ['Removal', 'Instant']},
|
||||
{'name': 'Rampant Growth', 'role': 'ramp', 'tags': ['Ramp', 'Sorcery']},
|
||||
{'name': 'Counterspell', 'role': 'control', 'tags': ['Counterspell', 'Instant']},
|
||||
{'name': 'Swords to Plowshares', 'role': 'removal', 'tags': ['Removal', 'Instant']},
|
||||
{'name': 'Birds of Paradise', 'role': 'ramp', 'tags': ['Ramp', 'Creature']}
|
||||
], size='medium', columns=3) }}
|
||||
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- PANELS -->
|
||||
<section id="panels" style="margin-top: 2rem;">
|
||||
{% call panel(title='Panel Components', padding='lg') %}
|
||||
{% block body %}
|
||||
|
||||
<h4 style="margin-top: 0;">Panel Variants</h4>
|
||||
{{ simple_panel(title='Default Panel', content='<p>This is a default panel with standard background.</p>', variant='default') }}
|
||||
{{ simple_panel(title='Alt Panel', content='<p>This is an alternate panel with lighter background.</p>', variant='alt') }}
|
||||
{{ simple_panel(title='Dark Panel', content='<p>This is a dark panel with darker background.</p>', variant='dark') }}
|
||||
{{ simple_panel(title='Bordered Panel', content='<p>This is a bordered panel with no background.</p>', variant='bordered') }}
|
||||
|
||||
<h4 style="margin-top: 2rem;">Info Panels</h4>
|
||||
{{ info_panel(
|
||||
icon='ℹ️',
|
||||
title='Information',
|
||||
content='This is an informational message.',
|
||||
type='info'
|
||||
) }}
|
||||
{{ info_panel(
|
||||
icon='✅',
|
||||
title='Success',
|
||||
content='Operation completed successfully!',
|
||||
type='success'
|
||||
) }}
|
||||
{{ info_panel(
|
||||
icon='⚠️',
|
||||
title='Warning',
|
||||
content='Please review your selections.',
|
||||
type='warning'
|
||||
) }}
|
||||
{{ info_panel(
|
||||
icon='❌',
|
||||
title='Error',
|
||||
content='An error occurred. Please try again.',
|
||||
type='error',
|
||||
action_text='Retry',
|
||||
action_onclick='alert("Retrying...")'
|
||||
) }}
|
||||
|
||||
<h4 style="margin-top: 2rem;">Stat Panels</h4>
|
||||
<div class="panel-grid panel-grid-cols-4">
|
||||
{{ stat_panel('Total Cards', value=100) }}
|
||||
{{ stat_panel('Avg MV', value='3.2', sublabel='Mana Value', variant='primary') }}
|
||||
{{ stat_panel('Lands', value=37, variant='success') }}
|
||||
{{ stat_panel('Budget', value='$125', variant='warning') }}
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 2rem;">Collapsible Panel</h4>
|
||||
{% call collapsible_panel(title='Advanced Options', expanded=False) %}
|
||||
{% block body %}
|
||||
<p>These are advanced settings that are hidden by default.</p>
|
||||
<ul>
|
||||
<li>Option 1: Enable feature X</li>
|
||||
<li>Option 2: Adjust threshold Y</li>
|
||||
<li>Option 3: Configure behavior Z</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
|
||||
<h4 style="margin-top: 2rem;">Empty State</h4>
|
||||
{{ empty_state_panel(
|
||||
icon='📋',
|
||||
title='No Decks Found',
|
||||
message='You haven\'t created any decks yet. Start building your first deck!',
|
||||
action_text='Build Deck',
|
||||
action_href='/build'
|
||||
) }}
|
||||
|
||||
<h4 style="margin-top: 2rem;">Loading State</h4>
|
||||
{{ loading_panel(message='Building deck...', spinner=True) }}
|
||||
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- Back to Top -->
|
||||
<div style="margin-top: 3rem; text-align: center;">
|
||||
{{ button('Back to Top', href='#', variant='ghost', onclick='window.scrollTo({top:0,behavior:"smooth"}); return false;') }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Modal examples
|
||||
function showSimpleModalExample() {
|
||||
const modal = document.createElement('div');
|
||||
modal.innerHTML = `{{ simple_modal(
|
||||
title='Example Modal',
|
||||
content='<p>This is a simple modal with content. You can put any HTML here.</p><p>Click outside or press Escape to close.</p>',
|
||||
footer_buttons=[
|
||||
{'text': 'Cancel', 'variant': 'secondary', 'onclick': "this.closest('.modal').remove()"},
|
||||
{'text': 'OK', 'variant': 'primary', 'onclick': "alert('OK clicked'); this.closest('.modal').remove()"}
|
||||
],
|
||||
size='md'
|
||||
) }}`;
|
||||
document.body.appendChild(modal.firstElementChild);
|
||||
}
|
||||
|
||||
function showConfirmExample() {
|
||||
const modal = document.createElement('div');
|
||||
modal.innerHTML = `{{ confirm_dialog(
|
||||
message='Are you sure you want to delete this deck?',
|
||||
confirm_text='Delete',
|
||||
confirm_variant='danger',
|
||||
on_confirm="alert('Deleted!'); this.closest('.modal').remove()"
|
||||
) }}`;
|
||||
document.body.appendChild(modal.firstElementChild);
|
||||
}
|
||||
|
||||
function showAlertExample(type) {
|
||||
const messages = {
|
||||
success: 'Deck saved successfully!',
|
||||
error: 'Failed to save deck. Please try again.'
|
||||
};
|
||||
const titles = {
|
||||
success: 'Success',
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal modal-sm modal-center modal-alert modal-alert-' + type;
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-backdrop" onclick="this.closest('.modal').remove()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<div class="alert-icon alert-icon-${type}"></div>
|
||||
<p>${messages[type]}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="this.closest('.modal').remove()">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function showSizedModal(size) {
|
||||
const sizes = {
|
||||
sm: '480px',
|
||||
md: '620px',
|
||||
lg: '720px',
|
||||
xl: '960px'
|
||||
};
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = `modal modal-${size} modal-center`;
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-backdrop" onclick="this.closest('.modal').remove()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">${size.toUpperCase()} Modal (${sizes[size]} max-width)</h2>
|
||||
<button type="button" class="btn btn-icon btn-ghost btn-sm btn-close" onclick="this.closest('.modal').remove()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This is a ${size.toUpperCase()} sized modal with a maximum width of ${sizes[size]}.</p>
|
||||
<p>Modal content goes here. You can put forms, cards, or any other content.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="this.closest('.modal').remove()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Setup card popups after page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupCardPopups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Component library specific styles */
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
section {
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ring);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,13 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button %}
|
||||
{% from 'partials/_panels.html' import simple_panel %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Page not found</h2>
|
||||
<p>The page you requested could not be found.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
<details>
|
||||
{{ simple_panel(
|
||||
title='Page Not Found',
|
||||
content='<p>The page you requested could not be found.</p>
|
||||
<p class="muted">Request ID: <code>' ~ (request_id or request.state.request_id) ~ '</code></p>',
|
||||
variant='bordered',
|
||||
padding='lg'
|
||||
) }}
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
{{ button('Go Home', variant='primary', href='/') }}
|
||||
</div>
|
||||
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary>Details</summary>
|
||||
<pre>Status: {{ status }}
|
||||
Path: {{ request.url.path }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button %}
|
||||
{% from 'partials/_panels.html' import info_panel %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p>Something went wrong.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
{{ info_panel(
|
||||
icon='❌',
|
||||
title='Internal Server Error',
|
||||
content='Something went wrong. Our team has been notified.<br><br>
|
||||
<span class="muted">Request ID: <code>' ~ (request_id or request.state.request_id) ~ '</code></span>',
|
||||
type='error',
|
||||
action_text='Go Home',
|
||||
action_href='/'
|
||||
) }}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="actions-grid">
|
||||
<a class="action-button primary" href="/build">Build a Deck</a>
|
||||
<a class="action-button" href="/configs">Run a JSON Config</a>
|
||||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/cards">Browse All Cards</a>
|
||||
{% if show_commanders %}<a class="action-button" href="/commanders">Browse Commanders</a>{% endif %}
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
|
||||
{% if show_diagnostics %}<a class="action-button" href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
{{ button('Build a Deck', variant='primary', href='/build', classes='action-button home-button') }}
|
||||
{{ button('Run a JSON Config', variant='secondary', href='/configs', classes='action-button home-button') }}
|
||||
{% if show_setup %}{{ button('Initial Setup', variant='secondary', href='/setup', classes='action-button home-button') }}{% endif %}
|
||||
{{ button('Owned Library', variant='secondary', href='/owned', classes='action-button home-button') }}
|
||||
{{ button('Browse All Cards', variant='secondary', href='/cards', classes='action-button home-button') }}
|
||||
{% if show_commanders %}{{ button('Browse Commanders', variant='secondary', href='/commanders', classes='action-button home-button') }}{% endif %}
|
||||
{{ button('Finished Decks', variant='secondary', href='/decks', classes='action-button home-button') }}
|
||||
{{ button('Browse Themes', variant='secondary', href='/themes/', classes='action-button home-button') }}
|
||||
{% if random_ui %}{{ button('Random Build', variant='secondary', href='/random', classes='action-button home-button') }}{% endif %}
|
||||
{% if show_diagnostics %}{{ button('Diagnostics', variant='secondary', href='/diagnostics', classes='action-button home-button') }}{% endif %}
|
||||
{% if show_logs %}{{ button('View Logs', variant='secondary', href='/logs', classes='action-button home-button') }}{% endif %}
|
||||
</div>
|
||||
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
|
||||
<span id="themes-quick-status">Themes: …</span>
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@
|
|||
<label class="owned-row" style="cursor:pointer;" tabindex="0" data-card-name="{{ n }}" data-original-name="{{ n }}">
|
||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||
<div class="owned-vstack">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="{{ n|card_image('small') }}" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||
srcset="{{ n|card_image('small') }} 160w, {{ n|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
// Helper: build Scryfall image URL with optional cache-busting
|
||||
function buildImageUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
|
||||
var url = '/api/images/' + (version||'small') + '/' + q;
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
|
|
|||
225
code/web/templates/partials/_buttons.html
Normal file
225
code/web/templates/partials/_buttons.html
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
{# Button Component Library #}
|
||||
{# Usage: {{ import_buttons() }} then call button macros #}
|
||||
|
||||
{#
|
||||
Primary Button Macro
|
||||
|
||||
Parameters:
|
||||
- text (str): Button text/label
|
||||
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'primary')
|
||||
- type (str): 'button', 'submit', 'reset' (default: 'button')
|
||||
- size (str): 'sm', 'md', 'lg' (default: 'md')
|
||||
- href (str): If provided, renders as <a> tag instead of <button>
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes (e.g., 'disabled', 'data-foo="bar"')
|
||||
- hx_get (str): HTMX hx-get attribute
|
||||
- hx_post (str): HTMX hx-post attribute
|
||||
- hx_target (str): HTMX hx-target attribute
|
||||
- hx_swap (str): HTMX hx-swap attribute
|
||||
- onclick (str): JavaScript onclick handler
|
||||
- aria_label (str): ARIA label for accessibility
|
||||
|
||||
Examples:
|
||||
{{ button('Click Me') }}
|
||||
{{ button('Submit', variant='primary', type='submit') }}
|
||||
{{ button('Cancel', variant='secondary') }}
|
||||
{{ button('Delete', variant='danger', onclick='confirmDelete()') }}
|
||||
{{ button('Go Back', variant='ghost', href='/build') }}
|
||||
{{ button('Load More', hx_get='/cards?page=2', hx_target='#cards') }}
|
||||
#}
|
||||
{% macro button(text, variant='primary', type='button', size='md', href='', classes='', attrs='', hx_get='', hx_post='', hx_target='', hx_swap='', onclick='', aria_label='') %}
|
||||
{%- set base_classes = 'btn' -%}
|
||||
{%- set variant_class = 'btn-' + variant if variant != 'primary' else '' -%}
|
||||
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
|
||||
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
|
||||
|
||||
{%- if href -%}
|
||||
<a href="{{ href }}"
|
||||
class="{{ all_classes }}"
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
|
||||
{{ attrs|safe }}>{{ text }}</a>
|
||||
{%- else -%}
|
||||
<button type="{{ type }}"
|
||||
class="{{ all_classes }}"
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
|
||||
{{ attrs|safe }}>{{ text }}</button>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Icon Button Macro
|
||||
|
||||
Parameters:
|
||||
- icon (str): Icon character or HTML (e.g., '×', '☰', '<svg>...</svg>')
|
||||
- variant (str): 'primary', 'secondary', 'ghost', 'danger' (default: 'ghost')
|
||||
- size (str): 'sm', 'md', 'lg' (default: 'md')
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
- onclick (str): JavaScript onclick handler
|
||||
- aria_label (str): Required ARIA label for accessibility
|
||||
|
||||
Examples:
|
||||
{{ icon_button('×', aria_label='Close', onclick='closeModal()') }}
|
||||
{{ icon_button('☰', aria_label='Menu', variant='primary') }}
|
||||
#}
|
||||
{% macro icon_button(icon, variant='ghost', size='md', classes='', attrs='', onclick='', aria_label='') %}
|
||||
{%- set base_classes = 'btn btn-icon' -%}
|
||||
{%- set variant_class = 'btn-' + variant if variant != 'ghost' else '' -%}
|
||||
{%- set size_class = 'btn-' + size if size != 'md' else '' -%}
|
||||
{%- set all_classes = [base_classes, variant_class, size_class, classes]|select|join(' ') -%}
|
||||
|
||||
<button type="button"
|
||||
class="{{ all_classes }}"
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if aria_label %}aria-label="{{ aria_label }}"{% else %}aria-label="Icon button"{% endif %}
|
||||
{{ attrs|safe }}>{{ icon|safe }}</button>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Close Button Macro (specialized icon button)
|
||||
|
||||
Parameters:
|
||||
- target (str): CSS selector of element to close (default: closest '.modal')
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
- aria_label (str): ARIA label (default: 'Close')
|
||||
|
||||
Examples:
|
||||
{{ close_button() }}
|
||||
{{ close_button(target='.alts') }}
|
||||
{{ close_button(classes='modal-close') }}
|
||||
#}
|
||||
{% macro close_button(target='.modal', classes='', attrs='', aria_label='Close') %}
|
||||
{{ icon_button(
|
||||
'×',
|
||||
variant='ghost',
|
||||
size='sm',
|
||||
classes='btn-close ' + classes,
|
||||
onclick="try{this.closest('" + target + "').remove();}catch(_){}",
|
||||
aria_label=aria_label,
|
||||
attrs=attrs
|
||||
) }}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Button Group Macro
|
||||
|
||||
Parameters:
|
||||
- buttons (list): List of button dicts with keys: text, variant, type, href, onclick, etc.
|
||||
- alignment (str): 'left', 'center', 'right', 'between' (default: 'right')
|
||||
- classes (str): Additional CSS classes for container
|
||||
|
||||
Examples:
|
||||
{{ button_group([
|
||||
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'close()'},
|
||||
{'text': 'Save', 'variant': 'primary', 'type': 'submit'}
|
||||
]) }}
|
||||
#}
|
||||
{% macro button_group(buttons, alignment='right', classes='') %}
|
||||
{%- set alignment_class = 'btn-group-' + alignment -%}
|
||||
<div class="btn-group {{ alignment_class }} {{ classes }}">
|
||||
{% for btn in buttons %}
|
||||
{{ button(
|
||||
btn.text,
|
||||
variant=btn.get('variant', 'primary'),
|
||||
type=btn.get('type', 'button'),
|
||||
size=btn.get('size', 'md'),
|
||||
href=btn.get('href', ''),
|
||||
classes=btn.get('classes', ''),
|
||||
attrs=btn.get('attrs', ''),
|
||||
hx_get=btn.get('hx_get', ''),
|
||||
hx_post=btn.get('hx_post', ''),
|
||||
hx_target=btn.get('hx_target', ''),
|
||||
hx_swap=btn.get('hx_swap', ''),
|
||||
onclick=btn.get('onclick', ''),
|
||||
aria_label=btn.get('aria_label', '')
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Tag/Chip Button Macro
|
||||
|
||||
Parameters:
|
||||
- text (str): Tag text
|
||||
- removable (bool): Show remove 'x' button (default: False)
|
||||
- selected (bool): Tag is selected (default: False)
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
- onclick (str): JavaScript onclick handler
|
||||
- on_remove (str): JavaScript handler for remove button
|
||||
- data_attrs (dict): Data attributes as key-value pairs
|
||||
|
||||
Examples:
|
||||
{{ tag_button('Flying') }}
|
||||
{{ tag_button('Ramp', selected=True) }}
|
||||
{{ tag_button('Blue', removable=True, on_remove='removeTag(this)') }}
|
||||
{{ tag_button('Simic', data_attrs={'color': 'ug', 'value': '2'}) }}
|
||||
#}
|
||||
{% macro tag_button(text, removable=False, selected=False, classes='', attrs='', onclick='', on_remove='', data_attrs={}) %}
|
||||
{%- set base_classes = 'btn btn-tag' -%}
|
||||
{%- set state_class = 'btn-tag-selected' if selected else '' -%}
|
||||
{%- set all_classes = [base_classes, state_class, classes]|select|join(' ') -%}
|
||||
|
||||
<button type="button"
|
||||
class="{{ all_classes }}"
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% for key, value in data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
|
||||
{{ attrs|safe }}>
|
||||
<span>{{ text }}</span>
|
||||
{% if removable %}
|
||||
<button type="button"
|
||||
class="btn-tag-remove"
|
||||
aria-label="Remove {{ text }}"
|
||||
{% if on_remove %}onclick="{{ on_remove }}"{% else %}onclick="this.closest('.btn-tag').remove()"{% endif %}>×</button>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Action Button (Legacy - use button() with variant instead)
|
||||
Kept for backward compatibility during migration
|
||||
|
||||
Parameters: Same as button()
|
||||
|
||||
Note: This is deprecated. Use {{ button(text, variant='primary', size='lg') }} instead.
|
||||
#}
|
||||
{% macro action_button(text, type='button', classes='', attrs='', onclick='', aria_label='') %}
|
||||
{{ button(text, variant='primary', type=type, size='lg', classes='action-btn ' + classes, attrs=attrs, onclick=onclick, aria_label=aria_label) }}
|
||||
{% endmacro %}
|
||||
|
||||
{# CSS Classes Reference #}
|
||||
{#
|
||||
Button Variants:
|
||||
- .btn (base)
|
||||
- .btn-primary (default)
|
||||
- .btn-secondary (gray, for cancel/back)
|
||||
- .btn-ghost (transparent, subtle)
|
||||
- .btn-danger (red, for destructive actions)
|
||||
|
||||
Button Sizes:
|
||||
- .btn-sm (small: padding 4px 12px, font 12px)
|
||||
- .btn-md (default: padding 8px 16px, font 14px)
|
||||
- .btn-lg (large: padding 12px 24px, font 16px)
|
||||
|
||||
Button Modifiers:
|
||||
- .btn-icon (icon-only button, square aspect)
|
||||
- .btn-close (close button, positioned top-right)
|
||||
- .btn-tag (pill-shaped tag/chip)
|
||||
- .btn-tag-selected (selected tag state)
|
||||
- .btn-tag-remove (remove button within tag)
|
||||
|
||||
Button Groups:
|
||||
- .btn-group (container)
|
||||
- .btn-group-left (align left)
|
||||
- .btn-group-center (align center)
|
||||
- .btn-group-right (align right, default)
|
||||
- .btn-group-between (space-between)
|
||||
#}
|
||||
375
code/web/templates/partials/_card_display.html
Normal file
375
code/web/templates/partials/_card_display.html
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
{# Card Display Component Library #}
|
||||
{# Usage: {{ import '_card_display.html' }} then call card macros #}
|
||||
|
||||
{#
|
||||
Card Thumbnail Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Card name (required)
|
||||
- size (str): 'small' (160px), 'medium' (230px), 'large' (360px) (default: 'medium')
|
||||
- layout (str): Card layout type ('modal_dfc', 'transform', 'normal', etc.)
|
||||
- version (str): Scryfall image version ('small', 'normal', 'large') (auto-selected by size)
|
||||
- loading (str): 'lazy', 'eager' (default: 'lazy')
|
||||
- show_flip (bool): Show flip button for dual-faced cards (default: True)
|
||||
- show_name (bool): Show card name label below image (default: False)
|
||||
- classes (str): Additional CSS classes for container
|
||||
- img_classes (str): Additional CSS classes for img tag
|
||||
- data_attrs (dict): Additional data attributes as key-value pairs
|
||||
- role (str): Card role (commander, ramp, removal, etc.)
|
||||
- tags (list or str): Theme/mechanic tags (list or comma-separated string)
|
||||
- overlaps (list or str): Theme overlaps
|
||||
- count (int): Card count in deck
|
||||
- lqip (bool): Use low-quality image placeholder (default: True)
|
||||
- onclick (str): JavaScript onclick handler
|
||||
|
||||
Examples:
|
||||
{{ card_thumb('Sol Ring', size='medium') }}
|
||||
{{ card_thumb('Halana, Kessig Ranger', size='large', show_name=True) }}
|
||||
{{ card_thumb('Delver of Secrets', layout='transform', show_flip=True) }}
|
||||
{{ card_thumb('Rampant Growth', role='ramp', tags=['Ramp', 'Green']) }}
|
||||
#}
|
||||
{% macro card_thumb(name, size='medium', layout='normal', version='', loading='lazy', show_flip=True, show_name=False, classes='', img_classes='', data_attrs={}, role='', tags='', overlaps='', count=0, lqip=True, onclick='') %}
|
||||
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
|
||||
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
|
||||
|
||||
{# Auto-select Scryfall image version based on size #}
|
||||
{%- if not version -%}
|
||||
{%- if size == 'small' -%}
|
||||
{%- set version = 'small' -%}
|
||||
{%- elif size == 'large' -%}
|
||||
{%- set version = 'normal' -%}
|
||||
{%- else -%}
|
||||
{%- set version = 'small' -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
{# Build CSS classes #}
|
||||
{%- set size_class = 'card-thumb-' + size -%}
|
||||
{%- set dfc_class = 'card-thumb-dfc' if is_dfc else '' -%}
|
||||
{%- set container_classes = ['card-thumb-container', size_class, dfc_class, classes]|select|join(' ') -%}
|
||||
{%- set img_base_classes = 'card-thumb' -%}
|
||||
{%- set all_img_classes = [img_base_classes, img_classes]|select|join(' ') -%}
|
||||
|
||||
{# Build data attributes #}
|
||||
{%- set all_data_attrs = {
|
||||
'card-name': base_name,
|
||||
'layout': layout
|
||||
} -%}
|
||||
{%- if role -%}
|
||||
{%- set _ = all_data_attrs.update({'role': role}) -%}
|
||||
{%- endif -%}
|
||||
{%- if tags -%}
|
||||
{%- set tags_str = tags if tags is string else tags|join(', ') -%}
|
||||
{%- set _ = all_data_attrs.update({'tags': tags_str}) -%}
|
||||
{%- endif -%}
|
||||
{%- if overlaps -%}
|
||||
{%- set overlaps_str = overlaps if overlaps is string else overlaps|join(',') -%}
|
||||
{%- set _ = all_data_attrs.update({'overlaps': overlaps_str}) -%}
|
||||
{%- endif -%}
|
||||
{%- if count > 0 -%}
|
||||
{%- set _ = all_data_attrs.update({'count': count|string}) -%}
|
||||
{%- endif -%}
|
||||
{%- if lqip -%}
|
||||
{%- set _ = all_data_attrs.update({'lqip': '1'}) -%}
|
||||
{%- endif -%}
|
||||
{%- set _ = all_data_attrs.update(data_attrs) -%}
|
||||
|
||||
<div class="{{ container_classes }}" {% if onclick %}onclick="{{ onclick }}"{% endif %}>
|
||||
<img class="{{ all_img_classes }}"
|
||||
loading="{{ loading }}"
|
||||
decoding="async"
|
||||
src="{{ base_name|card_image(version) }}"
|
||||
alt="{{ name }} image"
|
||||
{% for key, value in all_data_attrs.items() %}data-{{ key }}="{{ value }}" {% endfor %}
|
||||
{% if lqip %}style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';"{% endif %} />
|
||||
|
||||
{% if is_dfc and show_flip %}
|
||||
{{ card_flip_button(name) }}
|
||||
{% endif %}
|
||||
|
||||
{% if show_name %}
|
||||
<div class="card-name-label" data-card-name="{{ base_name }}">{{ name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Card Flip Button Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Full card name (with // separator for DFCs)
|
||||
- classes (str): Additional CSS classes
|
||||
- aria_label (str): ARIA label (default: auto-generated)
|
||||
|
||||
Examples:
|
||||
{{ card_flip_button('Delver of Secrets // Insectile Aberration') }}
|
||||
#}
|
||||
{% macro card_flip_button(name, classes='', aria_label='') %}
|
||||
{%- set faces = name.split(' // ') -%}
|
||||
{%- set label = aria_label if aria_label else 'Flip to ' + (faces[1] if faces|length > 1 else 'other face') -%}
|
||||
|
||||
<button type="button"
|
||||
class="card-flip-btn {{ classes }}"
|
||||
data-card-name="{{ name }}"
|
||||
onclick="flipCard(this)"
|
||||
aria-label="{{ label }}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 3.293l2.646 2.647.708-.708L8 2.879 4.646 5.232l.708.708L8 3.293zM8 12.707L5.354 10.06l-.708.708L8 13.121l3.354-2.353-.708-.708L8 12.707z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Card Hover Popup Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Card name (required)
|
||||
- layout (str): Card layout type
|
||||
- tags (list or str): Theme/mechanic tags
|
||||
- highlight_tags (list or str): Tags to highlight
|
||||
- role (str): Card role
|
||||
- show_flip (bool): Show flip button for DFCs (default: True)
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Note: This macro generates the popup HTML. Actual hover/tap behavior
|
||||
should be handled by JavaScript (see card_popup.js)
|
||||
|
||||
Examples:
|
||||
{{ card_popup('Sol Ring', tags=['Ramp', 'Artifact']) }}
|
||||
{{ card_popup('Delver of Secrets', layout='transform', show_flip=True) }}
|
||||
#}
|
||||
{% macro card_popup(name, layout='normal', tags='', highlight_tags='', role='', show_flip=True, classes='') %}
|
||||
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
|
||||
{%- set is_dfc = layout in ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'] -%}
|
||||
{%- set tags_list = tags if tags is sequence and tags is not string else (tags.split(', ') if tags else []) -%}
|
||||
{%- set highlight_list = highlight_tags if highlight_tags is sequence and highlight_tags is not string else (highlight_tags.split(', ') if highlight_tags else []) -%}
|
||||
|
||||
<div class="card-popup {{ classes }}" data-card-name="{{ base_name }}" role="dialog" aria-label="{{ name }} details">
|
||||
<div class="card-popup-backdrop" onclick="closeCardPopup(this)"></div>
|
||||
<div class="card-popup-content">
|
||||
{# Card Image (360px) #}
|
||||
<div class="card-popup-image">
|
||||
<img src="{{ base_name|card_image('normal') }}"
|
||||
alt="{{ name }} image"
|
||||
data-card-name="{{ base_name }}"
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
{% if is_dfc and show_flip %}
|
||||
{{ card_flip_button(name) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Card Info #}
|
||||
<div class="card-popup-info">
|
||||
<h3 class="card-popup-name">{{ name }}</h3>
|
||||
{% if role %}
|
||||
<div class="card-popup-role">Role: <span>{{ role }}</span></div>
|
||||
{% endif %}
|
||||
{% if tags_list %}
|
||||
<div class="card-popup-tags">
|
||||
{% for tag in tags_list %}
|
||||
{%- set is_highlight = tag in highlight_list -%}
|
||||
<span class="card-popup-tag{% if is_highlight %} card-popup-tag-highlight{% endif %}">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Close Button #}
|
||||
<button type="button"
|
||||
class="card-popup-close"
|
||||
onclick="closeCardPopup(this)"
|
||||
aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Card Grid Container Macro
|
||||
|
||||
Parameters:
|
||||
- cards (list): List of card dicts with keys: name, layout, role, tags, count, etc.
|
||||
- size (str): Thumbnail size ('small', 'medium', 'large')
|
||||
- columns (int or str): Number of columns (auto, 2, 3, 4, 5, 6) (default: 'auto')
|
||||
- gap (str): Grid gap (default: '0.75rem')
|
||||
- show_names (bool): Show card name labels (default: False)
|
||||
- show_popups (bool): Enable hover/tap popups (default: True)
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ card_grid(deck_cards, size='medium', columns=4) }}
|
||||
{{ card_grid(commander_examples, size='large', show_names=True) }}
|
||||
#}
|
||||
{% macro card_grid(cards, size='medium', columns='auto', gap='0.75rem', show_names=False, show_popups=True, classes='') %}
|
||||
{%- set columns_class = 'card-grid-cols-' + (columns|string) -%}
|
||||
{%- set popup_class = 'card-grid-with-popups' if show_popups else '' -%}
|
||||
{%- set all_classes = ['card-grid', columns_class, popup_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}" style="gap: {{ gap }};">
|
||||
{% for card in cards %}
|
||||
{{ card_thumb(
|
||||
name=card.name,
|
||||
size=size,
|
||||
layout=card.get('layout', 'normal'),
|
||||
role=card.get('role', ''),
|
||||
tags=card.get('tags', []),
|
||||
overlaps=card.get('overlaps', []),
|
||||
count=card.get('count', 0),
|
||||
show_name=show_names,
|
||||
show_flip=True
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Card List Item Macro (for vertical lists)
|
||||
|
||||
Parameters:
|
||||
- name (str): Card name (required)
|
||||
- count (int): Card quantity (default: 1)
|
||||
- role (str): Card role
|
||||
- tags (list or str): Theme/mechanic tags
|
||||
- show_thumb (bool): Show thumbnail image (default: True)
|
||||
- thumb_size (str): Thumbnail size if shown (default: 'small')
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ card_list_item('Sol Ring', count=1, role='ramp') }}
|
||||
{{ card_list_item('Rampant Growth', count=1, tags=['Ramp', 'Green'], show_thumb=True) }}
|
||||
#}
|
||||
{% macro card_list_item(name, count=1, role='', tags='', show_thumb=True, thumb_size='small', classes='') %}
|
||||
{%- set base_name = name.split(' // ')[0] if ' // ' in name else name -%}
|
||||
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
|
||||
|
||||
<li class="card-list-item {{ classes }}" data-card-name="{{ base_name }}">
|
||||
{% if show_thumb %}
|
||||
{{ card_thumb(name, size=thumb_size, show_flip=False, role=role, tags=tags) }}
|
||||
{% endif %}
|
||||
<div class="card-list-item-info">
|
||||
<span class="card-list-item-name">{{ name }}</span>
|
||||
{% if count > 1 %}
|
||||
<span class="card-list-item-count">×{{ count }}</span>
|
||||
{% endif %}
|
||||
{% if role %}
|
||||
<span class="card-list-item-role">{{ role }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Synthetic Card Placeholder Macro (for theme previews)
|
||||
|
||||
Parameters:
|
||||
- name (str): Card name (required)
|
||||
- tags (list or str): Theme/mechanic tags
|
||||
- reasons (list or str): Inclusion reasons
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ synthetic_card('Placeholder Ramp', tags=['Ramp'], reasons=['synergy with commander']) }}
|
||||
#}
|
||||
{% macro synthetic_card(name, tags='', reasons='', classes='') %}
|
||||
{%- set tags_str = tags if tags is string else (tags|join(', ') if tags else '') -%}
|
||||
{%- set reasons_str = reasons if reasons is string else (reasons|join('; ') if reasons else '') -%}
|
||||
|
||||
<div class="card-sample synthetic {{ classes }}"
|
||||
data-card-name="{{ name }}"
|
||||
data-role="synthetic"
|
||||
data-tags="{{ tags_str }}"
|
||||
data-reasons="{{ reasons_str }}">
|
||||
<div class="synthetic-card-placeholder">
|
||||
<div class="synthetic-card-icon">?</div>
|
||||
<div class="synthetic-card-name">{{ name }}</div>
|
||||
{% if reasons_str %}
|
||||
<div class="synthetic-card-reason">{{ reasons_str }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# CSS Classes Reference #}
|
||||
{#
|
||||
Card Thumbnail Sizes:
|
||||
- .card-thumb-small (160px width, for lists and grids)
|
||||
- .card-thumb-medium (230px width, for previews and examples, default)
|
||||
- .card-thumb-large (360px width, for prominent displays and deck views)
|
||||
|
||||
Card Thumbnail Modifiers:
|
||||
- .card-thumb-dfc (dual-faced card, shows flip button)
|
||||
- .card-thumb-container (wrapper with position relative)
|
||||
- .card-thumb (img tag with consistent styling)
|
||||
|
||||
Card Flip Button:
|
||||
- .card-flip-btn (flip button overlay on card image)
|
||||
|
||||
Card Popup:
|
||||
- .card-popup (popup container, fixed positioning)
|
||||
- .card-popup-backdrop (backdrop overlay)
|
||||
- .card-popup-content (popup content box)
|
||||
- .card-popup-image (360px card image)
|
||||
- .card-popup-info (card name, role, tags)
|
||||
- .card-popup-name (card name heading)
|
||||
- .card-popup-role (role label)
|
||||
- .card-popup-tags (tag list)
|
||||
- .card-popup-tag (individual tag)
|
||||
- .card-popup-tag-highlight (highlighted tag)
|
||||
- .card-popup-close (close button)
|
||||
|
||||
Card Grid:
|
||||
- .card-grid (grid container)
|
||||
- .card-grid-cols-auto (auto columns based on card size)
|
||||
- .card-grid-cols-2, .card-grid-cols-3, etc. (fixed columns)
|
||||
- .card-grid-with-popups (enables popup on hover/tap)
|
||||
|
||||
Card List:
|
||||
- .card-list-item (list item with thumbnail and info)
|
||||
- .card-list-item-info (text info container)
|
||||
- .card-list-item-name (card name)
|
||||
- .card-list-item-count (quantity indicator)
|
||||
- .card-list-item-role (role label)
|
||||
|
||||
Synthetic Cards:
|
||||
- .card-sample.synthetic (synthetic card placeholder)
|
||||
- .synthetic-card-placeholder (placeholder content)
|
||||
- .synthetic-card-icon (question mark icon)
|
||||
- .synthetic-card-name (placeholder name)
|
||||
- .synthetic-card-reason (inclusion reason text)
|
||||
#}
|
||||
|
||||
{# JavaScript Helper Functions #}
|
||||
{#
|
||||
These functions should be included in card_display.js or inline script:
|
||||
|
||||
// Flip dual-faced card image
|
||||
function flipCard(button) {
|
||||
const container = button.closest('.card-thumb-container, .card-popup-image');
|
||||
const img = container.querySelector('img');
|
||||
const cardName = img.dataset.cardName;
|
||||
const faces = cardName.split(' // ');
|
||||
|
||||
if (faces.length < 2) return;
|
||||
|
||||
// Toggle current face
|
||||
const currentFace = img.dataset.currentFace || 0;
|
||||
const nextFace = currentFace == 0 ? 1 : 0;
|
||||
const faceName = faces[nextFace];
|
||||
|
||||
// Update image source
|
||||
img.src = '/api/images/normal/' + encodeURIComponent(faceName);
|
||||
img.dataset.currentFace = nextFace;
|
||||
}
|
||||
|
||||
// Show card popup on hover/tap
|
||||
function showCardPopup(cardName, event) {
|
||||
// Implementation depends on popup positioning strategy
|
||||
// Could append popup to body and position near cursor/tap location
|
||||
}
|
||||
|
||||
// Close card popup
|
||||
function closeCardPopup(element) {
|
||||
const popup = element.closest('.card-popup');
|
||||
if (popup) popup.remove();
|
||||
}
|
||||
#}
|
||||
396
code/web/templates/partials/_forms.html
Normal file
396
code/web/templates/partials/_forms.html
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
{# Form Component Library #}
|
||||
{# Usage: {{ import '_forms.html' }} then call form macros #}
|
||||
|
||||
{#
|
||||
Form Field Wrapper Macro
|
||||
|
||||
Parameters:
|
||||
- label (str): Field label text
|
||||
- name (str): Input name attribute
|
||||
- required (bool): Mark field as required (default: False)
|
||||
- help_text (str): Optional help text below field
|
||||
- error (str): Error message to display
|
||||
- classes (str): Additional CSS classes for wrapper
|
||||
|
||||
Content Block:
|
||||
- body: Input element (required)
|
||||
|
||||
Examples:
|
||||
{% call form_field('Email', 'email', required=True) %}
|
||||
{% block body %}
|
||||
<input type="email" name="email" />
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro form_field(label='', name='', required=False, help_text='', error='', classes='') %}
|
||||
{%- set has_error = error|length > 0 -%}
|
||||
{%- set error_class = 'form-field-error' if has_error else '' -%}
|
||||
{%- set all_classes = ['form-field', error_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
{% if label %}
|
||||
<label for="{{ name }}" class="form-label">
|
||||
{{ label }}
|
||||
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-input-wrapper">
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
|
||||
{% if help_text %}
|
||||
<div class="form-help-text">{{ help_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="form-error-text" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Text Input Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Input name attribute (required)
|
||||
- label (str): Field label
|
||||
- type (str): Input type ('text', 'email', 'password', 'url', etc.) (default: 'text')
|
||||
- value (str): Input value
|
||||
- placeholder (str): Placeholder text
|
||||
- required (bool): Required field (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- readonly (bool): Read-only field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ text_input('username', label='Username', required=True) }}
|
||||
{{ text_input('email', label='Email', type='email', placeholder='you@example.com') }}
|
||||
#}
|
||||
{% macro text_input(name, label='', type='text', value='', placeholder='', required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
|
||||
{% call form_field(label, name, required, help_text, error, classes) %}
|
||||
{% block body %}
|
||||
<input type="{{ type }}"
|
||||
id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-input"
|
||||
{% if value %}value="{{ value }}"{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{{ attrs|safe }} />
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Textarea Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Textarea name attribute (required)
|
||||
- label (str): Field label
|
||||
- value (str): Textarea value
|
||||
- placeholder (str): Placeholder text
|
||||
- rows (int): Number of visible rows (default: 4)
|
||||
- required (bool): Required field (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- readonly (bool): Read-only field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ textarea('notes', label='Notes', rows=6) }}
|
||||
{{ textarea('description', label='Description', placeholder='Enter description...') }}
|
||||
#}
|
||||
{% macro textarea(name, label='', value='', placeholder='', rows=4, required=False, disabled=False, readonly=False, help_text='', error='', classes='', attrs='') %}
|
||||
{% call form_field(label, name, required, help_text, error, classes) %}
|
||||
{% block body %}
|
||||
<textarea id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-textarea"
|
||||
rows="{{ rows }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{{ attrs|safe }}>{{ value }}</textarea>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Select Dropdown Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Select name attribute (required)
|
||||
- label (str): Field label
|
||||
- options (list): List of option dicts with keys: value, text, selected, disabled
|
||||
- value (str): Selected value
|
||||
- required (bool): Required field (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ select('color', label='Color', options=[
|
||||
{'value': 'W', 'text': 'White'},
|
||||
{'value': 'U', 'text': 'Blue'},
|
||||
{'value': 'B', 'text': 'Black'}
|
||||
]) }}
|
||||
#}
|
||||
{% macro select(name, label='', options=[], value='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
|
||||
{% call form_field(label, name, required, help_text, error, classes) %}
|
||||
{% block body %}
|
||||
<select id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-select"
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{{ attrs|safe }}>
|
||||
{% for option in options %}
|
||||
<option value="{{ option.value }}"
|
||||
{% if option.value == value or option.get('selected') %}selected{% endif %}
|
||||
{% if option.get('disabled') %}disabled{% endif %}>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Checkbox Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Checkbox name attribute (required)
|
||||
- label (str): Checkbox label text (required)
|
||||
- value (str): Checkbox value (default: '1')
|
||||
- checked (bool): Checked state (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ checkbox('accept_terms', label='I accept the terms', required=True) }}
|
||||
{{ checkbox('owned_only', label='Owned cards only', checked=True) }}
|
||||
#}
|
||||
{% macro checkbox(name, label, value='1', checked=False, disabled=False, help_text='', error='', classes='', attrs='') %}
|
||||
{%- set has_error = error|length > 0 -%}
|
||||
{%- set error_class = 'form-field-error' if has_error else '' -%}
|
||||
{%- set all_classes = ['form-field', 'form-field-checkbox', error_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
<label class="form-checkbox-label">
|
||||
<input type="checkbox"
|
||||
id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-checkbox"
|
||||
value="{{ value }}"
|
||||
{% if checked %}checked{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{{ attrs|safe }} />
|
||||
<span class="form-checkbox-text">{{ label }}</span>
|
||||
</label>
|
||||
|
||||
{% if help_text %}
|
||||
<div class="form-help-text">{{ help_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="form-error-text" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Radio Button Group Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Radio name attribute (required)
|
||||
- label (str): Field label
|
||||
- options (list): List of option dicts with keys: value, text, checked, disabled
|
||||
- value (str): Selected value
|
||||
- required (bool): Required field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ radio_group('theme', label='Theme', options=[
|
||||
{'value': 'system', 'text': 'System'},
|
||||
{'value': 'light', 'text': 'Light'},
|
||||
{'value': 'dark', 'text': 'Dark'}
|
||||
]) }}
|
||||
#}
|
||||
{% macro radio_group(name, label='', options=[], value='', required=False, help_text='', error='', classes='') %}
|
||||
{%- set has_error = error|length > 0 -%}
|
||||
{%- set error_class = 'form-field-error' if has_error else '' -%}
|
||||
{%- set all_classes = ['form-field', 'form-field-radio', error_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
{% if label %}
|
||||
<div class="form-label">
|
||||
{{ label }}
|
||||
{% if required %}<span class="form-required" aria-label="required">*</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-radio-group">
|
||||
{% for option in options %}
|
||||
<label class="form-radio-label">
|
||||
<input type="radio"
|
||||
name="{{ name }}"
|
||||
class="form-radio"
|
||||
value="{{ option.value }}"
|
||||
{% if option.value == value or option.get('checked') %}checked{% endif %}
|
||||
{% if option.get('disabled') %}disabled{% endif %}
|
||||
{% if required %}required{% endif %} />
|
||||
<span class="form-radio-text">{{ option.text }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if help_text %}
|
||||
<div class="form-help-text">{{ help_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="form-error-text" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Number Input Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Input name attribute (required)
|
||||
- label (str): Field label
|
||||
- value (int or float): Input value
|
||||
- min (int or float): Minimum value
|
||||
- max (int or float): Maximum value
|
||||
- step (int or float): Step increment (default: 1)
|
||||
- placeholder (str): Placeholder text
|
||||
- required (bool): Required field (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ number_input('quantity', label='Quantity', min=1, max=10, value=1) }}
|
||||
{{ number_input('price', label='Price', min=0, step=0.01, placeholder='0.00') }}
|
||||
#}
|
||||
{% macro number_input(name, label='', value='', min='', max='', step=1, placeholder='', required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
|
||||
{% call form_field(label, name, required, help_text, error, classes) %}
|
||||
{% block body %}
|
||||
<input type="number"
|
||||
id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-input form-input-number"
|
||||
{% if value != '' %}value="{{ value }}"{% endif %}
|
||||
{% if min != '' %}min="{{ min }}"{% endif %}
|
||||
{% if max != '' %}max="{{ max }}"{% endif %}
|
||||
step="{{ step }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{{ attrs|safe }} />
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
File Input Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Input name attribute (required)
|
||||
- label (str): Field label
|
||||
- accept (str): Accepted file types (e.g., '.csv,.txt', 'image/*')
|
||||
- multiple (bool): Allow multiple files (default: False)
|
||||
- required (bool): Required field (default: False)
|
||||
- disabled (bool): Disabled field (default: False)
|
||||
- help_text (str): Help text
|
||||
- error (str): Error message
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Examples:
|
||||
{{ file_input('deck_file', label='Upload Deck', accept='.csv,.txt') }}
|
||||
{{ file_input('cards', label='Card Images', accept='image/*', multiple=True) }}
|
||||
#}
|
||||
{% macro file_input(name, label='', accept='', multiple=False, required=False, disabled=False, help_text='', error='', classes='', attrs='') %}
|
||||
{% call form_field(label, name, required, help_text, error, classes) %}
|
||||
{% block body %}
|
||||
<input type="file"
|
||||
id="{{ name }}"
|
||||
name="{{ name }}"
|
||||
class="form-input form-input-file"
|
||||
{% if accept %}accept="{{ accept }}"{% endif %}
|
||||
{% if multiple %}multiple{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{{ attrs|safe }} />
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Hidden Input Macro
|
||||
|
||||
Parameters:
|
||||
- name (str): Input name attribute (required)
|
||||
- value (str): Input value (required)
|
||||
|
||||
Examples:
|
||||
{{ hidden_input('csrf_token', value='abc123') }}
|
||||
#}
|
||||
{% macro hidden_input(name, value) %}
|
||||
<input type="hidden" name="{{ name }}" value="{{ value }}" />
|
||||
{% endmacro %}
|
||||
|
||||
{# CSS Classes Reference #}
|
||||
{#
|
||||
Form Structure:
|
||||
- .form-field (field wrapper)
|
||||
- .form-field-error (error state)
|
||||
- .form-field-checkbox (checkbox field modifier)
|
||||
- .form-field-radio (radio field modifier)
|
||||
- .form-label (label text)
|
||||
- .form-required (required indicator *)
|
||||
- .form-input-wrapper (input container)
|
||||
- .form-help-text (help text below field)
|
||||
- .form-error-text (error message)
|
||||
|
||||
Input Types:
|
||||
- .form-input (base input class)
|
||||
- .form-input-number (number input modifier)
|
||||
- .form-input-file (file input modifier)
|
||||
- .form-textarea (textarea)
|
||||
- .form-select (select dropdown)
|
||||
- .form-checkbox (checkbox input)
|
||||
- .form-checkbox-label (checkbox label wrapper)
|
||||
- .form-checkbox-text (checkbox label text)
|
||||
- .form-radio (radio input)
|
||||
- .form-radio-group (radio button container)
|
||||
- .form-radio-label (radio label wrapper)
|
||||
- .form-radio-text (radio label text)
|
||||
|
||||
Form Layout Utilities (to be defined in CSS):
|
||||
- .form-row (horizontal row of fields)
|
||||
- .form-cols-2, .form-cols-3 (multi-column layouts)
|
||||
- .form-inline (inline form layout)
|
||||
- .form-compact (reduced spacing)
|
||||
#}
|
||||
|
|
@ -1,5 +1,19 @@
|
|||
{# Reusable Jinja macros for UI elements #}
|
||||
|
||||
{#
|
||||
Component Library Imports
|
||||
|
||||
To use components in your templates:
|
||||
{% from 'partials/_macros.html' import component_name %}
|
||||
|
||||
Or import specific component libraries:
|
||||
{% from 'partials/_buttons.html' import button, icon_button %}
|
||||
{% from 'partials/_modals.html' import modal, simple_modal %}
|
||||
{% from 'partials/_card_display.html' import card_thumb, card_grid %}
|
||||
{% from 'partials/_forms.html' import text_input, select, checkbox %}
|
||||
{% from 'partials/_panels.html' import panel, stat_panel %}
|
||||
#}
|
||||
|
||||
{% macro lock_button(name, locked=False, from_list=False, target_selector='closest .lock-box') -%}
|
||||
{# Emits a lock/unlock button with correct hx-vals and aria state. #}
|
||||
<button type="button" class="btn-lock"
|
||||
|
|
|
|||
351
code/web/templates/partials/_modals.html
Normal file
351
code/web/templates/partials/_modals.html
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
{# Modal Component Library #}
|
||||
{# Usage: {{ import '_modals.html' }} then call modal macros #}
|
||||
|
||||
{#
|
||||
Modal Container Macro
|
||||
|
||||
Parameters:
|
||||
- id (str): Modal HTML id attribute (optional)
|
||||
- title (str): Modal title (shows in header)
|
||||
- size (str): 'sm' (480px), 'md' (620px), 'lg' (720px), 'xl' (960px) (default: 'md')
|
||||
- position (str): 'center', 'top' (default: 'center')
|
||||
- scrollable (bool): Allow content scrolling (default: True)
|
||||
- classes (str): Additional CSS classes for modal container
|
||||
- content_classes (str): Additional CSS classes for modal content box
|
||||
- show_close (bool): Show close button in header (default: True)
|
||||
- backdrop_click_close (bool): Close on backdrop click (default: True)
|
||||
- role (str): ARIA role (default: 'dialog')
|
||||
- aria_labelledby (str): ARIA labelledby id (default: auto-generated from title)
|
||||
|
||||
Content Blocks:
|
||||
- header: Optional custom header content (overrides default title)
|
||||
- body: Main modal content (required)
|
||||
- footer: Optional footer with action buttons
|
||||
|
||||
Examples:
|
||||
{% call modal(title='Edit Card', size='md') %}
|
||||
{% block body %}
|
||||
<form>...</form>
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
{{ button('Cancel', variant='secondary', onclick='closeModal()') }}
|
||||
{{ button('Save', type='submit') }}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro modal(id='', title='', size='md', position='center', scrollable=True, classes='', content_classes='', show_close=True, backdrop_click_close=True, role='dialog', aria_labelledby='') %}
|
||||
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
|
||||
{%- set title_id = aria_labelledby if aria_labelledby else modal_id + '-title' -%}
|
||||
{%- set size_class = 'modal-' + size -%}
|
||||
{%- set position_class = 'modal-' + position -%}
|
||||
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
|
||||
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}"
|
||||
{% if id %}id="{{ modal_id }}"{% endif %}
|
||||
role="{{ role }}"
|
||||
aria-modal="true"
|
||||
aria-labelledby="{{ title_id }}">
|
||||
|
||||
{# Backdrop #}
|
||||
<div class="modal-backdrop"
|
||||
{% if backdrop_click_close %}onclick="try{this.closest('.modal').remove();}catch(_){}"{% endif %}></div>
|
||||
|
||||
{# Content Container #}
|
||||
<div class="modal-content {{ content_classes }}">
|
||||
|
||||
{# Header #}
|
||||
{% if caller.header is defined %}
|
||||
{{ caller.header() }}
|
||||
{% else %}
|
||||
{% if title or show_close %}
|
||||
<div class="modal-header">
|
||||
{% if title %}
|
||||
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
|
||||
{% endif %}
|
||||
{% if show_close %}
|
||||
{% from '_buttons.html' import close_button %}
|
||||
{{ close_button() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Body #}
|
||||
<div class="modal-body">
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
{% if caller.footer is defined %}
|
||||
<div class="modal-footer">
|
||||
{{ caller.footer() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Simple Modal Macro (no block structure)
|
||||
|
||||
Parameters: Same as modal() plus:
|
||||
- content (str): Body HTML content
|
||||
- footer_buttons (list): List of button dicts (see button_group in _buttons.html)
|
||||
|
||||
Examples:
|
||||
{{ simple_modal(
|
||||
title='Confirm Delete',
|
||||
content='<p>Are you sure you want to delete this deck?</p>',
|
||||
footer_buttons=[
|
||||
{'text': 'Cancel', 'variant': 'secondary', 'onclick': 'closeModal()'},
|
||||
{'text': 'Delete', 'variant': 'danger', 'onclick': 'deleteDeck()'}
|
||||
]
|
||||
) }}
|
||||
#}
|
||||
{% macro simple_modal(title='', content='', footer_buttons=[], id='', size='md', position='center', scrollable=True, classes='', show_close=True) %}
|
||||
{%- set modal_id = id if id else 'modal-' ~ title|lower|replace(' ', '-') -%}
|
||||
{%- set title_id = modal_id + '-title' -%}
|
||||
{%- set size_class = 'modal-' + size -%}
|
||||
{%- set position_class = 'modal-' + position -%}
|
||||
{%- set scrollable_class = 'modal-scrollable' if scrollable else '' -%}
|
||||
{%- set all_classes = ['modal', size_class, position_class, scrollable_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}"
|
||||
{% if id %}id="{{ modal_id }}"{% endif %}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="{{ title_id }}">
|
||||
|
||||
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
|
||||
|
||||
<div class="modal-content">
|
||||
{% if title or show_close %}
|
||||
<div class="modal-header">
|
||||
{% if title %}
|
||||
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
|
||||
{% endif %}
|
||||
{% if show_close %}
|
||||
{% from '_buttons.html' import close_button %}
|
||||
{{ close_button() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal-body">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
|
||||
{% if footer_buttons %}
|
||||
<div class="modal-footer">
|
||||
{% from '_buttons.html' import button_group %}
|
||||
{{ button_group(footer_buttons, alignment='right') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Confirm Dialog Macro
|
||||
|
||||
Parameters:
|
||||
- title (str): Dialog title (default: 'Confirm')
|
||||
- message (str): Confirmation message (required)
|
||||
- confirm_text (str): Confirm button text (default: 'Confirm')
|
||||
- cancel_text (str): Cancel button text (default: 'Cancel')
|
||||
- confirm_variant (str): Confirm button variant (default: 'primary')
|
||||
- on_confirm (str): JavaScript handler for confirm action (required)
|
||||
- on_cancel (str): JavaScript handler for cancel (default: close modal)
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ confirm_dialog(
|
||||
message='Delete this deck?',
|
||||
confirm_text='Delete',
|
||||
confirm_variant='danger',
|
||||
on_confirm='deleteDeck(123)'
|
||||
) }}
|
||||
#}
|
||||
{% macro confirm_dialog(title='Confirm', message='', confirm_text='Confirm', cancel_text='Cancel', confirm_variant='primary', on_confirm='', on_cancel='', classes='') %}
|
||||
{{ simple_modal(
|
||||
title=title,
|
||||
content='<p>' + message + '</p>',
|
||||
footer_buttons=[
|
||||
{'text': cancel_text, 'variant': 'secondary', 'onclick': on_cancel if on_cancel else "this.closest('.modal').remove()"},
|
||||
{'text': confirm_text, 'variant': confirm_variant, 'onclick': on_confirm}
|
||||
],
|
||||
size='sm',
|
||||
classes='modal-confirm ' + classes
|
||||
) }}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Form Modal Macro
|
||||
|
||||
Parameters: Similar to modal() plus:
|
||||
- form_action (str): Form action URL (hx-post or action)
|
||||
- form_method (str): 'post', 'get' (default: 'post')
|
||||
- use_htmx (bool): Use HTMX for form submission (default: True)
|
||||
- hx_target (str): HTMX target selector (default: 'closest .modal')
|
||||
- hx_swap (str): HTMX swap strategy (default: 'outerHTML')
|
||||
- submit_text (str): Submit button text (default: 'Submit')
|
||||
- cancel_text (str): Cancel button text (default: 'Cancel')
|
||||
- form_attrs (str): Additional form attributes
|
||||
|
||||
Content Blocks:
|
||||
- body: Form fields (required)
|
||||
|
||||
Examples:
|
||||
{% call form_modal(
|
||||
title='Add Card',
|
||||
form_action='/build/add-card',
|
||||
submit_text='Add',
|
||||
hx_target='#deck-list'
|
||||
) %}
|
||||
{% block body %}
|
||||
<input type="text" name="card_name" placeholder="Card name" />
|
||||
<input type="number" name="quantity" value="1" />
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro form_modal(title='', form_action='', form_method='post', use_htmx=True, hx_target='closest .modal', hx_swap='outerHTML', submit_text='Submit', cancel_text='Cancel', size='md', classes='', form_attrs='') %}
|
||||
{%- set modal_id = 'modal-form-' ~ title|lower|replace(' ', '-') -%}
|
||||
{%- set title_id = modal_id + '-title' -%}
|
||||
{%- set size_class = 'modal-' + size -%}
|
||||
{%- set all_classes = ['modal', 'modal-form', size_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}"
|
||||
id="{{ modal_id }}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="{{ title_id }}">
|
||||
|
||||
<div class="modal-backdrop" onclick="try{this.closest('.modal').remove();}catch(_){}"></div>
|
||||
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="{{ title_id }}">{{ title }}</h2>
|
||||
{% from '_buttons.html' import close_button %}
|
||||
{{ close_button() }}
|
||||
</div>
|
||||
|
||||
<form {% if use_htmx %}hx-post="{{ form_action }}" hx-target="{{ hx_target }}" hx-swap="{{ hx_swap }}"{% else %}action="{{ form_action }}" method="{{ form_method }}"{% endif %} {{ form_attrs|safe }}>
|
||||
<div class="modal-body">
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
{% from '_buttons.html' import button %}
|
||||
{{ button(cancel_text, variant='secondary', onclick="this.closest('.modal').remove()") }}
|
||||
{{ button(submit_text, type='submit', variant='primary') }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Alert Modal Macro
|
||||
|
||||
Parameters:
|
||||
- title (str): Alert title (default: 'Alert')
|
||||
- message (str): Alert message (required)
|
||||
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
|
||||
- ok_text (str): OK button text (default: 'OK')
|
||||
- on_ok (str): JavaScript handler for OK button (default: close modal)
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ alert_modal(
|
||||
title='Success',
|
||||
message='Deck saved successfully!',
|
||||
type='success'
|
||||
) }}
|
||||
|
||||
{{ alert_modal(
|
||||
title='Error',
|
||||
message='Failed to save deck. Please try again.',
|
||||
type='error'
|
||||
) }}
|
||||
#}
|
||||
{% macro alert_modal(title='Alert', message='', type='info', ok_text='OK', on_ok='', classes='') %}
|
||||
{%- set type_class = 'modal-alert-' + type -%}
|
||||
{{ simple_modal(
|
||||
title=title,
|
||||
content='<div class="alert-icon alert-icon-' + type + '"></div><p>' + message + '</p>',
|
||||
footer_buttons=[
|
||||
{'text': ok_text, 'variant': 'primary', 'onclick': on_ok if on_ok else "this.closest('.modal').remove()"}
|
||||
],
|
||||
size='sm',
|
||||
classes='modal-alert ' + type_class + ' ' + classes,
|
||||
show_close=False
|
||||
) }}
|
||||
{% endmacro %}
|
||||
|
||||
{# CSS Classes Reference #}
|
||||
{#
|
||||
Modal Sizes:
|
||||
- .modal-sm (480px max-width)
|
||||
- .modal-md (620px max-width, default)
|
||||
- .modal-lg (720px max-width)
|
||||
- .modal-xl (960px max-width)
|
||||
|
||||
Modal Position:
|
||||
- .modal-center (vertically centered, default)
|
||||
- .modal-top (aligned to top with padding)
|
||||
|
||||
Modal Modifiers:
|
||||
- .modal-scrollable (allows body scrolling)
|
||||
- .modal-form (form-specific styling)
|
||||
- .modal-confirm (confirmation dialog styling)
|
||||
- .modal-alert (alert dialog styling)
|
||||
- .modal-alert-info (blue theme)
|
||||
- .modal-alert-success (green theme)
|
||||
- .modal-alert-warning (yellow theme)
|
||||
- .modal-alert-error (red theme)
|
||||
|
||||
Modal Structure:
|
||||
- .modal (outer container, fixed positioning)
|
||||
- .modal-backdrop (backdrop overlay)
|
||||
- .modal-content (content box)
|
||||
- .modal-header (header with title and close button)
|
||||
- .modal-title (h2 title)
|
||||
- .modal-body (main content area)
|
||||
- .modal-footer (action buttons)
|
||||
#}
|
||||
|
||||
{# JavaScript Helper Functions #}
|
||||
{#
|
||||
These functions should be included in a global JavaScript file or inline script:
|
||||
|
||||
// Open modal by ID
|
||||
function openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal by ID or element
|
||||
function closeModal(modalOrId) {
|
||||
const modal = typeof modalOrId === 'string'
|
||||
? document.getElementById(modalOrId)
|
||||
: modalOrId;
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
// Check if any other modals are open
|
||||
if (!document.querySelector('.modal')) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close all modals
|
||||
function closeAllModals() {
|
||||
document.querySelectorAll('.modal').forEach(modal => modal.remove());
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
#}
|
||||
399
code/web/templates/partials/_panels.html
Normal file
399
code/web/templates/partials/_panels.html
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
{# Panel/Tile Component Library #}
|
||||
{# Usage: {{ import '_panels.html' }} then call panel macros #}
|
||||
|
||||
{#
|
||||
Panel Container Macro
|
||||
|
||||
Parameters:
|
||||
- title (str): Panel title (optional)
|
||||
- variant (str): 'default', 'alt', 'dark', 'bordered' (default: 'default')
|
||||
- padding (str): 'none', 'sm', 'md', 'lg' (default: 'md')
|
||||
- classes (str): Additional CSS classes
|
||||
- attrs (str): Additional HTML attributes
|
||||
|
||||
Content Block:
|
||||
- header: Optional custom header (overrides title)
|
||||
- body: Panel content (required)
|
||||
- footer: Optional footer content
|
||||
|
||||
Examples:
|
||||
{% call panel(title='Deck Stats') %}
|
||||
{% block body %}
|
||||
<p>Cards: 100</p>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
|
||||
{% call panel(variant='alt', padding='lg') %}
|
||||
{% block body %}
|
||||
<p>Content here</p>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro panel(title='', variant='default', padding='md', classes='', attrs='') %}
|
||||
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
|
||||
{%- set padding_class = 'panel-padding-' + padding -%}
|
||||
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}" {{ attrs|safe }}>
|
||||
{% if caller.header is defined %}
|
||||
{{ caller.header() }}
|
||||
{% elif title %}
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">{{ title }}</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel-body">
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
|
||||
{% if caller.footer is defined %}
|
||||
<div class="panel-footer">
|
||||
{{ caller.footer() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Simple Panel Macro (no block structure)
|
||||
|
||||
Parameters: Same as panel() plus:
|
||||
- content (str): Body HTML content
|
||||
|
||||
Examples:
|
||||
{{ simple_panel(title='Welcome', content='<p>Hello, user!</p>') }}
|
||||
{{ simple_panel(content='<p>No title panel</p>', variant='alt') }}
|
||||
#}
|
||||
{% macro simple_panel(title='', content='', variant='default', padding='md', classes='', attrs='') %}
|
||||
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
|
||||
{%- set padding_class = 'panel-padding-' + padding -%}
|
||||
{%- set all_classes = ['panel', variant_class, padding_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}" {{ attrs|safe }}>
|
||||
{% if title %}
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">{{ title }}</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel-body">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Info Panel Macro (with icon and optional action)
|
||||
|
||||
Parameters:
|
||||
- icon (str): Icon HTML or character
|
||||
- title (str): Panel title (required)
|
||||
- content (str): Panel content (required)
|
||||
- type (str): 'info', 'success', 'warning', 'error' (default: 'info')
|
||||
- action_text (str): Optional action button text
|
||||
- action_href (str): Action button URL
|
||||
- action_onclick (str): Action button onclick handler
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ info_panel(
|
||||
icon='ℹ️',
|
||||
title='Setup Required',
|
||||
content='Please run the setup process before building decks.',
|
||||
type='info',
|
||||
action_text='Run Setup',
|
||||
action_href='/setup'
|
||||
) }}
|
||||
#}
|
||||
{% macro info_panel(icon='', title='', content='', type='info', action_text='', action_href='', action_onclick='', classes='') %}
|
||||
{%- set type_class = 'panel-info-' + type -%}
|
||||
{%- set all_classes = ['panel', 'panel-info', type_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
<div class="panel-info-content">
|
||||
{% if icon %}
|
||||
<div class="panel-info-icon">{{ icon|safe }}</div>
|
||||
{% endif %}
|
||||
<div class="panel-info-text">
|
||||
{% if title %}
|
||||
<h4 class="panel-info-title">{{ title }}</h4>
|
||||
{% endif %}
|
||||
<div class="panel-info-message">{{ content|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if action_text %}
|
||||
<div class="panel-info-action">
|
||||
{% from '_buttons.html' import button %}
|
||||
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary', size='sm') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Stat Panel Macro (for displaying key metrics)
|
||||
|
||||
Parameters:
|
||||
- label (str): Stat label (required)
|
||||
- value (str or int): Stat value (required)
|
||||
- sublabel (str): Optional secondary label
|
||||
- icon (str): Optional icon
|
||||
- variant (str): 'default', 'primary', 'success', 'warning', 'error' (default: 'default')
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ stat_panel('Total Cards', value=100) }}
|
||||
{{ stat_panel('Mana Value', value='3.2', sublabel='Average', icon='⚡') }}
|
||||
#}
|
||||
{% macro stat_panel(label, value, sublabel='', icon='', variant='default', classes='') %}
|
||||
{%- set variant_class = 'panel-stat-' + variant if variant != 'default' else '' -%}
|
||||
{%- set all_classes = ['panel', 'panel-stat', variant_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
{% if icon %}
|
||||
<div class="panel-stat-icon">{{ icon|safe }}</div>
|
||||
{% endif %}
|
||||
<div class="panel-stat-content">
|
||||
<div class="panel-stat-value">{{ value }}</div>
|
||||
<div class="panel-stat-label">{{ label }}</div>
|
||||
{% if sublabel %}
|
||||
<div class="panel-stat-sublabel">{{ sublabel }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Collapsible Panel Macro
|
||||
|
||||
Parameters:
|
||||
- title (str): Panel title (required)
|
||||
- id (str): Panel HTML id (auto-generated if not provided)
|
||||
- expanded (bool): Initially expanded (default: False)
|
||||
- variant (str): Panel variant (default: 'default')
|
||||
- padding (str): Panel padding (default: 'md')
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Content Block:
|
||||
- body: Panel content (required)
|
||||
|
||||
Examples:
|
||||
{% call collapsible_panel(title='Advanced Options', expanded=False) %}
|
||||
{% block body %}
|
||||
<p>Advanced settings here</p>
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro collapsible_panel(title, id='', expanded=False, variant='default', padding='md', classes='') %}
|
||||
{%- set panel_id = id if id else 'panel-' + title|lower|replace(' ', '-') -%}
|
||||
{%- set content_id = panel_id + '-content' -%}
|
||||
{%- set variant_class = 'panel-' + variant if variant != 'default' else '' -%}
|
||||
{%- set padding_class = 'panel-padding-' + padding -%}
|
||||
{%- set expanded_class = 'panel-expanded' if expanded else 'panel-collapsed' -%}
|
||||
{%- set all_classes = ['panel', 'panel-collapsible', variant_class, padding_class, expanded_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}" id="{{ panel_id }}">
|
||||
<button type="button"
|
||||
class="panel-toggle"
|
||||
aria-expanded="{{ 'true' if expanded else 'false' }}"
|
||||
aria-controls="{{ content_id }}"
|
||||
onclick="togglePanel('{{ panel_id }}')">
|
||||
<span class="panel-toggle-icon"></span>
|
||||
<span class="panel-title">{{ title }}</span>
|
||||
</button>
|
||||
|
||||
<div class="panel-body panel-collapse-content"
|
||||
id="{{ content_id }}"
|
||||
{% if not expanded %}style="display:none;"{% endif %}>
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Grid Container Macro (for laying out multiple panels)
|
||||
|
||||
Parameters:
|
||||
- columns (int or str): Number of columns (1, 2, 3, 4, 'auto') (default: 'auto')
|
||||
- gap (str): Grid gap (default: '1rem')
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Content Block:
|
||||
- body: Grid items (panels or other content)
|
||||
|
||||
Examples:
|
||||
{% call grid_container(columns=3) %}
|
||||
{% block body %}
|
||||
{{ stat_panel('Stat 1', 100) }}
|
||||
{{ stat_panel('Stat 2', 200) }}
|
||||
{{ stat_panel('Stat 3', 300) }}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
#}
|
||||
{% macro grid_container(columns='auto', gap='1rem', classes='') %}
|
||||
{%- set columns_class = 'panel-grid-cols-' + (columns|string) -%}
|
||||
{%- set all_classes = ['panel-grid', columns_class, classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}" style="gap: {{ gap }};">
|
||||
{{ caller.body() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Empty State Panel Macro
|
||||
|
||||
Parameters:
|
||||
- icon (str): Icon HTML or character
|
||||
- title (str): Empty state title (required)
|
||||
- message (str): Empty state message (required)
|
||||
- action_text (str): Optional action button text
|
||||
- action_href (str): Action button URL
|
||||
- action_onclick (str): Action button onclick handler
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ empty_state_panel(
|
||||
icon='📋',
|
||||
title='No Decks Found',
|
||||
message='You haven\'t created any decks yet. Start building your first deck!',
|
||||
action_text='Build Deck',
|
||||
action_href='/build'
|
||||
) }}
|
||||
#}
|
||||
{% macro empty_state_panel(icon='', title='', message='', action_text='', action_href='', action_onclick='', classes='') %}
|
||||
{%- set all_classes = ['panel', 'panel-empty-state', classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
{% if icon %}
|
||||
<div class="panel-empty-icon">{{ icon|safe }}</div>
|
||||
{% endif %}
|
||||
{% if title %}
|
||||
<h3 class="panel-empty-title">{{ title }}</h3>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
<p class="panel-empty-message">{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if action_text %}
|
||||
<div class="panel-empty-action">
|
||||
{% from '_buttons.html' import button %}
|
||||
{{ button(action_text, href=action_href, onclick=action_onclick, variant='primary') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
Loading Panel Macro
|
||||
|
||||
Parameters:
|
||||
- message (str): Loading message (default: 'Loading...')
|
||||
- spinner (bool): Show spinner animation (default: True)
|
||||
- classes (str): Additional CSS classes
|
||||
|
||||
Examples:
|
||||
{{ loading_panel() }}
|
||||
{{ loading_panel(message='Building deck...', spinner=True) }}
|
||||
#}
|
||||
{% macro loading_panel(message='Loading...', spinner=True, classes='') %}
|
||||
{%- set all_classes = ['panel', 'panel-loading', classes]|select|join(' ') -%}
|
||||
|
||||
<div class="{{ all_classes }}">
|
||||
{% if spinner %}
|
||||
<div class="panel-loading-spinner" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
<div class="panel-loading-message">{{ message }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# CSS Classes Reference #}
|
||||
{#
|
||||
Panel Base:
|
||||
- .panel (base panel class)
|
||||
|
||||
Panel Variants:
|
||||
- .panel-default (default background, var(--panel))
|
||||
- .panel-alt (alternate background, var(--panel-alt))
|
||||
- .panel-dark (dark background, #0f1115)
|
||||
- .panel-bordered (bordered, no background)
|
||||
|
||||
Panel Padding:
|
||||
- .panel-padding-none (no padding)
|
||||
- .panel-padding-sm (0.5rem)
|
||||
- .panel-padding-md (0.75rem, default)
|
||||
- .panel-padding-lg (1.5rem)
|
||||
|
||||
Panel Structure:
|
||||
- .panel-header (header section)
|
||||
- .panel-title (title text, h3)
|
||||
- .panel-body (content section)
|
||||
- .panel-footer (footer section)
|
||||
|
||||
Info Panel:
|
||||
- .panel-info (info panel container)
|
||||
- .panel-info-info (blue theme)
|
||||
- .panel-info-success (green theme)
|
||||
- .panel-info-warning (yellow theme)
|
||||
- .panel-info-error (red theme)
|
||||
- .panel-info-content (content wrapper)
|
||||
- .panel-info-icon (icon container)
|
||||
- .panel-info-text (text wrapper)
|
||||
- .panel-info-title (info title, h4)
|
||||
- .panel-info-message (info message)
|
||||
- .panel-info-action (action button wrapper)
|
||||
|
||||
Stat Panel:
|
||||
- .panel-stat (stat panel container)
|
||||
- .panel-stat-default, .panel-stat-primary, etc. (color variants)
|
||||
- .panel-stat-icon (stat icon)
|
||||
- .panel-stat-content (stat content wrapper)
|
||||
- .panel-stat-value (stat value, large)
|
||||
- .panel-stat-label (stat label)
|
||||
- .panel-stat-sublabel (optional secondary label)
|
||||
|
||||
Collapsible Panel:
|
||||
- .panel-collapsible (collapsible panel)
|
||||
- .panel-expanded (expanded state)
|
||||
- .panel-collapsed (collapsed state)
|
||||
- .panel-toggle (toggle button)
|
||||
- .panel-toggle-icon (chevron/arrow icon)
|
||||
- .panel-collapse-content (collapsible content)
|
||||
|
||||
Panel Grid:
|
||||
- .panel-grid (grid container)
|
||||
- .panel-grid-cols-auto (auto columns)
|
||||
- .panel-grid-cols-1, .panel-grid-cols-2, etc. (fixed columns)
|
||||
|
||||
Empty State:
|
||||
- .panel-empty-state (empty state container)
|
||||
- .panel-empty-icon (empty state icon)
|
||||
- .panel-empty-title (empty state title, h3)
|
||||
- .panel-empty-message (empty state message, p)
|
||||
- .panel-empty-action (action button wrapper)
|
||||
|
||||
Loading Panel:
|
||||
- .panel-loading (loading panel)
|
||||
- .panel-loading-spinner (spinner animation)
|
||||
- .panel-loading-message (loading message text)
|
||||
#}
|
||||
|
||||
{# JavaScript Helper Functions #}
|
||||
{#
|
||||
These functions should be included in a global JavaScript file or inline script:
|
||||
|
||||
// Toggle collapsible panel
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const button = panel.querySelector('.panel-toggle');
|
||||
const content = panel.querySelector('.panel-collapse-content');
|
||||
const isExpanded = button.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
// Toggle state
|
||||
button.setAttribute('aria-expanded', !isExpanded);
|
||||
content.style.display = isExpanded ? 'none' : 'block';
|
||||
panel.classList.toggle('panel-expanded');
|
||||
panel.classList.toggle('panel-collapsed');
|
||||
}
|
||||
#}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<div id="deck-summary" data-summary>
|
||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<hr class="summary-divider" />
|
||||
<h4>Deck Summary</h4>
|
||||
<section style="margin-top:.5rem;">
|
||||
<section class="summary-section">
|
||||
<h5>Card Types</h5>
|
||||
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||
<div class="summary-view-controls">
|
||||
<span class="muted">View:</span>
|
||||
<div class="seg" role="tablist" aria-label="Type view">
|
||||
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
<div class="summary-type-heading">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
|
|
@ -85,13 +85,13 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
<div class="muted mb-3">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="typeview-thumbs" class="typeview hidden">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
<div class="summary-type-heading">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
|
|
@ -111,8 +111,8 @@
|
|||
{% endfor %}
|
||||
{% endif %}
|
||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
<img 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|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
||||
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"
|
||||
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
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>
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
<div class="muted mb-3">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -138,40 +138,40 @@
|
|||
<!-- Land Summary -->
|
||||
{% set land = summary.land_summary if summary else None %}
|
||||
{% if land %}
|
||||
<section style="margin-top:1rem;">
|
||||
<section class="summary-section-lg">
|
||||
<h5>Land Summary</h5>
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
|
||||
<div class="muted summary-type-heading mb-1">
|
||||
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
|
||||
<div class="deck-metrics-wrap">
|
||||
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
|
||||
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
|
||||
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
|
||||
</div>
|
||||
{% if land.dfc_cards %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<details class="mt-2">
|
||||
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
<ul class="land-breakdown-list">
|
||||
{% for card in land.dfc_cards %}
|
||||
{% set extra = card.adds_extra_land or card.counts_as_extra %}
|
||||
{% set colors = card.colors or [] %}
|
||||
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
|
||||
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
|
||||
<li class="muted land-breakdown-item">
|
||||
<span class="chip"><span class="dot dot-green"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
|
||||
<span>Colors: {{ colors|join(', ') if colors else '–' }}</span>
|
||||
{% if extra %}
|
||||
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
|
||||
<span class="chip land-note-chip-expand">{{ card.note or 'Adds extra land slot' }}</span>
|
||||
{% else %}
|
||||
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
|
||||
<span class="chip land-note-chip-counts">{{ card.note or 'Counts as land slot' }}</span>
|
||||
{% endif %}
|
||||
{% if card.faces %}
|
||||
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
|
||||
<ul class="land-breakdown-subs">
|
||||
{% for face in card.faces %}
|
||||
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
|
||||
{% set face_type = face.get('type') or '–' %}
|
||||
{% set mana_cost = face.get('mana_cost') %}
|
||||
{% set mana_value = face.get('mana_value') %}
|
||||
{% set produces = face.get('produces_mana') %}
|
||||
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
|
||||
<li class="land-breakdown-sub">
|
||||
<span>{{ face_name }}</span>
|
||||
<span>— {{ face_type }}</span>
|
||||
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
|
||||
|
|
@ -190,27 +190,27 @@
|
|||
{% endif %}
|
||||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<section class="summary-section-lg">
|
||||
<details class="analytics-accordion" id="mana-overview-accordion" data-lazy-load data-analytics-type="mana">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<summary class="combo-summary">
|
||||
<span>Mana Overview</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(pips • sources • curve)</span>
|
||||
<span class="muted text-xs font-normal ml-2">(pips • sources • curve)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .5rem 0;">Mana Overview</h5>
|
||||
<div class="analytics-content mt-3">
|
||||
<h5 class="mt-0 mb-2">Mana Overview</h5>
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||
<div class="mana-row">
|
||||
<!-- Pips Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
|
||||
<div class="mana-panel">
|
||||
<div class="muted mana-panel-heading">Mana Pips (non-lands)</div>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
<div class="chart-bars">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
<div class="chart-column">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pc = pd['cards'] if 'cards' in pd else None %}
|
||||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
|
|
@ -224,14 +224,14 @@
|
|||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" style="cursor:pointer;" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%" class="chart-svg" data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<div class="muted mt-1">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -241,10 +241,10 @@
|
|||
</div>
|
||||
|
||||
<!-- Sources Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
|
||||
<div class="muted" style="font-weight:600;">Mana Sources</div>
|
||||
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
|
||||
<div class="mana-panel">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<div class="muted mana-panel-heading">Mana Sources</div>
|
||||
<label class="muted text-xs form-label-icon">
|
||||
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
<div class="sources-bars chart-bars">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
|
|
@ -273,31 +273,31 @@
|
|||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
<div style="text-align:center;" class="chart-column" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" style="cursor:pointer;" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<div class="chart-column" data-color="{{ color }}">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}" class="chart-svg" data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<div class="muted mt-1">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
<div class="muted mt-1">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Curve Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
|
||||
<div class="mana-panel">
|
||||
<div class="muted mana-panel-heading">Mana Curve (non-lands)</div>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
<div class="chart-bars">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
|
|
@ -308,18 +308,18 @@
|
|||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<div style="text-align:center;" class="chart-column">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" style="cursor:pointer;" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<div class="chart-column">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}" class="chart-svg" data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}">
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4" pointer-events="all"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4" pointer-events="all"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
<div class="muted mt-1">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
<div class="muted mt-1">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
|
|
@ -330,17 +330,17 @@
|
|||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<section class="summary-section-lg">
|
||||
<details class="analytics-accordion" id="test-hand-accordion" data-lazy-load data-analytics-type="testhand">
|
||||
<summary style="cursor:pointer; user-select:none; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#12161c; font-weight:600;">
|
||||
<summary class="combo-summary">
|
||||
<span>Test Hand</span>
|
||||
<span class="muted" style="font-size:12px; font-weight:400; margin-left:.5rem;">(draw 7 random cards)</span>
|
||||
<span class="muted text-xs font-normal ml-2">(draw 7 random cards)</span>
|
||||
</summary>
|
||||
<div class="analytics-content" style="margin-top:.75rem;">
|
||||
<h5 style="margin:0 0 .35rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">Test Hand
|
||||
<span class="muted" style="font-size:12px; font-weight:400;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
<div class="analytics-content mt-3">
|
||||
<h5 class="flex items-center gap-3 flex-wrap mt-0 mb-1">Test Hand
|
||||
<span class="muted text-xs font-normal">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
</h5>
|
||||
<div style="display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2">
|
||||
<button type="button" id="btn-new-hand">New Hand</button>
|
||||
</div>
|
||||
<div class="stack-wrap hand-row-overlap" id="test-hand">
|
||||
|
|
@ -440,7 +440,7 @@
|
|||
if (mid >= 2 && Math.abs(diff - (mid - 1)) < 0.001) { y += 2; }
|
||||
div.style.setProperty('--ty', y + 'px');
|
||||
div.innerHTML = (
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
||||
'<img src="/api/images/normal/' + encodeURIComponent(name) + '" alt="' + name + '" data-card-name="' + name + '" />'
|
||||
);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@
|
|||
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
|
||||
<div class="commander-thumb" style="flex:0 0 auto;">
|
||||
<img
|
||||
src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small"
|
||||
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"
|
||||
src="{{ commander|card_image('small') }}"
|
||||
srcset="{{ commander|card_image('small') }} 160w, {{ commander|card_image('normal') }} 488w"
|
||||
sizes="(max-width: 600px) 120px, 160px"
|
||||
alt="{{ commander }} image"
|
||||
width="160" height="220"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,51 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'partials/_buttons.html' import button %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Setup page-specific styling */
|
||||
#content details > summary {
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
#content details > summary:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
#content details > div {
|
||||
background: #050607 !important;
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
#content .muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
/* Make all buttons on this page blue */
|
||||
#content button,
|
||||
#content input[type="submit"] {
|
||||
background: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
color: white !important;
|
||||
}
|
||||
#content button:hover,
|
||||
#content input[type="submit"]:hover {
|
||||
background: #2563eb !important;
|
||||
border-color: #2563eb !important;
|
||||
}
|
||||
#content button:active,
|
||||
#content input[type="submit"]:active {
|
||||
background: #1d4ed8 !important;
|
||||
}
|
||||
/* Progress bars */
|
||||
#content [id$="-progress-bar"] {
|
||||
background: #0a0c10 !important;
|
||||
}
|
||||
/* Log output areas */
|
||||
#content pre {
|
||||
background: #030405 !important;
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<section>
|
||||
<h2>Setup / Tagging</h2>
|
||||
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
|
||||
|
|
@ -29,22 +75,42 @@
|
|||
Download pre-tagged card database and similarity cache from GitHub (updated weekly).
|
||||
<strong>Note:</strong> A fresh local tagging run will be most up-to-date with the latest card data.
|
||||
</p>
|
||||
<button type="button" class="action-btn" onclick="downloadFromGitHub()" id="btn-download-github">
|
||||
Download from GitHub
|
||||
</button>
|
||||
{{ button('Download from GitHub', variant='priamry', onclick='downloadFromGitHub()', attrs='id="btn-download-github"') }}
|
||||
<div id="download-status" class="muted" style="margin-top:.5rem; display:none;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if image_cache_enabled %}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary>Download Card Images (Optional)</summary>
|
||||
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
|
||||
<p class="muted" style="margin:0 0 .75rem 0; font-size:.9rem;">
|
||||
Download card images from Scryfall CDN for faster loading and offline use.
|
||||
<strong>Note:</strong> Requires ~3-6 GB disk space and 1-2 hours download time (~30k cards).
|
||||
</p>
|
||||
<div id="image-cache-status" style="margin-bottom:.75rem;">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="image-status-line" style="margin-top:.25rem;">Checking…</div>
|
||||
<div class="muted" id="image-stats-line" style="margin-top:.25rem; display:none;"></div>
|
||||
</div>
|
||||
{{ button('Download Card Images', variant='priamry', onclick='downloadCardImages()', attrs='id="btn-download-images"') }}
|
||||
<div id="image-download-status" class="muted" style="margin-top:.5rem; display:none;"></div>
|
||||
<div id="image-progress-bar" style="margin-top:.5rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||
<div id="image-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
|
||||
<button type="submit" id="btn-start-setup" class="action-btn">Run Setup/Tagging</button>
|
||||
{{ button('Run Setup/Tagging', variant='primary', type='submit', attrs='id="btn-start-setup"') }}
|
||||
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
|
||||
<input type="checkbox" id="chk-force" checked /> Force run
|
||||
</label>
|
||||
</form>
|
||||
<form method="get" action="/setup/running?start=1&force=1">
|
||||
<button type="submit" class="action-btn">Open Progress Page</button>
|
||||
{{ button('Open Progress Page', variant='primary', type='submit') }}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -58,7 +124,7 @@
|
|||
</div>
|
||||
</details>
|
||||
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button>
|
||||
{{ button('Refresh Themes Only', variant='priamry', onclick='refreshThemes()', attrs='id="btn-refresh-themes"') }}
|
||||
</div>
|
||||
|
||||
{% if similarity_enabled %}
|
||||
|
|
@ -72,7 +138,7 @@
|
|||
</div>
|
||||
</details>
|
||||
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<button type="button" id="btn-build-similarity" class="action-btn" onclick="buildSimilarityCache()">Build Similarity Cache</button>
|
||||
{{ button('Build Similarity Cache', variant='priamry', onclick='buildSimilarityCache()', attrs='id="btn-build-similarity"') }}
|
||||
<label class="muted" style="align-self:center; font-size:.85rem;">
|
||||
<input type="checkbox" id="chk-skip-download" /> Skip GitHub download (build locally)
|
||||
</label>
|
||||
|
|
@ -85,7 +151,7 @@
|
|||
// Minimal styling helper to unify button widths
|
||||
try {
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.action-btn{min-width:180px;}';
|
||||
style.textContent = '.btn{min-width:180px;}';
|
||||
document.head.appendChild(style);
|
||||
} catch(e){}
|
||||
function update(data){
|
||||
|
|
@ -219,6 +285,137 @@
|
|||
.then(function(){ setTimeout(function(){ pollThemes(); if(btn) btn.disabled=false; }, 1200); })
|
||||
.catch(function(){ if(btn) btn.disabled=false; });
|
||||
};
|
||||
|
||||
// Card image download functions
|
||||
function pollImageStatus(){
|
||||
fetch('/api/images/status', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
var statusLine = document.getElementById('image-status-line');
|
||||
var statsLine = document.getElementById('image-stats-line');
|
||||
var downloadStatus = document.getElementById('image-download-status');
|
||||
var progressBar = document.getElementById('image-progress-bar');
|
||||
var progressBarInner = document.getElementById('image-progress-bar-inner');
|
||||
|
||||
if (!statusLine) return;
|
||||
|
||||
if (data.running) {
|
||||
// Download in progress
|
||||
var phase = data.phase || 'unknown';
|
||||
var message = data.message || 'Downloading...';
|
||||
var percentage = data.percentage || 0;
|
||||
|
||||
statusLine.textContent = 'Download in progress';
|
||||
statusLine.style.color = '#3b82f6';
|
||||
|
||||
if (downloadStatus) {
|
||||
downloadStatus.style.display = '';
|
||||
downloadStatus.textContent = message + ' (' + percentage + '%)';
|
||||
}
|
||||
|
||||
if (progressBar && progressBarInner) {
|
||||
progressBar.style.display = '';
|
||||
progressBarInner.style.width = percentage + '%';
|
||||
}
|
||||
} else if (data.stats) {
|
||||
// Show cache statistics
|
||||
var stats = data.stats;
|
||||
if (stats.enabled === false) {
|
||||
statusLine.textContent = 'Image caching disabled';
|
||||
statusLine.style.color = '#94a3b8';
|
||||
if (statsLine) statsLine.style.display = 'none';
|
||||
} else {
|
||||
var totalCount = 0;
|
||||
var totalSizeMB = 0;
|
||||
|
||||
if (stats.small) {
|
||||
totalCount += stats.small.count || 0;
|
||||
totalSizeMB += stats.small.size_mb || 0;
|
||||
}
|
||||
if (stats.normal) {
|
||||
totalCount += stats.normal.count || 0;
|
||||
totalSizeMB += stats.normal.size_mb || 0;
|
||||
}
|
||||
|
||||
if (totalCount > 0) {
|
||||
statusLine.textContent = 'Cache exists';
|
||||
statusLine.style.color = '#34d399';
|
||||
if (statsLine) {
|
||||
statsLine.style.display = '';
|
||||
statsLine.textContent = totalCount.toLocaleString() + ' images cached • ' + totalSizeMB.toFixed(1) + ' MB';
|
||||
}
|
||||
} else {
|
||||
statusLine.textContent = 'No images cached';
|
||||
statusLine.style.color = '#94a3b8';
|
||||
if (statsLine) statsLine.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hide download progress
|
||||
if (downloadStatus) downloadStatus.style.display = 'none';
|
||||
if (progressBar) progressBar.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function(){
|
||||
var statusLine = document.getElementById('image-status-line');
|
||||
if (statusLine) {
|
||||
statusLine.textContent = 'Status unavailable';
|
||||
statusLine.style.color = '#94a3b8';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.downloadCardImages = function(){
|
||||
var btn = document.getElementById('btn-download-images');
|
||||
var statusEl = document.getElementById('image-download-status');
|
||||
|
||||
if (!confirm('Download card images from Scryfall? This will download ~30k images (~3-6 GB) and may take 1-2 hours.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) {
|
||||
statusEl.style.display = '';
|
||||
statusEl.textContent = 'Starting download...';
|
||||
}
|
||||
|
||||
fetch('/api/images/download', { method: 'POST' })
|
||||
.then(function(r){
|
||||
if (!r.ok) {
|
||||
return r.json().then(function(data){
|
||||
throw new Error(data.message || 'Download failed');
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data){
|
||||
if (statusEl) {
|
||||
statusEl.style.color = '#34d399';
|
||||
statusEl.textContent = '✓ ' + (data.message || 'Download started');
|
||||
}
|
||||
|
||||
// Poll status every 2 seconds while downloading
|
||||
var pollCount = 0;
|
||||
var imagePoll = setInterval(function(){
|
||||
pollImageStatus();
|
||||
pollCount++;
|
||||
// Stop intensive polling after 2 hours (3600 polls)
|
||||
if (pollCount > 3600) clearInterval(imagePoll);
|
||||
}, 2000);
|
||||
|
||||
setTimeout(function(){
|
||||
if (btn) btn.disabled = false;
|
||||
}, 3000);
|
||||
})
|
||||
.catch(function(err){
|
||||
if (statusEl) {
|
||||
statusEl.style.color = '#f87171';
|
||||
statusEl.textContent = '✗ ' + (err.message || 'Download failed');
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
};
|
||||
|
||||
function rapidPoll(times, delay){
|
||||
var i = 0;
|
||||
function tick(){
|
||||
|
|
@ -395,6 +592,10 @@
|
|||
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
|
||||
{% endif %}
|
||||
|
||||
// Initialize image status polling
|
||||
pollImageStatus();
|
||||
setInterval(pollImageStatus, 10000); // Poll every 10s
|
||||
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
pollThemes();
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
{% block content %}
|
||||
<h2>Theme Catalog (Simple)</h2>
|
||||
<div id="theme-catalog-simple">
|
||||
<div style="display:flex; gap:.75rem; flex-wrap:wrap; margin-bottom:.85rem; align-items:flex-end;">
|
||||
<div style="flex:1; min-width:220px; position:relative;">
|
||||
<label style="font-size:11px; display:block; opacity:.7;">Search</label>
|
||||
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" style="width:100%;" autocomplete="off" />
|
||||
<div id="theme-search-results" class="search-suggestions" style="position:absolute; top:100%; left:0; right:0; background:var(--panel); border:1px solid var(--border); border-top:none; z-index:25; display:none; max-height:300px; overflow:auto; border-radius:0 0 8px 8px;"></div>
|
||||
<div class="flex gap-3 flex-wrap mb-3.5 items-end">
|
||||
<div class="flex-1 min-w-[220px] relative">
|
||||
<label class="text-[11px] block opacity-70">Search</label>
|
||||
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" class="w-full" autocomplete="off" />
|
||||
<div id="theme-search-results" class="search-suggestions"></div>
|
||||
</div>
|
||||
<div style="min-width:160px;">
|
||||
<label style="font-size:11px; display:block; opacity:.7;">Popularity</label>
|
||||
<select id="pop-filter" style="width:100%; font-size:13px;">
|
||||
<div class="min-w-[160px]">
|
||||
<label class="text-[11px] block opacity-70">Popularity</label>
|
||||
<select id="pop-filter" class="w-full text-[13px]">
|
||||
<option value="">All</option>
|
||||
<option>Very Common</option>
|
||||
<option>Common</option>
|
||||
|
|
@ -19,26 +19,20 @@
|
|||
<option>Rare</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
|
||||
<button id="clear-search" class="btn btn-ghost text-xs" hidden>Clear</button>
|
||||
</div>
|
||||
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
|
||||
<div id="quick-popularity" class="flex gap-1.5 flex-wrap mb-2">
|
||||
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %}
|
||||
<button class="btn btn-ghost pop-chip" data-pop="{{ b }}" style="font-size:11px; padding:2px 8px;">{{ b }}</button>
|
||||
<button class="btn btn-ghost pop-chip text-[11px] px-2 py-0.5" data-pop="{{ b }}">{{ b }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="active-filters" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:.55rem; font-size:11px;"></div>
|
||||
<div id="active-filters" class="flex gap-1.5 flex-wrap mb-2 text-[11px]"></div>
|
||||
<div id="theme-results" aria-live="polite" aria-busy="true">
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
{% for i in range(6) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for i in range(6) %}<div class="skeleton-card"></div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); transition:background .15s ease; }
|
||||
.search-suggestions a:last-child { border-bottom:none; }
|
||||
.search-suggestions a:hover, .search-suggestions a.selected { background:var(--hover); }
|
||||
.search-suggestions a.selected { border-left:3px solid var(--ring); padding-left:calc(.6rem - 3px); }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
const input = document.getElementById('theme-search');
|
||||
|
|
@ -60,8 +54,8 @@
|
|||
}
|
||||
function addChip(label, remover){
|
||||
const span=document.createElement('span');
|
||||
span.style.cssText='background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; display:inline-flex; align-items:center; gap:6px;';
|
||||
span.innerHTML='<span>'+label+'</span><button style="background:none; border:none; cursor:pointer; font-size:12px;" aria-label="Remove">×</button>';
|
||||
span.className='filter-chip';
|
||||
span.innerHTML='<span>'+label+'</span><button class="filter-chip-remove" aria-label="Remove">×</button>';
|
||||
span.querySelector('button').addEventListener('click', remover);
|
||||
activeFilters.appendChild(span);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{% if theme %}
|
||||
<div class="theme-detail-card">
|
||||
{% if standalone_page %}
|
||||
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost" style="font-size:11px; padding:2px 6px;">← Catalog</a></div>
|
||||
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost text-[11px] px-1.5 py-0.5">← Catalog</a></div>
|
||||
{% endif %}
|
||||
<h3 id="theme-detail-heading-{{ theme.id }}" tabindex="-1">{{ theme.theme }}
|
||||
{% if diagnostics and yaml_available %}
|
||||
<a href="/themes/yaml/{{ theme.id }}" target="_blank" style="font-size:11px; font-weight:400; margin-left:.5rem;">(YAML)</a>
|
||||
<a href="/themes/yaml/{{ theme.id }}" target="_blank" class="text-[11px] font-normal ml-2">(YAML)</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if theme.description %}
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<p class="desc" data-fallback-desc="1">No description.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div style="font-size:12px; margin-bottom:.5rem; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<div class="text-xs mb-2 flex gap-2 flex-wrap">
|
||||
{% if theme.popularity_bucket %}<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ theme.popularity_bucket }}" aria-label="Popularity bucket: {{ theme.popularity_bucket }}">{{ theme.popularity_bucket }}</span>{% endif %}
|
||||
{% if diagnostics and theme.editorial_quality %}<span class="theme-badge badge-quality-{{ theme.editorial_quality }}" title="Editorial quality: {{ theme.editorial_quality }}" aria-label="Editorial quality: {{ theme.editorial_quality }}">{{ theme.editorial_quality }}</span>{% endif %}
|
||||
{% if diagnostics and theme.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %}
|
||||
|
|
@ -29,44 +29,44 @@
|
|||
</div>
|
||||
{% if diagnostics %}
|
||||
{% if not uncapped and theme.uncapped_synergies %}
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" class="mt-2">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
|
||||
{% elif uncapped %}
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Hide Uncapped</button>
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" class="mt-2">Hide Uncapped</button>
|
||||
{% if theme.uncapped_synergies %}
|
||||
<div class="theme-synergies" style="margin-top:.4rem;">
|
||||
<div class="theme-synergies mt-1.5">
|
||||
{% for s in theme.uncapped_synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="examples" style="margin-top:.75rem;">
|
||||
<h4 style="margin-bottom:.4rem;">Example Cards</h4>
|
||||
<div class="example-card-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
<div class="examples mt-3">
|
||||
<h4 class="mb-1.5">Example Cards</h4>
|
||||
<div class="example-card-grid grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3.5">
|
||||
{% if theme.example_cards %}
|
||||
{% for c in theme.example_cards %}
|
||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
<div class="ex-card card-sample text-center" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
||||
<div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="font-size:12px; opacity:.7;">No curated example cards.</div>
|
||||
<div class="text-xs opacity-70">No curated example cards.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4 style="margin:.9rem 0 .4rem;">Example Commanders</h4>
|
||||
<div class="example-commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
<h4 class="my-3.5 mb-1.5">Example Commanders</h4>
|
||||
<div class="example-commander-grid grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3.5">
|
||||
{% if theme.example_commanders %}
|
||||
{% for c in theme.example_commanders %}
|
||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
<div class="ex-commander commander-cell text-center" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
||||
<div class="text-[11px] mt-1 font-semibold whitespace-nowrap overflow-hidden text-ellipsis card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="font-size:12px; opacity:.7;">No curated commander examples.</div>
|
||||
<div class="text-xs opacity-70">No curated commander examples.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -74,10 +74,6 @@
|
|||
{% else %}
|
||||
<div class="empty">Theme not found.</div>
|
||||
{% endif %}
|
||||
<style>
|
||||
.card-ref { cursor:pointer; text-decoration:underline dotted; }
|
||||
.card-ref:hover { color:var(--accent); }
|
||||
</style>
|
||||
<script>
|
||||
// Accessibility: automatically move focus to the detail heading after the fragment is swapped in
|
||||
(function(){
|
||||
|
|
@ -97,7 +93,7 @@
|
|||
// Replace fuzzy param only if it still contains the annotated portion
|
||||
var before = decodeURIComponent((current.split('fuzzy=')[1]||'').split('&')[0] || '');
|
||||
if(before && before !== base){
|
||||
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
|
||||
img.src = '/api/images/small/' + encodeURIComponent(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,46 @@
|
|||
{% if items %}
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.5rem; font-size:12px;">
|
||||
<div class="flex justify-between items-center mb-2 text-xs">
|
||||
<div>Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}</div>
|
||||
<div style="display:flex; gap:.4rem;">
|
||||
<div class="flex gap-2">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">« Prev</button>{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">Next »</button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="theme-simple-list" style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.65rem;">
|
||||
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
|
||||
{% for it in items %}
|
||||
<li style="padding:.6rem .75rem; border:1px solid var(--border); border-radius:8px; background:var(--panel-alt);">
|
||||
<a href="/themes/{{ it.id }}" style="font-weight:600; font-size:14px; text-decoration:none; color:var(--text);">{{ it.theme }}</a>
|
||||
{% if it.short_description %}<div style="font-size:12px; opacity:.85; margin-top:2px;">{{ it.short_description }}</div>{% endif %}
|
||||
<li class="theme-list-card">
|
||||
<a href="/themes/{{ it.id }}" class="font-semibold text-sm no-underline text-[var(--text)]">{{ it.theme }}</a>
|
||||
{% if it.short_description %}<div class="text-xs opacity-85 mt-0.5">{{ it.short_description }}</div>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.75rem; font-size:12px;">
|
||||
<div class="flex justify-between items-center mt-3 text-xs">
|
||||
<div>Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}</div>
|
||||
<div style="display:flex; gap:.4rem;">
|
||||
<div class="flex gap-2">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">« Prev</button>{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost text-[11px] px-2 py-0.5">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost opacity-30 text-[11px] px-2 py-0.5">Next »</button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if total == 0 %}
|
||||
<div class="empty" style="font-size:13px;">No themes found.</div>
|
||||
<div class="empty text-xs">No themes found.</div>
|
||||
{% else %}
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
{% for i in range(8) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
|
||||
<div class="flex flex-col gap-2">
|
||||
{% for i in range(8) %}<div class="h-12 rounded-lg skeleton-shimmer"></div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<style>
|
||||
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
|
||||
.theme-simple-list li:hover { background:var(--hover); }
|
||||
@keyframes shimmer {0%{background-position:0 0;}100%{background-position:-200% 0;}}
|
||||
.skeleton-shimmer { background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation:shimmer 1.2s ease-in-out infinite; }
|
||||
.theme-list-card { background:var(--panel); padding:0.6rem 0.75rem; border:1px solid var(--border); border-radius:8px; box-shadow:0 1px 3px rgba(0,0,0,0.2); transition:background-color 0.15s ease; }
|
||||
.theme-list-card:hover { background:var(--hover); }
|
||||
</style>
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
{% if preview %}
|
||||
<div class="preview-modal-content theme-preview-expanded{% if minimal %} minimal-variant{% endif %}">
|
||||
{% if not minimal %}
|
||||
<div class="preview-header" style="display:flex; justify-content:space-between; align-items:center; gap:1rem;">
|
||||
<h3 style="margin:0; font-size:16px;" data-preview-heading>{{ preview.theme }}</h3>
|
||||
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost" style="font-size:12px; line-height:1;">Close ✕</button>
|
||||
<div class="preview-header">
|
||||
<h3 data-preview-heading>{{ preview.theme }}</h3>
|
||||
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost">Close ✕</button>
|
||||
</div>
|
||||
{% if preview.stub %}<div class="note note-stub">Stub sample (placeholder logic)</div>{% endif %}
|
||||
<div class="preview-controls" style="display:flex; gap:1rem; align-items:center; margin:.5rem 0 .75rem; font-size:11px;">
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="reasons-toggle" checked/> Reasons <span style="opacity:.55; font-size:10px; cursor:help;" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="show-duplicates-toggle"/> Show Collapsed Duplicates</label>
|
||||
<span id="preview-status" aria-live="polite" style="opacity:.65;"></span>
|
||||
<div class="preview-controls">
|
||||
<label><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
|
||||
<label><input type="checkbox" id="reasons-toggle" checked/> Reasons <span class="help-icon" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
|
||||
<label><input type="checkbox" id="show-duplicates-toggle"/> Show Collapsed Duplicates</label>
|
||||
<span id="preview-status" aria-live="polite"></span>
|
||||
</div>
|
||||
<details id="preview-rationale" class="preview-rationale" style="margin:.25rem 0 .85rem; font-size:11px; background:var(--panel-alt); border:1px solid var(--border); padding:.55rem .7rem; border-radius:8px;">
|
||||
<summary style="cursor:pointer; font-weight:600; letter-spacing:.05em;">Commander Overlap & Diversity Rationale</summary>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-top:.4rem;">
|
||||
<button type="button" class="btn btn-ghost" style="font-size:10px; padding:4px 8px;" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
|
||||
<span id="hover-compact-indicator" style="font-size:10px; opacity:.7;">Mode: <span data-mode>normal</span></span>
|
||||
<details id="preview-rationale" class="preview-rationale">
|
||||
<summary>Commander Overlap & Diversity Rationale</summary>
|
||||
<div class="preview-rationale-controls">
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
|
||||
<span id="hover-compact-indicator">Mode: <span data-mode>normal</span></span>
|
||||
</div>
|
||||
<ul id="rationale-points" style="margin:.5rem 0 0 .9rem; padding:0; list-style:disc; line-height:1.35;">
|
||||
<ul id="rationale-points">
|
||||
{% if preview.commander_rationale and preview.commander_rationale|length > 0 %}
|
||||
{% for r in preview.commander_rationale %}
|
||||
<li>
|
||||
<strong>{{ r.label }}</strong>: {{ r.value }}
|
||||
{% if r.detail %}<span style="opacity:.75;">({{ r.detail|join(', ') }})</span>{% endif %}
|
||||
{% if r.instances %}<span style="opacity:.65;"> ({{ r.instances }} instances)</span>{% endif %}
|
||||
{% if r.detail %}<span class="detail">({{ r.detail|join(', ') }})</span>{% endif %}
|
||||
{% if r.instances %}<span class="instances"> ({{ r.instances }} instances)</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
|
@ -33,55 +33,55 @@
|
|||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="two-col" style="display:grid; grid-template-columns: 1fr 480px; gap:1.25rem; align-items:start; position:relative;" role="group" aria-label="Theme preview cards and commanders">
|
||||
<div class="col-divider" style="position:absolute; top:0; bottom:0; left:calc(100% - 480px - .75rem); width:1px; background:var(--border); opacity:.55;"></div>
|
||||
<div class="preview-two-col" role="group" aria-label="Theme preview cards and commanders">
|
||||
<div class="preview-col-divider"></div>
|
||||
<div class="col-left">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Cards</h4>{% else %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Sampled Synergy Cards</h4>{% endif %}{% endif %}
|
||||
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
|
||||
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}" data-pin-scope="{{ preview.theme_id }}">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 class="preview-section-header">Example Cards</h4>{% else %}<h4 class="preview-section-header">Sampled Synergy Cards</h4>{% endif %}{% endif %}
|
||||
<hr class="preview-section-hr" />
|
||||
<div class="cards-flow" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}" data-pin-scope="{{ preview.theme_id }}">
|
||||
{% set inserted = {'examples': False, 'curated_synergy': False, 'payoff': False, 'enabler_support': False, 'wildcard': False} %}
|
||||
{% for c in preview.sample if (not suppress_curated and ('example' in c.roles or 'curated_synergy' in c.roles)) or 'payoff' in c.roles or 'enabler' in c.roles or 'support' in c.roles or 'wildcard' in c.roles %}
|
||||
{% if c.dup_collapsed %}{% set dup_class = ' is-collapsed-duplicate' %}{% else %}{% set dup_class = '' %}{% endif %}
|
||||
{% set primary = c.roles[0] if c.roles else '' %}
|
||||
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.25rem;">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
|
||||
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator" data-group="curated_synergy" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
|
||||
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator" data-group="payoff" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
|
||||
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator" data-group="enabler_support" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
|
||||
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator" data-group="wildcard" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
|
||||
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
|
||||
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator mt-larger" data-group="curated_synergy">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
|
||||
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator mt-larger" data-group="payoff">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
|
||||
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator mt-larger" data-group="enabler_support">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
|
||||
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator mt-larger" data-group="wildcard">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
|
||||
{% set overlaps = [] %}
|
||||
{% if preview.synergies_used and c.tags %}
|
||||
{% for tg in c.tags %}{% if tg in preview.synergies_used %}{% set _ = overlaps.append(tg) %}{% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
<div class="card-sample{{ dup_class }}{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}" {% if c.dup_group_size %}data-dup-group-size="{{ c.dup_group_size }}"{% endif %} {% if c.dup_anchor %}data-dup-anchor="1"{% endif %} {% if c.dup_collapsed %}data-dup-collapsed="1" data-dup-anchor-name="{{ c.dup_anchor_name }}"{% endif %}>
|
||||
<div class="thumb-wrap" style="position:relative;">
|
||||
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<div class="card-sample{{ dup_class }}{% if overlaps %} has-overlap{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}" {% if c.dup_group_size %}data-dup-group-size="{{ c.dup_group_size }}"{% endif %} {% if c.dup_anchor %}data-dup-anchor="1"{% endif %} {% if c.dup_collapsed %}data-dup-collapsed="1" data-dup-anchor-name="{{ c.dup_anchor_name }}"{% endif %}>
|
||||
<div class="thumb-wrap">
|
||||
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="{{ c.name|card_image('small') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
|
||||
<span class="role-chip role-{{ c.roles[0] if c.roles }}" title="Primary role: {{ c.roles[0] if c.roles }}">{{ c.roles[0][0]|upper if c.roles }}</span>
|
||||
{% if overlaps %}<span class="overlap-badge" title="Synergy overlaps: {{ overlaps|join(', ') }}">{{ overlaps|length }}</span>{% endif %}
|
||||
{% if c.dup_anchor and c.dup_group_size and c.dup_group_size > 1 %}<span class="dup-badge" title="{{ c.dup_group_size - 1 }} similar cards collapsed" style="position:absolute; bottom:4px; right:4px; background:#4b5563; color:#fff; font-size:10px; padding:2px 5px; border-radius:10px;">+{{ c.dup_group_size - 1 }}</span>{% endif %}
|
||||
<button type="button" class="pin-btn" aria-label="Pin card" title="Pin card" data-pin-btn style="position:absolute; top:4px; right:4px; background:rgba(0,0,0,0.55); color:#fff; border:1px solid var(--border); border-radius:6px; font-size:10px; padding:2px 5px; cursor:pointer;">☆</button>
|
||||
{% if c.dup_anchor and c.dup_group_size and c.dup_group_size > 1 %}<span class="dup-badge" title="{{ c.dup_group_size - 1 }} similar cards collapsed">+{{ c.dup_group_size - 1 }}</span>{% endif %}
|
||||
<button type="button" class="pin-btn" aria-label="Pin card" title="Pin card" data-pin-btn>☆</button>
|
||||
</div>
|
||||
<div class="meta" style="font-size:12px; margin-top:2px;">
|
||||
<div class="ci-ribbon" aria-label="Color identity" style="display:flex; gap:2px; margin-bottom:2px; min-height:10px;"></div>
|
||||
<div class="nm" style="font-weight:600; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ c.name }}">{{ c.name }}</div>
|
||||
<div class="mana-line" aria-label="Mana Cost" style="min-height:14px; display:flex; flex-wrap:wrap; gap:2px; font-size:10px;"></div>
|
||||
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}" style="font-size:9px; letter-spacing:.5px; text-transform:uppercase; opacity:.7;">{{ c.rarity }}</div>{% endif %}
|
||||
<div class="role" style="opacity:.75; font-size:11px; display:flex; flex-wrap:wrap; gap:3px;">
|
||||
<div class="meta">
|
||||
<div class="ci-ribbon" aria-label="Color identity"></div>
|
||||
<div class="nm" title="{{ c.name }}">{{ c.name }}</div>
|
||||
<div class="mana-line" aria-label="Mana Cost"></div>
|
||||
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}">{{ c.rarity }}</div>{% endif %}
|
||||
<div class="role">
|
||||
{% for r in c.roles %}<span class="mini-badge role-{{ r }}" title="{{ r }} role">{{ r[0]|upper }}</span>{% endfor %}
|
||||
</div>
|
||||
{% if c.reasons %}<div class="reasons" data-reasons-block style="font-size:9px; opacity:.55; line-height:1.15;" title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
|
||||
{% if c.reasons %}<div class="reasons" data-reasons-block title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% set has_synth = false %}
|
||||
{% for c in preview.sample %}{% if 'synthetic' in c.roles %}{% set has_synth = true %}{% endif %}{% endfor %}
|
||||
{% if has_synth %}
|
||||
<div style="flex-basis:100%; height:0;"></div>
|
||||
<div class="full-width-spacer"></div>
|
||||
{% for c in preview.sample %}
|
||||
{% if 'synthetic' in c.roles %}
|
||||
<div class="card-sample synthetic" style="width:230px; border:1px dashed var(--border); padding:8px; border-radius:10px; background:var(--panel-alt);" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
|
||||
<div style="font-size:12px; font-weight:600; line-height:1.2;">{{ c.name }}</div>
|
||||
<div style="font-size:11px; opacity:.8;">{{ c.roles|join(', ') }}</div>
|
||||
{% if c.reasons %}<div style="font-size:10px; margin-top:2px; opacity:.6; line-height:1.15;">{{ c.reasons|join(', ') }}</div>{% endif %}
|
||||
<div class="card-sample synthetic" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
|
||||
<div class="name">{{ c.name }}</div>
|
||||
<div class="roles">{{ c.roles|join(', ') }}</div>
|
||||
{% if c.reasons %}<div class="reasons-text">{{ c.reasons|join(', ') }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -89,10 +89,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="col-right">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Commanders</h4>{% else %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Synergy Commanders</h4>{% endif %}{% endif %}
|
||||
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 class="preview-section-header">Example Commanders</h4>{% else %}<h4 class="preview-section-header">Synergy Commanders</h4>{% endif %}{% endif %}
|
||||
<hr class="preview-section-hr" />
|
||||
{% if example_commanders and not suppress_curated %}
|
||||
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
|
||||
<div class="commander-grid">
|
||||
{% for name in example_commanders %}
|
||||
{# Derive per-commander overlaps; still show full theme synergy set in data-tags for context #}
|
||||
{% set base = name %}
|
||||
|
|
@ -104,22 +104,22 @@
|
|||
{% endif %}
|
||||
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
|
||||
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
|
||||
<div class="commander-cell" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<div class="commander-name" style="font-size:13px; text-align:center; line-height:1.35; font-weight:600; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
|
||||
<div class="commander-cell" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="{{ base|card_image('small') }}" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
|
||||
<div class="commander-name" title="{{ name }}">{{ name }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif not suppress_curated %}
|
||||
<div style="font-size:11px; opacity:.7;">No curated commander examples.</div>
|
||||
<div class="no-commanders-message">No curated commander examples.</div>
|
||||
{% endif %}
|
||||
{% if synergy_commanders %}
|
||||
<div style="margin-top:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:.4rem; margin-bottom:.4rem;">
|
||||
<h5 style="margin:0; font-size:11px; letter-spacing:.05em; text-transform:uppercase; opacity:.75;">Synergy Commanders</h5>
|
||||
<span title="Derived from synergy overlap heuristics" style="background:var(--panel-alt); border:1px solid var(--border); border-radius:10px; padding:2px 6px; font-size:10px; line-height:1;">Derived</span>
|
||||
<div class="synergy-commanders-section">
|
||||
<div class="synergy-commanders-header">
|
||||
<h5>Synergy Commanders</h5>
|
||||
<span class="derived-badge" title="Derived from synergy overlap heuristics">Derived</span>
|
||||
</div>
|
||||
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
|
||||
<div class="commander-grid">
|
||||
{% for name in synergy_commanders[:8] %}
|
||||
{# Strip any appended ' - Synergy (...' suffix for image lookup while preserving display #}
|
||||
{% set base = name %}
|
||||
|
|
@ -131,9 +131,9 @@
|
|||
{% endif %}
|
||||
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
|
||||
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
|
||||
<div class="commander-cell synergy" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<div class="commander-name" style="font-size:12px; text-align:center; line-height:1.3; font-weight:500; opacity:.92; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
|
||||
<div class="commander-cell synergy" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="{{ base|card_image('small') }}" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" onload="this.setAttribute('data-loaded', '1');" />
|
||||
<div class="commander-name" title="{{ name }}">{{ name }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -141,16 +141,16 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not minimal %}<div style="margin-top:1rem; font-size:10px; opacity:.65; line-height:1.4;">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
|
||||
{% if not minimal %}<div class="preview-help-text">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="preview-modal-content">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div class="sk-bar" style="height:16px; width:200px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-bar" style="height:16px; width:60px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="preview-modal-content preview-skeleton">
|
||||
<div class="sk-header">
|
||||
<div class="sk-bar title"></div>
|
||||
<div class="sk-bar close"></div>
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-top:1rem;">
|
||||
{% for i in range(8) %}<div style="width:230px; height:327px; background:var(--hover); border-radius:10px;"></div>{% endfor %}
|
||||
<div class="sk-cards">
|
||||
{% for i in range(8) %}<div class="sk-card"></div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -352,7 +352,7 @@
|
|||
var base = m[1].trim();
|
||||
if(base && base !== n){
|
||||
img.setAttribute('data-card-name', base);
|
||||
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
|
||||
img.src = '/api/images/small/' + encodeURIComponent(base);
|
||||
}
|
||||
// Attempt to derive overlaps if not already present
|
||||
if(!img.getAttribute('data-overlaps')){
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue