Fix ranking filter to search across all pages using full JSON dataset
Embed the complete ranking JSON into the page and replace the DOM-only filter with a client-side search over ALL_ITEMS. When a filter is active the grid is re-rendered from the full dataset; clearing filters restores the server-paginated view. Also adds summary zoom overlay on the book page.
This commit is contained in:
@@ -815,6 +815,7 @@ const rankingTmpl = `
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
var ALL_ITEMS = {{.AllItemsJSON}};
|
||||||
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 clearBtn = document.getElementById('ranking-clear-filters');
|
var clearBtn = document.getElementById('ranking-clear-filters');
|
||||||
@@ -822,6 +823,9 @@ const rankingTmpl = `
|
|||||||
var grid = document.getElementById('ranking-grid');
|
var grid = document.getElementById('ranking-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
|
// Snapshot of the server-rendered paginated HTML so we can restore it.
|
||||||
|
var pagedHTML = grid.innerHTML;
|
||||||
|
|
||||||
// active facets: { status: Set<string>, genre: Set<string>, local: Set<string> }
|
// active facets: { status: Set<string>, genre: Set<string>, local: Set<string> }
|
||||||
var active = { status: new Set(), genre: new Set(), local: new Set() };
|
var active = { status: new Set(), genre: new Set(), local: new Set() };
|
||||||
|
|
||||||
@@ -832,56 +836,120 @@ const rankingTmpl = `
|
|||||||
active.local.size > 0;
|
active.local.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build an HTML card string from a full-dataset item (mirrors server-rendered HTML).
|
||||||
|
function buildCard(it) {
|
||||||
|
var genres = (it.genres || []);
|
||||||
|
var genreStr = genres.join('|') + (genres.length ? '|' : '');
|
||||||
|
var coverHtml = it.cover
|
||||||
|
? '<img src="' + it.cover + '" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 cursor-zoom-in"' +
|
||||||
|
' onclick="event.preventDefault();event.stopPropagation();(function(src){var o=document.getElementById(\'cover-zoom-overlay\');document.getElementById(\'cover-zoom-img\').src=src;o.classList.remove(\'hidden\');o.classList.add(\'flex\');})(this.src)">'
|
||||||
|
: '';
|
||||||
|
var statusBadge = it.status
|
||||||
|
? '<span class="text-xs px-1.5 py-0.5 rounded-full ' + (it.local ? 'bg-teal-900 text-teal-300' : 'bg-zinc-800 text-zinc-300') + '">' + esc(it.status) + '</span>'
|
||||||
|
: '';
|
||||||
|
var genreBadges = genres.map(function(g) {
|
||||||
|
return '<span class="text-xs px-1.5 py-0.5 rounded ' + (it.local ? 'bg-teal-900 text-teal-400' : 'bg-zinc-800 text-zinc-400') + '">' + esc(g) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
var rankBadge = it.rank ? '<span class="text-xs font-bold text-amber-400 flex-shrink-0">#' + it.rank + '</span>' : '';
|
||||||
|
var sourceBtn = it.source_url
|
||||||
|
? '<button onclick="event.stopPropagation();window.open(\'' + it.source_url + '\',\'_blank\',\'noopener,noreferrer\')"' +
|
||||||
|
' class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded text-zinc-600 hover:text-zinc-300 transition-colors cursor-pointer border-0 bg-transparent ml-auto">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>' +
|
||||||
|
'</button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (it.local) {
|
||||||
|
return '<a href="/books/' + it.slug + '"' +
|
||||||
|
' hx-get="/books/' + it.slug + '"' +
|
||||||
|
' hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
|
||||||
|
' data-filter="' + esc(it.title) + ' ' + esc(it.author || '') + '"' +
|
||||||
|
' data-status="' + esc(it.status || '') + '"' +
|
||||||
|
' data-genres="' + esc(genreStr) + '"' +
|
||||||
|
' 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">' +
|
||||||
|
coverHtml +
|
||||||
|
'<div class="min-w-0 flex-1">' +
|
||||||
|
'<div class="flex items-start gap-1.5 flex-wrap">' + rankBadge +
|
||||||
|
'<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">' + esc(it.title) + '</h2>' +
|
||||||
|
'<span class="text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>' +
|
||||||
|
'</div>' +
|
||||||
|
(it.author ? '<p class="text-xs text-zinc-400 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||||||
|
'<div class="flex gap-1.5 mt-1.5 flex-wrap">' + statusBadge + genreBadges + '</div>' +
|
||||||
|
(it.source_url ? '<div class="mt-2">' + sourceBtn + '</div>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</a>';
|
||||||
|
} else {
|
||||||
|
return '<div' +
|
||||||
|
' data-filter="' + esc(it.title) + ' ' + esc(it.author || '') + '"' +
|
||||||
|
' data-status="' + esc(it.status || '') + '"' +
|
||||||
|
' data-genres="' + esc(genreStr) + '"' +
|
||||||
|
' data-local="0"' +
|
||||||
|
(it.source_url ? ' data-scrape-url="' + esc(it.source_url) + '"' : '') +
|
||||||
|
' class="ranking-card group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 transition-colors min-w-0' + (it.source_url ? ' cursor-pointer hover:border-zinc-600' : '') + '">' +
|
||||||
|
coverHtml +
|
||||||
|
'<div class="min-w-0 flex-1">' +
|
||||||
|
'<div class="flex items-start gap-1.5 flex-wrap">' + rankBadge +
|
||||||
|
'<h2 class="font-semibold text-zinc-100 break-words leading-snug flex-1 min-w-0">' + esc(it.title) + '</h2>' +
|
||||||
|
'</div>' +
|
||||||
|
(it.author ? '<p class="text-xs text-zinc-400 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||||||
|
'<div class="flex gap-1.5 mt-1.5 flex-wrap">' + statusBadge + genreBadges + '</div>' +
|
||||||
|
(it.source_url ? '<div class="mt-2 flex items-center gap-2"><span class="scrape-hint text-xs text-zinc-500 hidden">Click again to scrape</span>' + sourceBtn + '</div>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
var q = input ? input.value.trim().toLowerCase() : '';
|
var q = input ? input.value.trim().toLowerCase() : '';
|
||||||
var cards = grid.querySelectorAll('[data-filter]');
|
|
||||||
var shown = 0;
|
|
||||||
var filtering = isFiltering();
|
var filtering = isFiltering();
|
||||||
|
|
||||||
cards.forEach(function (card) {
|
if (!filtering) {
|
||||||
var match = true;
|
// Restore paginated server-rendered HTML.
|
||||||
|
grid.innerHTML = pagedHTML;
|
||||||
// Text search against title/author
|
htmx.process(grid);
|
||||||
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';
|
|
||||||
if (match) shown++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show/hide result count
|
|
||||||
if (filtering) {
|
|
||||||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
|
||||||
countEl.classList.remove('hidden');
|
|
||||||
clearBtn.classList.remove('hidden');
|
|
||||||
if (pagination) pagination.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
countEl.classList.add('hidden');
|
countEl.classList.add('hidden');
|
||||||
clearBtn.classList.add('hidden');
|
clearBtn.classList.add('hidden');
|
||||||
if (pagination) pagination.style.display = '';
|
if (pagination) pagination.style.display = '';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter across ALL items from JSON.
|
||||||
|
var matched = ALL_ITEMS.filter(function(it) {
|
||||||
|
var filterStr = ((it.title || '') + ' ' + (it.author || '')).toLowerCase();
|
||||||
|
if (q && filterStr.indexOf(q) === -1) return false;
|
||||||
|
|
||||||
|
if (active.status.size > 0) {
|
||||||
|
var cs = (it.status || '').toLowerCase();
|
||||||
|
var hit = false;
|
||||||
|
active.status.forEach(function(v) { if (cs === v.toLowerCase()) hit = true; });
|
||||||
|
if (!hit) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.genre.size > 0) {
|
||||||
|
var genres = (it.genres || []).map(function(g){ return g.toLowerCase(); });
|
||||||
|
var ok = true;
|
||||||
|
active.genre.forEach(function(v) {
|
||||||
|
if (genres.indexOf(v.toLowerCase()) === -1) ok = false;
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.local.size > 0 && !it.local) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.innerHTML = matched.map(buildCard).join('');
|
||||||
|
htmx.process(grid);
|
||||||
|
|
||||||
|
countEl.textContent = matched.length + ' result' + (matched.length !== 1 ? 's' : '') + ' across all pages';
|
||||||
|
countEl.classList.remove('hidden');
|
||||||
|
clearBtn.classList.remove('hidden');
|
||||||
|
if (pagination) pagination.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up text input
|
// Wire up text input
|
||||||
@@ -939,7 +1007,6 @@ const rankingTmpl = `
|
|||||||
function submitScrape(card) {
|
function submitScrape(card) {
|
||||||
var url = card.dataset.scrapeUrl;
|
var url = card.dataset.scrapeUrl;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
// Replace inner content with a running badge immediately.
|
|
||||||
var badge = document.createElement('div');
|
var badge = document.createElement('div');
|
||||||
badge.className = 'flex items-center text-sm px-3 py-2 rounded-lg border text-amber-300 bg-amber-950 border-amber-800';
|
badge.className = 'flex items-center text-sm px-3 py-2 rounded-lg border text-amber-300 bg-amber-950 border-amber-800';
|
||||||
badge.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>Scraping…';
|
badge.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>Scraping…';
|
||||||
@@ -963,28 +1030,26 @@ const rankingTmpl = `
|
|||||||
grid.addEventListener('click', function (e) {
|
grid.addEventListener('click', function (e) {
|
||||||
var card = e.target.closest('.ranking-card[data-scrape-url]');
|
var card = e.target.closest('.ranking-card[data-scrape-url]');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
// Ignore clicks on the source button.
|
|
||||||
if (e.target.closest('button')) return;
|
if (e.target.closest('button')) return;
|
||||||
|
|
||||||
if (pendingCard && pendingCard !== card) cancelPending();
|
if (pendingCard && pendingCard !== card) cancelPending();
|
||||||
|
|
||||||
if (card === pendingCard) {
|
if (pendingCard === card) {
|
||||||
// Second click — go.
|
clearTimeout(pendingTimer);
|
||||||
cancelPending();
|
pendingTimer = null;
|
||||||
|
pendingCard = null;
|
||||||
submitScrape(card);
|
submitScrape(card);
|
||||||
} else {
|
return;
|
||||||
// First click — enter pending state.
|
|
||||||
pendingCard = card;
|
|
||||||
card.style.borderColor = '#f59e0b';
|
|
||||||
card.style.background = 'rgba(245,158,11,0.04)';
|
|
||||||
var hint = card.querySelector('.scrape-hint');
|
|
||||||
if (hint) hint.classList.remove('hidden');
|
|
||||||
// Auto-cancel after 3 s if no second click.
|
|
||||||
pendingTimer = setTimeout(cancelPending, 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingCard = card;
|
||||||
|
card.style.borderColor = '#f59e0b';
|
||||||
|
card.style.background = 'rgba(245,158,11,0.05)';
|
||||||
|
var hint = card.querySelector('.scrape-hint');
|
||||||
|
if (hint) hint.classList.remove('hidden');
|
||||||
|
pendingTimer = setTimeout(cancelPending, 3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicking anywhere outside a card cancels pending state.
|
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (pendingCard && !e.target.closest('.ranking-card')) cancelPending();
|
if (pendingCard && !e.target.closest('.ranking-card')) cancelPending();
|
||||||
});
|
});
|
||||||
@@ -1129,26 +1194,57 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
allGenres := sortedKeys(genreSet)
|
allGenres := sortedKeys(genreSet)
|
||||||
allStatuses := sortedKeys(statusSet)
|
allStatuses := sortedKeys(statusSet)
|
||||||
|
|
||||||
|
// Encode full dataset + local slugs for client-side cross-page filtering.
|
||||||
|
localSlugs := s.writer.LocalSlugs()
|
||||||
|
type rankingJSONItem struct {
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
Cover string `json:"cover,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
SourceURL string `json:"source_url,omitempty"`
|
||||||
|
Local bool `json:"local"`
|
||||||
|
}
|
||||||
|
allItemsForJS := make([]rankingJSONItem, len(rankingItems))
|
||||||
|
for i, it := range rankingItems {
|
||||||
|
allItemsForJS[i] = rankingJSONItem{
|
||||||
|
Rank: it.Rank,
|
||||||
|
Slug: it.Slug,
|
||||||
|
Title: it.Title,
|
||||||
|
Author: it.Author,
|
||||||
|
Cover: it.Cover,
|
||||||
|
Status: it.Status,
|
||||||
|
Genres: it.Genres,
|
||||||
|
SourceURL: it.SourceURL,
|
||||||
|
Local: localSlugs[it.Slug],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allItemsJSON, _ := json.Marshal(allItemsForJS)
|
||||||
|
|
||||||
_ = t.Execute(&buf, struct {
|
_ = t.Execute(&buf, struct {
|
||||||
Books interface{}
|
Books interface{}
|
||||||
CachedAt string
|
CachedAt string
|
||||||
FetchNums []pageNum
|
FetchNums []pageNum
|
||||||
DisplayNums []pageNum
|
DisplayNums []pageNum
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
TotalItems int
|
TotalItems int
|
||||||
AllGenres []string
|
AllGenres []string
|
||||||
AllStatuses []string
|
AllStatuses []string
|
||||||
|
AllItemsJSON template.JS
|
||||||
}{
|
}{
|
||||||
Books: toRankingViewItems(pageItems, s.writer.LocalSlugs()),
|
Books: toRankingViewItems(pageItems, localSlugs),
|
||||||
CachedAt: cachedAt,
|
CachedAt: cachedAt,
|
||||||
FetchNums: rankingPageNums(100, 0),
|
FetchNums: rankingPageNums(100, 0),
|
||||||
DisplayNums: rankingPageNums(totalPages, currentPage),
|
DisplayNums: rankingPageNums(totalPages, currentPage),
|
||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
AllGenres: allGenres,
|
AllGenres: allGenres,
|
||||||
AllStatuses: allStatuses,
|
AllStatuses: allStatuses,
|
||||||
|
AllItemsJSON: template.JS(allItemsJSON),
|
||||||
})
|
})
|
||||||
s.respond(w, r, "Rankings", buf.String())
|
s.respond(w, r, "Rankings", buf.String())
|
||||||
}
|
}
|
||||||
@@ -1331,6 +1427,17 @@ const bookTmpl = `
|
|||||||
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
|
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary zoom overlay -->
|
||||||
|
<div id="summary-zoom-overlay"
|
||||||
|
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out p-6"
|
||||||
|
onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
|
||||||
|
<div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
|
||||||
|
<p id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed whitespace-pre-wrap"></p>
|
||||||
|
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
|
||||||
|
class="mt-4 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-5 mb-8 mt-4">
|
<div class="flex gap-5 mb-8 mt-4">
|
||||||
{{if .Meta.Cover}}
|
{{if .Meta.Cover}}
|
||||||
<img src="{{.Meta.Cover}}" alt="cover"
|
<img src="{{.Meta.Cover}}" alt="cover"
|
||||||
@@ -1367,7 +1474,9 @@ const bookTmpl = `
|
|||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .Meta.Summary}}
|
{{if .Meta.Summary}}
|
||||||
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
|
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
|
||||||
|
onclick="(function(t){var o=document.getElementById('summary-zoom-overlay');document.getElementById('summary-zoom-text').textContent=t;o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
|
||||||
|
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user