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

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

@ -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-rebuild-cards" class="action-btn" onclick="rebuildCards()">Rebuild Card Files</button>
</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>
<script>
(function(){
@ -239,6 +258,123 @@
}, 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);
poll();
pollThemes();