mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
Add card browser with similar cards and performance optimizations
This commit is contained in:
parent
a8dc1835eb
commit
c2960c808e
25 changed files with 4841 additions and 1392 deletions
|
|
@ -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">
|
||||
|
|
|
|||
250
code/web/templates/browse/cards/_similar_cards.html
Normal file
250
code/web/templates/browse/cards/_similar_cards.html
Normal 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>
|
||||
273
code/web/templates/browse/cards/detail.html
Normal file
273
code/web/templates/browse/cards/detail.html
Normal 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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
88
code/web/templates/error.html
Normal file
88
code/web/templates/error.html
Normal 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 %}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue