Merge pull request #45 from mwisnowski/maintenance/ui-user-friendliness

feat(ui): add similar cards refresh button and reduce sidebar animati…
This commit is contained in:
mwisnowski 2025-10-17 18:41:40 -07:00 committed by GitHub
commit b5d11b30ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 204 additions and 9 deletions

View file

@ -9,13 +9,14 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased]
### Summary
_No unreleased changes yet_
Improved similar cards section with refresh button and reduced sidebar animation distractions.
### Added
_None_
- Similar cards now have a refresh button to see different recommendations without reloading the page
- Explanation text clarifying that similarities are based on shared themes and tags
### Changed
_None_
- Sidebar generally no longer animates during page loads and partial updates, reducing visual distractions
### Removed
_None_

View file

@ -1,13 +1,14 @@
# MTG Python Deckbuilder ${VERSION}
### Summary
_No unreleased changes yet_
Improved similar cards section with refresh button and reduced sidebar animation distractions.
### Added
_None_
- Similar cards now have a refresh button to see different recommendations without reloading the page
- Explanation text clarifying that similarities are based on shared themes and tags
### Changed
_None_
- Sidebar generally no longer animates during page loads and partial updates, reducing visual distractions
### Removed
_None_

View file

@ -1271,3 +1271,86 @@ async def card_detail(request: Request, card_name: str):
)
@router.get("/{card_name}/similar")
async def get_similar_cards_partial(request: Request, card_name: str):
"""
HTMX endpoint: Returns just the similar cards section for a given card.
Used for refreshing similar cards without reloading the entire page.
"""
try:
from urllib.parse import unquote
# Decode URL-encoded card name
card_name = unquote(card_name)
# Load cards data
loader = get_loader()
df = loader.load()
# Get main card for theme tags
card_row = df[df['name'] == card_name]
if card_row.empty:
return templates.TemplateResponse(
"browse/cards/_similar_cards.html",
{
"request": request,
"similar_cards": [],
"main_card_tags": [],
}
)
card = card_row.iloc[0].to_dict()
main_card_tags = parse_theme_tags(card.get('themeTags', ''))
# Calculate similar cards
similarity = get_similarity()
similar_cards = similarity.find_similar(
card_name,
threshold=0.8,
limit=5,
min_results=3,
adaptive=True
)
# Enrich similar cards with full data
for similar in similar_cards:
similar_row = df[df['name'] == similar['name']]
if not similar_row.empty:
similar_data = similar_row.iloc[0].to_dict()
theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', ''))
similar.update(similar_data)
similar['themeTags'] = theme_tags_parsed
logger.info(f"Similar cards refresh for '{card_name}': {len(similar_cards)} cards")
return templates.TemplateResponse(
"browse/cards/_similar_cards.html",
{
"request": request,
"card": card,
"similar_cards": similar_cards,
"main_card_tags": main_card_tags,
}
)
except Exception as e:
logger.error(f"Error loading similar cards for '{card_name}': {e}", exc_info=True)
# Try to get card data for error case too
try:
loader = get_loader()
df = loader.load()
card_row = df[df['name'] == card_name]
card = card_row.iloc[0].to_dict() if not card_row.empty else {"name": card_name}
except Exception:
card = {"name": card_name}
return templates.TemplateResponse(
"browse/cards/_similar_cards.html",
{
"request": request,
"card": card,
"similar_cards": [],
"main_card_tags": [],
}
)

View file

