""" 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'