Add card browser with similar cards and performance optimizations

This commit is contained in:
matt 2025-10-17 16:17:36 -07:00
parent a8dc1835eb
commit c2960c808e
25 changed files with 4841 additions and 1392 deletions

View file

@ -1,6 +1,6 @@
{# 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 #}
{# Card image (uses hover system for preview) #}
<div class="card-browser-tile-image">
<img
loading="lazy"
@ -55,6 +55,16 @@
{% 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">

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

@ -345,7 +345,7 @@
<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-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">