@ -125,6 +125,13 @@ body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; }
body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; }
/* Smooth hide/show on mobile while keeping fixed positioning */
.sidebar{ transition: transform .2s ease-out, visibility .2s linear; }
/* Suppress sidebar transitions during page load to prevent pop-in */
body.no-transition .sidebar{ transition: none !important; }
/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */
body.htmx-settling .sidebar{ transition: none !important; }
body.htmx-settling .layout{ transition: none !important; }
body.htmx-settling .content{ transition: none !important; }
body.htmx-settling *{ transition-duration: 0s !important; }
/* Mobile tweaks */
@media (max-width: 900px){

View file

@ -39,6 +39,16 @@
window.__telemetryEndpoint = '/telemetry/events';
</script>
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
<style>
/* Disable all transitions until page is loaded to prevent sidebar flash */
.no-transition,
.no-transition *,
.no-transition *::before,
.no-transition *::after {
transition: none !important;
animation: none !important;
}
</style>
<!-- Performance hints -->
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
<link rel="dns-prefetch" href="https://api.scryfall.com">
@ -50,7 +60,7 @@
<link rel="manifest" href="/static/manifest.webmanifest" />
{% endif %}
</head>
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
<body class="no-transition" data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
<header class="top-banner">
<div class="top-inner">
<div style="display:flex; align-items:center; gap:.5rem; padding-left: 1rem;">
@ -239,6 +249,7 @@
var SIDEBAR = document.getElementById('sidebar');
var TOGGLE = document.getElementById('nav-toggle');
var KEY = 'mtg:navCollapsed';
function apply(collapsed){
if (collapsed){
BODY.classList.add('nav-collapsed');
@ -254,6 +265,22 @@
var saved = localStorage.getItem(KEY);
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
apply(initialCollapsed);
// Re-enable transitions after page is fully loaded
// Use longer delay for pages with heavy content (like card browser)
var enableTransitions = function(){
BODY.classList.remove('no-transition');
};
if (document.readyState === 'complete') {
// Already loaded
setTimeout(enableTransitions, 150);
} else {
window.addEventListener('load', function(){
setTimeout(enableTransitions, 150);
});
}
if (TOGGLE){
TOGGLE.addEventListener('click', function(){
var isCollapsed = BODY.classList.contains('nav-collapsed');
@ -269,6 +296,23 @@
});
}catch(_){ }
// Suppress sidebar transitions during HTMX partial updates (not full page loads)
document.addEventListener('htmx:beforeRequest', function(evt){
// Only suppress for small partial updates (identified by specific IDs)
var target = evt.detail && evt.detail.target;
if (target && target.id) {
var targetId = target.id;
// List of partial update containers that should suppress sidebar transitions
var partialContainers = ['similar-cards-container', 'card-list', 'theme-list'];
if (partialContainers.indexOf(targetId) !== -1 || targetId.indexOf('-container') !== -1) {
document.body.classList.add('htmx-settling');
}
}
});
document.addEventListener('htmx:afterSettle', function(){
document.body.classList.remove('htmx-settling');
});
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){

View file

@ -4,6 +4,7 @@
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
gap: 1rem;
}
.similar-cards-title {
@ -12,6 +13,46 @@
color: var(--text);
}
.refresh-similar-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.refresh-similar-btn:hover {
background: var(--ring);
color: white;
border-color: var(--ring);
transform: translateY(-1px);
}
.refresh-similar-btn svg {
transition: transform 0.3s;
}
.refresh-similar-btn:hover svg {
transform: rotate(180deg);
}
.refresh-similar-btn.htmx-request svg {
animation: spin 0.6s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.similar-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 280px);
@ -165,9 +206,27 @@
}
</style>
<div class="similar-cards-section">
<div class="similar-cards-section" id="similar-cards-container">
<div class="similar-cards-header">
<h2 class="similar-cards-title">Similar Cards</h2>
<div>
<h2 class="similar-cards-title">Similar Cards</h2>
<p style="font-size: 0.9rem; color: var(--muted); margin-top: 0.5rem;">
Similarities based on shared themes and tags. Cards may differ in power level, cost, or function.
</p>
</div>
{% if similar_cards and similar_cards|length > 0 %}
<button hx-get="/cards/{{ card.name|urlencode }}/similar"
hx-target="#similar-cards-container"
hx-swap="outerHTML"
hx-indicator=".refresh-similar-btn"
class="refresh-similar-btn"
title="Refresh to see different similar cards">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
Refresh
</button>
{% endif %}
</div>
{% if similar_cards and similar_cards|length > 0 %}