From a8dc1835eb030452c7bdb7e065b1dd6f344979c7 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 16 Oct 2025 19:02:33 -0700 Subject: [PATCH] feat(card-browser): advanced filters, keyboard shortcuts, and responsive design --- CHANGELOG.md | 23 +- README.md | 14 + RELEASE_NOTES_TEMPLATE.md | 16 +- code/scripts/generate_theme_catalog.py | 30 +- code/web/app.py | 9 + code/web/routes/card_browser.py | 1122 +++++++++++++++++ code/web/static/styles.css | 336 +++++ code/web/templates/base.html | 1 + .../templates/browse/cards/_card_grid.html | 22 + .../templates/browse/cards/_card_tile.html | 67 + code/web/templates/browse/cards/index.html | 958 ++++++++++++++ code/web/templates/home.html | 1 + 12 files changed, 2591 insertions(+), 8 deletions(-) create mode 100644 code/web/routes/card_browser.py create mode 100644 code/web/templates/browse/cards/_card_grid.html create mode 100644 code/web/templates/browse/cards/_card_tile.html create mode 100644 code/web/templates/browse/cards/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ef19564..08e5d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -_No unreleased changes yet._ +Card browser with advanced filters, keyboard shortcuts, and responsive design. ### Added -_No unreleased changes yet._ +- **Card Browser**: Browse 26,427 Magic cards with powerful filtering at `/browse/cards` + - Fuzzy autocomplete for card names and themes with typo tolerance + - Multi-theme filtering (up to 5 themes with AND logic) + - Color identity, card type, rarity, CMC range, power/toughness filters + - Six sorting options: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular + - Cursor-based pagination with infinite scroll + - Shareable filter URLs for saving and sharing searches +- **Keyboard Shortcuts**: Efficient navigation without mouse + - `Enter`: Add first autocomplete match to theme filters + - `Shift+Enter`: Apply all active filters from any input field + - `Esc` (double-tap): Clear all filters globally (500ms window) + - Desktop-only keyboard shortcuts help button with tooltip + - Auto-focus theme input after adding theme (desktop only) +- **Responsive Design**: Mobile-optimized card browser with touch-friendly controls + - Adaptive grid layout (1-4 columns based on screen width) + - Theme chips with remove buttons + - Graceful 5-theme limit (input disabled, no intrusive alerts) + - Desktop-only UI elements hidden on mobile with media queries ### Changed -_No unreleased changes yet._ +- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries ### Fixed _No unreleased changes yet._ diff --git a/README.md b/README.md index e12e294..e567ec2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A web-first Commander/EDH deckbuilder with a shared core for CLI, headless, and - [Initial Setup](#initial-setup) - [Owned Library](#owned-library) - [Browse Commanders](#browse-commanders) + - [Browse Cards](#browse-cards) - [Browse Themes](#browse-themes) - [Finished Decks](#finished-decks) - [Random Build](#random-build) @@ -164,6 +165,19 @@ Explore the curated commander catalog. - Refresh via Initial Setup or the commander catalog script above. - MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot. +### Browse Cards +Search and filter all 26,427 Magic cards. +- **Filtering**: Search by name, themes (up to 5), color identity, type, rarity, CMC range, power/toughness +- **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular +- **Keyboard Shortcuts**: + - `Enter`: Add first autocomplete match to theme filters + - `Shift+Enter`: Apply all active filters + - `Esc` (double-tap): Clear all filters + - `?` button (desktop): Show keyboard shortcuts reference +- **Responsive Design**: Mobile-optimized with adaptive grid and touch controls +- **Shareable URLs**: Filter state persists in URL for saving and sharing searches +- Powered by `card_files/all_cards.parquet` with theme tag index for fast lookups + ### Browse Themes Investigate theme synergies and diagnostics. - `ENABLE_THEMES=1` keeps the tile visible (default). diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 27d6e5b..3341cc9 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,13 +1,23 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -_No unreleased changes yet._ +Card browser with advanced filters, keyboard shortcuts, and responsive design. ### Added -_No unreleased changes yet._ +- **Card Browser**: Browse 26,427 Magic cards with powerful filtering + - Multi-theme filtering (up to 5 themes), color identity, type, rarity, CMC, power/toughness + - Six sorting options including EDHREC popularity + - Infinite scroll with cursor-based pagination + - Shareable filter URLs +- **Keyboard Shortcuts**: Efficient navigation + - `Enter`: Add first autocomplete match + - `Shift+Enter`: Apply filters + - `Esc` (double-tap): Clear all filters + - Desktop-only help button with keyboard shortcuts reference +- **Responsive Design**: Mobile-optimized with adaptive grid layout and touch-friendly controls ### Changed -_No unreleased changes yet._ +- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries ### Fixed _No unreleased changes yet._ diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py index e5c7e77..c3698d7 100644 --- a/code/scripts/generate_theme_catalog.py +++ b/code/scripts/generate_theme_catalog.py @@ -218,6 +218,7 @@ def build_theme_catalog( cards_filename: str = "cards.csv", logs_directory: Optional[Path] = None, use_parquet: bool = True, + min_card_count: int = 3, ) -> CatalogBuildResult: """Build theme catalog from card data. @@ -229,6 +230,8 @@ def build_theme_catalog( cards_filename: Name of cards CSV file logs_directory: Optional directory to copy output to use_parquet: If True, try to use all_cards.parquet first (default: True) + min_card_count: Minimum number of cards required to include theme (default: 3) + use_parquet: If True, try to use all_cards.parquet first (default: True) Returns: CatalogBuildResult with generated rows and metadata @@ -251,11 +254,16 @@ def build_theme_catalog( commander_parquet, theme_variants=theme_variants ) - # CSV method doesn't load non-commander cards, so we don't either - card_counts = Counter() + # Load all card counts from all_cards.parquet to include all themes + all_cards_parquet = parquet_dir / "all_cards.parquet" + card_counts = _load_theme_counts_from_parquet( + all_cards_parquet, theme_variants=theme_variants + ) used_parquet = True print("✓ Loaded theme data from parquet files") + print(f" - Commanders: {len(commander_counts)} themes") + print(f" - All cards: {len(card_counts)} themes") except Exception as e: print(f"⚠ Failed to load from parquet: {e}") @@ -285,12 +293,19 @@ def build_theme_catalog( version_hash = _compute_version_hash(display_names) rows: List[CatalogRow] = [] + filtered_count = 0 for key, display in zip(keys, display_names): if not display: continue card_count = int(card_counts.get(key, 0)) commander_count = int(commander_counts.get(key, 0)) source_count = card_count + commander_count + + # Filter out themes below minimum threshold + if source_count < min_card_count: + filtered_count += 1 + continue + rows.append( CatalogRow( theme=display, @@ -330,6 +345,9 @@ def build_theme_catalog( row.version, ]) + if filtered_count > 0: + print(f" Filtered {filtered_count} themes with <{min_card_count} cards") + if logs_directory is not None: logs_directory = logs_directory.resolve() logs_directory.mkdir(parents=True, exist_ok=True) @@ -376,6 +394,13 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: default=None, help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)", ) + parser.add_argument( + "--min-cards", + dest="min_cards", + type=int, + default=3, + help="Minimum number of cards required to include theme (default: 3)", + ) args = parser.parse_args(argv) csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None) @@ -383,6 +408,7 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: csv_directory=csv_dir, output_path=args.output, logs_directory=args.logs_dir, + min_card_count=args.min_cards, ) print( f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})", diff --git a/code/web/app.py b/code/web/app.py index 767eb36..3b524a2 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -62,6 +62,13 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue maybe_build_index() except Exception: pass + # Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing) + try: + from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore + get_theme_catalog() # Fast: just reads CSV + get_theme_index() # Slower: parses cards for theme-to-card mapping + except Exception: + pass yield # (no shutdown tasks currently) @@ -2206,6 +2213,7 @@ from .routes import commanders as commanders_routes # noqa: E402 from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402 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 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2216,6 +2224,7 @@ app.include_router(commanders_routes.router) app.include_router(partner_suggestions_routes.router) app.include_router(telemetry_routes.router) app.include_router(cards_routes.router) +app.include_router(card_browser_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/card_browser.py b/code/web/routes/card_browser.py new file mode 100644 index 0000000..d256b6f --- /dev/null +++ b/code/web/routes/card_browser.py @@ -0,0 +1,1122 @@ +""" +Card browser web UI routes (HTML views with HTMX). + +Provides paginated card browsing with filters, search, and cursor-based pagination. +Complements the existing API routes in cards.py for tag-based card queries. +""" + +from __future__ import annotations + +import logging +from difflib import SequenceMatcher + +import pandas as pd +from fastapi import APIRouter, Request, Query +from fastapi.responses import HTMLResponse +from ..app import templates + +# Import existing services +try: + from code.services.all_cards_loader import AllCardsLoader + from code.deck_builder.builder_utils import parse_theme_tags +except ImportError: + from services.all_cards_loader import AllCardsLoader + from deck_builder.builder_utils import parse_theme_tags + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/cards", tags=["card-browser"]) + +# Cached loader instance and theme index +_loader: AllCardsLoader | None = None +_theme_index: dict[str, set[int]] | None = None # theme_lower -> set of card indices +_theme_catalog: list[str] | None = None # cached list of all theme names from catalog + + +def get_loader() -> AllCardsLoader: + """Get cached AllCardsLoader instance.""" + global _loader + if _loader is None: + _loader = AllCardsLoader() + return _loader + + +def get_theme_catalog() -> list[str]: + """ + Get cached list of all theme names from theme_catalog.csv. + + Reads from the catalog CSV which includes all themes from all_cards.parquet + (not just commander themes). Much faster than parsing themes from 26k+ cards. + Used for autocomplete suggestions. + + Returns ~900+ themes (as of latest generation). + """ + global _theme_catalog + if _theme_catalog is None: + import csv + from pathlib import Path + import os + + print("Loading theme catalog...", flush=True) + + # Try multiple possible paths (local dev vs Docker) + possible_paths = [ + Path(__file__).parent.parent.parent / "config" / "themes" / "theme_catalog.csv", # Local dev + Path("/app/config/themes/theme_catalog.csv"), # Docker + Path(os.environ.get("CONFIG_DIR", "/app/config")) / "themes" / "theme_catalog.csv", # Env var + ] + + themes = [] + loaded = False + + for catalog_path in possible_paths: + print(f"Checking path: {catalog_path} (exists: {catalog_path.exists()})", flush=True) + if catalog_path.exists(): + try: + with open(catalog_path, 'r', encoding='utf-8') as f: + # Skip comment lines starting with # + lines = [line for line in f if not line.strip().startswith('#')] + + # Parse CSV from non-comment lines + from io import StringIO + csv_content = StringIO(''.join(lines)) + reader = csv.DictReader(csv_content) + + for row in reader: + if 'theme' in row and row['theme']: + themes.append(row['theme']) + + _theme_catalog = themes + print(f"Loaded {len(themes)} themes from catalog: {catalog_path}", flush=True) + logger.info(f"Loaded {len(themes)} themes from catalog: {catalog_path}") + loaded = True + break + except Exception as e: + print(f"❌ Failed to load from {catalog_path}: {e}", flush=True) # Debug log + logger.warning(f"Failed to load theme catalog from {catalog_path}: {e}") + + if not loaded: + print("⚠️ No catalog found, falling back to parsing cards", flush=True) # Debug log + logger.warning("Failed to load theme catalog from all paths, falling back to parsing cards") + # Fallback: extract from theme index + theme_index = get_theme_index() + _theme_catalog = [theme.title() for theme in theme_index.keys()] + + return _theme_catalog + + +def get_theme_index() -> dict[str, set[int]]: + """ + Get cached theme-to-card-index mapping for fast lookups. + + Returns dict mapping lowercase theme names to sets of card indices. + Built once on first access and reused for all subsequent theme queries. + """ + global _theme_index + if _theme_index is None: + logger.info("Building theme index for fast lookups...") + _theme_index = {} + loader = get_loader() + df = loader.load() + + for idx, row in enumerate(df.itertuples()): + themes = parse_theme_tags(row.themeTags if hasattr(row, 'themeTags') else '') + for theme in themes: + theme_lower = theme.lower() + if theme_lower not in _theme_index: + _theme_index[theme_lower] = set() + _theme_index[theme_lower].add(idx) + + logger.info(f"Theme index built with {len(_theme_index)} unique themes") + + return _theme_index + + +@router.get("/", response_class=HTMLResponse) +async def card_browser_index( + request: Request, + search: str = Query("", description="Card name search query"), + themes: list[str] = Query([], description="Theme tag filters (AND logic)"), + color: str = Query("", description="Color identity filter"), + card_type: str = Query("", description="Card type filter"), + rarity: str = Query("", description="Rarity filter"), + sort: str = Query("name_asc", description="Sort order"), + cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), + cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), + power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), + power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), + tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), + tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), +): + """ + Main card browser page. + + Displays initial grid of cards with filters and search bar. + Uses HTMX for dynamic updates (pagination, filtering, search). + """ + try: + loader = get_loader() + df = loader.load() + + # Apply filters + filtered_df = df.copy() + + if search: + # Prioritize exact matches first, then word-count matches, then fuzzy + query_lower = search.lower().strip() + query_words = set(query_lower.split()) + + # 1. Check for exact match (case-insensitive) + # For double-faced cards, check both full name and name before " //" + exact_matches = [] + word_count_matches = [] + fuzzy_candidates = [] + fuzzy_indices = [] + + for idx, card_name in enumerate(filtered_df['name']): + card_lower = card_name.lower() + # For double-faced cards, get the front face name + front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower + + # Exact match (full name or front face) + if card_lower == query_lower or front_name == query_lower: + exact_matches.append(idx) + # Word count match (same number of words + high similarity) + elif len(query_lower.split()) == len(front_name.split()) and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_matches.append((idx, card_name)) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + fuzzy_indices.append(idx) + + # Build final match list + final_matches = [] + + # If we have exact matches, ONLY return those (don't add fuzzy results) + if exact_matches: + final_matches = exact_matches + else: + # 2. Add word-count matches with fuzzy scoring + if word_count_matches: + scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) + for idx, name in word_count_matches] + scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) + + # 3. Add fuzzy matches + if fuzzy_candidates: + scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) + for i, name in enumerate(fuzzy_candidates)] + scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) + + # Apply matches + if final_matches: + # Remove duplicates while preserving order + seen = set() + unique_matches = [] + for idx in final_matches: + if idx not in seen: + seen.add(idx) + unique_matches.append(idx) + filtered_df = filtered_df.iloc[unique_matches] + else: + filtered_df = filtered_df.iloc[0:0] + + # Multi-select theme filtering (AND logic: card must have ALL selected themes) + if themes: + theme_index = get_theme_index() + + # For each theme, get matching card indices + all_theme_matches = [] + for theme in themes: + theme_lower = theme.lower().strip() + + # Try exact match first (instant lookup) + if theme_lower in theme_index: + # Direct index lookup - O(1) instead of O(n) + matching_indices = theme_index[theme_lower] + all_theme_matches.append(matching_indices) + else: + # Fuzzy match: check all themes in index for similarity + matching_indices = set() + for indexed_theme, card_indices in theme_index.items(): + if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: + matching_indices.update(card_indices) + all_theme_matches.append(matching_indices) + + # Apply AND logic: card must be in ALL theme match sets + if all_theme_matches: + # Start with first theme's matches + intersection = all_theme_matches[0] + # Intersect with all other theme matches + for theme_matches in all_theme_matches[1:]: + intersection = intersection & theme_matches + + # Intersect with current filtered_df indices + current_indices = set(filtered_df.index) + valid_indices = intersection & current_indices + if valid_indices: + filtered_df = filtered_df.loc[list(valid_indices)] + else: + filtered_df = filtered_df.iloc[0:0] + + if color: + filtered_df = filtered_df[ + filtered_df['colorIdentity'] == color + ] + + if card_type: + filtered_df = filtered_df[ + filtered_df['type'].str.contains(card_type, case=False, na=False) + ] + + if rarity and 'rarity' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['rarity'].str.lower() == rarity.lower() + ] + + # CMC range filter + if cmc_min is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] >= cmc_min + ] + + if cmc_max is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] <= cmc_max + ] + + # Power range filter (only applies to cards with power values) + if power_min is not None and 'power' in filtered_df.columns: + # Filter: either no power (NaN) OR power >= min + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) + ] + + if power_max is not None and 'power' in filtered_df.columns: + # Filter: either no power (NaN) OR power <= max + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) + ] + + # Toughness range filter (only applies to cards with toughness values) + if tough_min is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) + ] + + if tough_max is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) + ] + + # Apply sorting + if sort == "name_desc": + # Name Z-A + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) + filtered_df = filtered_df.drop('_sort_key', axis=1) + elif sort == "cmc_asc": + # CMC Low-High, then name + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) + elif sort == "cmc_desc": + # CMC High-Low, then name + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) + elif sort == "power_desc": + # Power High-Low (creatures first, then non-creatures) + # Convert power to numeric, NaN becomes -1 for sorting + filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) + filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) + filtered_df = filtered_df.drop('_power_sort', axis=1) + elif sort == "edhrec_asc": + # EDHREC rank (low number = popular) + if 'edhrecRank' in filtered_df.columns: + # NaN goes to end (high value) + filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) + filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) + filtered_df = filtered_df.drop('_edhrec_sort', axis=1) + else: + # Fallback to name sort + filtered_df = filtered_df.sort_values('name') + else: + # Default: Name A-Z (name_asc) + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) + filtered_df = filtered_df.drop('_sort_key', axis=1) + + total_cards = len(filtered_df) + + # Get first page (20 cards) + per_page = 20 + cards_page = filtered_df.head(per_page) + + # Convert to list of dicts + cards_list = cards_page.to_dict('records') + + # Parse theme tags and color identity for each card + for card in cards_list: + card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) + # Parse colorIdentity which can be: + # - "Colorless" -> [] (but mark as colorless) + # - "W" -> ['W'] + # - "B, R, U" -> ['B', 'R', 'U'] + # - "['W', 'U']" -> ['W', 'U'] + # - empty/None -> [] + raw_color = card.get('colorIdentity', '') + is_colorless = False + if raw_color and isinstance(raw_color, str): + if raw_color.lower() == 'colorless': + card['colorIdentity'] = [] + is_colorless = True + elif raw_color.startswith('['): + # Parse list-like strings e.g. "['W', 'U']" + card['colorIdentity'] = parse_theme_tags(raw_color) + elif ', ' in raw_color: + # Parse comma-separated e.g. "B, R, U" + card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] + else: + # Single color e.g. "W" + card['colorIdentity'] = [raw_color.strip()] + elif not raw_color: + card['colorIdentity'] = [] + card['is_colorless'] = is_colorless + # TODO: Add owned card checking when integrated + card['is_owned'] = False + + # Get unique values for filters + # Build structured color identity list with proper names + unique_color_ids = df['colorIdentity'].dropna().unique().tolist() + + # Define color identity groups with proper names + color_groups = { + 'Colorless': ['Colorless'], + 'Mono-Color': ['W', 'U', 'B', 'R', 'G'], + 'Two-Color': [ + ('W, U', 'Azorius'), + ('U, B', 'Dimir'), + ('B, R', 'Rakdos'), + ('R, G', 'Gruul'), + ('G, W', 'Selesnya'), + ('W, B', 'Orzhov'), + ('U, R', 'Izzet'), + ('B, G', 'Golgari'), + ('R, W', 'Boros'), + ('G, U', 'Simic'), + ], + 'Three-Color': [ + ('B, G, U', 'Sultai'), + ('G, U, W', 'Bant'), + ('B, U, W', 'Esper'), + ('B, R, U', 'Grixis'), + ('B, G, R', 'Jund'), + ('G, R, W', 'Naya'), + ('B, G, W', 'Abzan'), + ('R, U, W', 'Jeskai'), + ('B, R, W', 'Mardu'), + ('G, R, U', 'Temur'), + ], + 'Four-Color': [ + ('B, G, R, U', 'Non-White'), + ('B, G, R, W', 'Non-Blue'), + ('B, G, U, W', 'Non-Red'), + ('B, R, U, W', 'Non-Green'), + ('G, R, U, W', 'Non-Black'), + ], + 'Five-Color': ['B, G, R, U, W'], + } + + # Flatten and filter to only include combinations present in data + all_colors = [] + for group_name, entries in color_groups.items(): + group_colors = [] + for entry in entries: + if isinstance(entry, tuple): + color_id, display_name = entry + if color_id in unique_color_ids: + group_colors.append((color_id, display_name)) + else: + color_id = entry + if color_id in unique_color_ids: + group_colors.append((color_id, color_id)) + if group_colors: + all_colors.append((group_name, group_colors)) + + all_types = sorted( + set( + df['type'].dropna().str.extract(r'([A-Za-z]+)', expand=False).dropna().unique().tolist() + ) + )[:20] # Limit to top 20 types + + all_rarities = [] + if 'rarity' in df.columns: + all_rarities = sorted(df['rarity'].dropna().unique().tolist()) + + # Calculate pagination info + per_page = 20 + total_filtered = len(filtered_df) + total_pages = (total_filtered + per_page - 1) // per_page # Ceiling division + current_page = 1 # Always page 1 on initial load (cursor-based makes exact page tricky) + + # Determine if there's a next page + has_next = total_cards > per_page + last_card_name = cards_list[-1]['name'] if cards_list else "" + + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": cards_list, + "total_cards": len(df), # Original unfiltered count + "filtered_count": total_filtered, # After filters applied + "has_next": has_next, + "last_card": last_card_name, + "search": search, + "themes": themes, + "color": color, + "card_type": card_type, + "rarity": rarity, + "sort": sort, + "cmc_min": cmc_min, + "cmc_max": cmc_max, + "power_min": power_min, + "power_max": power_max, + "tough_min": tough_min, + "tough_max": tough_max, + "all_colors": all_colors, + "all_types": all_types, + "all_rarities": all_rarities, + "per_page": per_page, + "current_page": current_page, + "total_pages": total_pages, + }, + ) + + except FileNotFoundError as e: + logger.error(f"Card data not found: {e}") + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": [], + "total_cards": 0, + "has_next": False, + "last_card": "", + "search": "", + "color": "", + "card_type": "", + "rarity": "", + "all_colors": [], + "all_types": [], + "all_rarities": [], + "per_page": 20, + "error": "Card data not available. Please run setup to generate all_cards.parquet.", + }, + ) + except Exception as e: + logger.error(f"Error loading card browser: {e}", exc_info=True) + return templates.TemplateResponse( + "browse/cards/index.html", + { + "request": request, + "cards": [], + "total_cards": 0, + "has_next": False, + "last_card": "", + "search": "", + "color": "", + "card_type": "", + "rarity": "", + "all_colors": [], + "all_types": [], + "all_rarities": [], + "per_page": 20, + "error": f"Error loading cards: {str(e)}", + }, + ) + + +@router.get("/grid", response_class=HTMLResponse) +async def card_browser_grid( + request: Request, + cursor: str = Query("", description="Last card name from previous page"), + search: str = Query("", description="Card name search query"), + themes: list[str] = Query([], description="Theme tag filters (AND logic)"), + color: str = Query("", description="Color identity filter"), + card_type: str = Query("", description="Card type filter"), + rarity: str = Query("", description="Rarity filter"), + sort: str = Query("name_asc", description="Sort order"), + cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), + cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), + power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), + power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), + tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), + tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), +): + """ + HTMX endpoint for paginated card grid. + + Returns only the grid partial HTML for seamless pagination. + Uses cursor-based pagination (last_card_name) for performance. + """ + try: + loader = get_loader() + df = loader.load() + + # Apply filters + filtered_df = df.copy() + + if search: + # Prioritize exact matches first, then word-count matches, then fuzzy + query_lower = search.lower().strip() + query_words = set(query_lower.split()) + + # 1. Check for exact match (case-insensitive) + # For double-faced cards, check both full name and name before " //" + exact_matches = [] + word_count_matches = [] + fuzzy_candidates = [] + fuzzy_indices = [] + + for idx, card_name in enumerate(filtered_df['name']): + card_lower = card_name.lower() + # For double-faced cards, get the front face name + front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower + + # Exact match (full name or front face) + if card_lower == query_lower or front_name == query_lower: + exact_matches.append(idx) + # Word count match (same number of words + high similarity) + elif len(query_lower.split()) == len(front_name.split()) and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_matches.append((idx, card_name)) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + fuzzy_indices.append(idx) + + # Build final match list + final_matches = [] + + # If we have exact matches, ONLY return those (don't add fuzzy results) + if exact_matches: + final_matches = exact_matches + else: + # 2. Add word-count matches with fuzzy scoring + if word_count_matches: + scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) + for idx, name in word_count_matches] + scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) + + # 3. Add fuzzy matches + if fuzzy_candidates: + scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) + for i, name in enumerate(fuzzy_candidates)] + scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc + final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) + + # Apply matches + if final_matches: + # Remove duplicates while preserving order + seen = set() + unique_matches = [] + for idx in final_matches: + if idx not in seen: + seen.add(idx) + unique_matches.append(idx) + filtered_df = filtered_df.iloc[unique_matches] + else: + filtered_df = filtered_df.iloc[0:0] + + # Multi-select theme filtering (AND logic: card must have ALL selected themes) + if themes: + theme_index = get_theme_index() + + # For each theme, get matching card indices + all_theme_matches = [] + for theme in themes: + theme_lower = theme.lower().strip() + + # Try exact match first (instant lookup) + if theme_lower in theme_index: + # Direct index lookup - O(1) instead of O(n) + matching_indices = theme_index[theme_lower] + all_theme_matches.append(matching_indices) + else: + # Fuzzy match: check all themes in index for similarity + matching_indices = set() + for indexed_theme, card_indices in theme_index.items(): + if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: + matching_indices.update(card_indices) + all_theme_matches.append(matching_indices) + + # Apply AND logic: card must be in ALL theme match sets + if all_theme_matches: + # Start with first theme's matches + intersection = all_theme_matches[0] + # Intersect with all other theme matches + for theme_matches in all_theme_matches[1:]: + intersection = intersection & theme_matches + + # Intersect with current filtered_df indices + current_indices = set(filtered_df.index) + valid_indices = intersection & current_indices + if valid_indices: + filtered_df = filtered_df.loc[list(valid_indices)] + else: + filtered_df = filtered_df.iloc[0:0] + + if color: + filtered_df = filtered_df[ + filtered_df['colorIdentity'] == color + ] + + if card_type: + filtered_df = filtered_df[ + filtered_df['type'].str.contains(card_type, case=False, na=False) + ] + + if rarity and 'rarity' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['rarity'].str.lower() == rarity.lower() + ] + + # CMC range filter (grid endpoint) + if cmc_min is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] >= cmc_min + ] + + if cmc_max is not None and 'manaValue' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['manaValue'] <= cmc_max + ] + + # Power range filter (grid endpoint) + if power_min is not None and 'power' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) + ] + + if power_max is not None and 'power' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) + ] + + # Toughness range filter (grid endpoint) + if tough_min is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) + ] + + if tough_max is not None and 'toughness' in filtered_df.columns: + filtered_df = filtered_df[ + filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) + ] + + # Apply sorting (same logic as main endpoint) + if sort == "name_desc": + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) + filtered_df = filtered_df.drop('_sort_key', axis=1) + elif sort == "cmc_asc": + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) + elif sort == "cmc_desc": + filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) + elif sort == "power_desc": + filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) + filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) + filtered_df = filtered_df.drop('_power_sort', axis=1) + elif sort == "edhrec_asc": + if 'edhrecRank' in filtered_df.columns: + filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) + filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) + filtered_df = filtered_df.drop('_edhrec_sort', axis=1) + else: + filtered_df = filtered_df.sort_values('name') + else: + # Default: Name A-Z + filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) + filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( + lambda x: x.replace('_', ' ') if x.startswith('_') else x + ) + filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) + filtered_df = filtered_df.drop('_sort_key', axis=1) + + # Cursor-based pagination + if cursor: + filtered_df = filtered_df[filtered_df['name'] > cursor] + + per_page = 20 + cards_page = filtered_df.head(per_page) + cards_list = cards_page.to_dict('records') + + # Parse theme tags and color identity + for card in cards_list: + card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) + # Parse colorIdentity which can be: + # - "Colorless" -> [] (but mark as colorless) + # - "W" -> ['W'] + # - "B, R, U" -> ['B', 'R', 'U'] + # - "['W', 'U']" -> ['W', 'U'] + # - empty/None -> [] + raw_color = card.get('colorIdentity', '') + is_colorless = False + if raw_color and isinstance(raw_color, str): + if raw_color.lower() == 'colorless': + card['colorIdentity'] = [] + is_colorless = True + elif raw_color.startswith('['): + # Parse list-like strings e.g. "['W', 'U']" + card['colorIdentity'] = parse_theme_tags(raw_color) + elif ', ' in raw_color: + # Parse comma-separated e.g. "B, R, U" + card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] + else: + # Single color e.g. "W" + card['colorIdentity'] = [raw_color.strip()] + elif not raw_color: + card['colorIdentity'] = [] + card['is_colorless'] = is_colorless + card['is_owned'] = False # TODO: Add owned card checking + + has_next = len(filtered_df) > per_page + last_card_name = cards_list[-1]['name'] if cards_list else "" + + return templates.TemplateResponse( + "browse/cards/_card_grid.html", + { + "request": request, + "cards": cards_list, + "has_next": has_next, + "last_card": last_card_name, + "search": search, + "themes": themes, + "color": color, + "card_type": card_type, + "rarity": rarity, + "sort": sort, + "cmc_min": cmc_min, + "cmc_max": cmc_max, + "power_min": power_min, + "power_max": power_max, + "tough_min": tough_min, + "tough_max": tough_max, + }, + ) + + except Exception as e: + logger.error(f"Error loading card grid: {e}", exc_info=True) + return HTMLResponse( + f'
Error loading cards: {str(e)}
', + status_code=500, + ) + + +def _fuzzy_theme_match_score(query: str, theme: str) -> float: + """ + Calculate fuzzy match score between query and theme name. + Handles typos in the middle of words. + + Returns score from 0.0 to 1.0, higher is better match. + """ + query_lower = query.lower() + theme_lower = theme.lower() + + # Use sequence matcher for proper fuzzy matching (handles typos) + base_score = SequenceMatcher(None, query_lower, theme_lower).ratio() + + # Bonus for substring match + substring_bonus = 0.0 + if theme_lower.startswith(query_lower): + substring_bonus = 0.3 # Strong bonus for prefix + elif query_lower in theme_lower: + substring_bonus = 0.2 # Moderate bonus for substring + + # Word overlap bonus (for multi-word themes) + query_words = set(query_lower.split()) + theme_words = set(theme_lower.split()) + word_overlap = 0.0 + if query_words and theme_words: + overlap_ratio = len(query_words & theme_words) / len(query_words) + word_overlap = overlap_ratio * 0.2 + + # Combine scores + return min(1.0, base_score + substring_bonus + word_overlap) + + +@router.get("/search", response_class=HTMLResponse) +async def card_browser_search( + request: Request, + q: str = Query("", description="Search query"), +): + """ + Live search autocomplete endpoint. + + Returns matching card names for autocomplete suggestions. + """ + try: + if not q or len(q) < 2: + return HTMLResponse("") + + loader = get_loader() + df = loader.load() + + # Search by card name (case-insensitive) + matches = df[df['name'].str.contains(q, case=False, na=False)] + matches = matches.sort_values('name').head(10) + + card_names = matches['name'].tolist() + + # Return as simple HTML list + html = "" + + return HTMLResponse(html) + + except Exception as e: + logger.error(f"Error in card search: {e}", exc_info=True) + return HTMLResponse("") + + +def _normalize_search_text(value: str | None) -> str: + """Normalize search text for fuzzy matching (lowercase, alphanumeric only).""" + if not value: + return "" + # Keep letters, numbers, spaces; convert to lowercase + import re + tokens = re.findall(r"[a-z0-9]+", value.lower()) + return " ".join(tokens) if tokens else "" + + +def _fuzzy_card_name_score(query: str, card_name: str) -> float: + """ + Calculate fuzzy match score between query and card name. + + Uses multiple scoring methods similar to commanders.py: + - Base sequence matching + - Partial ratio (substring matching) + - Token matching + - Word count matching bonus + - Substring bonuses + + Returns score from 0.0 to 1.0, higher is better match. + """ + normalized_query = _normalize_search_text(query) + normalized_card = _normalize_search_text(card_name) + + if not normalized_query or not normalized_card: + return 0.0 + + # Base sequence matching + base_score = SequenceMatcher(None, normalized_query, normalized_card).ratio() + + # Partial ratio - best matching substring + query_len = len(normalized_query) + if query_len <= len(normalized_card): + best_partial = 0.0 + for i in range(len(normalized_card) - query_len + 1): + substr = normalized_card[i:i + query_len] + ratio = SequenceMatcher(None, normalized_query, substr).ratio() + if ratio > best_partial: + best_partial = ratio + else: + best_partial = base_score + + # Token matching + query_tokens = normalized_query.split() + card_tokens = normalized_card.split() + + if query_tokens and card_tokens: + # Average token score + token_scores = [] + for q_token in query_tokens: + best_token_match = max( + (SequenceMatcher(None, q_token, c_token).ratio() for c_token in card_tokens), + default=0.0 + ) + token_scores.append(best_token_match) + token_avg = sum(token_scores) / len(token_scores) if token_scores else 0.0 + + # Word count bonus: prioritize same number of words + # "peer parker" (2 words) should match "peter parker" (2 words) over "peter parker amazing" (3 words) + word_count_bonus = 0.0 + if len(query_tokens) == len(card_tokens): + word_count_bonus = 0.15 # Significant bonus for same word count + else: + token_avg = 0.0 + word_count_bonus = 0.0 + + # Substring bonuses + substring_bonus = 0.0 + if normalized_card.startswith(normalized_query): + substring_bonus = 1.0 + elif normalized_query in normalized_card: + substring_bonus = 0.9 + elif query_tokens and all(token in card_tokens for token in query_tokens): + substring_bonus = 0.85 + + # Combine scores with word count bonus + base_result = max(base_score, best_partial, token_avg, substring_bonus) + return min(1.0, base_result + word_count_bonus) # Cap at 1.0 + + + +@router.get("/search-autocomplete", response_class=HTMLResponse) +async def card_search_autocomplete( + request: Request, + q: str = Query(..., min_length=2, description="Card name search query"), + limit: int = Query(10, ge=1, le=50), +) -> HTMLResponse: + """ + HTMX endpoint for card name autocomplete with fuzzy matching. + + Similar to commanders theme autocomplete, returns HTML suggestions + with keyboard navigation support. + """ + try: + loader = get_loader() + df = loader.load() + + # Quick filter: prioritize exact match, then word count match, then fuzzy + query_lower = q.lower() + query_words = set(query_lower.split()) + query_word_count = len(query_lower.split()) + + # Fast categorization + exact_matches = [] + word_count_candidates = [] + fuzzy_candidates = [] + + for card_name in df['name'].unique(): + card_lower = card_name.lower() + + # Exact match + if card_lower == query_lower: + exact_matches.append(card_name) + # Same word count with substring/word overlap + elif len(card_lower.split()) == query_word_count and ( + query_lower in card_lower or any(word in card_lower for word in query_words) + ): + word_count_candidates.append(card_name) + # Fuzzy candidate + elif query_lower in card_lower or any(word in card_lower for word in query_words): + fuzzy_candidates.append(card_name) + + # Build final scored list + scored_cards: list[tuple[float, str, int]] = [] # (score, name, priority) + + # 1. Exact matches (priority 0 = highest) + for card_name in exact_matches[:limit]: # Take top N exact matches + scored_cards.append((1.0, card_name, 0)) + + # 2. Word count matches (priority 1) + if len(scored_cards) < limit and word_count_candidates: + # Limit word count candidates before fuzzy scoring + if len(word_count_candidates) > 200: + word_count_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) + word_count_candidates = word_count_candidates[:200] + + for card_name in word_count_candidates: + score = _fuzzy_card_name_score(q, card_name) + if score >= 0.3: + scored_cards.append((score, card_name, 1)) + + # 3. Fuzzy matches (priority 2) + if len(scored_cards) < limit and fuzzy_candidates: + # Limit fuzzy candidates before scoring + if len(fuzzy_candidates) > 200: + fuzzy_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) + fuzzy_candidates = fuzzy_candidates[:200] + + for card_name in fuzzy_candidates: + score = _fuzzy_card_name_score(q, card_name) + if score >= 0.3: + scored_cards.append((score, card_name, 2)) + + # Sort by priority first, then score desc, then name asc + scored_cards.sort(key=lambda x: (x[2], -x[0], x[1].lower())) + + # Take top matches + top_matches = scored_cards[:limit] + + # Generate HTML suggestions with ARIA attributes + html_parts = [] + for score, card_name, priority in top_matches: + # Escape HTML special characters + safe_name = card_name.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + html_parts.append( + f'
' + f'{safe_name}
' + ) + + html = "\n".join(html_parts) if html_parts else '
No matching cards
' + + return HTMLResponse(content=html) + + except Exception as e: + logger.error(f"Error in card autocomplete: {e}", exc_info=True) + return HTMLResponse(content=f'
Error: {str(e)}
') + + +@router.get("/theme-autocomplete", response_class=HTMLResponse) +async def card_theme_autocomplete( + request: Request, + q: str = Query(..., min_length=2, description="Theme search query"), + limit: int = Query(10, ge=1, le=20), +) -> HTMLResponse: + """ + HTMX endpoint for theme tag autocomplete with fuzzy matching. + + Uses theme catalog for instant lookups (no card parsing required). + """ + try: + # Use cached theme catalog (loaded from CSV, not parsed from cards) + all_themes = get_theme_catalog() + + # Fuzzy match themes using helper function + scored_themes: list[tuple[float, str]] = [] + + # Only check against theme names from catalog (~575 themes) + for theme in all_themes: + score = _fuzzy_theme_match_score(q, theme) + # Only include if score is reasonable (0.5+ = 50%+ match) + if score >= 0.5: + scored_themes.append((score, theme)) + + # Sort by score (desc), then alphabetically + scored_themes.sort(key=lambda x: (-x[0], x[1].lower())) + top_matches = scored_themes[:limit] + + # Generate HTML suggestions + html_parts = [] + for score, theme in top_matches: + safe_theme = theme.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + html_parts.append( + f'
' + f'{safe_theme}
' + ) + + html = "\n".join(html_parts) if html_parts else '
No matching themes
' + + return HTMLResponse(content=html) + + except Exception as e: + logger.error(f"Error in theme autocomplete: {e}", exc_info=True) + return HTMLResponse(content=f'
Error: {str(e)}
') + diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 4c610c3..5bee967 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -727,3 +727,339 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } border: 1px solid var(--border); border-radius: 4px; } + +/* ======================================== + Card Browser Styles + ======================================== */ + +/* Card browser container */ +.card-browser-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Filter panel */ +.card-browser-filters { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.filter-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.filter-row label { + font-weight: 600; + min-width: 80px; + color: var(--text); + font-size: 0.95rem; +} + +.filter-row select, +.filter-row input[type="text"], +.filter-row input[type="search"] { + flex: 1; + min-width: 150px; + max-width: 300px; +} + +/* Search bar styling */ +.card-search-wrapper { + position: relative; + flex: 1; + max-width: 100%; +} + +.card-search-wrapper input[type="search"] { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +/* Results count and info bar */ +.card-browser-info { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.results-count { + font-size: 0.95rem; + color: var(--muted); +} + +.page-indicator { + font-size: 0.95rem; + color: var(--text); + font-weight: 600; +} + +/* Card browser grid */ +.card-browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); + gap: 0.5rem; + padding: 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + min-height: 480px; + justify-content: start; +} + +/* Individual card tile in browser */ +.card-browser-tile { + break-inside: avoid; + display: flex; + flex-direction: column; + background: var(--card-bg, #1a1d24); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.card-browser-tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); +} + +.card-browser-tile-image { + position: relative; + width: 100%; + aspect-ratio: 488/680; + overflow: hidden; + background: #0a0b0e; +} + +.card-browser-tile-image img { + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.3s ease; +} + +.card-browser-tile:hover .card-browser-tile-image img { + transform: scale(1.05); +} + +.card-browser-tile-info { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-browser-tile-name { + font-weight: 600; + font-size: 0.95rem; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-type { + font-size: 0.85rem; + color: var(--muted); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.3; +} + +.card-browser-tile-stats { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.card-browser-tile-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.card-browser-tile-tags .tag { + font-size: 0.7rem; + padding: 0.15rem 0.4rem; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + border-radius: 3px; + white-space: nowrap; +} + +/* Pagination controls */ +.card-browser-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 1rem 0; + flex-wrap: wrap; +} + +.card-browser-pagination .btn { + min-width: 120px; +} + +.card-browser-pagination .page-info { + font-size: 0.95rem; + color: var(--text); + padding: 0 1rem; +} + +/* No results message */ +.no-results { + text-align: center; + padding: 3rem 1rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.no-results-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.no-results-message { + color: var(--muted); + margin-bottom: 1rem; + line-height: 1.5; +} + +.no-results-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + margin-bottom: 1rem; +} + +.no-results-filter-tag { + padding: 0.25rem 0.75rem; + background: rgba(148, 163, 184, 0.15); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9rem; + color: var(--text); +} + +/* Loading indicator */ +.card-browser-loading { + text-align: center; + padding: 2rem; + color: var(--muted); +} + +/* Responsive adjustments */ +/* Large tablets and below - reduce to ~180px cards */ +@media (max-width: 1024px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); + } +} + +/* Tablets - reduce to ~160px cards */ +@media (max-width: 768px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); + gap: 0.5rem; + padding: 0.5rem; + } + + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-row label { + min-width: auto; + } + + .filter-row select, + .filter-row input { + max-width: 100%; + } + + .card-browser-info { + flex-direction: column; + align-items: flex-start; + } +} + +/* Small tablets/large phones - reduce to ~140px cards */ +@media (max-width: 600px) { + .card-browser-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); + gap: 0.5rem; + } +} + +/* Phones - 2 column layout with flexible width */ +@media (max-width: 480px) { + .card-browser-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.375rem; + } + + .card-browser-tile-name { + font-size: 0.85rem; + } + + .card-browser-tile-type { + font-size: 0.75rem; + } + + .card-browser-tile-info { + padding: 0.5rem; + } +} + +/* Theme chips for multi-select */ +.theme-chip { + display: inline-flex; + align-items: center; + background: var(--primary-bg); + color: var(--primary-fg); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border-color); +} + +.theme-chip button { + margin-left: 0.5rem; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-weight: bold; + font-size: 1.2rem; + line-height: 1; +} + +.theme-chip button:hover { + color: var(--error-color); +} diff --git a/code/web/templates/base.html b/code/web/templates/base.html index b8a0d88..f0c014d 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -85,6 +85,7 @@ Build from JSON {% if show_setup %}Setup/Tag{% endif %} Owned Library + All Cards {% if show_commanders %}Commanders{% endif %} Finished Decks Themes diff --git a/code/web/templates/browse/cards/_card_grid.html b/code/web/templates/browse/cards/_card_grid.html new file mode 100644 index 0000000..1720da4 --- /dev/null +++ b/code/web/templates/browse/cards/_card_grid.html @@ -0,0 +1,22 @@ +{# HTMX-loadable card grid for pagination #} +{% for card in cards %} +{% include "browse/cards/_card_tile.html" %} +{% endfor %} + +{# Pagination button (uses out-of-band swap to replace itself) #} +
+{% if has_next %} + + + Loading... + +{% endif %} +
diff --git a/code/web/templates/browse/cards/_card_tile.html b/code/web/templates/browse/cards/_card_tile.html new file mode 100644 index 0000000..cbd9cbe --- /dev/null +++ b/code/web/templates/browse/cards/_card_tile.html @@ -0,0 +1,67 @@ +{# Single card tile for grid display #} +
+ {# Card image #} +
+ {{ card.name }} + {# Fallback for missing images #} +
+ {{ card.name }} +
+ + {# Owned indicator #} + {% if card.is_owned %} +
+ ✓ Owned +
+ {% endif %} +
+ + {# Card info #} +
+ {# Card name #} +
+ {{ card.name }} +
+ + {# Type line #} + {% if card.type %} +
+ {{ card.type }} +
+ {% endif %} + + {# Mana cost and color identity #} +
+ {% if card.manaValue is defined and card.manaValue is not none %} + CMC: {{ card.manaValue }} + {% endif %} + + {% if card.is_colorless %} +
+ +
+ {% elif card.colorIdentity %} +
+ {% for color in card.colorIdentity %} + + {% endfor %} +
+ {% endif %} +
+ + {# Theme tags (show all tags, not truncated) #} + {% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %} +
+ {% for tag in card.themeTags_parsed %} + {{ tag }} + {% endfor %} +
+ {% endif %} +
+
diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html new file mode 100644 index 0000000..f891bf5 --- /dev/null +++ b/code/web/templates/browse/cards/index.html @@ -0,0 +1,958 @@ +{% extends "base.html" %} +{% block content %} + + +
+

Card Browser

+

Browse all {{ total_cards }} cards with filters and search.

+ + {# Error message #} + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {# Filters Panel #} +
+ {# Keyboard shortcuts help button (desktop only) #} + + + {# Shortcuts help tooltip #} + + + {# Search bar #} +
+
+
+ +
+ +
+
+ {% if search %} + + {% endif %} + +
+
+
+ + {# Filter controls #} +
+
+ {# Multi-select theme filter #} + +
+ {# Selected themes as chips #} +
+ {% if themes %} + {% for t in themes %} + + {{ t }} + + + {% endfor %} + {% endif %} +
+ + {# Autocomplete input #} +
+ +
+
+
+
+ +
+ {# Color filter #} + {% if all_colors %} + + + {% endif %} + + {# Type filter #} + {% if all_types %} + + + {% endif %} + + {# Rarity filter #} + {% if all_rarities %} + + + {% endif %} + + {# Sort dropdown #} + + + + + +
+ + {# Advanced filters row #} +
+ {# CMC range filter #} + +
+ + + +
+ + {# Power range filter #} + +
+ + + +
+ + {# Toughness range filter #} + +
+ + + +
+
+
+
+ + {# Results info bar with page indicator #} +
+ + {% if filtered_count is defined and filtered_count != total_cards %} + Showing {{ cards|length }} of {{ filtered_count }} filtered cards ({{ total_cards }} total) + {% else %} + Showing {{ cards|length }} of {{ total_cards }} cards + {% endif %} + {% if search %} matching "{{ search }}"{% endif %} + +
+ + {# Card grid container or no results message #} + {% if cards and cards|length %} +
800 %}data-virtualize="1"{% endif %}> +
+ {% for card in cards %} + {% include "browse/cards/_card_tile.html" %} + {% endfor %} +
+
+ + {# Pagination controls #} + {% if has_next %} +
+ + + Loading... + +
+ {% endif %} + {% else %} + {# No results message with helpful info #} +
+
No cards found
+
+ {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} + No cards match your current filters. + {% if search %}Try a different search term{% endif %}{% if search and (color or card_type or rarity or theme or cmc_min or cmc_max) %} or {% endif %}{% if color or card_type or rarity or theme or cmc_min or cmc_max %}adjust your filters{% endif %}. + {% else %} + Unable to load cards. Please try refreshing the page. + {% endif %} +
+ + {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} +
+ Active filters: + {% if search %} + Search: "{{ search }}" + {% endif %} + {% if theme %} + Theme: {{ theme }} + {% endif %} + {% if color %} + Color: {{ color }} + {% endif %} + {% if card_type %} + Type: {{ card_type }} + {% endif %} + {% if rarity %} + Rarity: {{ rarity|title }} + {% endif %} + {% if cmc_min or cmc_max %} + CMC: {% if cmc_min %}{{ cmc_min }}{% else %}0{% endif %}–{% if cmc_max %}{{ cmc_max }}{% else %}16+{% endif %} + {% endif %} +
+

Clear All Filters

+ {% endif %} +
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/code/web/templates/home.html b/code/web/templates/home.html index b4434bd..2f6c5fd 100644 --- a/code/web/templates/home.html +++ b/code/web/templates/home.html @@ -6,6 +6,7 @@ Run a JSON Config {% if show_setup %}Initial Setup{% endif %} Owned Library + Browse All Cards {% if show_commanders %}Browse Commanders{% endif %} Finished Decks Browse Themes