Merge pull request #43 from mwisnowski/feature/card-browser

Feature/card browser
This commit is contained in:
mwisnowski 2025-10-17 16:22:47 -07:00 committed by GitHub
commit 951f5ef45a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 7386 additions and 1354 deletions

View file

@ -45,6 +45,11 @@ WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1" ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons) SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons)
WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only) WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only)
# ENABLE_CARD_DETAILS=0 # 1=show Card Details button in card browser (experimental feature)
# ENABLE_CARD_SIMILARITIES=0 # 1=enable similarity/synergy features (requires ENABLE_CARD_DETAILS=1 and manual cache build)
# SIMILARITY_CACHE_PATH= # Override similarity cache location (default: card_files/similarity_cache.json)
# SIMILARITY_CACHE_MAX_AGE_DAYS=7 # Days before showing cache refresh prompt (default: 7)
# SIMILARITY_CACHE_DOWNLOAD=1 # 1=download pre-built cache from GitHub (saves 15-20 min), 0=always build locally
############################ ############################
# Partner / Background Mechanics # Partner / Background Mechanics

View file

@ -0,0 +1,171 @@
name: Build Similarity Cache
# Manual trigger + weekly schedule + callable from other workflows
on:
workflow_dispatch:
inputs:
force_rebuild:
description: 'Force rebuild even if cache exists'
required: false
type: boolean
default: true
workflow_call: # Allow this workflow to be called by other workflows
schedule:
# Run every Sunday at 2 AM UTC
- cron: '0 2 * * 0'
jobs:
build-cache:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Check if cache needs rebuild
id: check_cache
run: |
FORCE="${{ github.event.inputs.force_rebuild }}"
if [ "$FORCE" = "true" ] || [ ! -f "card_files/similarity_cache.parquet" ]; then
echo "needs_build=true" >> $GITHUB_OUTPUT
echo "Cache doesn't exist or force rebuild requested"
else
# Check cache age via metadata JSON
CACHE_AGE_DAYS=$(python -c "
import json
from datetime import datetime
from pathlib import Path
metadata_path = Path('card_files/similarity_cache_metadata.json')
if metadata_path.exists():
with open(metadata_path) as f:
data = json.load(f)
build_date = data.get('build_date')
if build_date:
age = (datetime.now() - datetime.fromisoformat(build_date)).days
print(age)
else:
print(999)
else:
print(999)
" || echo "999")
if [ "$CACHE_AGE_DAYS" -gt 7 ]; then
echo "needs_build=true" >> $GITHUB_OUTPUT
echo "Cache is $CACHE_AGE_DAYS days old, rebuilding"
else
echo "needs_build=false" >> $GITHUB_OUTPUT
echo "Cache is only $CACHE_AGE_DAYS days old, skipping"
fi
fi
- name: Run initial setup
if: steps.check_cache.outputs.needs_build == 'true'
run: |
python -c "from code.file_setup.setup import initial_setup; initial_setup()"
- name: Run tagging (serial - more reliable in CI)
if: steps.check_cache.outputs.needs_build == 'true'
run: |
python -c "from code.tagging.tagger import run_tagging; run_tagging(parallel=False)"
- name: Build all_cards.parquet (needed for similarity cache, but not committed)
if: steps.check_cache.outputs.needs_build == 'true'
run: |
python -c "from code.web.services.card_loader import CardCatalogLoader; loader = CardCatalogLoader(); df = loader.load(); print(f'Created all_cards.parquet with {len(df):,} cards')"
- name: Build similarity cache (Parquet)
if: steps.check_cache.outputs.needs_build == 'true'
run: |
python -m code.scripts.build_similarity_cache_parquet --parallel --checkpoint-interval 1000 --force
- name: Verify cache was created
if: steps.check_cache.outputs.needs_build == 'true'
run: |
if [ ! -f "card_files/similarity_cache.parquet" ]; then
echo "ERROR: Cache Parquet file was not created"
exit 1
fi
if [ ! -f "card_files/similarity_cache_metadata.json" ]; then
echo "ERROR: Cache metadata file was not created"
exit 1
fi
# Check cache validity
python -c "
import json
from pathlib import Path
from code.web.services.similarity_cache import get_cache
cache = get_cache()
stats = cache.get_stats()
if stats['total_cards'] < 20000:
raise ValueError(f\"Cache only has {stats['total_cards']} cards, expected ~30k\")
print(f\"✓ Cache is valid with {stats['total_cards']:,} cards, {stats['total_entries']:,} entries\")
print(f\" File size: {stats['file_size_mb']:.2f} MB\")
"
- name: Get cache metadata for commit message
if: steps.check_cache.outputs.needs_build == 'true'
id: cache_meta
run: |
METADATA=$(python -c "
import json
from pathlib import Path
from code.web.services.similarity_cache import get_cache
cache = get_cache()
stats = cache.get_stats()
metadata = cache._metadata or {}
build_date = metadata.get('build_date', 'unknown')
print(f\"{stats['total_cards']} cards, {stats['total_entries']} entries, {stats['file_size_mb']:.1f}MB, built {build_date}\")
")
echo "metadata=$METADATA" >> $GITHUB_OUTPUT
- name: Commit and push cache
if: steps.check_cache.outputs.needs_build == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
# Switch to or create dedicated cache branch
git checkout -b similarity-cache-data || git checkout similarity-cache-data
# Add only the similarity cache files (not all_cards.parquet)
git add card_files/similarity_cache.parquet
git add card_files/similarity_cache_metadata.json
# Check if there are changes to commit
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "chore: update similarity cache [${{ steps.cache_meta.outputs.metadata }}]"
git push origin similarity-cache-data --force
fi
- name: Summary
if: always()
run: |
if [ "${{ steps.check_cache.outputs.needs_build }}" = "true" ]; then
echo "✓ Similarity cache built and committed"
echo " Metadata: ${{ steps.cache_meta.outputs.metadata }}"
else
echo "⊘ Cache is recent, no rebuild needed"
fi

View file

@ -7,9 +7,15 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-cache:
name: Build similarity cache
uses: ./.github/workflows/build-similarity-cache.yml
secrets: inherit
prepare: prepare:
name: Prepare metadata name: Prepare metadata
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-cache
permissions: permissions:
contents: read contents: read
outputs: outputs:
@ -63,6 +69,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5.0.0 uses: actions/checkout@v5.0.0
- name: Download similarity cache from branch
run: |
# Download cache files from similarity-cache-data branch
mkdir -p card_files
wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it"
wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found"
if [ -f card_files/similarity_cache.parquet ]; then
echo "✓ Downloaded similarity cache"
ls -lh card_files/similarity_cache.parquet
fi
- name: Compute amd64 tag - name: Compute amd64 tag
id: arch_tag id: arch_tag
shell: bash shell: bash
@ -120,6 +138,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v5.0.0 uses: actions/checkout@v5.0.0
- name: Download similarity cache from branch
run: |
# Download cache files from similarity-cache-data branch
mkdir -p card_files
wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it"
wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found"
if [ -f card_files/similarity_cache.parquet ]; then
echo "✓ Downloaded similarity cache"
ls -lh card_files/similarity_cache.parquet
fi
- name: Compute arm64 tag - name: Compute arm64 tag
id: arch_tag id: arch_tag
shell: bash shell: bash

View file

@ -9,16 +9,41 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Summary ### Summary
_No unreleased changes yet._ New card browser for exploring 29,839 Magic cards with advanced filters, similar card recommendations, and performance optimizations.
### Added ### Added
_No unreleased changes yet._ - **Card Browser**: Browse and search all Magic cards at `/browse/cards`
- Smart autocomplete for card names and themes with typo tolerance
- Multi-theme filtering (up to 5 themes)
- Color, type, rarity, CMC, power/toughness filters
- Multiple sorting options including EDHREC popularity
- Infinite scroll with shareable filter URLs
- **Card Detail Pages**: Individual card pages with similar card suggestions
- Full card stats, oracle text, and theme tags
- Similar cards based on theme overlap
- Color-coded similarity scores
- Card preview on hover
- Enable with `ENABLE_CARD_DETAILS=1` environment variable
- **Similarity Cache**: Pre-computed card similarities for fast page loads
- Build cache with parallel processing script
- Automatically used when available
- Control with `SIMILARITY_CACHE_ENABLED` environment variable
- **Keyboard Shortcuts**: Quick navigation in card browser
- `Enter` to add autocomplete matches
- `Shift+Enter` to apply filters
- Double `Esc` to clear all filters
### Changed ### Changed
_No unreleased changes yet._ - **Card Database**: Expanded to 29,839 cards (updated from 26,427)
- **Theme Catalog**: Improved coverage with better filtering
### Removed
- **Unused Scripts**: Removed `regenerate_parquet.py` (functionality now in web UI setup)
### Fixed ### Fixed
_No unreleased changes yet._ - **Card Browser UI**: Improved styling consistency and card image loading
- **Infinite Scroll**: Fixed cards appearing multiple times when loading more results
- **Sorting**: Sort order now persists correctly when scrolling through all pages
## [2.8.1] - 2025-10-16 ## [2.8.1] - 2025-10-16
### Summary ### Summary

View file

@ -256,6 +256,8 @@ See `.env.example` for the full catalog. Common knobs:
| `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). |
| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). |
| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). |
| `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/<name>`. |
| `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. |
### Random build controls ### Random build controls

View file

@ -21,6 +21,7 @@ A web-first Commander/EDH deckbuilder with a shared core for CLI, headless, and
- [Initial Setup](#initial-setup) - [Initial Setup](#initial-setup)
- [Owned Library](#owned-library) - [Owned Library](#owned-library)
- [Browse Commanders](#browse-commanders) - [Browse Commanders](#browse-commanders)
- [Browse Cards](#browse-cards)
- [Browse Themes](#browse-themes) - [Browse Themes](#browse-themes)
- [Finished Decks](#finished-decks) - [Finished Decks](#finished-decks)
- [Random Build](#random-build) - [Random Build](#random-build)
@ -164,6 +165,15 @@ Explore the curated commander catalog.
- Refresh via Initial Setup or the commander catalog script above. - 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. - MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot.
### Browse Cards
Search and explore all 29,839 Magic cards.
- **Search & Filters**: Smart autocomplete for card names and themes, multi-theme filtering (up to 5), color identity, type, rarity, CMC range, power/toughness
- **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular
- **Card Details** (optional): Enable with `ENABLE_CARD_DETAILS=1` for individual card pages with similar card recommendations
- **Keyboard Shortcuts**: `Enter` to add matches, `Shift+Enter` to apply filters, double `Esc` to clear all
- **Shareable URLs**: Filter state persists in URL for easy sharing
- Fast lookups powered by pre-built card index and optional similarity cache (`SIMILARITY_CACHE_ENABLED=1`)
### Browse Themes ### Browse Themes
Investigate theme synergies and diagnostics. Investigate theme synergies and diagnostics.
- `ENABLE_THEMES=1` keeps the tile visible (default). - `ENABLE_THEMES=1` keeps the tile visible (default).

View file

@ -1,13 +1,36 @@
# MTG Python Deckbuilder ${VERSION} # MTG Python Deckbuilder ${VERSION}
### Summary ### Summary
_No unreleased changes yet._ New card browser for exploring and discovering cards with advanced filters, similar card recommendations, and fast performance.
### Added ### Added
_No unreleased changes yet._ - **Card Browser**: Browse and search all 29,839 Magic cards at `/browse/cards`
- Smart autocomplete with typo tolerance for card names and themes
- Multi-theme filtering (up to 5 themes)
- Color, type, rarity, CMC, power/toughness filters
- Multiple sorting options including EDHREC popularity
- Infinite scroll with shareable URLs
- **Card Detail Pages**: Individual card pages with similar card suggestions
- Enable with `ENABLE_CARD_DETAILS=1` environment variable
- Full card stats, oracle text, and theme tags
- Similar cards based on theme overlap with color-coded scores
- Card preview on hover
- **Similarity Cache**: Pre-computed card similarities for instant page loads
- Build cache with `python -m code.scripts.build_similarity_cache_parquet --parallel`
- Control with `SIMILARITY_CACHE_ENABLED` environment variable
- **Keyboard Shortcuts**: Quick navigation
- `Enter` to add autocomplete matches
- `Shift+Enter` to apply filters
- Double `Esc` to clear all filters
### Changed ### Changed
_No unreleased changes yet._ - **Card Database**: Expanded to 29,839 cards (from 26,427)
- **Theme Catalog**: Improved coverage and filtering
### Removed
- **Unused Scripts**: Removed redundant `regenerate_parquet.py`
### Fixed ### Fixed
_No unreleased changes yet._ - **Card Browser**: Improved UI consistency and image loading
- **Infinite Scroll**: No more duplicate cards when loading more
- **Sorting**: Sort order now persists correctly across pages

View file

@ -0,0 +1,445 @@
"""
Build similarity cache for all cards in the database using Parquet format.
Pre-computes and stores similarity calculations for ~29k cards to improve
card detail page performance from 2-6s down to <500ms.
NOTE: This script assumes card data and tagging are already complete.
Run setup and tagging separately before building the cache.
Usage:
python -m code.scripts.build_similarity_cache_parquet [--parallel] [--checkpoint-interval 100]
Options:
--parallel Enable parallel processing (faster but uses more CPU)
--checkpoint-interval Save cache every N cards (default: 100)
--force Rebuild cache even if it exists
--dry-run Calculate without saving (for testing)
--workers N Number of parallel workers (default: auto-detect)
"""
import argparse
import logging
import sys
import time
import pandas as pd
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parents[2]
sys.path.insert(0, str(project_root))
from code.web.services.card_similarity import CardSimilarity
from code.web.services.similarity_cache import SimilarityCache, get_cache
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Shared data for worker processes (passed during initialization, not reloaded per worker)
_shared_cards_df = None
_shared_theme_frequencies = None
_shared_cleaned_tags = None
_worker_similarity = None
def _init_worker(cards_df_pickled: bytes, theme_frequencies: dict, cleaned_tags: dict):
"""
Initialize worker process with shared data.
Called once when each worker process starts.
Args:
cards_df_pickled: Pickled DataFrame of all cards
theme_frequencies: Pre-computed theme frequency dict
cleaned_tags: Pre-computed cleaned tags cache
"""
import pickle
import logging
global _shared_cards_df, _shared_theme_frequencies, _shared_cleaned_tags, _worker_similarity
# Unpickle shared data once per worker
_shared_cards_df = pickle.loads(cards_df_pickled)
_shared_theme_frequencies = theme_frequencies
_shared_cleaned_tags = cleaned_tags
# Create worker-level CardSimilarity instance with shared data
_worker_similarity = CardSimilarity(cards_df=_shared_cards_df)
# Override pre-computed data to avoid recomputation
_worker_similarity.theme_frequencies = _shared_theme_frequencies
_worker_similarity.cleaned_tags_cache = _shared_cleaned_tags
# Suppress verbose logging in workers
logging.getLogger("card_similarity").setLevel(logging.WARNING)
def calculate_similarity_for_card(args: tuple) -> tuple[str, list[dict], bool]:
"""
Calculate similarity for a single card (worker function for parallel processing).
Args:
args: Tuple of (card_name, threshold, min_results, limit)
Returns:
Tuple of (card_name, similar_cards, success)
"""
card_name, threshold, min_results, limit = args
try:
# Use the global worker-level CardSimilarity instance
global _worker_similarity
if _worker_similarity is None:
# Fallback if initializer wasn't called (shouldn't happen)
_worker_similarity = CardSimilarity()
# Calculate without using cache (we're building it)
similar_cards = _worker_similarity.find_similar(
card_name=card_name,
threshold=threshold,
min_results=min_results,
limit=limit,
adaptive=True,
use_cache=False,
)
return card_name, similar_cards, True
except Exception as e:
logger.error(f"Failed to calculate similarity for '{card_name}': {e}")
return card_name, [], False
def _add_results_to_cache(cache_df: pd.DataFrame, card_name: str, similar_cards: list[dict]) -> pd.DataFrame:
"""
Add similarity results for a card to the cache DataFrame.
Args:
cache_df: Existing cache DataFrame
card_name: Name of the card
similar_cards: List of similar cards with scores
Returns:
Updated DataFrame
"""
# Build new rows
new_rows = []
for rank, card in enumerate(similar_cards):
new_rows.append({
"card_name": card_name,
"similar_name": card["name"],
"similarity": card["similarity"],
"edhrecRank": card.get("edhrecRank", float("inf")),
"rank": rank,
})
if new_rows:
new_df = pd.DataFrame(new_rows)
cache_df = pd.concat([cache_df, new_df], ignore_index=True)
return cache_df
def build_cache(
parallel: bool = False,
workers: int | None = None,
checkpoint_interval: int = 100,
force: bool = False,
dry_run: bool = False,
) -> None:
"""
Build similarity cache for all cards.
NOTE: Assumes card data (cards.csv, all_cards.parquet) and tagged data already exist.
Run setup and tagging separately before building cache.
Args:
parallel: Enable parallel processing
workers: Number of parallel workers (None = auto-detect)
checkpoint_interval: Save cache every N cards
force: Rebuild even if cache exists
dry_run: Calculate without saving
"""
logger.info("=" * 80)
logger.info("Similarity Cache Builder (Parquet Edition)")
logger.info("=" * 80)
logger.info("")
# Initialize cache
cache = get_cache()
# Quick check for complete cache - if metadata says build is done, exit
if not force and cache.cache_path.exists() and not dry_run:
metadata = cache._metadata or {}
is_complete = metadata.get("build_complete", False)
if is_complete:
stats = cache.get_stats()
logger.info(f"Cache already complete with {stats['total_cards']:,} cards")
logger.info("Use --force to rebuild")
return
else:
stats = cache.get_stats()
logger.info(f"Resuming incomplete cache with {stats['total_cards']:,} cards")
if dry_run:
logger.info("DRY RUN MODE - No changes will be saved")
logger.info("")
# Initialize similarity engine
logger.info("Initializing similarity engine...")
similarity = CardSimilarity()
total_cards = len(similarity.cards_df)
logger.info(f"Loaded {total_cards:,} cards")
logger.info("")
# Filter out low-value lands (single-sided with <3 tags)
df = similarity.cards_df
df["is_land"] = df["type"].str.contains("Land", case=False, na=False)
df["is_multifaced"] = df["layout"].str.lower().isin(["modal_dfc", "transform", "reversible_card", "double_faced_token"])
df["tag_count"] = df["themeTags"].apply(lambda x: len(x.split("|")) if pd.notna(x) and x else 0)
# Keep cards that are either:
# 1. Not lands, OR
# 2. Multi-faced lands, OR
# 3. Single-sided lands with >= 3 tags
keep_mask = (~df["is_land"]) | (df["is_multifaced"]) | (df["is_land"] & (df["tag_count"] >= 3))
card_names = df[keep_mask]["name"].tolist()
skipped_lands = (~keep_mask & df["is_land"]).sum()
logger.info(f"Filtered out {skipped_lands} low-value lands (single-sided with <3 tags)")
logger.info(f"Processing {len(card_names):,} cards ({len(card_names)/total_cards*100:.1f}% of total)")
logger.info("")
# Configuration for similarity calculation
threshold = 0.8
min_results = 3
limit = 20 # Cache up to 20 similar cards per card for variety
# Initialize cache data structure - try to load existing for resume
existing_cache_df = cache.load_cache()
already_processed = set()
if len(existing_cache_df) > 0 and not dry_run:
# Resume from checkpoint - keep existing data
cache_df = existing_cache_df
already_processed = set(existing_cache_df["card_name"].unique())
logger.info(f"Resuming from checkpoint with {len(already_processed):,} cards already processed")
# Setup metadata
metadata = cache._metadata or cache._empty_metadata()
else:
# Start fresh
cache_df = cache._empty_cache_df()
metadata = cache._empty_metadata()
metadata["build_date"] = datetime.now().isoformat()
metadata["threshold"] = threshold
metadata["min_results"] = min_results
# Track stats
start_time = time.time()
processed = len(already_processed) # Start count from checkpoint
failed = 0
checkpoint_count = 0
try:
if parallel:
# Parallel processing - use available CPU cores
import os
import pickle
if workers is not None:
max_workers = max(1, workers) # User-specified, minimum 1
logger.info(f"Using {max_workers} worker processes (user-specified)")
else:
cpu_count = os.cpu_count() or 4
# Use CPU count - 1 to leave one core for system, minimum 4
max_workers = max(4, cpu_count - 1)
logger.info(f"Detected {cpu_count} CPUs, using {max_workers} worker processes")
# Prepare shared data (pickle DataFrame once, share with all workers)
logger.info("Preparing shared data for workers...")
cards_df_pickled = pickle.dumps(similarity.cards_df)
theme_frequencies = similarity.theme_frequencies.copy()
cleaned_tags = similarity.cleaned_tags_cache.copy()
logger.info(f"Shared data prepared: {len(cards_df_pickled):,} bytes (DataFrame), "
f"{len(theme_frequencies)} themes, {len(cleaned_tags)} cleaned tag sets")
# Prepare arguments for cards not yet processed
cards_to_process = [name for name in card_names if name not in already_processed]
logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)")
card_args = [(name, threshold, min_results, limit) for name in cards_to_process]
with ProcessPoolExecutor(
max_workers=max_workers,
initializer=_init_worker,
initargs=(cards_df_pickled, theme_frequencies, cleaned_tags)
) as executor:
# Submit all tasks
future_to_card = {
executor.submit(calculate_similarity_for_card, args): args[0]
for args in card_args
}
# Process results as they complete
for future in as_completed(future_to_card):
card_name, similar_cards, success = future.result()
if success:
cache_df = _add_results_to_cache(cache_df, card_name, similar_cards)
processed += 1
else:
failed += 1
# Progress reporting
total_to_process = len(card_names)
if processed % 100 == 0:
elapsed = time.time() - start_time
# Calculate rate based on cards processed THIS session
cards_this_session = processed - len(already_processed)
rate = cards_this_session / elapsed if elapsed > 0 else 0
cards_remaining = total_to_process - processed
eta = cards_remaining / rate if rate > 0 else 0
logger.info(
f"Progress: {processed}/{total_to_process} "
f"({processed/total_to_process*100:.1f}%) - "
f"Rate: {rate:.1f} cards/sec - "
f"ETA: {eta/60:.1f} min"
)
# Checkpoint save
if not dry_run and processed % checkpoint_interval == 0:
checkpoint_count += 1
cache.save_cache(cache_df, metadata)
logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {processed:,} cards")
else:
# Serial processing - skip already processed cards
cards_to_process = [name for name in card_names if name not in already_processed]
logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)")
for i, card_name in enumerate(cards_to_process, start=1):
try:
similar_cards = similarity.find_similar(
card_name=card_name,
threshold=threshold,
min_results=min_results,
limit=limit,
adaptive=True,
use_cache=False,
)
cache_df = _add_results_to_cache(cache_df, card_name, similar_cards)
processed += 1
except Exception as e:
logger.error(f"Failed to process '{card_name}': {e}")
failed += 1
# Progress reporting
if i % 100 == 0:
elapsed = time.time() - start_time
rate = i / elapsed if elapsed > 0 else 0
cards_remaining = len(card_names) - i
eta = cards_remaining / rate if rate > 0 else 0
logger.info(
f"Progress: {i}/{len(card_names)} "
f"({i/len(card_names)*100:.1f}%) - "
f"Rate: {rate:.1f} cards/sec - "
f"ETA: {eta/60:.1f} min"
)
# Checkpoint save
if not dry_run and i % checkpoint_interval == 0:
checkpoint_count += 1
cache.save_cache(cache_df, metadata)
logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {i:,} cards")
# Final save
if not dry_run:
metadata["last_updated"] = datetime.now().isoformat()
metadata["build_complete"] = True
cache.save_cache(cache_df, metadata)
# Summary
elapsed = time.time() - start_time
logger.info("")
logger.info("=" * 80)
logger.info("Build Complete")
logger.info("=" * 80)
logger.info(f"Total time: {elapsed/60:.2f} minutes")
logger.info(f"Cards processed: {processed:,}")
logger.info(f"Failed: {failed}")
logger.info(f"Checkpoints saved: {checkpoint_count}")
if processed > 0:
logger.info(f"Average rate: {processed/elapsed:.2f} cards/sec")
if not dry_run:
stats = cache.get_stats()
logger.info(f"Cache file size: {stats.get('file_size_mb', 0):.2f} MB")
logger.info(f"Cache location: {cache.cache_path}")
except KeyboardInterrupt:
logger.warning("\nBuild interrupted by user")
# Save partial cache
if not dry_run and len(cache_df) > 0:
metadata["last_updated"] = datetime.now().isoformat()
cache.save_cache(cache_df, metadata)
logger.info(f"Saved partial cache with {processed:,} cards")
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Build similarity cache for all cards (Parquet format)"
)
parser.add_argument(
"--parallel",
action="store_true",
help="Enable parallel processing",
)
parser.add_argument(
"--workers",
type=int,
default=None,
help="Number of parallel workers (default: auto-detect)",
)
parser.add_argument(
"--checkpoint-interval",
type=int,
default=100,
help="Save cache every N cards (default: 100)",
)
parser.add_argument(
"--force",
action="store_true",
help="Rebuild cache even if it exists",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Calculate without saving (for testing)",
)
args = parser.parse_args()
build_cache(
parallel=args.parallel,
workers=args.workers,
checkpoint_interval=args.checkpoint_interval,
force=args.force,
dry_run=args.dry_run,
)
if __name__ == "__main__":
main()

View file

@ -218,6 +218,7 @@ def build_theme_catalog(
cards_filename: str = "cards.csv", cards_filename: str = "cards.csv",
logs_directory: Optional[Path] = None, logs_directory: Optional[Path] = None,
use_parquet: bool = True, use_parquet: bool = True,
min_card_count: int = 3,
) -> CatalogBuildResult: ) -> CatalogBuildResult:
"""Build theme catalog from card data. """Build theme catalog from card data.
@ -229,6 +230,8 @@ def build_theme_catalog(
cards_filename: Name of cards CSV file cards_filename: Name of cards CSV file
logs_directory: Optional directory to copy output to logs_directory: Optional directory to copy output to
use_parquet: If True, try to use all_cards.parquet first (default: True) 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: Returns:
CatalogBuildResult with generated rows and metadata CatalogBuildResult with generated rows and metadata
@ -251,11 +254,16 @@ def build_theme_catalog(
commander_parquet, theme_variants=theme_variants commander_parquet, theme_variants=theme_variants
) )
# CSV method doesn't load non-commander cards, so we don't either # Load all card counts from all_cards.parquet to include all themes
card_counts = Counter() 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 used_parquet = True
print("✓ Loaded theme data from parquet files") 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: except Exception as e:
print(f"⚠ Failed to load from parquet: {e}") print(f"⚠ Failed to load from parquet: {e}")
@ -285,12 +293,19 @@ def build_theme_catalog(
version_hash = _compute_version_hash(display_names) version_hash = _compute_version_hash(display_names)
rows: List[CatalogRow] = [] rows: List[CatalogRow] = []
filtered_count = 0
for key, display in zip(keys, display_names): for key, display in zip(keys, display_names):
if not display: if not display:
continue continue
card_count = int(card_counts.get(key, 0)) card_count = int(card_counts.get(key, 0))
commander_count = int(commander_counts.get(key, 0)) commander_count = int(commander_counts.get(key, 0))
source_count = card_count + commander_count source_count = card_count + commander_count
# Filter out themes below minimum threshold
if source_count < min_card_count:
filtered_count += 1
continue
rows.append( rows.append(
CatalogRow( CatalogRow(
theme=display, theme=display,
@ -330,6 +345,9 @@ def build_theme_catalog(
row.version, row.version,
]) ])
if filtered_count > 0:
print(f" Filtered {filtered_count} themes with <{min_card_count} cards")
if logs_directory is not None: if logs_directory is not None:
logs_directory = logs_directory.resolve() logs_directory = logs_directory.resolve()
logs_directory.mkdir(parents=True, exist_ok=True) logs_directory.mkdir(parents=True, exist_ok=True)
@ -376,6 +394,13 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult:
default=None, default=None,
help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)", 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) args = parser.parse_args(argv)
csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None) 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, csv_directory=csv_dir,
output_path=args.output, output_path=args.output,
logs_directory=args.logs_dir, logs_directory=args.logs_dir,
min_card_count=args.min_cards,
) )
print( print(
f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})", f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})",

View file

@ -124,4 +124,25 @@ TAG_PROTECTION_GRANTS = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() not in (
TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled') TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled')
# M5: Enable protection scope filtering in deck builder (completed - Phase 1-3, in progress Phase 4+) # M5: Enable protection scope filtering in deck builder (completed - Phase 1-3, in progress Phase 4+)
TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled') TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled')
# ----------------------------------------------------------------------------------
# CARD BROWSER FEATURE FLAGS
# ----------------------------------------------------------------------------------
# Enable card detail pages (default: OFF)
# Set to '1' or 'true' to enable card detail pages in card browser
ENABLE_CARD_DETAILS = os.getenv('ENABLE_CARD_DETAILS', '0').lower() not in ('0', 'false', 'off', 'disabled')
# Enable similarity/synergy features (default: OFF)
# Requires ENABLE_CARD_DETAILS=1 and manual cache build via Setup/Tag page
# Shows similar cards based on theme tag overlap using containment scoring
ENABLE_CARD_SIMILARITIES = os.getenv('ENABLE_CARD_SIMILARITIES', '0').lower() not in ('0', 'false', 'off', 'disabled')
# Similarity cache configuration
SIMILARITY_CACHE_PATH = os.getenv('SIMILARITY_CACHE_PATH', 'card_files/similarity_cache.json')
SIMILARITY_CACHE_MAX_AGE_DAYS = int(os.getenv('SIMILARITY_CACHE_MAX_AGE_DAYS', '7'))
# Allow downloading pre-built cache from GitHub (saves 15-20 min build time)
# Set to '0' to always build locally (useful for custom seeds or offline environments)
SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled')

View file

@ -62,6 +62,21 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
maybe_build_index() maybe_build_index()
except Exception: except Exception:
pass 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
# Warm CardSimilarity singleton (if card details enabled) - runs after theme index loads cards
try:
from code.settings import ENABLE_CARD_DETAILS
if ENABLE_CARD_DETAILS:
from .routes.card_browser import get_similarity # type: ignore
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
except Exception:
pass
yield # (no shutdown tasks currently) yield # (no shutdown tasks currently)
@ -2195,6 +2210,7 @@ async def setup_status():
except Exception: except Exception:
return JSONResponse({"running": False, "phase": "error"}) return JSONResponse({"running": False, "phase": "error"})
# Routers # Routers
from .routes import build as build_routes # noqa: E402 from .routes import build as build_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402
@ -2206,6 +2222,7 @@ from .routes import commanders as commanders_routes # noqa: E402
from .routes import partner_suggestions as partner_suggestions_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 telemetry as telemetry_routes # noqa: E402
from .routes import cards as cards_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(build_routes.router)
app.include_router(config_routes.router) app.include_router(config_routes.router)
app.include_router(decks_routes.router) app.include_router(decks_routes.router)
@ -2216,6 +2233,7 @@ app.include_router(commanders_routes.router)
app.include_router(partner_suggestions_routes.router) app.include_router(partner_suggestions_routes.router)
app.include_router(telemetry_routes.router) app.include_router(telemetry_routes.router)
app.include_router(cards_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 # Warm validation cache early to reduce first-call latency in tests and dev
try: try:
@ -2224,6 +2242,8 @@ except Exception:
pass pass
## (Additional startup warmers consolidated into lifespan handler) ## (Additional startup warmers consolidated into lifespan handler)
## Note: CardSimilarity uses lazy initialization pattern like AllCardsLoader
## First card detail page loads in ~200ms (singleton init), subsequent in ~60ms
# --- Exception handling --- # --- Exception handling ---
def _wants_html(request: Request) -> bool: def _wants_html(request: Request) -> bool:

File diff suppressed because it is too large Load diff

View file

@ -157,4 +157,8 @@ async def rebuild_cards():
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def setup_index(request: Request) -> HTMLResponse: async def setup_index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("setup/index.html", {"request": request}) import code.settings as settings
return templates.TemplateResponse("setup/index.html", {
"request": request,
"similarity_enabled": settings.ENABLE_CARD_SIMILARITIES
})

View file

@ -0,0 +1,483 @@
"""
Card similarity service using Jaccard index on theme tags.
Provides similarity scoring between cards based on theme tag overlap.
Used for "Similar Cards" feature in card browser.
Supports persistent caching for improved performance (2-6s <500ms).
Uses "signature tags" approach: compares top 5 most frequent tags instead
of all tags, significantly improving performance and quality.
"""
import ast
import logging
import random
from pathlib import Path
from typing import Optional
import pandas as pd
from code.web.services.similarity_cache import SimilarityCache, get_cache
logger = logging.getLogger(__name__)
class CardSimilarity:
"""Calculate card similarity using theme tag overlap (Jaccard index) with caching."""
def __init__(self, cards_df: Optional[pd.DataFrame] = None, cache: Optional[SimilarityCache] = None):
"""
Initialize similarity calculator.
Args:
cards_df: DataFrame with card data. If None, loads from all_cards.parquet
cache: SimilarityCache instance. If None, uses global singleton
"""
if cards_df is None:
# Load from default location
parquet_path = Path(__file__).parents[3] / "card_files" / "all_cards.parquet"
logger.info(f"Loading cards from {parquet_path}")
self.cards_df = pd.read_parquet(parquet_path)
else:
self.cards_df = cards_df
# Initialize cache
self.cache = cache if cache is not None else get_cache()
# Load theme frequencies from catalog
self.theme_frequencies = self._load_theme_frequencies()
# Pre-compute cleaned tags (with exclusions) for all cards (one-time cost, huge speedup)
# This removes "Historics Matter" and "Legends Matter" from all cards
self.cleaned_tags_cache = self._precompute_cleaned_tags()
# Pre-compute card metadata (EDHREC rank) for fast lookups
self._card_metadata = self._precompute_card_metadata()
# Inverted index (tag -> set of card names) - built lazily on first use
self._tag_to_cards_index = None
logger.info(
f"Initialized CardSimilarity with {len(self.cards_df)} cards "
f"and {len(self.theme_frequencies)} theme frequencies "
f"(cache: {'enabled' if self.cache.enabled else 'disabled'})"
)
def _load_theme_frequencies(self) -> dict[str, int]:
"""
Load theme frequencies from theme_catalog.csv.
Returns:
Dict mapping theme name to card_count (higher = more common)
"""
catalog_path = Path(__file__).parents[3] / "config" / "themes" / "theme_catalog.csv"
try:
# Read CSV, skipping comment line
df = pd.read_csv(catalog_path, comment="#")
# Create dict mapping theme -> card_count
# Higher card_count = more common/frequent theme
frequencies = dict(zip(df["theme"], df["card_count"]))
logger.info(f"Loaded {len(frequencies)} theme frequencies from catalog")
return frequencies
except Exception as e:
logger.warning(f"Failed to load theme frequencies: {e}, using empty dict")
return {}
def _precompute_cleaned_tags(self) -> dict[str, set[str]]:
"""
Pre-compute cleaned tags for all cards.
Removes overly common tags like "Historics Matter" and "Legends Matter"
that don't provide meaningful similarity. This is done once during
initialization to avoid recalculating for every comparison.
Returns:
Dict mapping card name -> cleaned tags (full set minus exclusions)
"""
logger.info("Pre-computing cleaned tags for all cards...")
excluded_tags = {"Historics Matter", "Legends Matter"}
cleaned = {}
for _, row in self.cards_df.iterrows():
card_name = row["name"]
tags = self.parse_theme_tags(row["themeTags"])
if tags:
# Remove excluded tags
cleaned_tags = tags - excluded_tags
if cleaned_tags: # Only store if card has tags after exclusion
cleaned[card_name] = cleaned_tags
logger.info(f"Pre-computed {len(cleaned)} card tag sets")
return cleaned
def _precompute_card_metadata(self) -> dict[str, dict]:
"""
Pre-compute card metadata (EDHREC rank, etc.) for fast lookups.
Returns:
Dict mapping card name -> metadata dict
"""
logger.info("Pre-computing card metadata...")
metadata = {}
for _, row in self.cards_df.iterrows():
card_name = row["name"]
edhrec_rank = row.get("edhrecRank")
# Convert to float, use inf for NaN/None
edhrec_rank = float(edhrec_rank) if pd.notna(edhrec_rank) else float('inf')
metadata[card_name] = {
"edhrecRank": edhrec_rank,
}
logger.info(f"Pre-computed metadata for {len(metadata)} cards")
return metadata
def _build_tag_index(self) -> None:
"""
Build inverted index: tag -> set of card names that have this tag.
This allows fast candidate filtering - instead of checking all 29k cards,
we only check cards that share at least one tag with the target.
Performance impact: Reduces 29k comparisons to typically 100-2000 comparisons.
"""
logger.info("Building inverted tag index...")
index = {}
for card_name, tags in self.cleaned_tags_cache.items():
for tag in tags:
if tag not in index:
index[tag] = set()
index[tag].add(card_name)
self._tag_to_cards_index = index
# Log statistics
avg_cards_per_tag = sum(len(cards) for cards in index.values()) / len(index) if index else 0
logger.info(
f"Built tag index: {len(index)} unique tags, "
f"avg {avg_cards_per_tag:.1f} cards per tag"
)
def get_signature_tags(
self,
card_tags: set[str],
top_n: int = 5,
random_n: Optional[int] = None,
seed: Optional[int] = None,
) -> set[str]:
"""
Get signature tags for similarity comparison.
Takes the most frequent (popular) tags PLUS random tags for diversity.
This balances defining characteristics with discovery of niche synergies.
Excludes overly common tags like "Historics Matter" and "Legends Matter"
that appear on most legendary cards and don't provide meaningful similarity.
Args:
card_tags: Full set of card theme tags
top_n: Number of most frequent tags to use (default 5)
random_n: Number of random tags to add. If None, auto-scales:
- 6-10 tags: 1 random
- 11-15 tags: 2 random
- 16+ tags: 3 random
seed: Random seed for reproducibility (default: None)
Returns:
Set of signature tags (top_n most frequent + random_n random)
"""
# Exclude overly common tags that don't provide meaningful similarity
excluded_tags = {"Historics Matter", "Legends Matter"}
card_tags = card_tags - excluded_tags
if len(card_tags) <= top_n:
return card_tags # Use all if card has few tags
# Auto-scale random_n based on total tag count if not specified
if random_n is None:
tag_count = len(card_tags)
if tag_count >= 16:
random_n = 3
elif tag_count >= 11:
random_n = 2
elif tag_count >= 6:
random_n = 1
else:
random_n = 0 # Very few tags, no random needed
# Sort tags by frequency (higher card_count = more common = higher priority)
sorted_tags = sorted(
card_tags,
key=lambda t: -self.theme_frequencies.get(t, 0), # Negate for descending order
)
# Take top N most frequent tags
signature = set(sorted_tags[:top_n])
# Add random tags from remaining tags
remaining_tags = card_tags - signature
if remaining_tags and random_n > 0:
if seed is not None:
random.seed(seed)
# Sample min(random_n, len(remaining_tags)) to avoid errors
sample_size = min(random_n, len(remaining_tags))
random_tags = set(random.sample(list(remaining_tags), sample_size))
signature = signature | random_tags
return signature
@staticmethod
def parse_theme_tags(tags: str | list) -> set[str]:
"""
Parse theme tags from string or list format.
Args:
tags: Theme tags as string representation of list or actual list
Returns:
Set of theme tag strings
"""
if pd.isna(tags) or not tags:
return set()
if isinstance(tags, list):
return set(tags)
if isinstance(tags, str):
# Handle string representation of list: "['tag1', 'tag2']"
try:
parsed = ast.literal_eval(tags)
if isinstance(parsed, list):
return set(parsed)
return set()
except (ValueError, SyntaxError):
# If parsing fails, return empty set
logger.warning(f"Failed to parse theme tags: {tags[:100]}")
return set()
return set()
@staticmethod
def calculate_similarity(tags_a: set[str], tags_b: set[str]) -> float:
"""
Calculate Jaccard similarity between two sets of theme tags.
Jaccard index = intersection / union
Args:
tags_a: First set of theme tags
tags_b: Second set of theme tags
Returns:
Similarity score from 0.0 (no overlap) to 1.0 (identical)
"""
if not tags_a or not tags_b:
return 0.0
intersection = len(tags_a & tags_b)
union = len(tags_a | tags_b)
if union == 0:
return 0.0
return intersection / union
def get_card_tags(self, card_name: str) -> Optional[set[str]]:
"""
Get theme tags for a specific card.
Args:
card_name: Name of the card
Returns:
Set of theme tags, or None if card not found
"""
card_row = self.cards_df[self.cards_df["name"] == card_name]
if card_row.empty:
return None
tags = card_row.iloc[0]["themeTags"]
return self.parse_theme_tags(tags)
def find_similar(
self,
card_name: str,
threshold: float = 0.8,
limit: int = 10,
min_results: int = 3,
adaptive: bool = True,
use_cache: bool = True,
) -> list[dict]:
"""
Find cards with similar theme tags.
Uses adaptive threshold scaling to ensure minimum number of results.
Tries 80% 60% thresholds until min_results is met (skips 70% for performance).
Checks cache first for pre-computed results, falls back to real-time calculation.
Args:
card_name: Name of the target card
threshold: Starting similarity threshold (0.0-1.0), default 0.8 (80%)
limit: Maximum number of results, default 10
min_results: Minimum desired results for adaptive scaling, default 3
adaptive: Enable adaptive threshold scaling, default True
use_cache: Check cache first before calculating, default True
Returns:
List of dicts with keys: name, similarity, themeTags, edhrecRank, threshold_used
Sorted by similarity descending, then by EDHREC rank ascending (more popular first)
Returns empty list if card not found or has no tags
"""
# Check cache first
if use_cache and self.cache.enabled:
cached_results = self.cache.get_similar(card_name, limit=limit, randomize=True)
if cached_results is not None:
logger.info(f"Cache HIT for '{card_name}' ({len(cached_results)} results, randomized)")
return cached_results
else:
logger.info(f"Cache MISS for '{card_name}', calculating...")
# Get target card tags
target_tags = self.get_card_tags(card_name)
if target_tags is None:
logger.warning(f"Card not found: {card_name}")
return []
if not target_tags:
logger.info(f"Card has no theme tags: {card_name}")
return []
# Get signature tags for TARGET card only (top 5 most frequent + 1-3 random)
# This focuses the search on the target's defining characteristics
# with some diversity from random tags
# Use card name hash as seed for reproducible randomness per card
card_seed = hash(card_name) % (2**31)
target_signature = self.get_signature_tags(
target_tags,
top_n=5,
seed=card_seed
)
logger.debug(
f"Target '{card_name}': {len(target_tags)} tags → "
f"{len(target_signature)} signature tags"
)
# Try adaptive thresholds if enabled
thresholds_to_try = [threshold]
if adaptive:
# Build list of thresholds to try: 80% → 60% → 50% (skip 70% for performance)
thresholds_to_try = []
if threshold >= 0.8:
thresholds_to_try.append(0.8)
if threshold >= 0.6:
thresholds_to_try.append(0.6)
if threshold >= 0.5:
thresholds_to_try.append(0.5)
# Remove duplicates and sort descending
thresholds_to_try = sorted(set(thresholds_to_try), reverse=True)
results = []
threshold_used = threshold
for current_threshold in thresholds_to_try:
# Use inverted index for fast candidate filtering
# Instead of checking all 29k cards, only check cards that share at least one signature tag
results = []
# Build inverted index on first use (lazily)
if self._tag_to_cards_index is None:
self._build_tag_index()
# Get candidate cards that share at least one signature tag
# This drastically reduces the number of cards we need to check
candidate_cards = set()
for tag in target_signature:
if tag in self._tag_to_cards_index:
candidate_cards.update(self._tag_to_cards_index[tag])
# Remove the target card itself
candidate_cards.discard(card_name)
if not candidate_cards:
continue # No candidates at all, try lower threshold
# Now calculate scores only for candidates (vectorized where possible)
# Pre-filter candidates by checking if they meet minimum overlap requirement
min_overlap = int(len(target_signature) * current_threshold)
for candidate_name in candidate_cards:
candidate_tags = self.cleaned_tags_cache.get(candidate_name)
if not candidate_tags:
continue
# Fast overlap check using set intersection
overlap = target_signature & candidate_tags
overlap_count = len(overlap)
# Quick filter: skip if overlap too small
if overlap_count < min_overlap:
continue
# Calculate exact containment score
containment_score = overlap_count / len(target_signature)
if containment_score >= current_threshold:
# Get EDHREC rank efficiently from card metadata
edhrec_rank = self._card_metadata.get(candidate_name, {}).get('edhrecRank', float('inf'))
results.append({
"name": candidate_name,
"similarity": containment_score,
"themeTags": list(candidate_tags),
"edhrecRank": edhrec_rank,
})
# Sort by similarity descending, then by EDHREC rank ascending (lower is better)
# Unranked cards (inf) will appear last
results.sort(key=lambda x: (-x["similarity"], x["edhrecRank"]))
# Check if we have enough results
if len(results) >= min_results or not adaptive:
threshold_used = current_threshold
break
# Log that we're trying a lower threshold
logger.info(
f"Found {len(results)} results at {current_threshold:.0%} "
f"for '{card_name}', trying lower threshold..."
)
# Add threshold_used to results
for result in results:
result["threshold_used"] = threshold_used
logger.info(
f"Found {len(results)} similar cards for '{card_name}' "
f"at {threshold_used:.0%} threshold"
)
final_results = results[:limit]
# Cache the results for future lookups
if use_cache and self.cache.enabled and final_results:
self.cache.set_similar(card_name, final_results)
logger.debug(f"Cached {len(final_results)} results for '{card_name}'")
return final_results

View file

@ -0,0 +1,386 @@
"""
Similarity cache manager for card similarity calculations.
Provides persistent caching of pre-computed card similarity scores to improve
card detail page load times from 2-6s down to <500ms.
Cache format: Parquet file with columnar structure:
- card_name: str (source card)
- similar_name: str (similar card name)
- similarity: float (similarity score)
- edhrecRank: float (EDHREC rank of similar card)
- rank: int (ranking position, 0-19 for top 20)
Metadata stored in separate JSON sidecar file.
Benefits vs JSON:
- 5-10x faster load times
- 50-70% smaller file size
- Better compression for large datasets
- Consistent with other card data storage
"""
import json
import logging
import os
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from datetime import datetime
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Default cache settings
CACHE_VERSION = "2.0" # Bumped for Parquet format
DEFAULT_CACHE_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache.parquet"
DEFAULT_METADATA_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache_metadata.json"
class SimilarityCache:
"""Manages persistent cache for card similarity calculations using Parquet."""
def __init__(self, cache_path: Optional[Path] = None, enabled: bool = True):
"""
Initialize similarity cache manager.
Args:
cache_path: Path to cache file. If None, uses DEFAULT_CACHE_PATH
enabled: Whether cache is enabled (can be disabled via env var)
"""
self.cache_path = cache_path or DEFAULT_CACHE_PATH
self.metadata_path = self.cache_path.with_name(
self.cache_path.stem + "_metadata.json"
)
self.enabled = enabled and os.getenv("SIMILARITY_CACHE_ENABLED", "1") == "1"
self._cache_df: Optional[pd.DataFrame] = None
self._metadata: Optional[dict] = None
# Ensure cache directory exists
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
if self.enabled:
logger.info(f"SimilarityCache initialized at {self.cache_path}")
else:
logger.info("SimilarityCache disabled")
def load_cache(self) -> pd.DataFrame:
"""
Load cache from disk.
Returns:
DataFrame with columns: card_name, similar_name, similarity, edhrecRank, rank
Returns empty DataFrame if file doesn't exist or loading fails
"""
if not self.enabled:
return self._empty_cache_df()
if self._cache_df is not None:
return self._cache_df
if not self.cache_path.exists():
logger.info("Cache file not found, returning empty cache")
self._cache_df = self._empty_cache_df()
return self._cache_df
try:
# Load Parquet file
self._cache_df = pq.read_table(self.cache_path).to_pandas()
# Load metadata
if self.metadata_path.exists():
with open(self.metadata_path, "r", encoding="utf-8") as f:
self._metadata = json.load(f)
else:
self._metadata = self._empty_metadata()
# Validate cache structure
if not self._validate_cache(self._cache_df):
logger.warning("Cache validation failed, returning empty cache")
self._cache_df = self._empty_cache_df()
return self._cache_df
total_cards = len(self._cache_df["card_name"].unique()) if len(self._cache_df) > 0 else 0
logger.info(
f"Loaded similarity cache v{self._metadata.get('version', 'unknown')} with {total_cards:,} cards ({len(self._cache_df):,} entries)"
)
return self._cache_df
except Exception as e:
logger.error(f"Failed to load cache: {e}")
self._cache_df = self._empty_cache_df()
return self._cache_df
def save_cache(self, cache_df: pd.DataFrame, metadata: Optional[dict] = None) -> bool:
"""
Save cache to disk.
Args:
cache_df: DataFrame with similarity data
metadata: Optional metadata dict. If None, uses current metadata with updates.
Returns:
True if save successful, False otherwise
"""
if not self.enabled:
logger.debug("Cache disabled, skipping save")
return False
try:
# Ensure directory exists
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
# Update metadata
if metadata is None:
metadata = self._metadata or self._empty_metadata()
total_cards = len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0
metadata["total_cards"] = total_cards
metadata["last_updated"] = datetime.now().isoformat()
metadata["total_entries"] = len(cache_df)
# Write Parquet file (with compression)
temp_cache = self.cache_path.with_suffix(".tmp")
pq.write_table(
pa.table(cache_df),
temp_cache,
compression="snappy",
version="2.6",
)
temp_cache.replace(self.cache_path)
# Write metadata file
temp_meta = self.metadata_path.with_suffix(".tmp")
with open(temp_meta, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
temp_meta.replace(self.metadata_path)
self._cache_df = cache_df
self._metadata = metadata
logger.info(f"Saved similarity cache with {total_cards:,} cards ({len(cache_df):,} entries)")
return True
except Exception as e:
logger.error(f"Failed to save cache: {e}")
return False
def get_similar(self, card_name: str, limit: int = 5, randomize: bool = True) -> Optional[list[dict]]:
"""
Get cached similar cards for a given card.
Args:
card_name: Name of the card to look up
limit: Maximum number of results to return
randomize: If True, randomly sample from cached results; if False, return top by rank
Returns:
List of similar cards with similarity scores, or None if not in cache
"""
if not self.enabled:
return None
cache_df = self.load_cache()
if len(cache_df) == 0:
return None
# Filter to this card
card_data = cache_df[cache_df["card_name"] == card_name]
if len(card_data) == 0:
return None
# Randomly sample if requested and we have more results than limit
if randomize and len(card_data) > limit:
card_data = card_data.sample(n=limit, random_state=None)
else:
# Sort by rank and take top N
card_data = card_data.sort_values("rank").head(limit)
# Convert to list of dicts
results = []
for _, row in card_data.iterrows():
results.append({
"name": row["similar_name"],
"similarity": row["similarity"],
"edhrecRank": row["edhrecRank"],
})
return results
def set_similar(self, card_name: str, similar_cards: list[dict]) -> bool:
"""
Cache similar cards for a given card.
Args:
card_name: Name of the card
similar_cards: List of similar cards with similarity scores
Returns:
True if successful, False otherwise
"""
if not self.enabled:
return False
cache_df = self.load_cache()
# Remove existing entries for this card
cache_df = cache_df[cache_df["card_name"] != card_name]
# Add new entries
new_rows = []
for rank, card in enumerate(similar_cards):
new_rows.append({
"card_name": card_name,
"similar_name": card["name"],
"similarity": card["similarity"],
"edhrecRank": card.get("edhrecRank", float("inf")),
"rank": rank,
})
if new_rows:
new_df = pd.DataFrame(new_rows)
cache_df = pd.concat([cache_df, new_df], ignore_index=True)
return self.save_cache(cache_df)
def invalidate(self, card_name: Optional[str] = None) -> bool:
"""
Invalidate cache entries.
Args:
card_name: If provided, invalidate only this card. If None, clear entire cache.
Returns:
True if successful, False otherwise
"""
if not self.enabled:
return False
if card_name is None:
# Clear entire cache
logger.info("Clearing entire similarity cache")
self._cache_df = self._empty_cache_df()
self._metadata = self._empty_metadata()
return self.save_cache(self._cache_df, self._metadata)
# Clear specific card
cache_df = self.load_cache()
initial_len = len(cache_df)
cache_df = cache_df[cache_df["card_name"] != card_name]
if len(cache_df) < initial_len:
logger.info(f"Invalidated cache for card: {card_name}")
return self.save_cache(cache_df)
return False
def get_stats(self) -> dict:
"""
Get cache statistics.
Returns:
Dictionary with cache stats (version, total_cards, build_date, file_size, etc.)
"""
if not self.enabled:
return {"enabled": False}
cache_df = self.load_cache()
metadata = self._metadata or self._empty_metadata()
stats = {
"enabled": True,
"version": metadata.get("version", "unknown"),
"total_cards": len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0,
"total_entries": len(cache_df),
"build_date": metadata.get("build_date"),
"last_updated": metadata.get("last_updated"),
"file_exists": self.cache_path.exists(),
"file_path": str(self.cache_path),
"format": "parquet",
}
if self.cache_path.exists():
stats["file_size_mb"] = round(
self.cache_path.stat().st_size / (1024 * 1024), 2
)
return stats
@staticmethod
def _empty_cache_df() -> pd.DataFrame:
"""
Create empty cache DataFrame.
Returns:
Empty DataFrame with correct schema
"""
return pd.DataFrame(columns=["card_name", "similar_name", "similarity", "edhrecRank", "rank"])
@staticmethod
def _empty_metadata() -> dict:
"""
Create empty metadata structure.
Returns:
Empty metadata dictionary
"""
return {
"version": CACHE_VERSION,
"total_cards": 0,
"total_entries": 0,
"build_date": None,
"last_updated": None,
"threshold": 0.6,
"min_results": 3,
}
@staticmethod
def _validate_cache(cache_df: pd.DataFrame) -> bool:
"""
Validate cache DataFrame structure.
Args:
cache_df: DataFrame to validate
Returns:
True if valid, False otherwise
"""
if not isinstance(cache_df, pd.DataFrame):
return False
# Check required columns
required_cols = {"card_name", "similar_name", "similarity", "edhrecRank", "rank"}
if not required_cols.issubset(cache_df.columns):
logger.warning(f"Cache missing required columns. Expected: {required_cols}, Got: {set(cache_df.columns)}")
return False
return True
# Singleton instance for global access
_cache_instance: Optional[SimilarityCache] = None
def get_cache() -> SimilarityCache:
"""
Get singleton cache instance.
Returns:
Global SimilarityCache instance
"""
global _cache_instance
if _cache_instance is None:
# Check environment variables for custom path
cache_path_str = os.getenv("SIMILARITY_CACHE_PATH")
cache_path = Path(cache_path_str) if cache_path_str else None
_cache_instance = SimilarityCache(cache_path=cache_path)
return _cache_instance

View file

@ -727,3 +727,475 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; 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;
}
/* Card Details button on tiles */
.card-details-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: var(--primary);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.85rem;
transition: all 0.2s;
margin-top: 0.5rem;
border: none;
cursor: pointer;
}
.card-details-btn:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
.card-details-btn svg {
flex-shrink: 0;
}
/* Card Preview Modal */
.preview-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
align-items: center;
justify-content: center;
}
.preview-modal.active {
display: flex;
}
.preview-content {
position: relative;
max-width: 90%;
max-height: 90%;
}
.preview-content img {
max-width: 100%;
max-height: 90vh;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.preview-close {
position: absolute;
top: -40px;
right: 0;
background: rgba(255, 255, 255, 0.9);
color: #000;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 24px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.preview-close:hover {
background: #fff;
transform: scale(1.1);
}
/* 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);
}
/* Card Detail Page Styles */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
margin-bottom: 1rem;
}
.card-tag {
background: var(--ring);
color: white;
padding: 0.35rem 0.75rem;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 500;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--panel);
color: var(--text);
text-decoration: none;
border-radius: 8px;
border: 1px solid var(--border);
font-weight: 500;
transition: all 0.2s;
margin-bottom: 2rem;
}
.back-button:hover {
background: var(--ring);
color: white;
border-color: var(--ring);
}
/* Card Detail Page - Main Card Image */
.card-image-large {
flex: 0 0 auto;
max-width: 360px !important;
width: 100%;
}
.card-image-large img {
width: 100%;
height: auto;
border-radius: 12px;
}

View file

@ -85,6 +85,7 @@
<a href="/configs">Build from JSON</a> <a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %} {% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a> <a href="/owned">Owned Library</a>
<a href="/cards">All Cards</a>
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %} {% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
<a href="/decks">Finished Decks</a> <a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a> <a href="/themes/">Themes</a>

View file

@ -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) #}
<div id="load-more-container" hx-swap-oob="true" style="grid-column: 1 / -1; text-align:center; margin-top:1rem;">
{% if has_next %}
<button
type="button"
class="btn"
hx-get="/cards/grid?cursor={{ last_card|urlencode }}{% if search %}&search={{ search|urlencode }}{% endif %}{% for theme in themes %}&themes={{ theme|urlencode }}{% endfor %}{% if color %}&color={{ color|urlencode }}{% endif %}{% if card_type %}&card_type={{ card_type|urlencode }}{% endif %}{% if rarity %}&rarity={{ rarity|urlencode }}{% endif %}{% if sort and sort != 'name_asc' %}&sort={{ sort|urlencode }}{% endif %}{% if cmc_min %}&cmc_min={{ cmc_min }}{% endif %}{% if cmc_max %}&cmc_max={{ cmc_max }}{% endif %}{% if power_min %}&power_min={{ power_min }}{% endif %}{% if power_max %}&power_max={{ power_max }}{% endif %}{% if tough_min %}&tough_min={{ tough_min }}{% endif %}{% if tough_max %}&tough_max={{ tough_max }}{% endif %}"
hx-target="#card-grid"
hx-swap="beforeend"
hx-indicator="#load-indicator">
Load More
</button>
<span id="load-indicator" class="htmx-indicator muted" style="margin-left:.5rem;">
Loading...
</span>
{% endif %}
</div>

View file

@ -0,0 +1,77 @@
{# 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 '' }}">
{# 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"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
/>
{# 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;">
{{ card.name }}
</div>
{# Owned indicator #}
{% if card.is_owned %}
<div style="position:absolute; top:4px; right:4px; background:rgba(34,197,94,0.9); color:white; padding:2px 6px; border-radius:4px; font-size:12px; font-weight:600;">
✓ Owned
</div>
{% endif %}
</div>
{# Card info #}
<div class="card-browser-tile-info">
{# Card name #}
<div class="card-browser-tile-name" title="{{ card.name }}">
{{ card.name }}
</div>
{# Type line #}
{% if card.type %}
<div class="card-browser-tile-type" title="{{ card.type }}">
{{ card.type }}
</div>
{% endif %}
{# Mana cost and color identity #}
<div class="card-browser-tile-stats">
{% if card.manaValue is defined and card.manaValue is not none %}
<span style="font-size:12px; color:#cbd5e1;">CMC: {{ card.manaValue }}</span>
{% endif %}
{% if card.is_colorless %}
<div style="display:flex; gap:2px;">
<span class="mana mana-C" style="width:16px; height:16px; font-size:10px;" title="Colorless"></span>
</div>
{% elif card.colorIdentity %}
<div style="display:flex; gap:2px;">
{% for color in card.colorIdentity %}
<span class="mana mana-{{ color }}" style="width:16px; height:16px; font-size:10px;" title="{{ color }}"></span>
{% endfor %}
</div>
{% endif %}
</div>
{# Card Details button (only show if feature enabled) #}
{% if enable_card_details %}
<a href="/cards/{{ card.name }}" class="card-details-btn" onclick="event.stopPropagation()">
Card Details
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M8.707 3.293a1 1 0 010 1.414L5.414 8l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" transform="rotate(180 8 8)"/>
</svg>
</a>
{% endif %}
{# Theme tags (show all tags, not truncated) #}
{% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %}
<div class="card-browser-tile-tags">
{% for tag in card.themeTags_parsed %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,250 @@
<style>
.similar-cards-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.similar-cards-title {
font-size: 1.5rem;
font-weight: bold;
color: var(--text);
}
.similar-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
gap: 1.25rem;
margin-bottom: 2rem;
justify-content: start;
}
.similar-card-tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.85rem;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 280px;
}
.similar-card-tile:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
border-color: var(--ring);
}
.similar-card-image {
width: 100%;
cursor: pointer;
border-radius: 8px;
transition: transform 0.2s;
}
.similar-card-image:hover {
transform: scale(1.02);
}
.similar-card-image img {
width: 100%;
height: auto;
border-radius: 8px;
}
.similar-card-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.similar-card-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.similarity-score {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: var(--ring);
color: white;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 600;
width: fit-content;
}
.similarity-score-high {
background: #28a745;
}
.similarity-score-medium {
background: #ffc107;
color: #000;
}
.similarity-score-low {
background: #6c757d;
}
.similar-card-details-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--ring);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: all 0.2s;
margin-top: auto;
}
.similar-card-details-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.no-similar-cards {
text-align: center;
padding: 3rem 1rem;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
}
.no-similar-cards-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-similar-cards-text {
font-size: 1.1rem;
font-weight: 500;
}
.similar-card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
}
.similar-tag {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: rgba(148, 163, 184, 0.15);
color: var(--muted);
border-radius: 4px;
white-space: nowrap;
transition: all 0.2s;
}
.similar-tag-overlap {
background: var(--accent, #38bdf8);
color: white;
font-weight: 600;
border: 1px solid rgba(56, 189, 248, 0.3);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.2);
}
@media (max-width: 768px) {
.similar-cards-grid {
grid-template-columns: 1fr;
}
}
</style>
<div class="similar-cards-section">
<div class="similar-cards-header">
<h2 class="similar-cards-title">Similar Cards</h2>
</div>
{% if similar_cards and similar_cards|length > 0 %}
<div class="similar-cards-grid">
{% for card in similar_cards %}
<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"
alt="{{ card.name }}"
loading="lazy"
data-card-name="{{ card.name }}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
{# Fallback for missing images #}
<div style="display:none; width:100%; aspect-ratio:488/680; align-items:center; justify-content:center; background:#1a1d24; color:#9ca3af; font-size:14px; padding:1rem; text-align:center; border-radius:8px;">
{{ card.name }}
</div>
</div>
<!-- Card Info -->
<div class="similar-card-info">
<div class="similar-card-name">{{ card.name }}</div>
<!-- Matching Themes Summary -->
{% if card.themeTags and card.themeTags|length > 0 %}
{% set main_card_tags = main_card_tags|default([]) %}
{% set matching_tags = [] %}
{% for tag in card.themeTags %}
{% if tag in main_card_tags %}
{% set _ = matching_tags.append(tag) %}
{% endif %}
{% endfor %}
{% if matching_tags|length > 0 %}
<div style="font-size: 0.8rem; color: var(--accent, #38bdf8); font-weight: 600; margin-top: 0.25rem;">
✓ {{ matching_tags|length }} matching theme{{ 's' if matching_tags|length > 1 else '' }}
</div>
{% endif %}
{% endif %}
<!-- EDHREC Rank -->
{% if card.edhrecRank %}
<div class="card-stat" style="font-size: 0.85rem; color: var(--muted);">
EDHREC Rank: #{{ card.edhrecRank }}
</div>
{% endif %}
<!-- Theme Tags with Overlap Highlighting -->
{% if card.themeTags and card.themeTags|length > 0 %}
<div class="similar-card-tags">
{% set main_card_tags = main_card_tags|default([]) %}
{% for tag in card.themeTags %}
{% set is_overlap = tag in main_card_tags %}
<span class="similar-tag {% if is_overlap %}similar-tag-overlap{% endif %}" title="{% if is_overlap %}Matches main card{% endif %}">
{{ tag }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Card Details Button -->
<a href="/cards/{{ card.name }}" class="similar-card-details-btn" onclick="event.stopPropagation()">
Card Details
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8.707 3.293a1 1 0 010 1.414L5.414 8l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" transform="rotate(180 8 8)"/>
</svg>
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-similar-cards">
<div class="no-similar-cards-icon">🔍</div>
<div class="no-similar-cards-text">No similar cards found</div>
<p style="margin-top: 0.5rem; font-size: 0.9rem;">
This card has unique theme tags or no cards share similar characteristics.
</p>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,273 @@
{% extends "base.html" %}
{% block title %}{{ card.name }} - Card Details{% endblock %}
{% block head %}
<style>
.card-detail-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1rem;
}
.card-detail-header {
display: flex;
gap: 2rem;
margin-bottom: 3rem;
flex-wrap: wrap;
}
.card-image-large {
flex: 0 0 auto;
max-width: 360px;
cursor: pointer;
transition: transform 0.2s;
}
.card-image-large:hover {
transform: scale(1.02);
}
.card-image-large img {
width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.card-info {
flex: 1;
min-width: 300px;
}
.card-title {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
color: var(--text);
}
.card-type {
font-size: 1.1rem;
color: var(--muted);
margin-bottom: 1rem;
}
.card-stats {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.card-stat {
display: flex;
flex-direction: column;
}
.card-stat-label {
font-size: 0.85rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.card-stat-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.card-text {
background: var(--panel);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
line-height: 1.6;
white-space: pre-wrap;
border: 1px solid var(--border);
}
.card-colors {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.color-symbol {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
border: 2px solid currentColor;
}
.color-W { background: #F0E68C; color: #000; }
.color-U { background: #0E68AB; color: #fff; }
.color-B { background: #150B00; color: #fff; }
.color-R { background: #D32029; color: #fff; }
.color-G { background: #00733E; color: #fff; }
.color-C { background: #ccc; color: #000; }
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.card-tag {
background: var(--ring);
color: white;
padding: 0.35rem 0.75rem;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 500;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--panel);
color: var(--text);
text-decoration: none;
border-radius: 8px;
border: 1px solid var(--border);
font-weight: 500;
transition: all 0.2s;
margin-bottom: 2rem;
}
.back-button:hover {
background: var(--ring);
color: white;
border-color: var(--ring);
}
.similar-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 2px solid var(--border);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card-detail-header {
flex-direction: column;
align-items: center;
}
.card-image-large {
max-width: 100%;
}
.card-stats {
gap: 1rem;
}
.card-title {
font-size: 1.5rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="card-detail-container">
<!-- Back Button -->
<a href="/cards" class="back-button">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/>
</svg>
Back to Card Browser
</a>
<!-- Card Header -->
<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"
alt="{{ card.name }}"
loading="lazy"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
{# Fallback for missing images #}
<div style="display:none; width:100%; height:680px; align-items:center; justify-content:center; background:#1a1d24; color:#9ca3af; font-size:18px; padding:2rem; text-align:center; border-radius:12px;">
{{ card.name }}
</div>
</div>
<!-- Card Info -->
<div class="card-info">
<h1 class="card-title">{{ card.name }}</h1>
<div class="card-type">{{ card.type }}</div>
<!-- Color Identity -->
{% if card.colors %}
<div class="card-colors">
{% for color in card.colors %}
<span class="color-symbol color-{{ color }}">{{ color }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Stats -->
<div class="card-stats">
{% if card.manaValue is not none %}
<div class="card-stat">
<span class="card-stat-label">Mana Value</span>
<span class="card-stat-value">{{ card.manaValue }}</span>
</div>
{% endif %}
{% if card.power is not none and card.power != 'NaN' and card.power|string != 'nan' %}
<div class="card-stat">
<span class="card-stat-label">Power / Toughness</span>
<span class="card-stat-value">{{ card.power }} / {{ card.toughness }}</span>
</div>
{% endif %}
{% if card.edhrecRank %}
<div class="card-stat">
<span class="card-stat-label">EDHREC Rank</span>
<span class="card-stat-value">#{{ card.edhrecRank }}</span>
</div>
{% endif %}
{% if card.rarity %}
<div class="card-stat">
<span class="card-stat-label">Rarity</span>
<span class="card-stat-value">{{ card.rarity | capitalize }}</span>
</div>
{% endif %}
</div>
<!-- Oracle Text -->
{% if card.text %}
<div class="card-text" style="white-space: pre-line;">{{ card.text | replace('\\n', '\n') }}</div>
{% endif %}
<!-- Theme Tags -->
{% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %}
<div class="card-tags">
{% for tag in card.themeTags_parsed %}
<span class="card-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Similar Cards Section -->
<div class="similar-section">
{% include "browse/cards/_similar_cards.html" %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,958 @@
{% extends "base.html" %}
{% block content %}
<style>
/* Autocomplete dropdown styles (matching commanders page) */
.autocomplete-container { position: relative; }
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: var(--card-bg, #1a1d24);
border: 1px solid var(--border, #374151);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.autocomplete-dropdown:empty { display: none; }
.autocomplete-item {
padding: .75rem;
cursor: pointer;
border-bottom: 1px solid rgba(55, 65, 81, 0.5);
transition: background 0.15s;
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover, .autocomplete-item:focus, .autocomplete-item.selected {
background: rgba(148, 163, 184, .15);
}
.autocomplete-item.selected {
background: rgba(148, 163, 184, .25);
border-left: 3px solid var(--ring, #3b82f6);
padding-left: calc(.75rem - 3px);
}
.autocomplete-empty {
padding: .75rem;
text-align: center;
color: var(--muted, #9ca3af);
font-size: .85rem;
}
.autocomplete-error {
padding: .75rem;
text-align: center;
color: #f87171;
font-size: .85rem;
}
/* Keyboard shortcuts help button - desktop only */
.shortcuts-help-btn {
display: none !important; /* Hidden by default (mobile) */
}
@media (min-width: 768px) {
.shortcuts-help-btn {
display: flex !important; /* Show on desktop */
}
}
</style>
<section class="card-browser-container">
<h3>Card Browser</h3>
<p class="muted">Browse all {{ total_cards }} cards with filters and search.</p>
{# Error message #}
{% if error %}
<div class="error" style="margin:.5rem 0 1rem 0; padding:.75rem; background:#7f1d1d; border:1px solid #dc2626; border-radius:6px; color:#fef2f2;">
{{ error }}
</div>
{% endif %}
{# Filters Panel #}
<div class="card-browser-filters" style="position: relative;">
{# Keyboard shortcuts help button (desktop only) #}
<button
type="button"
id="shortcuts-help-btn"
class="shortcuts-help-btn"
style="position: absolute; top: 0.5rem; right: 0.5rem; width: 28px; height: 28px; border-radius: 50%; background: #444; border: 1px solid #666; color: #fff; font-weight: bold; cursor: pointer; font-size: 16px; display: none; align-items: center; justify-content: center; padding: 0; line-height: 1;"
title="Keyboard Shortcuts"
onclick="toggleShortcutsHelp()"
>?</button>
{# Shortcuts help tooltip #}
<div
id="shortcuts-help-tooltip"
style="display: none; position: absolute; top: 2.5rem; right: 0.5rem; background: #2a2a2a; border: 1px solid #666; border-radius: 6px; padding: 1rem; min-width: 320px; max-width: 400px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);"
>
<h4 style="margin-top: 0; margin-bottom: 0.75rem; font-size: 1.1rem;">Keyboard Shortcuts</h4>
<div style="font-size: 0.9rem; line-height: 1.6;">
<div style="margin-bottom: 0.5rem;"><strong>Enter</strong> - Add first theme match / Select autocomplete</div>
<div style="margin-bottom: 0.5rem;"><strong>Shift+Enter</strong> - Add current theme & apply filters</div>
<div style="margin-bottom: 0.5rem;"><strong>Escape</strong> - Close dropdown</div>
<div style="margin-bottom: 0.5rem;"><strong>Escape×2</strong> - Clear all filters (within 0.5s)</div>
<div style="margin-bottom: 0.5rem;"><strong>↑/↓</strong> - Navigate autocomplete</div>
</div>
<button
type="button"
onclick="toggleShortcutsHelp()"
style="margin-top: 0.75rem; padding: 0.4rem 0.8rem; background: #555; border: 1px solid #777; border-radius: 4px; color: #fff; cursor: pointer; width: 100%;"
>Close</button>
</div>
{# Search bar #}
<div class="filter-section">
<form method="get" id="card-search-form">
<div class="filter-row">
<label for="search-input">Search</label>
<div class="autocomplete-container" style="position:relative; flex: 1; max-width: 300px;">
<input
type="text"
name="search"
id="search-input"
data-autocomplete-param="q"
value="{{ search }}"
placeholder="Search card names..."
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-controls="search-autocomplete-dropdown"
aria-expanded="false"
style="width: 100%;"
/>
<div id="search-autocomplete-dropdown" class="autocomplete-dropdown" role="listbox" aria-label="Card name suggestions"></div>
</div>
{% if search %}
<button type="button" onclick="document.getElementById('search-input').value=''; document.getElementById('card-search-form').submit();" class="btn" style="padding:.3rem .75rem;">Clear</button>
{% endif %}
<button type="submit" class="btn">Search</button>
</div>
</form>
</div>
{# Filter controls #}
<div class="filter-section" style="margin-top: 1rem;">
<div class="filter-row">
{# Multi-select theme filter #}
<label for="filter-theme-input">Themes (up to 5)</label>
<div style="flex: 1; max-width: 500px;">
{# Selected themes as chips #}
<div id="selected-themes" style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem; min-height: 2rem;">
{% if themes %}
{% for t in themes %}
<span class="theme-chip" data-theme="{{ t }}">
{{ t }}
<button type="button" onclick="removeTheme('{{ t }}')" style="margin-left:0.5rem; background:none; border:none; color:inherit; cursor:pointer; padding:0; font-weight:bold;">&times;</button>
</span>
{% endfor %}
{% endif %}
</div>
{# Autocomplete input #}
<div class="autocomplete-container" style="position:relative;">
<input
type="text"
id="filter-theme-input"
placeholder="Add theme..."
autocomplete="off"
role="combobox"
aria-autocomplete="list"
aria-controls="theme-autocomplete-dropdown"
aria-expanded="false"
style="width: 100%;"
/>
<div id="theme-autocomplete-dropdown" class="autocomplete-dropdown" role="listbox" aria-label="Theme suggestions"></div>
</div>
</div>
</div>
<div class="filter-row">
{# Color filter #}
{% if all_colors %}
<label for="filter-color">Color</label>
<select
name="color"
id="filter-color"
onchange="applyFilter()">
<option value="">All Colors</option>
{% for group_name, group_colors in all_colors %}
<optgroup label="{{ group_name }}">
{% for color_id, display_name in group_colors %}
<option value="{{ color_id }}" {% if color == color_id %}selected{% endif %}>{{ display_name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
{% endif %}
{# Type filter #}
{% if all_types %}
<label for="filter-type">Type</label>
<select
name="type"
id="filter-type"
onchange="applyFilter()">
<option value="">All Types</option>
{% for t in all_types %}
<option value="{{ t }}" {% if card_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
{% endif %}
{# Rarity filter #}
{% if all_rarities %}
<label for="filter-rarity">Rarity</label>
<select
name="rarity"
id="filter-rarity"
onchange="applyFilter()">
<option value="">All Rarities</option>
{% for r in all_rarities %}
<option value="{{ r }}" {% if rarity == r %}selected{% endif %}>{{ r|title }}</option>
{% endfor %}
</select>
{% endif %}
{# Sort dropdown #}
<label for="filter-sort">Sort By</label>
<select
name="sort"
id="filter-sort"
onchange="applyFilter()">
<option value="name_asc" {% if sort == 'name_asc' or not sort %}selected{% endif %}>Name (A-Z)</option>
<option value="name_desc" {% if sort == 'name_desc' %}selected{% endif %}>Name (Z-A)</option>
<option value="cmc_asc" {% if sort == 'cmc_asc' %}selected{% endif %}>CMC (Low-High)</option>
<option value="cmc_desc" {% if sort == 'cmc_desc' %}selected{% endif %}>CMC (High-Low)</option>
<option value="power_desc" {% if sort == 'power_desc' %}selected{% endif %}>Power (High-Low)</option>
<option value="edhrec_asc" {% if sort == 'edhrec_asc' %}selected{% endif %}>EDHREC Rank (Popular)</option>
</select>
<button type="button" class="btn" onclick="applyFilter()">Apply Filters</button>
<button type="button" class="btn" onclick="window.location.href='/cards'" style="background-color: #666;">Clear Filters</button>
</div>
{# Advanced filters row #}
<div class="filter-row" style="margin-top: 0.75rem;">
{# CMC range filter #}
<label for="filter-cmc-min">CMC Range</label>
<div style="display:flex; align-items:center; gap:0.5rem; flex:1; max-width:300px;">
<input
type="number"
id="filter-cmc-min"
min="0"
max="16"
value="{{ cmc_min if cmc_min is defined else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
/>
<span></span>
<input
type="number"
id="filter-cmc-max"
min="0"
max="16"
value="{{ cmc_max if cmc_max is defined else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"
/>
</div>
{# Power range filter #}
<label for="filter-power-min">Power</label>
<div style="display:flex; align-items:center; gap:0.5rem; flex:1; max-width:300px;">
<input
type="number"
id="filter-power-min"
min="0"
max="99"
value="{{ power_min if power_min is defined else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
/>
<span></span>
<input
type="number"
id="filter-power-max"
min="0"
max="99"
value="{{ power_max if power_max is defined else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"
/>
</div>
{# Toughness range filter #}
<label for="filter-tough-min">Toughness</label>
<div style="display:flex; align-items:center; gap:0.5rem; flex:1; max-width:300px;">
<input
type="number"
id="filter-tough-min"
min="0"
max="99"
value="{{ tough_min if tough_min is defined else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
/>
<span></span>
<input
type="number"
id="filter-tough-max"
min="0"
max="99"
value="{{ tough_max if tough_max is defined else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"
/>
</div>
</div>
</div>
</div>
{# Results info bar with page indicator #}
<div id="card-browser-info" class="card-browser-info">
<span id="results-count" class="results-count">
{% 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 %}
</span>
</div>
{# Card grid container or no results message #}
{% if cards and cards|length %}
<div id="card-grid-container"
class="card-browser-grid"
{% if total_cards > 800 %}data-virtualize="1"{% endif %}>
<div id="card-grid" style="display:contents;">
{% for card in cards %}
{% include "browse/cards/_card_tile.html" %}
{% endfor %}
</div>
</div>
{# Pagination controls #}
{% if has_next %}
<div id="load-more-container" class="card-browser-pagination">
<button
type="button"
class="btn"
hx-get="/cards/grid?cursor={{ last_card|urlencode }}{% if search %}&search={{ search|urlencode }}{% endif %}{% for theme in themes %}&themes={{ theme|urlencode }}{% endfor %}{% if color %}&color={{ color|urlencode }}{% endif %}{% if card_type %}&card_type={{ card_type|urlencode }}{% endif %}{% if rarity %}&rarity={{ rarity|urlencode }}{% endif %}{% if sort and sort != 'name_asc' %}&sort={{ sort|urlencode }}{% endif %}{% if cmc_min %}&cmc_min={{ cmc_min }}{% endif %}{% if cmc_max %}&cmc_max={{ cmc_max }}{% endif %}{% if power_min %}&power_min={{ power_min }}{% endif %}{% if power_max %}&power_max={{ power_max }}{% endif %}{% if tough_min %}&tough_min={{ tough_min }}{% endif %}{% if tough_max %}&tough_max={{ tough_max }}{% endif %}"
hx-target="#card-grid"
hx-swap="beforeend"
hx-indicator="#load-indicator">
Load More
</button>
<span id="load-indicator" class="htmx-indicator muted card-browser-loading">
Loading...
</span>
</div>
{% endif %}
{% else %}
{# No results message with helpful info #}
<div class="no-results">
<div class="no-results-title">No cards found</div>
<div class="no-results-message">
{% 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 %}
</div>
{% if search or color or card_type or rarity or theme or cmc_min or cmc_max %}
<div class="no-results-filters">
<strong style="color: var(--text); margin-right: 0.5rem;">Active filters:</strong>
{% if search %}
<span class="no-results-filter-tag">Search: "{{ search }}"</span>
{% endif %}
{% if theme %}
<span class="no-results-filter-tag">Theme: {{ theme }}</span>
{% endif %}
{% if color %}
<span class="no-results-filter-tag">Color: {{ color }}</span>
{% endif %}
{% if card_type %}
<span class="no-results-filter-tag">Type: {{ card_type }}</span>
{% endif %}
{% if rarity %}
<span class="no-results-filter-tag">Rarity: {{ rarity|title }}</span>
{% endif %}
{% if cmc_min or cmc_max %}
<span class="no-results-filter-tag">CMC: {% if cmc_min %}{{ cmc_min }}{% else %}0{% endif %}{% if cmc_max %}{{ cmc_max }}{% else %}16+{% endif %}</span>
{% endif %}
</div>
<p><a href="/cards" class="btn">Clear All Filters</a></p>
{% endif %}
</div>
{% endif %}
</section>
<script>
// Toggle shortcuts help tooltip
function toggleShortcutsHelp() {
const tooltip = document.getElementById('shortcuts-help-tooltip');
if (tooltip) {
tooltip.style.display = tooltip.style.display === 'none' ? 'block' : 'none';
}
}
// Close tooltip when clicking outside
document.addEventListener('click', (e) => {
const tooltip = document.getElementById('shortcuts-help-tooltip');
const helpBtn = document.getElementById('shortcuts-help-btn');
if (tooltip && helpBtn &&
tooltip.style.display === 'block' &&
!tooltip.contains(e.target) &&
!helpBtn.contains(e.target)) {
tooltip.style.display = 'none';
}
});
// Global Escape key handler for clearing filters (works anywhere on page)
(function() {
let lastGlobalEscapeTime = 0;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const now = Date.now();
const timeSinceLastEscape = now - lastGlobalEscapeTime;
// Check if we're in a text input (let those handle their own Escape)
const activeElement = document.activeElement;
const isInTextInput = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA'
);
if (!isInTextInput && timeSinceLastEscape < 500) {
// Double Escape outside of inputs - clear all filters
console.log('Global double Escape detected - clearing filters');
e.preventDefault();
window.location.href = '/cards';
} else if (!isInTextInput) {
// First escape outside input
lastGlobalEscapeTime = now;
}
}
});
})();
function applyFilter() {
const color = document.getElementById('filter-color')?.value || '';
const type = document.getElementById('filter-type')?.value || '';
const rarity = document.getElementById('filter-rarity')?.value || '';
const search = document.getElementById('search-input')?.value || '';
const sort = document.getElementById('filter-sort')?.value || '';
const cmcMin = document.getElementById('filter-cmc-min')?.value || '';
const cmcMax = document.getElementById('filter-cmc-max')?.value || '';
const powerMin = document.getElementById('filter-power-min')?.value || '';
const powerMax = document.getElementById('filter-power-max')?.value || '';
const toughMin = document.getElementById('filter-tough-min')?.value || '';
const toughMax = document.getElementById('filter-tough-max')?.value || '';
// Collect selected themes
const themeChips = document.querySelectorAll('#selected-themes .theme-chip');
const themes = Array.from(themeChips).map(chip => chip.dataset.theme);
const params = new URLSearchParams();
if (search) params.set('search', search);
if (color) params.set('color', color);
if (type) params.set('card_type', type);
if (rarity) params.set('rarity', rarity);
// Add themes as multiple params (themes=t1&themes=t2&themes=t3)
themes.forEach(theme => params.append('themes', theme));
if (sort && sort !== 'name_asc') params.set('sort', sort); // Only include if not default
if (cmcMin) params.set('cmc_min', cmcMin);
if (cmcMax) params.set('cmc_max', cmcMax);
if (powerMin) params.set('power_min', powerMin);
if (powerMax) params.set('power_max', powerMax);
if (toughMin) params.set('tough_min', toughMin);
if (toughMax) params.set('tough_max', toughMax);
window.location.href = `/cards?${params.toString()}`;
}
// Autocomplete keyboard navigation
(function() {
const searchInput = document.getElementById('search-input');
const autocompleteDropdown = document.getElementById('search-autocomplete-dropdown');
const form = document.getElementById('card-search-form');
if (!searchInput || !autocompleteDropdown || !form) return;
let selectedIndex = -1;
let debounceTimer = null;
let lastEscapeTime = 0;
// Fetch autocomplete suggestions
const fetchSuggestions = () => {
const query = searchInput.value.trim();
if (query.length < 2) {
autocompleteDropdown.innerHTML = '';
return;
}
// Call the autocomplete endpoint
fetch(`/cards/search-autocomplete?q=${encodeURIComponent(query)}`)
.then(response => response.text())
.then(html => {
autocompleteDropdown.innerHTML = html;
})
.catch(err => {
console.error('Autocomplete error:', err);
autocompleteDropdown.innerHTML = '';
});
};
// Debounced input handler - reduced to 150ms for faster response
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchSuggestions, 150);
});
// Helper to get all autocomplete items
const getItems = () => Array.from(autocompleteDropdown.querySelectorAll('.autocomplete-item'));
// Helper to select an item by index
const selectItem = (index) => {
const items = getItems();
items.forEach((item, i) => {
if (i === index) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else {
item.classList.remove('selected');
}
});
selectedIndex = index;
};
// Helper to apply selected item
const applySelectedItem = () => {
const items = getItems();
const item = items[selectedIndex];
if (item && item.dataset.value) {
searchInput.value = item.dataset.value;
autocompleteDropdown.innerHTML = '';
selectedIndex = -1;
// Trigger search immediately
applyFilter();
}
};
// Reset selection when dropdown content changes
const observer = new MutationObserver(() => {
selectedIndex = -1;
getItems().forEach(item => item.classList.remove('selected'));
// Update aria-expanded based on dropdown content
const hasContent = autocompleteDropdown.children.length > 0;
searchInput.setAttribute('aria-expanded', hasContent ? 'true' : 'false');
});
observer.observe(autocompleteDropdown, { childList: true });
// Click handler for autocomplete items - instant navigation
document.body.addEventListener('click', (e) => {
const item = e.target.closest('.autocomplete-item');
if (item && item.dataset.value && autocompleteDropdown.contains(item)) {
e.preventDefault();
searchInput.value = item.dataset.value;
autocompleteDropdown.innerHTML = '';
selectedIndex = -1;
// Navigate immediately using applyFilter
applyFilter();
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete-container')) {
autocompleteDropdown.innerHTML = '';
selectedIndex = -1;
}
});
// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
const items = getItems();
const hasItems = items.length > 0;
if (e.key === 'Escape') {
const now = Date.now();
const timeSinceLastEscape = now - lastEscapeTime;
if (hasItems) {
// Close dropdown if open
autocompleteDropdown.innerHTML = '';
selectedIndex = -1;
lastEscapeTime = now;
e.preventDefault();
} else if (timeSinceLastEscape < 500) {
// Double-tap Escape within 500ms - clear all filters
console.log('Double Escape detected - clearing filters');
e.preventDefault();
window.location.href = '/cards';
} else {
// First escape with no dropdown
lastEscapeTime = now;
e.preventDefault();
}
} else if (e.key === 'ArrowDown' && hasItems) {
e.preventDefault();
const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
selectItem(newIndex);
} else if (e.key === 'ArrowUp' && hasItems) {
e.preventDefault();
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
selectItem(newIndex);
} else if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter: Apply all filters
e.preventDefault();
applyFilter();
} else if (selectedIndex >= 0 && hasItems) {
e.preventDefault();
applySelectedItem();
}
// Otherwise allow normal form submission
}
});
// Mouse hover to highlight items
autocompleteDropdown.addEventListener('mouseover', (e) => {
const item = e.target.closest('.autocomplete-item');
if (item) {
const items = getItems();
const index = items.indexOf(item);
if (index >= 0) {
selectItem(index);
}
}
});
})();
// Multi-select theme filter
(function() {
const themeInput = document.getElementById('filter-theme-input');
const themeDropdown = document.getElementById('theme-autocomplete-dropdown');
const selectedThemesContainer = document.getElementById('selected-themes');
if (!themeInput || !themeDropdown) return;
let selectedIndex = -1;
let debounceTimer = null;
let selectedThemes = new Set();
let lastEscapeTime = 0;
// Initialize with existing themes from URL
const existingChips = selectedThemesContainer.querySelectorAll('.theme-chip');
existingChips.forEach(chip => {
selectedThemes.add(chip.dataset.theme);
});
// Update input state based on theme count
const updateThemeInputState = () => {
if (selectedThemes.size >= 5) {
themeInput.disabled = true;
themeInput.placeholder = 'Maximum 5 themes selected';
themeInput.classList.add('disabled');
} else {
themeInput.disabled = false;
themeInput.placeholder = 'Add theme...';
themeInput.classList.remove('disabled');
}
};
// Initialize the input state
updateThemeInputState();
// Fetch theme suggestions
const fetchThemeSuggestions = () => {
const query = themeInput.value.trim();
if (query.length < 2) {
themeDropdown.innerHTML = '';
return;
}
fetch(`/cards/theme-autocomplete?q=${encodeURIComponent(query)}`)
.then(response => response.text())
.then(html => {
themeDropdown.innerHTML = html;
})
.catch(err => {
console.error('Theme autocomplete error:', err);
themeDropdown.innerHTML = '';
});
};
// Add theme chip
window.addTheme = function(theme) {
if (selectedThemes.size >= 5) {
return; // Input should already be disabled, but double-check
}
if (selectedThemes.has(theme)) {
return; // Already selected
}
selectedThemes.add(theme);
const chip = document.createElement('span');
chip.className = 'theme-chip';
chip.dataset.theme = theme;
chip.innerHTML = `${theme} <button type="button" onclick="removeTheme('${theme.replace(/'/g, "\\'")}')" style="margin-left:0.5rem; background:none; border:none; color:inherit; cursor:pointer; padding:0; font-weight:bold;">&times;</button>`;
selectedThemesContainer.appendChild(chip);
themeInput.value = '';
themeDropdown.innerHTML = '';
selectedIndex = -1;
updateThemeInputState();
// Auto-focus the input on desktop (not mobile) for quick multi-selection
// Focus immediately since we're NOT reloading the page anymore
if (selectedThemes.size < 5 && window.innerWidth >= 768) {
// Small delay to ensure DOM updates are complete
requestAnimationFrame(() => {
themeInput.focus();
});
}
// Don't auto-apply filter - let user add multiple themes then click Apply
// This allows the auto-focus to work and provides better UX
// applyFilter();
};
// Remove theme chip
window.removeTheme = function(theme) {
selectedThemes.delete(theme);
const chip = selectedThemesContainer.querySelector(`.theme-chip[data-theme="${theme}"]`);
if (chip) {
chip.remove();
}
updateThemeInputState();
// Don't auto-apply filter - let user manage themes then click Apply
// applyFilter();
};
// Debounced input handler
themeInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchThemeSuggestions, 150);
});
// Helper functions
const getItems = () => Array.from(themeDropdown.querySelectorAll('.autocomplete-item'));
const selectItem = (index) => {
const items = getItems();
items.forEach((item, i) => {
if (i === index) {
item.classList.add('selected');
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else {
item.classList.remove('selected');
}
});
selectedIndex = index;
};
const applySelectedItem = () => {
const items = getItems();
const item = items[selectedIndex];
if (item && item.dataset.value) {
addTheme(item.dataset.value);
}
};
// Reset selection when dropdown changes
const observer = new MutationObserver(() => {
selectedIndex = -1;
getItems().forEach(item => item.classList.remove('selected'));
const hasContent = themeDropdown.children.length > 0;
themeInput.setAttribute('aria-expanded', hasContent ? 'true' : 'false');
});
observer.observe(themeDropdown, { childList: true });
// Click handler
document.body.addEventListener('click', (e) => {
const item = e.target.closest('.autocomplete-item');
if (item && item.dataset.value && themeDropdown.contains(item)) {
e.preventDefault();
addTheme(item.dataset.value);
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#filter-theme-input') && !e.target.closest('#theme-autocomplete-dropdown')) {
themeDropdown.innerHTML = '';
selectedIndex = -1;
}
});
// Keyboard navigation
themeInput.addEventListener('keydown', (e) => {
const items = getItems();
const hasItems = items.length > 0;
if (e.key === 'Escape') {
const now = Date.now();
const timeSinceLastEscape = now - lastEscapeTime;
if (hasItems) {
// Close dropdown if open
themeDropdown.innerHTML = '';
selectedIndex = -1;
lastEscapeTime = now;
e.preventDefault();
} else if (timeSinceLastEscape < 500) {
// Double-tap Escape within 500ms - clear all filters
console.log('Double Escape detected - clearing filters');
e.preventDefault();
window.location.href = '/cards';
} else {
// First escape with no dropdown
lastEscapeTime = now;
e.preventDefault();
}
} else if (e.key === 'ArrowDown' && hasItems) {
e.preventDefault();
const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
selectItem(newIndex);
} else if (e.key === 'ArrowUp' && hasItems) {
e.preventDefault();
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
selectItem(newIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
// Shift+Enter: Add current theme if any, then apply all filters
if (hasItems) {
// Add the first match before applying
if (selectedIndex >= 0) {
applySelectedItem();
} else {
selectItem(0);
applySelectedItem();
}
// Small delay to ensure theme is added before applying filters
setTimeout(() => applyFilter(), 50);
} else {
// No autocomplete, just apply filters
applyFilter();
}
} else if (selectedIndex >= 0 && hasItems) {
// Apply the explicitly selected item
applySelectedItem();
} else if (hasItems) {
// No item selected, but there are items - select the first one
selectItem(0);
applySelectedItem();
}
}
});
// Mouse hover
themeDropdown.addEventListener('mouseover', (e) => {
const item = e.target.closest('.autocomplete-item');
if (item) {
const items = getItems();
const index = items.indexOf(item);
if (index >= 0) {
selectItem(index);
}
}
});
})();
// Update card count after loading more cards via HTMX
let autoLoadEnabled = true;
let lastCardCount = 0;
document.body.addEventListener('htmx:afterSwap', function(event) {
// Only update if this was a card grid load
if (event.detail.target.id === 'card-grid') {
const cardTiles = document.querySelectorAll('#card-grid .card-tile');
const totalCount = cardTiles.length;
const resultsCount = document.getElementById('results-count');
if (resultsCount && totalCount > 0) {
// Extract the "of X filtered cards" or "of X cards" part
const originalText = resultsCount.textContent;
const match = originalText.match(/of (\d+)( filtered)? cards/);
if (match) {
const totalCards = match[1];
const filtered = match[2] || '';
const searchMatch = originalText.match(/matching "([^"]+)"/);
const searchText = searchMatch ? ` matching "${searchMatch[1]}"` : '';
resultsCount.textContent = `Showing ${totalCount} of ${totalCards}${filtered} cards${searchText}`;
}
}
// Check if we just crossed a 100-card boundary
const currentHundred = Math.floor(totalCount / 100);
const lastHundred = Math.floor(lastCardCount / 100);
if (currentHundred > lastHundred && totalCount % 100 === 0) {
autoLoadEnabled = false;
}
lastCardCount = totalCount;
// Re-observe the new load more button
const loadMoreContainer = document.getElementById('load-more-container');
if (loadMoreContainer) {
scrollObserver.observe(loadMoreContainer);
}
}
});
// Scroll observer for infinite scroll
const scrollObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && autoLoadEnabled) {
const loadMoreBtn = document.querySelector('#load-more-container button');
if (loadMoreBtn) {
loadMoreBtn.click();
}
}
});
}, {
rootMargin: '200px' // Start loading 200px before reaching the button
});
// Manual "Load More" click re-enables auto-load
document.body.addEventListener('click', function(event) {
const loadMoreBtn = event.target.closest('#load-more-container button');
if (loadMoreBtn && !autoLoadEnabled) {
autoLoadEnabled = true;
}
});
// Initial observation
window.addEventListener('load', function() {
const loadMoreContainer = document.getElementById('load-more-container');
if (loadMoreContainer) {
scrollObserver.observe(loadMoreContainer);
}
// Initialize theme chips from URL params
{% if themes %}
{% for theme in themes %}
addTheme('{{ theme|replace("'", "\\'") }}');
{% endfor %}
{% endif %}
});
</script>
{% endblock %}

View file

@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}{{ error_code }} Error{% endblock %}
{% block content %}
<style>
.error-container {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: var(--ring);
margin-bottom: 1rem;
line-height: 1;
}
.error-message {
font-size: 1.5rem;
font-weight: 500;
color: var(--text);
margin-bottom: 2rem;
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.error-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--ring);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s;
}
.error-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.error-btn-secondary {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
}
.error-btn-secondary:hover {
background: var(--border);
}
</style>
<div class="error-container">
<div class="error-code">{{ error_code }}</div>
<div class="error-message">{{ error_message }}</div>
<div class="error-actions">
{% if back_link %}
<a href="{{ back_link }}" class="error-btn">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/>
</svg>
{{ back_text if back_text else "Go Back" }}
</a>
{% endif %}
<a href="/" class="error-btn error-btn-secondary">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/>
</svg>
Go Home
</a>
</div>
</div>
{% endblock %}

View file

@ -6,6 +6,7 @@
<a class="action-button" href="/configs">Run a JSON Config</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 %} {% 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="/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 %} {% 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="/decks">Finished Decks</a>
<a class="action-button" href="/themes/">Browse Themes</a> <a class="action-button" href="/themes/">Browse Themes</a>

View file

@ -47,6 +47,25 @@
<button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button> <button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button>
<button type="button" id="btn-rebuild-cards" class="action-btn" onclick="rebuildCards()">Rebuild Card Files</button> <button type="button" id="btn-rebuild-cards" class="action-btn" onclick="rebuildCards()">Rebuild Card Files</button>
</div> </div>
{% if similarity_enabled %}
<details style="margin-top:1.25rem;" open>
<summary>Similarity Cache Status</summary>
<div id="similarity-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div class="muted">Status:</div>
<div id="similarity-status-line" style="margin-top:.25rem;">Checking…</div>
<div class="muted" id="similarity-meta-line" style="margin-top:.25rem; display:none;"></div>
<div class="muted" id="similarity-warning-line" style="margin-top:.25rem; display:none; color:#f59e0b;"></div>
</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>
<label class="muted" style="align-self:center; font-size:.85rem;">
<input type="checkbox" id="chk-skip-download" /> Skip GitHub download (build locally)
</label>
<span class="muted" style="align-self:center; font-size:.85rem;">(~15-20 min local, instant if cached on GitHub)</span>
</div>
{% endif %}
</section> </section>
<script> <script>
(function(){ (function(){
@ -239,6 +258,123 @@
}, 2000); }, 2000);
}); });
}; };
// Similarity cache status polling
{% if similarity_enabled %}
function pollSimilarityStatus(){
fetch('/status/similarity', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(function(data){
var line = document.getElementById('similarity-status-line');
var metaLine = document.getElementById('similarity-meta-line');
var warnLine = document.getElementById('similarity-warning-line');
if (!line) return;
if (data.exists && data.valid) {
var cardCount = data.card_count ? data.card_count.toLocaleString() : '?';
var sizeMB = data.size_mb ? data.size_mb.toFixed(1) : '?';
var ageDays = data.age_days !== null ? data.age_days.toFixed(1) : '?';
line.textContent = 'Cache exists and is valid';
line.style.color = '#34d399';
if (metaLine) {
metaLine.style.display = '';
metaLine.textContent = cardCount + ' cards cached • ' + sizeMB + ' MB • ' + ageDays + ' days old';
}
if (warnLine && data.needs_refresh) {
warnLine.style.display = '';
warnLine.textContent = '⚠ Cache is ' + ageDays + ' days old. Consider rebuilding for fresher data.';
} else if (warnLine) {
warnLine.style.display = 'none';
}
} else if (data.exists && !data.valid) {
line.textContent = 'Cache file is invalid or corrupted';
line.style.color = '#f87171';
if (metaLine) metaLine.style.display = 'none';
if (warnLine) {
warnLine.style.display = '';
warnLine.textContent = '⚠ Rebuild cache to fix.';
}
} else {
line.textContent = 'No cache found';
line.style.color = '#94a3b8';
if (metaLine) metaLine.style.display = 'none';
if (warnLine) {
warnLine.style.display = '';
warnLine.textContent = ' Build cache to enable similar card features.';
}
}
})
.catch(function(){});
}
window.buildSimilarityCache = function(){
var btn = document.getElementById('btn-build-similarity');
var skipDownloadCheckbox = document.getElementById('chk-skip-download');
if (!btn) return;
var skipDownload = skipDownloadCheckbox && skipDownloadCheckbox.checked;
var confirmMsg = skipDownload
? 'Build similarity cache locally for ~30k cards? This will take approximately 15-20 minutes and uses parallel processing.'
: 'Build similarity cache? This will first try to download a pre-built cache from GitHub (instant), or build locally if unavailable (~15-20 minutes).';
if (!confirm(confirmMsg)) {
return;
}
btn.disabled = true;
btn.textContent = 'Building... (check terminal for progress)';
var body = skipDownload ? JSON.stringify({ skip_download: true }) : '{}';
fetch('/similarity/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function(r){
if (!r.ok) throw new Error('Build failed');
return r.json();
})
.then(function(data){
if (data.success) {
btn.textContent = 'Build Started! Check terminal for progress...';
// Poll status more frequently while building
var pollCount = 0;
var buildPoll = setInterval(function(){
pollSimilarityStatus();
pollCount++;
// Stop intensive polling after 2 minutes, rely on normal polling
if (pollCount > 40) clearInterval(buildPoll);
}, 3000);
setTimeout(function(){
btn.textContent = 'Build Similarity Cache';
btn.disabled = false;
}, 8000);
} else {
btn.textContent = 'Build Failed: ' + (data.error || 'Unknown error');
setTimeout(function(){
btn.textContent = 'Build Similarity Cache';
btn.disabled = false;
}, 3000);
}
})
.catch(function(err){
btn.textContent = 'Build Failed';
setTimeout(function(){
btn.textContent = 'Build Similarity Cache';
btn.disabled = false;
}, 3000);
});
};
pollSimilarityStatus();
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
{% endif %}
setInterval(poll, 3000); setInterval(poll, 3000);
poll(); poll();
pollThemes(); pollThemes();

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,12 @@ services:
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden) SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
SHOW_MISC_POOL: "0" SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
# Similarity Cache (Phase 2 - M5 Performance Optimization)
SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation
SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file
# Partner / Background mechanics (feature flag) # Partner / Background mechanics (feature flag)
ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs
ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset) ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset)

View file

@ -31,6 +31,12 @@ services:
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden) SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
SHOW_MISC_POOL: "0" SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)
# Similarity Cache (Phase 2 - M5 Performance Optimization)
SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation
SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON # HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs
ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset) ENABLE_PARTNER_SUGGESTIONS: "1" # 1=enable partner suggestion API/UI (requires dataset)

View file

@ -6,6 +6,13 @@ seed_defaults() {
# Ensure base config and data directories exist # Ensure base config and data directories exist
mkdir -p /app/config /app/config/card_lists /app/config/themes /app/card_files mkdir -p /app/config /app/config/card_lists /app/config/themes /app/card_files
# Download pre-built similarity cache from GitHub if not present
if [ ! -f /app/card_files/similarity_cache.parquet ]; then
echo "Downloading similarity cache from GitHub..."
wget -q https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/similarity-cache-data/card_files/similarity_cache.parquet -O /app/card_files/similarity_cache.parquet 2>/dev/null || echo "Warning: Could not download similarity cache"
wget -q https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/similarity-cache-data/card_files/similarity_cache_metadata.json -O /app/card_files/similarity_cache_metadata.json 2>/dev/null || true
fi
# Copy from baked-in defaults if targets are missing # Copy from baked-in defaults if targets are missing
if [ -d "/.defaults/config" ]; then if [ -d "/.defaults/config" ]; then
# deck.json # deck.json