feat: redesign ranking page with list layout, sticky filter bar, top genres fix
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -893,7 +894,15 @@ func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
|
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
|
||||||
|
|
||||||
const rankingTmpl = `
|
const rankingTmpl = `
|
||||||
<div class="max-w-4xl mx-auto px-4 py-10">
|
<style>
|
||||||
|
.facet-btn.active { background: rgba(245,158,11,0.12); color: #f59e0b; border-color: #92400e; }
|
||||||
|
.ranking-row { transition: background 0.12s, border-color 0.12s; }
|
||||||
|
.ranking-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.add-btn { transition: background 0.15s, color 0.15s; }
|
||||||
|
#ranking-filter-bar { position: sticky; top: 0; z-index: 20; background: #09090b; border-bottom: 1px solid #27272a; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto overflow-hidden">
|
||||||
|
|
||||||
<!-- Cover zoom overlay -->
|
<!-- Cover zoom overlay -->
|
||||||
<div id="cover-zoom-overlay"
|
<div id="cover-zoom-overlay"
|
||||||
@@ -902,147 +911,75 @@ const rankingTmpl = `
|
|||||||
<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>
|
||||||
|
|
||||||
<a href="/"
|
<!-- Header (non-sticky) -->
|
||||||
hx-get="/"
|
<div class="px-4 pt-6 pb-4">
|
||||||
hx-target="#main-content"
|
<a href="/" hx-get="/" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
|
||||||
hx-push-url="true"
|
class="text-xs text-zinc-500 hover:text-amber-400 mb-4 inline-flex items-center gap-1">
|
||||||
hx-swap="innerHTML"
|
← All books
|
||||||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
</a>
|
||||||
← All books
|
<div class="flex items-end justify-between gap-3 mt-3 flex-wrap">
|
||||||
</a>
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">Novel Rankings</h1>
|
||||||
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
|
<div class="flex items-center gap-2 mt-1 flex-wrap text-xs text-zinc-500">
|
||||||
<div>
|
{{if .TotalItems}}<span id="ranking-shown-count">{{.TotalItems}} novels</span>{{end}}
|
||||||
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
|
{{if .CachedAt}}<span>· cached {{.CachedAt}}</span>{{end}}
|
||||||
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
|
<span id="ranking-lib-count" class="text-teal-500 hidden"></span>
|
||||||
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-1 flex-wrap items-center">
|
<form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
|
||||||
<a href="/ranking/view"
|
<input type="hidden" name="pages" value="5">
|
||||||
hx-get="/ranking/view"
|
<button type="submit"
|
||||||
hx-target="#main-content"
|
class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-amber-600 hover:text-amber-400 transition-colors">
|
||||||
hx-push-url="true"
|
<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.5">
|
||||||
hx-swap="innerHTML"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors inline-flex items-center gap-1">
|
</svg>
|
||||||
View Markdown
|
Refresh
|
||||||
</a>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="ranking-refresh-status" class="mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ranking-refresh-status" class="mt-2"></div>
|
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<!-- Sticky filter bar -->
|
||||||
<div class="mt-4 mb-2 flex gap-2 items-center">
|
<div id="ranking-filter-bar" class="px-4 py-3 space-y-2.5">
|
||||||
<div class="flex-1 relative">
|
<!-- Search -->
|
||||||
<input id="ranking-filter" type="search" placeholder="Filter by title, author…"
|
<div class="relative">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 105 11a6 6 0 0012 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input id="ranking-filter" type="search" placeholder="Search title or 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-900 border border-zinc-700 pl-8 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-amber-500 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Facet filters -->
|
<!-- Filter chips row -->
|
||||||
<div id="ranking-facets" class="mb-4 space-y-2">
|
|
||||||
{{if .AllStatuses}}
|
|
||||||
<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 w-12 flex-shrink-0">Status</span>
|
<span class="text-[0.65rem] text-zinc-600 uppercase tracking-wide mr-0.5">Filter:</span>
|
||||||
{{range .AllStatuses}}
|
<button type="button" data-facet="local" data-value="1"
|
||||||
<button type="button"
|
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors font-medium">
|
||||||
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
|
In library
|
||||||
</button>
|
</button>
|
||||||
|
{{range .AllStatuses}}
|
||||||
|
<button type="button" data-facet="status" data-value="{{.}}"
|
||||||
|
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
|
||||||
|
{{.}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{range .TopGenres}}
|
||||||
|
<button type="button" data-facet="genre" data-value="{{.}}"
|
||||||
|
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
|
||||||
|
{{.}}
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
<button type="button" id="ranking-clear-filters"
|
<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">
|
class="hidden text-xs px-2 py-0.5 rounded-full text-zinc-600 hover:text-zinc-300 transition-colors">
|
||||||
✕ Clear filters
|
✕ clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="ranking-filter-count" class="text-xs text-zinc-500 hidden"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display pagination: browse cached items -->
|
<!-- List -->
|
||||||
{{if gt .TotalPages 1}}
|
<div id="ranking-grid" class="divide-y divide-zinc-800/60">
|
||||||
<div id="ranking-pagination" class="mb-4">
|
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<span class="text-xs text-zinc-500 mr-1">Page:</span>
|
|
||||||
{{$cur := .CurrentPage}}
|
|
||||||
{{range .DisplayNums}}
|
|
||||||
{{if eq .Num 0}}
|
|
||||||
<span class="text-xs text-zinc-600 px-1 select-none">…</span>
|
|
||||||
{{else if eq .Num $cur}}
|
|
||||||
<span class="text-xs w-8 h-7 rounded-lg text-amber-400 font-semibold flex items-center justify-center">{{.Num}}</span>
|
|
||||||
{{else}}
|
|
||||||
<a href="/ranking?page={{.Num}}"
|
|
||||||
hx-get="/ranking?page={{.Num}}"
|
|
||||||
hx-target="#main-content"
|
|
||||||
hx-push-url="true"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="text-xs w-8 h-7 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors flex items-center justify-center">
|
|
||||||
{{.Num}}
|
|
||||||
</a>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-zinc-600 mt-1">Showing {{.TotalItems}} cached novels · page {{.CurrentPage}} of {{.TotalPages}}</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Fetch: pull more pages from novelfire -->
|
|
||||||
<details class="mb-6 group">
|
|
||||||
<summary class="text-xs text-zinc-500 cursor-pointer select-none hover:text-zinc-300 transition-colors list-none flex items-center gap-1">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
|
||||||
Fetch more from novelfire.net
|
|
||||||
</summary>
|
|
||||||
<div class="mt-2 pl-4 border-l border-zinc-800">
|
|
||||||
<div class="flex items-center gap-1.5 mb-1 flex-wrap">
|
|
||||||
<span class="text-xs text-zinc-500 mr-1">Fetch up to page:</span>
|
|
||||||
{{range .FetchNums}}
|
|
||||||
{{if eq .Num 0}}
|
|
||||||
<span class="text-xs text-zinc-600 px-1 select-none">…</span>
|
|
||||||
{{else}}
|
|
||||||
<form hx-post="/ranking/refresh"
|
|
||||||
hx-target="#ranking-refresh-status"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<input type="hidden" name="pages" value="{{.Num}}">
|
|
||||||
<button type="submit"
|
|
||||||
class="text-xs w-8 h-7 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors text-center">
|
|
||||||
{{.Num}}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-zinc-600">
|
|
||||||
Each page ≈ 20 novels from
|
|
||||||
<a href="https://novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="text-zinc-500 hover:text-amber-400 underline underline-offset-2">novelfire.net popular</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Book grid -->
|
|
||||||
<div id="ranking-grid" class="grid gap-3 sm:grid-cols-2 mt-2">
|
|
||||||
{{range .Books}}
|
{{range .Books}}
|
||||||
{{if .Local}}
|
{{if .Local}}
|
||||||
<a href="/books/{{.Slug}}"
|
<a href="/books/{{.Slug}}"
|
||||||
@@ -1050,68 +987,76 @@ 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}}"
|
data-filter="{{.Title}} {{.Author}} {{range .Genres}}{{.}} {{end}}"
|
||||||
data-status="{{.Status}}"
|
data-status="{{.Status}}"
|
||||||
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||||||
data-local="1"
|
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="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">
|
||||||
{{if .Cover}}
|
<!-- Rank -->
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 cursor-zoom-in"
|
<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
|
||||||
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)">
|
{{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
|
||||||
{{end}}
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Cover -->
|
||||||
<div class="flex items-start gap-1.5 flex-wrap">
|
<div class="flex-shrink-0">
|
||||||
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
|
{{if .Cover}}
|
||||||
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
|
<img src="{{.Cover}}" alt="cover" loading="lazy"
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>
|
class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
|
||||||
</div>
|
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)">
|
||||||
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
{{else}}
|
||||||
<div class="flex gap-1.5 mt-1.5 flex-wrap">
|
<div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
|
||||||
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300">{{.Status}}</span>{{end}}
|
|
||||||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
|
||||||
</div>
|
|
||||||
{{if .SourceURL}}
|
|
||||||
<div class="mt-2">
|
|
||||||
<button onclick="event.preventDefault();event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
|
|
||||||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded text-zinc-400 hover:text-zinc-200 transition-colors cursor-pointer border-0">
|
|
||||||
<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>
|
|
||||||
Source
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1 py-0.5">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">{{.Title}}</h2>
|
||||||
|
<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>
|
||||||
|
</div>
|
||||||
|
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||||||
|
<div class="flex gap-1 flex-wrap items-center mt-1.5">
|
||||||
|
{{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-teal-900/60 text-teal-400">{{.Status}}</span>{{end}}
|
||||||
|
{{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div data-filter="{{.Title}} {{.Author}}"
|
<div data-filter="{{.Title}} {{.Author}} {{range .Genres}}{{.}} {{end}}"
|
||||||
data-status="{{.Status}}"
|
data-status="{{.Status}}"
|
||||||
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||||||
data-local="0"
|
data-local="0"
|
||||||
{{if .SourceURL}}data-scrape-url="{{.SourceURL}}"{{end}}
|
{{if .SourceURL}}data-scrape-url="{{.SourceURL}}"{{end}}
|
||||||
class="ranking-card group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 transition-colors min-w-0{{if .SourceURL}} cursor-pointer hover:border-zinc-600{{end}}">
|
class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">
|
||||||
{{if .Cover}}
|
<!-- Rank -->
|
||||||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 cursor-zoom-in"
|
<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
|
||||||
onclick="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)">
|
{{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
|
||||||
{{end}}
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Cover -->
|
||||||
<div class="flex items-start gap-1.5 flex-wrap">
|
<div class="flex-shrink-0">
|
||||||
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
|
{{if .Cover}}
|
||||||
<h2 class="font-semibold text-zinc-100 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
|
<img src="{{.Cover}}" alt="cover" loading="lazy"
|
||||||
</div>
|
class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
|
||||||
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
onclick="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)">
|
||||||
<div class="flex gap-1.5 mt-1.5 flex-wrap">
|
{{else}}
|
||||||
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
<div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
|
||||||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400">{{.}}</span>{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1 py-0.5">
|
||||||
|
<h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">{{.Title}}</h2>
|
||||||
|
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||||||
|
<div class="flex gap-1 flex-wrap items-center mt-1.5">
|
||||||
|
{{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
|
||||||
|
{{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .SourceURL}}
|
{{if .SourceURL}}
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="flex items-center gap-2 mt-2">
|
||||||
<span class="scrape-hint text-xs text-zinc-500 hidden">Click again to scrape</span>
|
<button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors"
|
||||||
|
data-action="add" data-url="{{.SourceURL}}">
|
||||||
|
+ Add to library
|
||||||
|
</button>
|
||||||
<button onclick="event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
|
<button onclick="event.stopPropagation();window.open('{{.SourceURL}}','_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">
|
class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">
|
||||||
<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">
|
source ↗
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1119,250 +1064,258 @@ const rankingTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-zinc-500 col-span-2">No ranking data. Use the page buttons above to fetch from novelfire.net.</p>
|
<div class="px-4 py-16 text-center">
|
||||||
|
<p class="text-zinc-500 mb-4 text-sm">No ranking data cached yet.</p>
|
||||||
|
<form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="pages" value="5">
|
||||||
|
<button type="submit" class="text-sm px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-zinc-900 font-semibold transition-colors">
|
||||||
|
Fetch rankings from novelfire.net
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div id="load-more-wrap" class="px-4 py-6 text-center hidden">
|
||||||
|
<button id="load-more-btn"
|
||||||
|
class="text-sm px-5 py-2 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-amber-600 hover:text-amber-400 transition-colors">
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
<p id="load-more-label" class="text-xs text-zinc-600 mt-2"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var ALL_ITEMS = {{.AllItemsJSON}};
|
var ALL_ITEMS = {{.AllItemsJSON}};
|
||||||
var input = document.getElementById('ranking-filter');
|
var PAGE_SIZE = 40;
|
||||||
var countEl = document.getElementById('ranking-filter-count');
|
|
||||||
var clearBtn = document.getElementById('ranking-clear-filters');
|
var input = document.getElementById('ranking-filter');
|
||||||
var pagination = document.getElementById('ranking-pagination');
|
var clearBtn = document.getElementById('ranking-clear-filters');
|
||||||
var grid = document.getElementById('ranking-grid');
|
var grid = document.getElementById('ranking-grid');
|
||||||
|
var loadWrap = document.getElementById('load-more-wrap');
|
||||||
|
var loadBtn = document.getElementById('load-more-btn');
|
||||||
|
var loadLabel = document.getElementById('load-more-label');
|
||||||
|
var libCount = document.getElementById('ranking-lib-count');
|
||||||
|
var shownCount = document.getElementById('ranking-shown-count');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
// Snapshot of the server-rendered paginated HTML so we can restore it.
|
// Show library count
|
||||||
var pagedHTML = grid.innerHTML;
|
var inLib = ALL_ITEMS.filter(function(it){ return it.local; }).length;
|
||||||
|
if (inLib > 0 && libCount) {
|
||||||
|
libCount.textContent = '· ' + inLib + ' in library';
|
||||||
|
libCount.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// 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() };
|
||||||
|
var visibleCount = 0;
|
||||||
function isFiltering() {
|
var currentItems = [];
|
||||||
return (input && input.value.trim() !== '') ||
|
|
||||||
active.status.size > 0 ||
|
|
||||||
active.genre.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) {
|
function esc(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function buildRow(it) {
|
||||||
var q = input ? input.value.trim().toLowerCase() : '';
|
var genres = (it.genres || []).slice(0, 5);
|
||||||
var filtering = isFiltering();
|
var genreStr = genres.join('|') + (genres.length ? '|' : '');
|
||||||
|
var rankHtml = it.rank
|
||||||
|
? '<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5"><span class="text-sm font-bold text-amber-400">#' + it.rank + '</span></div>'
|
||||||
|
: '<div class="w-8 flex-shrink-0"></div>';
|
||||||
|
var coverHtml = it.cover
|
||||||
|
? '<div class="flex-shrink-0"><img src="' + esc(it.cover) + '" alt="cover" loading="lazy" class="w-10 h-[3.75rem] object-cover rounded shadow 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)"></div>'
|
||||||
|
: '<div class="flex-shrink-0"><div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">' + esc(it.rank ? '#'+it.rank : '?') + '</div></div>';
|
||||||
|
var statusBadge = it.status
|
||||||
|
? '<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full ' + (it.local ? 'bg-teal-900/60 text-teal-400' : 'bg-zinc-800 text-zinc-400') + '">' + esc(it.status) + '</span>'
|
||||||
|
: '';
|
||||||
|
var genreBadges = genres.map(function(g) {
|
||||||
|
return '<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">' + esc(g) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
if (!filtering) {
|
if (it.local) {
|
||||||
// Restore paginated server-rendered HTML.
|
return '<a href="/books/' + esc(it.slug) + '"' +
|
||||||
grid.innerHTML = pagedHTML;
|
' hx-get="/books/' + esc(it.slug) + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
|
||||||
htmx.process(grid);
|
' data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
|
||||||
countEl.classList.add('hidden');
|
' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="1"' +
|
||||||
clearBtn.classList.add('hidden');
|
' class="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">' +
|
||||||
if (pagination) pagination.style.display = '';
|
rankHtml + coverHtml +
|
||||||
return;
|
'<div class="min-w-0 flex-1 py-0.5">' +
|
||||||
|
'<div class="flex items-start justify-between gap-2">' +
|
||||||
|
'<h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
|
||||||
|
'<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>' +
|
||||||
|
'</div>' +
|
||||||
|
(it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||||||
|
'<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
|
||||||
|
'</div></a>';
|
||||||
|
} else {
|
||||||
|
var addBtn = it.source_url
|
||||||
|
? '<button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors" data-action="add" data-url="' + esc(it.source_url) + '">+ Add to library</button>'
|
||||||
|
: '';
|
||||||
|
var srcBtn = it.source_url
|
||||||
|
? '<button onclick="event.stopPropagation();window.open(\'' + it.source_url.replace(/'/g,"\\'") + '\',\'_blank\',\'noopener,noreferrer\')" class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">source ↗</button>'
|
||||||
|
: '';
|
||||||
|
var actionRow = (addBtn || srcBtn)
|
||||||
|
? '<div class="flex items-center gap-2 mt-2">' + addBtn + srcBtn + '</div>'
|
||||||
|
: '';
|
||||||
|
return '<div data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
|
||||||
|
' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="0"' +
|
||||||
|
(it.source_url ? ' data-scrape-url="' + esc(it.source_url) + '"' : '') +
|
||||||
|
' class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">' +
|
||||||
|
rankHtml + coverHtml +
|
||||||
|
'<div class="min-w-0 flex-1 py-0.5">' +
|
||||||
|
'<h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
|
||||||
|
(it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||||||
|
'<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
|
||||||
|
actionRow +
|
||||||
|
'</div></div>';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter across ALL items from JSON.
|
function isFiltering() {
|
||||||
var matched = ALL_ITEMS.filter(function(it) {
|
return (input && input.value.trim() !== '') ||
|
||||||
var filterStr = ((it.title || '') + ' ' + (it.author || '')).toLowerCase();
|
active.status.size > 0 || active.genre.size > 0 || active.local.size > 0;
|
||||||
if (q && filterStr.indexOf(q) === -1) return false;
|
}
|
||||||
|
|
||||||
|
function updateShownCount(n, total) {
|
||||||
|
if (!shownCount) return;
|
||||||
|
if (isFiltering()) {
|
||||||
|
shownCount.textContent = n + ' of ' + total + ' matched';
|
||||||
|
} else {
|
||||||
|
shownCount.textContent = total + ' novels';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
var q = input ? input.value.trim().toLowerCase() : '';
|
||||||
|
currentItems = ALL_ITEMS.filter(function(it) {
|
||||||
|
var txt = ((it.title||'') + ' ' + (it.author||'')).toLowerCase();
|
||||||
|
if (q && txt.indexOf(q) === -1) return false;
|
||||||
if (active.status.size > 0) {
|
if (active.status.size > 0) {
|
||||||
var cs = (it.status || '').toLowerCase();
|
|
||||||
var hit = false;
|
var hit = false;
|
||||||
active.status.forEach(function(v) { if (cs === v.toLowerCase()) hit = true; });
|
active.status.forEach(function(v){ if ((it.status||'').toLowerCase() === v.toLowerCase()) hit = true; });
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.genre.size > 0) {
|
if (active.genre.size > 0) {
|
||||||
var genres = (it.genres || []).map(function(g){ return g.toLowerCase(); });
|
var gs = (it.genres||[]).map(function(g){ return g.toLowerCase(); });
|
||||||
var ok = true;
|
var ok = true;
|
||||||
active.genre.forEach(function(v) {
|
active.genre.forEach(function(v){ if (gs.indexOf(v.toLowerCase()) === -1) ok = false; });
|
||||||
if (genres.indexOf(v.toLowerCase()) === -1) ok = false;
|
|
||||||
});
|
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.local.size > 0 && !it.local) return false;
|
if (active.local.size > 0 && !it.local) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.innerHTML = matched.map(buildCard).join('');
|
visibleCount = 0;
|
||||||
htmx.process(grid);
|
grid.innerHTML = '';
|
||||||
|
renderMore();
|
||||||
|
|
||||||
countEl.textContent = matched.length + ' result' + (matched.length !== 1 ? 's' : '') + ' across all pages';
|
if (clearBtn) {
|
||||||
countEl.classList.remove('hidden');
|
if (isFiltering()) clearBtn.classList.remove('hidden');
|
||||||
clearBtn.classList.remove('hidden');
|
else clearBtn.classList.add('hidden');
|
||||||
if (pagination) pagination.style.display = 'none';
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up text input
|
function renderMore() {
|
||||||
if (input) input.addEventListener('input', applyFilters);
|
var batch = currentItems.slice(visibleCount, visibleCount + PAGE_SIZE);
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
batch.forEach(function(it) {
|
||||||
|
var tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = buildRow(it);
|
||||||
|
var el = tmp.firstElementChild;
|
||||||
|
frag.appendChild(el);
|
||||||
|
});
|
||||||
|
grid.appendChild(frag);
|
||||||
|
htmx.process(grid);
|
||||||
|
visibleCount += batch.length;
|
||||||
|
var remaining = currentItems.length - visibleCount;
|
||||||
|
updateShownCount(visibleCount, currentItems.length);
|
||||||
|
if (remaining > 0) {
|
||||||
|
loadWrap.classList.remove('hidden');
|
||||||
|
loadLabel.textContent = visibleCount + ' of ' + currentItems.length + ' shown · ' + remaining + ' more';
|
||||||
|
} else {
|
||||||
|
loadWrap.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up facet chips
|
if (loadBtn) loadBtn.addEventListener('click', renderMore);
|
||||||
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
// Initial render from ALL_ITEMS
|
||||||
|
currentItems = ALL_ITEMS;
|
||||||
|
grid.innerHTML = '';
|
||||||
|
renderMore();
|
||||||
|
|
||||||
|
// Facet chips
|
||||||
|
document.querySelectorAll('.facet-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
var facet = btn.dataset.facet;
|
var facet = btn.dataset.facet;
|
||||||
var val = btn.dataset.value;
|
var val = btn.dataset.value;
|
||||||
if (!active[facet]) return;
|
if (!active[facet]) return;
|
||||||
if (active[facet].has(val)) {
|
if (active[facet].has(val)) {
|
||||||
active[facet].delete(val);
|
active[facet].delete(val);
|
||||||
btn.style.color = '';
|
btn.classList.remove('active');
|
||||||
btn.style.borderColor = '';
|
|
||||||
btn.style.background = '';
|
|
||||||
} else {
|
} else {
|
||||||
active[facet].add(val);
|
active[facet].add(val);
|
||||||
btn.style.color = '#f59e0b';
|
btn.classList.add('active');
|
||||||
btn.style.borderColor = '#f59e0b';
|
|
||||||
btn.style.background = 'rgba(245,158,11,0.08)';
|
|
||||||
}
|
}
|
||||||
applyFilters();
|
applyFilters();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear all filters
|
if (input) input.addEventListener('input', applyFilters);
|
||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener('click', function () {
|
clearBtn.addEventListener('click', function() {
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
['status','genre','local'].forEach(function (facet) { active[facet].clear(); });
|
['status','genre','local'].forEach(function(f){ active[f].clear(); });
|
||||||
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
document.querySelectorAll('.facet-btn').forEach(function(b){ b.classList.remove('active'); });
|
||||||
btn.style.color = '';
|
|
||||||
btn.style.borderColor = '';
|
|
||||||
btn.style.background = '';
|
|
||||||
});
|
|
||||||
applyFilters();
|
applyFilters();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── two-click scrape on ranking cards ─────────────────────────────────── */
|
/* ── Add to library ─────────────────────────────────────────────────────── */
|
||||||
var pendingCard = null;
|
function submitScrape(url, cardEl) {
|
||||||
var pendingTimer = null;
|
|
||||||
|
|
||||||
function cancelPending() {
|
|
||||||
if (!pendingCard) return;
|
|
||||||
pendingCard.style.borderColor = '';
|
|
||||||
pendingCard.style.background = '';
|
|
||||||
var hint = pendingCard.querySelector('.scrape-hint');
|
|
||||||
if (hint) hint.classList.add('hidden');
|
|
||||||
pendingCard = null;
|
|
||||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitScrape(card) {
|
|
||||||
var url = card.dataset.scrapeUrl;
|
|
||||||
if (!url) return;
|
|
||||||
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-4 py-3 text-amber-300 bg-amber-950/60 border-l-2 border-amber-600';
|
||||||
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>Adding to library…';
|
||||||
badge.setAttribute('hx-get', '/ui/scrape/status');
|
badge.setAttribute('hx-get', '/ui/scrape/status');
|
||||||
badge.setAttribute('hx-trigger', 'every 3s');
|
badge.setAttribute('hx-trigger', 'every 3s');
|
||||||
badge.setAttribute('hx-target', 'this');
|
badge.setAttribute('hx-target', 'this');
|
||||||
badge.setAttribute('hx-swap', 'outerHTML');
|
badge.setAttribute('hx-swap', 'outerHTML');
|
||||||
card.innerHTML = '';
|
if (cardEl) {
|
||||||
card.appendChild(badge);
|
cardEl.replaceWith(badge);
|
||||||
card.style.borderColor = '';
|
} else {
|
||||||
card.style.background = '';
|
grid.prepend(badge);
|
||||||
card.style.cursor = 'default';
|
}
|
||||||
card.classList.remove('cursor-pointer');
|
|
||||||
htmx.process(badge);
|
htmx.process(badge);
|
||||||
|
|
||||||
var body = new URLSearchParams();
|
var body = new URLSearchParams();
|
||||||
body.set('url', url);
|
body.set('url', url);
|
||||||
fetch('/ui/scrape/book', { method: 'POST', body: body });
|
fetch('/ui/scrape/book', { method: 'POST', body: body });
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.addEventListener('click', function (e) {
|
grid.addEventListener('click', function(e) {
|
||||||
var card = e.target.closest('.ranking-card[data-scrape-url]');
|
var btn = e.target.closest('[data-action="add"]');
|
||||||
if (!card) return;
|
if (!btn) return;
|
||||||
if (e.target.closest('button')) return;
|
e.stopPropagation();
|
||||||
|
var url = btn.dataset.url;
|
||||||
if (pendingCard && pendingCard !== card) cancelPending();
|
if (!url) return;
|
||||||
|
var card = btn.closest('.ranking-card');
|
||||||
if (pendingCard === card) {
|
if (btn.dataset.confirm === '1') {
|
||||||
clearTimeout(pendingTimer);
|
submitScrape(url, card);
|
||||||
pendingTimer = null;
|
} else {
|
||||||
pendingCard = null;
|
btn.dataset.confirm = '1';
|
||||||
submitScrape(card);
|
btn.textContent = 'Confirm?';
|
||||||
return;
|
btn.classList.add('bg-amber-600','text-zinc-900','border-amber-600');
|
||||||
|
btn.classList.remove('bg-zinc-800','text-zinc-400','border-zinc-700');
|
||||||
|
setTimeout(function() {
|
||||||
|
if (btn.dataset.confirm === '1') {
|
||||||
|
btn.dataset.confirm = '';
|
||||||
|
btn.textContent = '+ Add to library';
|
||||||
|
btn.classList.remove('bg-amber-600','text-zinc-900','border-amber-600');
|
||||||
|
btn.classList.add('bg-zinc-800','text-zinc-400','border-zinc-700');
|
||||||
|
}
|
||||||
|
}, 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (pendingCard && !e.target.closest('.ranking-card')) cancelPending();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}());
|
}());
|
||||||
@@ -1492,19 +1445,42 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// Collect distinct genres and statuses across ALL items for facet filters.
|
// Collect distinct genres and statuses across ALL items for facet filters.
|
||||||
genreSet := map[string]bool{}
|
genreFreq := map[string]int{}
|
||||||
statusSet := map[string]bool{}
|
statusSet := map[string]bool{}
|
||||||
for _, it := range rankingItems {
|
for _, it := range rankingItems {
|
||||||
if it.Status != "" {
|
if it.Status != "" {
|
||||||
statusSet[it.Status] = true
|
statusSet[it.Status] = true
|
||||||
}
|
}
|
||||||
for _, g := range it.Genres {
|
for _, g := range it.Genres {
|
||||||
genreSet[g] = true
|
genreFreq[g]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allGenres := sortedKeys(genreSet)
|
|
||||||
allStatuses := sortedKeys(statusSet)
|
allStatuses := sortedKeys(statusSet)
|
||||||
|
|
||||||
|
// Build top-10 genres sorted by frequency descending.
|
||||||
|
type genreCount struct {
|
||||||
|
name string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
gcSlice := make([]genreCount, 0, len(genreFreq))
|
||||||
|
for g, c := range genreFreq {
|
||||||
|
gcSlice = append(gcSlice, genreCount{g, c})
|
||||||
|
}
|
||||||
|
sort.Slice(gcSlice, func(i, j int) bool {
|
||||||
|
if gcSlice[i].count != gcSlice[j].count {
|
||||||
|
return gcSlice[i].count > gcSlice[j].count
|
||||||
|
}
|
||||||
|
return gcSlice[i].name < gcSlice[j].name
|
||||||
|
})
|
||||||
|
topN := 10
|
||||||
|
if len(gcSlice) < topN {
|
||||||
|
topN = len(gcSlice)
|
||||||
|
}
|
||||||
|
topGenres := make([]string, topN)
|
||||||
|
for i := 0; i < topN; i++ {
|
||||||
|
topGenres[i] = gcSlice[i].name
|
||||||
|
}
|
||||||
|
|
||||||
// Encode full dataset + local slugs for client-side cross-page filtering.
|
// Encode full dataset + local slugs for client-side cross-page filtering.
|
||||||
localSlugs := s.writer.LocalSlugs()
|
localSlugs := s.writer.LocalSlugs()
|
||||||
type rankingJSONItem struct {
|
type rankingJSONItem struct {
|
||||||
@@ -1542,7 +1518,7 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
TotalItems int
|
TotalItems int
|
||||||
AllGenres []string
|
TopGenres []string
|
||||||
AllStatuses []string
|
AllStatuses []string
|
||||||
AllItemsJSON template.JS
|
AllItemsJSON template.JS
|
||||||
}{
|
}{
|
||||||
@@ -1553,7 +1529,7 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
AllGenres: allGenres,
|
TopGenres: topGenres,
|
||||||
AllStatuses: allStatuses,
|
AllStatuses: allStatuses,
|
||||||
AllItemsJSON: template.JS(allItemsJSON),
|
AllItemsJSON: template.JS(allItemsJSON),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user