Add facet filtering to ranking page: genre, status, in-library (AND logic)
- Server collects distinct genres and statuses from all cached items and passes them as AllGenres/AllStatuses slices to the template - Facet chips rendered as pill toggles below the text search bar - Cards gain data-status, data-genres (pipe-delimited), data-local attributes - JS filter engine: AND between facets, OR within status facet (a book has one status), AND within genre facet (all selected genres must be present) - Pagination row hidden while any filter is active; restored on clear - 'Clear filters' button appears when any filter is active - sortedKeys helper added to produce stable sorted facet chip order
This commit is contained in:
@@ -548,18 +548,58 @@ const rankingTmpl = `
|
|||||||
<div id="ranking-refresh-status" class="mt-2"></div>
|
<div id="ranking-refresh-status" class="mt-2"></div>
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<!-- Filter bar -->
|
||||||
<div class="mt-4 mb-4 flex gap-2 items-center">
|
<div class="mt-4 mb-2 flex gap-2 items-center">
|
||||||
<div class="flex-1 relative">
|
<div class="flex-1 relative">
|
||||||
<input id="ranking-filter" type="search" placeholder="Filter by title, author, genre, status…"
|
<input id="ranking-filter" type="search" placeholder="Filter by title, author…"
|
||||||
autocomplete="off" spellcheck="false"
|
autocomplete="off" spellcheck="false"
|
||||||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<p id="ranking-filter-count" class="text-xs text-zinc-500 whitespace-nowrap hidden"></p>
|
</div>
|
||||||
|
|
||||||
|
<!-- Facet filters -->
|
||||||
|
<div id="ranking-facets" class="mb-4 space-y-2">
|
||||||
|
{{if .AllStatuses}}
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Status</span>
|
||||||
|
{{range .AllStatuses}}
|
||||||
|
<button type="button"
|
||||||
|
data-facet="status" data-value="{{.}}"
|
||||||
|
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||||||
|
{{.}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .AllGenres}}
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Genre</span>
|
||||||
|
{{range .AllGenres}}
|
||||||
|
<button type="button"
|
||||||
|
data-facet="genre" data-value="{{.}}"
|
||||||
|
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||||||
|
{{.}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Library</span>
|
||||||
|
<button type="button"
|
||||||
|
data-facet="local" data-value="1"
|
||||||
|
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||||||
|
In library
|
||||||
|
</button>
|
||||||
|
<button type="button" id="ranking-clear-filters"
|
||||||
|
class="hidden text-xs px-2 py-0.5 rounded-full border border-zinc-800 text-zinc-600 hover:text-zinc-300 hover:border-zinc-600 transition-colors ml-2">
|
||||||
|
✕ Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="ranking-filter-count" class="text-xs text-zinc-500 hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display pagination: browse cached items -->
|
<!-- Display pagination: browse cached items -->
|
||||||
{{if gt .TotalPages 1}}
|
{{if gt .TotalPages 1}}
|
||||||
<div class="mb-4">
|
<div id="ranking-pagination" class="mb-4">
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<span class="text-xs text-zinc-500 mr-1">Page:</span>
|
<span class="text-xs text-zinc-500 mr-1">Page:</span>
|
||||||
{{$cur := .CurrentPage}}
|
{{$cur := .CurrentPage}}
|
||||||
@@ -627,7 +667,10 @@ const rankingTmpl = `
|
|||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
data-filter="{{.Title}} {{.Author}}"
|
||||||
|
data-status="{{.Status}}"
|
||||||
|
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||||||
|
data-local="1"
|
||||||
class="group flex gap-3 rounded-xl border border-teal-700 bg-teal-950 p-3 hover:border-teal-400 transition-colors min-w-0">
|
class="group flex gap-3 rounded-xl border border-teal-700 bg-teal-950 p-3 hover:border-teal-400 transition-colors min-w-0">
|
||||||
{{if .Cover}}
|
{{if .Cover}}
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
||||||
@@ -657,7 +700,10 @@ const rankingTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
<div data-filter="{{.Title}} {{.Author}}"
|
||||||
|
data-status="{{.Status}}"
|
||||||
|
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||||||
|
data-local="0"
|
||||||
class="group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 hover:border-amber-500 transition-colors min-w-0">
|
class="group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 hover:border-amber-500 transition-colors min-w-0">
|
||||||
{{if .Cover}}
|
{{if .Cover}}
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
||||||
@@ -701,27 +747,112 @@ const rankingTmpl = `
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var input = document.getElementById('ranking-filter');
|
var input = document.getElementById('ranking-filter');
|
||||||
var countEl = document.getElementById('ranking-filter-count');
|
var countEl = document.getElementById('ranking-filter-count');
|
||||||
var grid = document.getElementById('ranking-grid');
|
var clearBtn = document.getElementById('ranking-clear-filters');
|
||||||
if (!input || !grid) return;
|
var pagination = document.getElementById('ranking-pagination');
|
||||||
|
var grid = document.getElementById('ranking-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
// active facets: { status: Set<string>, genre: Set<string>, local: Set<string> }
|
||||||
|
var active = { status: new Set(), genre: new Set(), local: new Set() };
|
||||||
|
|
||||||
|
function isFiltering() {
|
||||||
|
return (input && input.value.trim() !== '') ||
|
||||||
|
active.status.size > 0 ||
|
||||||
|
active.genre.size > 0 ||
|
||||||
|
active.local.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
var q = input ? input.value.trim().toLowerCase() : '';
|
||||||
|
var cards = grid.querySelectorAll('[data-filter]');
|
||||||
|
var shown = 0;
|
||||||
|
var filtering = isFiltering();
|
||||||
|
|
||||||
input.addEventListener('input', function () {
|
|
||||||
var q = input.value.trim().toLowerCase();
|
|
||||||
var cards = grid.querySelectorAll('[data-filter]');
|
|
||||||
var shown = 0;
|
|
||||||
cards.forEach(function (card) {
|
cards.forEach(function (card) {
|
||||||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
var match = true;
|
||||||
|
|
||||||
|
// Text search against title/author
|
||||||
|
if (q && card.dataset.filter.toLowerCase().indexOf(q) === -1) match = false;
|
||||||
|
|
||||||
|
// Status facet (AND: all selected statuses must be present — but realistically
|
||||||
|
// a book has one status, so selecting two statuses shows books matching either;
|
||||||
|
// treat multiple status selections as OR within the facet, AND between facets)
|
||||||
|
if (match && active.status.size > 0) {
|
||||||
|
var cardStatus = (card.dataset.status || '').toLowerCase();
|
||||||
|
var hit = false;
|
||||||
|
active.status.forEach(function (v) { if (cardStatus === v.toLowerCase()) hit = true; });
|
||||||
|
if (!hit) match = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre facet — all selected genres must be present on the card (AND)
|
||||||
|
if (match && active.genre.size > 0) {
|
||||||
|
var cardGenres = (card.dataset.genres || '').toLowerCase();
|
||||||
|
active.genre.forEach(function (v) {
|
||||||
|
if (cardGenres.indexOf(v.toLowerCase() + '|') === -1) match = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local facet
|
||||||
|
if (match && active.local.size > 0) {
|
||||||
|
if (card.dataset.local !== '1') match = false;
|
||||||
|
}
|
||||||
|
|
||||||
card.style.display = match ? '' : 'none';
|
card.style.display = match ? '' : 'none';
|
||||||
if (match) shown++;
|
if (match) shown++;
|
||||||
});
|
});
|
||||||
if (q) {
|
|
||||||
|
// Show/hide result count
|
||||||
|
if (filtering) {
|
||||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||||||
countEl.classList.remove('hidden');
|
countEl.classList.remove('hidden');
|
||||||
|
clearBtn.classList.remove('hidden');
|
||||||
|
if (pagination) pagination.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
countEl.classList.add('hidden');
|
countEl.classList.add('hidden');
|
||||||
|
clearBtn.classList.add('hidden');
|
||||||
|
if (pagination) pagination.style.display = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up text input
|
||||||
|
if (input) input.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
// Wire up facet chips
|
||||||
|
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var facet = btn.dataset.facet;
|
||||||
|
var val = btn.dataset.value;
|
||||||
|
if (!active[facet]) return;
|
||||||
|
if (active[facet].has(val)) {
|
||||||
|
active[facet].delete(val);
|
||||||
|
btn.style.color = '';
|
||||||
|
btn.style.borderColor = '';
|
||||||
|
btn.style.background = '';
|
||||||
|
} else {
|
||||||
|
active[facet].add(val);
|
||||||
|
btn.style.color = '#f59e0b';
|
||||||
|
btn.style.borderColor = '#f59e0b';
|
||||||
|
btn.style.background = 'rgba(245,158,11,0.08)';
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
if (input) input.value = '';
|
||||||
|
['status','genre','local'].forEach(function (facet) { active[facet].clear(); });
|
||||||
|
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
||||||
|
btn.style.color = '';
|
||||||
|
btn.style.borderColor = '';
|
||||||
|
btn.style.background = '';
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
@@ -856,6 +987,21 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
t := template.Must(template.New("ranking").Parse(rankingTmpl))
|
t := template.Must(template.New("ranking").Parse(rankingTmpl))
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Collect distinct genres and statuses across ALL items for facet filters.
|
||||||
|
genreSet := map[string]bool{}
|
||||||
|
statusSet := map[string]bool{}
|
||||||
|
for _, it := range rankingItems {
|
||||||
|
if it.Status != "" {
|
||||||
|
statusSet[it.Status] = true
|
||||||
|
}
|
||||||
|
for _, g := range it.Genres {
|
||||||
|
genreSet[g] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allGenres := sortedKeys(genreSet)
|
||||||
|
allStatuses := sortedKeys(statusSet)
|
||||||
|
|
||||||
_ = t.Execute(&buf, struct {
|
_ = t.Execute(&buf, struct {
|
||||||
Books interface{}
|
Books interface{}
|
||||||
CachedAt string
|
CachedAt string
|
||||||
@@ -864,6 +1010,8 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
TotalItems int
|
TotalItems int
|
||||||
|
AllGenres []string
|
||||||
|
AllStatuses []string
|
||||||
}{
|
}{
|
||||||
Books: toRankingViewItems(pageItems, s.writer.LocalSlugs()),
|
Books: toRankingViewItems(pageItems, s.writer.LocalSlugs()),
|
||||||
CachedAt: cachedAt,
|
CachedAt: cachedAt,
|
||||||
@@ -872,6 +1020,8 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
|
AllGenres: allGenres,
|
||||||
|
AllStatuses: allStatuses,
|
||||||
})
|
})
|
||||||
s.respond(w, r, "Rankings", buf.String())
|
s.respond(w, r, "Rankings", buf.String())
|
||||||
}
|
}
|
||||||
@@ -2195,6 +2345,21 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// sortedKeys returns the keys of a string-bool map in sorted order.
|
||||||
|
func sortedKeys(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
// Simple insertion sort — sets are small (< 100 items).
|
||||||
|
for i := 1; i < len(out); i++ {
|
||||||
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||||
|
out[j], out[j-1] = out[j-1], out[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// stripMarkdown removes Markdown syntax and returns clean plain text.
|
// stripMarkdown removes Markdown syntax and returns clean plain text.
|
||||||
func stripMarkdown(src string) string {
|
func stripMarkdown(src string) string {
|
||||||
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user