mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat(card-browser): advanced filters, keyboard shortcuts, and responsive design
This commit is contained in:
parent
e0fe8a36e6
commit
a8dc1835eb
12 changed files with 2591 additions and 8 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -9,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Summary
|
||||
_No unreleased changes yet._
|
||||
Card browser with advanced filters, keyboard shortcuts, and responsive design.
|
||||
|
||||
### Added
|
||||
_No unreleased changes yet._
|
||||
- **Card Browser**: Browse 26,427 Magic cards with powerful filtering at `/browse/cards`
|
||||
- Fuzzy autocomplete for card names and themes with typo tolerance
|
||||
- Multi-theme filtering (up to 5 themes with AND logic)
|
||||
- Color identity, card type, rarity, CMC range, power/toughness filters
|
||||
- Six sorting options: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular
|
||||
- Cursor-based pagination with infinite scroll
|
||||
- Shareable filter URLs for saving and sharing searches
|
||||
- **Keyboard Shortcuts**: Efficient navigation without mouse
|
||||
- `Enter`: Add first autocomplete match to theme filters
|
||||
- `Shift+Enter`: Apply all active filters from any input field
|
||||
- `Esc` (double-tap): Clear all filters globally (500ms window)
|
||||
- Desktop-only keyboard shortcuts help button with tooltip
|
||||
- Auto-focus theme input after adding theme (desktop only)
|
||||
- **Responsive Design**: Mobile-optimized card browser with touch-friendly controls
|
||||
- Adaptive grid layout (1-4 columns based on screen width)
|
||||
- Theme chips with remove buttons
|
||||
- Graceful 5-theme limit (input disabled, no intrusive alerts)
|
||||
- Desktop-only UI elements hidden on mobile with media queries
|
||||
|
||||
### Changed
|
||||
_No unreleased changes yet._
|
||||
- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries
|
||||
|
||||
### Fixed
|
||||
_No unreleased changes yet._
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -21,6 +21,7 @@ A web-first Commander/EDH deckbuilder with a shared core for CLI, headless, and
|
|||
- [Initial Setup](#initial-setup)
|
||||
- [Owned Library](#owned-library)
|
||||
- [Browse Commanders](#browse-commanders)
|
||||
- [Browse Cards](#browse-cards)
|
||||
- [Browse Themes](#browse-themes)
|
||||
- [Finished Decks](#finished-decks)
|
||||
- [Random Build](#random-build)
|
||||
|
|
@ -164,6 +165,19 @@ Explore the curated commander catalog.
|
|||
- Refresh via Initial Setup or the commander catalog script above.
|
||||
- MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot.
|
||||
|
||||
### Browse Cards
|
||||
Search and filter all 26,427 Magic cards.
|
||||
- **Filtering**: Search by name, themes (up to 5), color identity, type, rarity, CMC range, power/toughness
|
||||
- **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular
|
||||
- **Keyboard Shortcuts**:
|
||||
- `Enter`: Add first autocomplete match to theme filters
|
||||
- `Shift+Enter`: Apply all active filters
|
||||
- `Esc` (double-tap): Clear all filters
|
||||
- `?` button (desktop): Show keyboard shortcuts reference
|
||||
- **Responsive Design**: Mobile-optimized with adaptive grid and touch controls
|
||||
- **Shareable URLs**: Filter state persists in URL for saving and sharing searches
|
||||
- Powered by `card_files/all_cards.parquet` with theme tag index for fast lookups
|
||||
|
||||
### Browse Themes
|
||||
Investigate theme synergies and diagnostics.
|
||||
- `ENABLE_THEMES=1` keeps the tile visible (default).
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
### Summary
|
||||
_No unreleased changes yet._
|
||||
Card browser with advanced filters, keyboard shortcuts, and responsive design.
|
||||
|
||||
### Added
|
||||
_No unreleased changes yet._
|
||||
- **Card Browser**: Browse 26,427 Magic cards with powerful filtering
|
||||
- Multi-theme filtering (up to 5 themes), color identity, type, rarity, CMC, power/toughness
|
||||
- Six sorting options including EDHREC popularity
|
||||
- Infinite scroll with cursor-based pagination
|
||||
- Shareable filter URLs
|
||||
- **Keyboard Shortcuts**: Efficient navigation
|
||||
- `Enter`: Add first autocomplete match
|
||||
- `Shift+Enter`: Apply filters
|
||||
- `Esc` (double-tap): Clear all filters
|
||||
- Desktop-only help button with keyboard shortcuts reference
|
||||
- **Responsive Design**: Mobile-optimized with adaptive grid layout and touch-friendly controls
|
||||
|
||||
### Changed
|
||||
_No unreleased changes yet._
|
||||
- **Theme Catalog**: Improved generation to include more themes and filter out ultra-rare entries
|
||||
|
||||
### Fixed
|
||||
_No unreleased changes yet._
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ def build_theme_catalog(
|
|||
cards_filename: str = "cards.csv",
|
||||
logs_directory: Optional[Path] = None,
|
||||
use_parquet: bool = True,
|
||||
min_card_count: int = 3,
|
||||
) -> CatalogBuildResult:
|
||||
"""Build theme catalog from card data.
|
||||
|
||||
|
|
@ -229,6 +230,8 @@ def build_theme_catalog(
|
|||
cards_filename: Name of cards CSV file
|
||||
logs_directory: Optional directory to copy output to
|
||||
use_parquet: If True, try to use all_cards.parquet first (default: True)
|
||||
min_card_count: Minimum number of cards required to include theme (default: 3)
|
||||
use_parquet: If True, try to use all_cards.parquet first (default: True)
|
||||
|
||||
Returns:
|
||||
CatalogBuildResult with generated rows and metadata
|
||||
|
|
@ -251,11 +254,16 @@ def build_theme_catalog(
|
|||
commander_parquet, theme_variants=theme_variants
|
||||
)
|
||||
|
||||
# CSV method doesn't load non-commander cards, so we don't either
|
||||
card_counts = Counter()
|
||||
# Load all card counts from all_cards.parquet to include all themes
|
||||
all_cards_parquet = parquet_dir / "all_cards.parquet"
|
||||
card_counts = _load_theme_counts_from_parquet(
|
||||
all_cards_parquet, theme_variants=theme_variants
|
||||
)
|
||||
|
||||
used_parquet = True
|
||||
print("✓ Loaded theme data from parquet files")
|
||||
print(f" - Commanders: {len(commander_counts)} themes")
|
||||
print(f" - All cards: {len(card_counts)} themes")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠ Failed to load from parquet: {e}")
|
||||
|
|
@ -285,12 +293,19 @@ def build_theme_catalog(
|
|||
version_hash = _compute_version_hash(display_names)
|
||||
|
||||
rows: List[CatalogRow] = []
|
||||
filtered_count = 0
|
||||
for key, display in zip(keys, display_names):
|
||||
if not display:
|
||||
continue
|
||||
card_count = int(card_counts.get(key, 0))
|
||||
commander_count = int(commander_counts.get(key, 0))
|
||||
source_count = card_count + commander_count
|
||||
|
||||
# Filter out themes below minimum threshold
|
||||
if source_count < min_card_count:
|
||||
filtered_count += 1
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
CatalogRow(
|
||||
theme=display,
|
||||
|
|
@ -330,6 +345,9 @@ def build_theme_catalog(
|
|||
row.version,
|
||||
])
|
||||
|
||||
if filtered_count > 0:
|
||||
print(f" Filtered {filtered_count} themes with <{min_card_count} cards")
|
||||
|
||||
if logs_directory is not None:
|
||||
logs_directory = logs_directory.resolve()
|
||||
logs_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -376,6 +394,13 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult:
|
|||
default=None,
|
||||
help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-cards",
|
||||
dest="min_cards",
|
||||
type=int,
|
||||
default=3,
|
||||
help="Minimum number of cards required to include theme (default: 3)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None)
|
||||
|
|
@ -383,6 +408,7 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult:
|
|||
csv_directory=csv_dir,
|
||||
output_path=args.output,
|
||||
logs_directory=args.logs_dir,
|
||||
min_card_count=args.min_cards,
|
||||
)
|
||||
print(
|
||||
f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
|||
maybe_build_index()
|
||||
except Exception:
|
||||
pass
|
||||
# Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing)
|
||||
try:
|
||||
from .routes.card_browser import get_theme_catalog, get_theme_index # type: ignore
|
||||
get_theme_catalog() # Fast: just reads CSV
|
||||
get_theme_index() # Slower: parses cards for theme-to-card mapping
|
||||
except Exception:
|
||||
pass
|
||||
yield # (no shutdown tasks currently)
|
||||
|
||||
|
||||
|
|
@ -2206,6 +2213,7 @@ from .routes import commanders as commanders_routes # noqa: E402
|
|||
from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402
|
||||
from .routes import telemetry as telemetry_routes # noqa: E402
|
||||
from .routes import cards as cards_routes # noqa: E402
|
||||
from .routes import card_browser as card_browser_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
|
|
@ -2216,6 +2224,7 @@ app.include_router(commanders_routes.router)
|
|||
app.include_router(partner_suggestions_routes.router)
|
||||
app.include_router(telemetry_routes.router)
|
||||
app.include_router(cards_routes.router)
|
||||
app.include_router(card_browser_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
|
|||
1122
code/web/routes/card_browser.py
Normal file
1122
code/web/routes/card_browser.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -727,3 +727,339 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Card Browser Styles
|
||||
======================================== */
|
||||
|
||||
/* Card browser container */
|
||||
.card-browser-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Filter panel */
|
||||
.card-browser-filters {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-row label {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.filter-row select,
|
||||
.filter-row input[type="text"],
|
||||
.filter-row input[type="search"] {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Search bar styling */
|
||||
.card-search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-search-wrapper input[type="search"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Results count and info bar */
|
||||
.card-browser-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Card browser grid */
|
||||
.card-browser-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 240px));
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
/* Individual card tile in browser */
|
||||
.card-browser-tile {
|
||||
break-inside: avoid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-bg, #1a1d24);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-browser-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%);
|
||||
}
|
||||
|
||||
.card-browser-tile-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 488/680;
|
||||
overflow: hidden;
|
||||
background: #0a0b0e;
|
||||
}
|
||||
|
||||
.card-browser-tile-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-browser-tile:hover .card-browser-tile-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-browser-tile-info {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-browser-tile-type {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-browser-tile-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-tags .tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: var(--muted);
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Pagination controls */
|
||||
.card-browser-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-browser-pagination .btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.card-browser-pagination .page-info {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* No results message */
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-results-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-results-message {
|
||||
color: var(--muted);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-results-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-results-filter-tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.card-browser-loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
/* Large tablets and below - reduce to ~180px cards */
|
||||
@media (max-width: 1024px) {
|
||||
.card-browser-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 200px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets - reduce to ~160px cards */
|
||||
@media (max-width: 768px) {
|
||||
.card-browser-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 180px));
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-row label {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.filter-row select,
|
||||
.filter-row input {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-browser-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablets/large phones - reduce to ~140px cards */
|
||||
@media (max-width: 600px) {
|
||||
.card-browser-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 160px));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones - 2 column layout with flexible width */
|
||||
@media (max-width: 480px) {
|
||||
.card-browser-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-name {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-type {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card-browser-tile-info {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme chips for multi-select */
|
||||
.theme-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary-fg);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.theme-chip button {
|
||||
margin-left: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-chip button:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@
|
|||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/cards">All Cards</a>
|
||||
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
|
||||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
|
|
|
|||
22
code/web/templates/browse/cards/_card_grid.html
Normal file
22
code/web/templates/browse/cards/_card_grid.html
Normal 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>
|
||||
67
code/web/templates/browse/cards/_card_tile.html
Normal file
67
code/web/templates/browse/cards/_card_tile.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{# 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 #}
|
||||
<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>
|
||||
|
||||
{# 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>
|
||||
958
code/web/templates/browse/cards/index.html
Normal file
958
code/web/templates/browse/cards/index.html
Normal 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;">×</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 %}{% if theme %}&theme={{ theme|urlencode }}{% endif %}{% if color %}&color={{ color|urlencode }}{% endif %}{% if card_type %}&card_type={{ card_type|urlencode }}{% endif %}{% if rarity %}&rarity={{ rarity|urlencode }}{% endif %}{% if cmc_min %}&cmc_min={{ cmc_min }}{% endif %}{% if cmc_max %}&cmc_max={{ cmc_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;">×</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 %}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
<a class="action-button" href="/configs">Run a JSON Config</a>
|
||||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/cards">Browse All Cards</a>
|
||||
{% if show_commanders %}<a class="action-button" href="/commanders">Browse Commanders</a>{% endif %}
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue