mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat: improve theme browser performance and add keyboard navigation
This commit is contained in:
parent
40e676e39b
commit
77302f895f
4 changed files with 75 additions and 22 deletions
|
|
@ -9,7 +9,7 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Summary
|
### Summary
|
||||||
Theme catalog improvements with faster processing, new tag search features, and regeneration fixes.
|
Theme catalog improvements with faster processing, new tag search features, regeneration fixes, and browser performance optimizations.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Theme Catalog Optimization**:
|
- **Theme Catalog Optimization**:
|
||||||
|
|
@ -18,9 +18,13 @@ Theme catalog improvements with faster processing, new tag search features, and
|
||||||
- Tag search API with new endpoints for card search, autocomplete, and popular tags
|
- Tag search API with new endpoints for card search, autocomplete, and popular tags
|
||||||
- Commander browser theme autocomplete with keyboard navigation
|
- Commander browser theme autocomplete with keyboard navigation
|
||||||
- Tag loading infrastructure for batch operations
|
- Tag loading infrastructure for batch operations
|
||||||
|
- **Theme Browser Keyboard Navigation**: Arrow keys now navigate search results (ArrowUp/Down, Enter to select, Escape to close)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet._
|
- **Theme Browser Performance**: Theme detail pages now load much faster
|
||||||
|
- Disabled YAML file scanning in production (use `THEME_CATALOG_CHECK_YAML_CHANGES=1` during theme authoring)
|
||||||
|
- Cache invalidation now checks theme_list.json instead of scanning all files
|
||||||
|
- **Theme Browser UI**: Removed color filter from theme catalog
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Theme Regeneration**: Theme catalog can now be fully rebuilt from scratch without placeholder data
|
- **Theme Regeneration**: Theme catalog can now be fully rebuilt from scratch without placeholder data
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# MTG Python Deckbuilder ${VERSION}
|
# MTG Python Deckbuilder ${VERSION}
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
Theme catalog improvements with faster processing, tag search features, and regeneration fixes.
|
Theme catalog improvements with faster processing, tag search features, regeneration fixes, and browser performance optimizations.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Theme Catalog Optimization**:
|
- **Theme Catalog Optimization**:
|
||||||
|
|
@ -9,13 +9,15 @@ Theme catalog improvements with faster processing, tag search features, and rege
|
||||||
- Tag search API for theme-based card discovery
|
- Tag search API for theme-based card discovery
|
||||||
- Commander browser theme autocomplete with keyboard navigation
|
- Commander browser theme autocomplete with keyboard navigation
|
||||||
- Tag index for faster queries
|
- Tag index for faster queries
|
||||||
|
- **Theme Browser Keyboard Navigation**: Arrow keys navigate search results (ArrowUp/Down, Enter, Escape)
|
||||||
- **Card Data Consolidation** (from previous release):
|
- **Card Data Consolidation** (from previous release):
|
||||||
- Optimized format with smaller file sizes
|
- Optimized format with smaller file sizes
|
||||||
- "Rebuild Card Files" button in Setup page
|
- "Rebuild Card Files" button in Setup page
|
||||||
- Automatic updates after tagging/setup
|
- Automatic updates after tagging/setup
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet._
|
- **Theme Browser Performance**: Theme pages now load much faster
|
||||||
|
- **Theme Browser UI**: Removed color filter for cleaner interface
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Theme Regeneration**: Theme catalog can now be fully rebuilt from scratch
|
- **Theme Regeneration**: Theme catalog can now be fully rebuilt from scratch
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,14 @@ def _needs_reload() -> bool:
|
||||||
return True
|
return True
|
||||||
if mtime > idx.mtime:
|
if mtime > idx.mtime:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# OPTIMIZATION: Skip YAML scanning unless explicitly enabled via env var.
|
||||||
|
# Checking 732 YAML files takes ~800ms and is only needed during theme authoring.
|
||||||
|
# In production, theme_list.json is the source of truth (built from YAMLs offline).
|
||||||
|
import os as _os
|
||||||
|
if _os.getenv("THEME_CATALOG_CHECK_YAML_CHANGES") != "1":
|
||||||
|
return False
|
||||||
|
|
||||||
# If any YAML newer than catalog mtime or newest YAML newer than cached scan -> reload
|
# If any YAML newer than catalog mtime or newest YAML newer than cached scan -> reload
|
||||||
if YAML_DIR.exists():
|
if YAML_DIR.exists():
|
||||||
import time as _t
|
import time as _t
|
||||||
|
|
@ -113,7 +121,6 @@ def _needs_reload() -> bool:
|
||||||
# Fast path: use os.scandir for lower overhead vs Path.glob
|
# Fast path: use os.scandir for lower overhead vs Path.glob
|
||||||
newest = 0.0
|
newest = 0.0
|
||||||
try:
|
try:
|
||||||
import os as _os
|
|
||||||
with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type]
|
with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type]
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.is_file() and entry.name.endswith('.yml'):
|
if entry.is_file() and entry.name.endswith('.yml'):
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,6 @@
|
||||||
<option>Rare</option>
|
<option>Rare</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="min-width:210px;">
|
|
||||||
<label style="font-size:11px; display:block; opacity:.7;">Colors</label>
|
|
||||||
<div id="color-filter" style="display:flex; gap:.45rem; font-size:12px; flex-wrap:wrap;">
|
|
||||||
{% for c in ['W','U','B','R','G'] %}
|
|
||||||
<label style="display:flex; gap:2px; align-items:center;"><input type="checkbox" value="{{ c }}"/> {{ c }}</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
|
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
|
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
|
||||||
|
|
@ -42,9 +34,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); }
|
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); transition:background .15s ease; }
|
||||||
.search-suggestions a:last-child { border-bottom:none; }
|
.search-suggestions a:last-child { border-bottom:none; }
|
||||||
.search-suggestions a:hover { background:var(--hover); }
|
.search-suggestions a:hover, .search-suggestions a.selected { background:var(--hover); }
|
||||||
|
.search-suggestions a.selected { border-left:3px solid var(--ring); padding-left:calc(.6rem - 3px); }
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
@ -53,16 +46,15 @@
|
||||||
const clearBtn = document.getElementById('clear-search');
|
const clearBtn = document.getElementById('clear-search');
|
||||||
const popSel = document.getElementById('pop-filter');
|
const popSel = document.getElementById('pop-filter');
|
||||||
const popChips = document.querySelectorAll('.pop-chip');
|
const popChips = document.querySelectorAll('.pop-chip');
|
||||||
const colorBox = document.getElementById('color-filter');
|
|
||||||
const activeFilters = document.getElementById('active-filters');
|
const activeFilters = document.getElementById('active-filters');
|
||||||
const resultsHost = document.getElementById('theme-results');
|
const resultsHost = document.getElementById('theme-results');
|
||||||
let lastQuery=''; let lastSearchIssued=0; const SEARCH_THROTTLE=150;
|
let lastQuery=''; let lastSearchIssued=0; const SEARCH_THROTTLE=150;
|
||||||
function hideResults(){ resultsBox.style.display='none'; resultsBox.innerHTML=''; }
|
let selectedIndex = -1;
|
||||||
|
function hideResults(){ resultsBox.style.display='none'; resultsBox.innerHTML=''; selectedIndex = -1; }
|
||||||
function buildParams(){
|
function buildParams(){
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const q = input.value.trim(); if(q) params.set('q', q);
|
const q = input.value.trim(); if(q) params.set('q', q);
|
||||||
const pop = popSel.value; if(pop) params.set('bucket', pop);
|
const pop = popSel.value; if(pop) params.set('bucket', pop);
|
||||||
const colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) params.set('colors', colors.join(','));
|
|
||||||
params.set('limit','50'); params.set('offset','0');
|
params.set('limit','50'); params.set('offset','0');
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +69,6 @@
|
||||||
activeFilters.innerHTML='';
|
activeFilters.innerHTML='';
|
||||||
const q = input.value.trim(); if(q) addChip('Search: '+q, ()=>{ input.value=''; fetchList(); });
|
const q = input.value.trim(); if(q) addChip('Search: '+q, ()=>{ input.value=''; fetchList(); });
|
||||||
const pop = popSel.value; if(pop) addChip('Popularity: '+pop, ()=>{ popSel.value=''; fetchList(); });
|
const pop = popSel.value; if(pop) addChip('Popularity: '+pop, ()=>{ popSel.value=''; fetchList(); });
|
||||||
const colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) addChip('Colors: '+colors.join(','), ()=>{ colorBox.querySelectorAll('input:checked').forEach(i=>i.checked=false); fetchList(); });
|
|
||||||
}
|
}
|
||||||
function fetchList(){
|
function fetchList(){
|
||||||
const ps = buildParams();
|
const ps = buildParams();
|
||||||
|
|
@ -107,20 +98,69 @@
|
||||||
const items=js.items||[]; if(!items.length){ hideResults(); return; }
|
const items=js.items||[]; if(!items.length){ hideResults(); return; }
|
||||||
resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme}</a>`).join('');
|
resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme}</a>`).join('');
|
||||||
resultsBox.style.display='block';
|
resultsBox.style.display='block';
|
||||||
|
selectedIndex = -1;
|
||||||
}).catch(()=>hideResults());
|
}).catch(()=>hideResults());
|
||||||
}
|
}
|
||||||
|
function getResultLinks(){ return Array.from(resultsBox.querySelectorAll('a[data-theme-id]')); }
|
||||||
|
function selectResultItem(index){
|
||||||
|
const links = getResultLinks();
|
||||||
|
links.forEach((link, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
link.classList.add('selected');
|
||||||
|
link.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
link.classList.remove('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
selectedIndex = index;
|
||||||
|
}
|
||||||
|
function applySelectedResult(){
|
||||||
|
const links = getResultLinks();
|
||||||
|
const link = links[selectedIndex];
|
||||||
|
if (link) {
|
||||||
|
window.location.href = link.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
input.addEventListener('input', function(){
|
input.addEventListener('input', function(){
|
||||||
const q=this.value.trim(); clearBtn.hidden=!q; if(q!==lastQuery){ lastQuery=q; performSearch(q); fetchList(); }
|
const q=this.value.trim(); clearBtn.hidden=!q; if(q!==lastQuery){ lastQuery=q; performSearch(q); fetchList(); }
|
||||||
});
|
});
|
||||||
input.addEventListener('keydown', function(ev){
|
input.addEventListener('keydown', function(ev){
|
||||||
if(ev.key==='Enter' && ev.shiftKey){ const q=input.value.trim(); if(!q) return; ev.preventDefault(); fetch('/themes/api/search?q='+encodeURIComponent(q)+'&include_synergies=1',{cache:'no-store'}).then(r=>r.json()).then(js=>{ if(!js.ok) return; const items=js.items||[]; if(!items.length) return; resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme} <span style=\"opacity:.55; font-size:10px;\">(w/ synergies)</span></a>`).join(''); resultsBox.style.display='block'; }); }
|
const links = getResultLinks();
|
||||||
|
const hasResults = links.length > 0;
|
||||||
|
|
||||||
|
if (ev.key === 'Escape' && hasResults) {
|
||||||
|
hideResults();
|
||||||
|
ev.preventDefault();
|
||||||
|
} else if (ev.key === 'ArrowDown' && hasResults) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const newIndex = selectedIndex < links.length - 1 ? selectedIndex + 1 : 0;
|
||||||
|
selectResultItem(newIndex);
|
||||||
|
} else if (ev.key === 'ArrowUp' && hasResults) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : links.length - 1;
|
||||||
|
selectResultItem(newIndex);
|
||||||
|
} else if (ev.key === 'Enter' && selectedIndex >= 0 && hasResults) {
|
||||||
|
ev.preventDefault();
|
||||||
|
applySelectedResult();
|
||||||
|
} else if(ev.key==='Enter' && ev.shiftKey){ const q=input.value.trim(); if(!q) return; ev.preventDefault(); fetch('/themes/api/search?q='+encodeURIComponent(q)+'&include_synergies=1',{cache:'no-store'}).then(r=>r.json()).then(js=>{ if(!js.ok) return; const items=js.items||[]; if(!items.length) return; resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme} <span style=\"opacity:.55; font-size:10px;\">(w/ synergies)</span></a>`).join(''); resultsBox.style.display='block'; selectedIndex = -1; }); }
|
||||||
});
|
});
|
||||||
clearBtn.addEventListener('click', function(){ input.value=''; lastQuery=''; hideResults(); clearBtn.hidden=true; input.focus(); fetchList(); });
|
clearBtn.addEventListener('click', function(){ input.value=''; lastQuery=''; hideResults(); clearBtn.hidden=true; input.focus(); fetchList(); });
|
||||||
resultsBox.addEventListener('click', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; ev.preventDefault(); window.location.href='/themes/'+a.getAttribute('data-theme-id'); });
|
resultsBox.addEventListener('click', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; ev.preventDefault(); window.location.href='/themes/'+a.getAttribute('data-theme-id'); });
|
||||||
resultsBox.addEventListener('mouseover', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; const id=a.getAttribute('data-theme-id'); if(!id || a._prefetched) return; a._prefetched=true; fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{}); });
|
resultsBox.addEventListener('mouseover', function(ev){
|
||||||
|
const a=ev.target.closest('a[data-theme-id]');
|
||||||
|
if(!a) return;
|
||||||
|
const links = getResultLinks();
|
||||||
|
const index = links.indexOf(a);
|
||||||
|
if (index >= 0) {
|
||||||
|
selectResultItem(index);
|
||||||
|
}
|
||||||
|
const id=a.getAttribute('data-theme-id');
|
||||||
|
if(!id || a._prefetched) return;
|
||||||
|
a._prefetched=true;
|
||||||
|
fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{});
|
||||||
|
});
|
||||||
document.addEventListener('click', function(ev){ if(!resultsBox.contains(ev.target) && ev.target!==input){ hideResults(); } });
|
document.addEventListener('click', function(ev){ if(!resultsBox.contains(ev.target) && ev.target!==input){ hideResults(); } });
|
||||||
popSel.addEventListener('change', fetchList); popChips.forEach(ch=> ch.addEventListener('click', ()=>{ popSel.value=ch.getAttribute('data-pop'); fetchList(); }));
|
popSel.addEventListener('change', fetchList); popChips.forEach(ch=> ch.addEventListener('click', ()=>{ popSel.value=ch.getAttribute('data-pop'); fetchList(); }));
|
||||||
colorBox.addEventListener('change', fetchList);
|
|
||||||
// Initial load
|
// Initial load
|
||||||
fetchList();
|
fetchList();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue