Files
libnovel/scraper/internal/server/ui.go
Admin 7ed5eb0d69
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
fix: remove duplicate chapter title h1, drop nav arrow, enlarge hamburger
2026-03-02 11:15:21 +05:00

3023 lines
116 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/libnovel/scraper/internal/orchestrator"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
goldhtml "github.com/yuin/goldmark/renderer/html"
)
// md is the shared goldmark instance used for all markdown→HTML conversions.
var md = goldmark.New(
goldmark.WithExtensions(extension.Typographer, extension.Table),
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
// grouped loosely by language prefix:
//
// af_ / am_ American English female / male
// bf_ / bm_ British English female / male
// ef_ / em_ Spanish female / male
// ff_ French female
// hf_ / hm_ Hindi female / male
// if_ / im_ Italian female / male
// jf_ / jm_ Japanese female / male
// pf_ / pm_ Portuguese female / male
// zf_ / zm_ Chinese female / male
var kokoroVoices = []string{
// American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky",
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
"am_michael", "am_onyx", "am_puck",
// British English
"bf_alice", "bf_emma", "bf_lily",
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
// Spanish
"ef_dora", "em_alex",
// French
"ff_siwis",
// Hindi
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
// Italian
"if_sara", "im_nicola",
// Japanese
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
// Portuguese
"pf_dora", "pm_alex",
// Chinese
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}
// voiceInfo holds the parsed display metadata for a single Kokoro voice.
type voiceInfo struct {
ID string // raw voice ID, e.g. "af_bella"
Name string // display name, e.g. "Bella"
Lang string // language label, e.g. "EN-US"
Gender string // "F" or "M"
}
// langLabel maps the two-letter prefix to a human-readable language tag.
var langLabel = map[string]string{
"a": "EN-US",
"b": "EN-GB",
"e": "ES",
"f": "FR",
"h": "HI",
"i": "IT",
"j": "JA",
"p": "PT",
"z": "ZH",
}
// parseVoice decodes a Kokoro voice ID into display metadata.
// IDs follow the pattern {lang}{gender}_{name} e.g. "af_bella".
func parseVoice(id string) voiceInfo {
v := voiceInfo{ID: id, Name: id, Lang: "?", Gender: "?"}
if len(id) < 3 || id[2] != '_' {
return v
}
lc := string(id[0])
gc := string(id[1])
name := id[3:]
if l, ok := langLabel[lc]; ok {
v.Lang = l
}
switch gc {
case "f":
v.Gender = "F"
case "m":
v.Gender = "M"
}
// Capitalise name, replace underscores with spaces.
if len(name) > 0 {
runes := []rune(name)
runes[0] -= 'a' - 'A'
v.Name = strings.ReplaceAll(string(runes), "_", " ")
}
return v
}
// parseVoices converts a slice of raw voice IDs to voiceInfo structs.
func parseVoices(ids []string) []voiceInfo {
out := make([]voiceInfo, len(ids))
for i, id := range ids {
out[i] = parseVoice(id)
}
return out
}
// ─── shared layout ────────────────────────────────────────────────────────────
const layoutHead = `<!DOCTYPE html>
<html lang="en" class="bg-zinc-950 text-zinc-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — libnovel</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
<style>
.prose p { margin-bottom: 1em; }
.prose h1,.prose h2,
.prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
.prose h4 { font-size: 1.05rem; }
.prose h3 { font-size: 1.2rem; }
.prose h2 { font-size: 1.4rem; }
.prose h1 { font-size: 1.7rem; }
.prose em { font-style: italic; }
.prose strong { font-weight: 700; }
.prose hr { border-color: #3f3f46; margin: 2em 0; }
.prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
</style>
</head>
<body class="min-h-screen">`
const layoutFoot = `</body></html>`
func renderPage(w http.ResponseWriter, title, body string) {
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = t.Execute(w, struct{ Title string }{Title: title})
}
func renderFragment(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, body)
}
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// respond writes either a full page or an HTMX fragment depending on the request.
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
if isHTMX(r) {
renderFragment(w, fragment)
return
}
renderPage(w, title,
`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
}
// ─── GET / — book catalogue ───────────────────────────────────────────────────
const homeTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10" hx-history="false">
<div class="flex items-center justify-between mb-2">
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
<div class="flex items-center gap-1">
<a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
</div>
</div>
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
<!-- Continue reading section (populated by JS) -->
<div id="continue-reading-section" class="hidden mb-6">
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
<div id="continue-reading-grid" class="grid gap-4 sm:grid-cols-2"></div>
</div>
<!-- Filter bar -->
<div class="mb-6">
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
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" />
<p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
</div>
<!-- All books grid -->
<div id="available-section">
<h2 id="available-heading" class="text-lg font-semibold text-zinc-200 mb-3{{if eq (len .Books) 0}} hidden{{end}}">Available</h2>
<div id="books-grid" class="grid gap-4 sm:grid-cols-2">
{{range .Books}}
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
data-slug="{{.Slug}}"
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer">
<div class="flex gap-4">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
{{end}}
<div class="min-w-0">
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
{{if .Downloaded}}<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.Downloaded}} downloaded</span>{{end}}
</div>
</div>
</div>
</a>
{{else}}
<p class="text-zinc-500 col-span-2">No books scraped yet.</p>
{{end}}
</div>
</div>
</div>
<script>
(function () {
/* ── continue-reading ──────────────────────────────────────────────────── */
var progress = {};
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
var inProgress = Object.keys(progress);
var continueSection = document.getElementById('continue-reading-section');
var continueGrid = document.getElementById('continue-reading-grid');
// Always clear before re-populating so back-navigation never duplicates cards.
if (continueGrid) continueGrid.innerHTML = '';
if (inProgress.length > 0 && continueGrid) {
var moved = 0;
inProgress.forEach(function(slug) {
// Find the source card in #books-grid by data-slug.
var card = document.querySelector('#books-grid [data-slug="' + slug + '"]');
if (!card) return;
var chapterNum = progress[slug];
// Build a fresh card element rather than cloning + mutating,
// so repeated back-navigations cannot accumulate injected nodes.
var a = document.createElement('a');
a.href = card.getAttribute('href');
a.setAttribute('hx-get', card.getAttribute('hx-get'));
a.setAttribute('hx-target', card.getAttribute('hx-target'));
a.setAttribute('hx-push-url', card.getAttribute('hx-push-url'));
a.setAttribute('hx-swap', card.getAttribute('hx-swap'));
a.setAttribute('data-slug', slug);
a.className = card.className;
// Copy inner HTML then inject the chapter-progress line.
a.innerHTML = card.innerHTML;
if (chapterNum) {
var meta = a.querySelector('.min-w-0');
if (meta) {
// Remove any previously injected progress line (defensive).
var old = meta.querySelector('.chapter-progress-line');
if (old) old.parentNode.removeChild(old);
var chLine = document.createElement('p');
chLine.className = 'chapter-progress-line text-xs text-amber-400 mt-1';
chLine.textContent = 'Chapter ' + chapterNum;
var author = meta.querySelector('p');
if (author) author.insertAdjacentElement('afterend', chLine);
else {
var title = meta.querySelector('h2');
if (title) title.insertAdjacentElement('afterend', chLine);
}
}
}
htmx.process(a);
// Add dismiss button (swipe-left to remove from continue reading).
var dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.title = 'Remove from continue reading';
dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
dismissBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var cardEl = this.closest('[data-slug]');
var cardSlug = cardEl ? cardEl.dataset.slug : null;
if (cardSlug) {
var p = {};
try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
delete p[cardSlug];
localStorage.setItem('reading_progress', JSON.stringify(p));
}
// Slide left and fade out, then remove.
var target = cardEl || this.parentElement;
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
target.style.transform = 'translateX(-60px)';
target.style.opacity = '0';
setTimeout(function() {
target.remove();
// Hide section if no cards remain.
var remaining = continueGrid.querySelectorAll('[data-slug]');
if (remaining.length === 0) {
continueSection.classList.add('hidden');
}
}, 260);
});
// Make card position:relative so the absolute button positions correctly.
a.style.position = 'relative';
a.appendChild(dismissBtn);
continueGrid.appendChild(a);
// Keep the original card visible in #books-grid — do not hide it.
// Books should always appear in Available regardless of reading progress.
moved++;
});
if (moved > 0) {
continueSection.classList.remove('hidden');
}
}
/* ── filter ────────────────────────────────────────────────────────────── */
var input = document.getElementById('filter-input');
var countEl = document.getElementById('filter-count');
var booksGrid = document.getElementById('books-grid');
function filterCards() {
var q = input.value.trim().toLowerCase();
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
var shown = 0;
cards.forEach(function(card) {
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
card.style.display = match ? '' : 'none';
if (match) shown++;
});
if (q && countEl) {
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
countEl.classList.remove('hidden');
} else if (countEl) {
countEl.classList.add('hidden');
}
}
if (input) input.addEventListener('input', filterCards);
}());
</script>`
// homeBookItem wraps BookMeta with the count of chapters already on disk.
type homeBookItem struct {
scraper.BookMeta
Downloaded int
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
books, err := s.writer.ListBooks()
if err != nil {
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
return
}
items := make([]homeBookItem, len(books))
for i, b := range books {
items[i] = homeBookItem{
BookMeta: b,
Downloaded: s.writer.CountChapters(b.Slug),
}
}
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
}{
Books: items,
})
s.respond(w, r, "Home", buf.String())
}
// ─── GET /scrape — add a new book ─────────────────────────────────────────────
const scrapeTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-1">Add a book</h1>
<p class="text-zinc-400 text-sm mb-8">Search the rankings or paste a novelfire.net URL to scrape a new book.</p>
<form id="scrape-form"
hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML">
<div class="relative mb-3" id="scrape-search-wrap">
<input type="text"
id="scrape-search"
autocomplete="off"
spellcheck="false"
placeholder="Search rankings or paste a URL…"
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
<!-- hidden url field submitted to HTMX -->
<input type="url" name="url" id="scrape-url" required class="hidden" />
<!-- dropdown -->
<ul id="scrape-dropdown"
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
</ul>
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-4"></div>
</div>
<script>
(function () {
var RANKING = {{.RankingJSON}};
if (!RANKING || !RANKING.length) return;
var searchInput = document.getElementById('scrape-search');
var urlInput = document.getElementById('scrape-url');
var dropdown = document.getElementById('scrape-dropdown');
var form = document.getElementById('scrape-form');
if (!searchInput || !urlInput || !dropdown || !form) return;
var activeIdx = -1;
function syncURLField(val) {
val = val.trim();
if (/^https?:\/\//i.test(val)) {
urlInput.value = val;
} else {
urlInput.value = '';
}
}
function closeDrop() {
dropdown.classList.add('hidden');
dropdown.innerHTML = '';
activeIdx = -1;
}
function selectItem(item) {
var url = item.source_url || '';
searchInput.value = url;
urlInput.value = url;
closeDrop();
}
function buildItem(item, q) {
var li = document.createElement('li');
li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
li.dataset.url = item.source_url || '';
if (item.cover) {
var img = document.createElement('img');
img.src = item.cover;
img.alt = '';
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
li.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
li.appendChild(ph);
}
var txt = document.createElement('div');
txt.className = 'min-w-0 flex-1';
var title = document.createElement('p');
title.className = 'text-sm font-medium text-zinc-100 truncate';
title.textContent = item.title || '';
txt.appendChild(title);
if (item.author) {
var author = document.createElement('p');
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
author.textContent = item.author;
txt.appendChild(author);
}
if (item.source_url) {
var urlHint = document.createElement('p');
urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
urlHint.textContent = item.source_url;
txt.appendChild(urlHint);
}
var meta = document.createElement('div');
meta.className = 'flex gap-1.5 mt-1 flex-wrap';
if (item.status) {
var s = document.createElement('span');
s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
s.textContent = item.status;
meta.appendChild(s);
}
if (item.rank) {
var r = document.createElement('span');
r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
r.textContent = '#' + item.rank;
meta.appendChild(r);
}
txt.appendChild(meta);
li.appendChild(txt);
return li;
}
function setActive(idx, items) {
var lis = dropdown.querySelectorAll('li');
lis.forEach(function (li, i) {
if (i === idx) li.classList.add('bg-zinc-800');
else li.classList.remove('bg-zinc-800');
});
activeIdx = idx;
if (idx >= 0 && idx < items.length) {
var url = items[idx].source_url || '';
searchInput.value = url;
urlInput.value = url;
}
}
function showDrop(items, q) {
dropdown.innerHTML = '';
activeIdx = -1;
if (!items.length) { closeDrop(); return; }
items.forEach(function (item, i) {
var li = buildItem(item, q);
li.addEventListener('mousedown', function (e) {
e.preventDefault();
selectItem(item);
});
dropdown.appendChild(li);
});
dropdown.classList.remove('hidden');
}
function filterRanking(q) {
return RANKING.filter(function (item) {
var genres = (item.genres || []).join(' ');
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '') + ' ' + genres).toLowerCase();
return haystack.indexOf(q) !== -1;
}).slice(0, 10);
}
searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
syncURLField(searchInput.value);
if (!q) { closeDrop(); return; }
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
showDrop(filterRanking(q), q);
});
searchInput.addEventListener('keydown', function (e) {
var q = searchInput.value.trim().toLowerCase();
var items = /^https?:\/\//i.test(q) ? [] : filterRanking(q);
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(Math.min(activeIdx + 1, items.length - 1), items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(Math.max(activeIdx - 1, 0), items);
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
if (items[activeIdx]) selectItem(items[activeIdx]);
} else if (e.key === 'Escape') {
closeDrop();
}
});
form.addEventListener('htmx:configRequest', function (e) {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
form.addEventListener('submit', function () {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop();
}
});
}());
</script>`
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
rankingItems, _ := s.writer.ReadRankingItems()
rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("scrape").Parse(scrapeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
RankingJSON template.JS
}{
RankingJSON: template.JS(rankingJSON),
})
s.respond(w, r, "Add a book", buf.String())
}
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
const rankingTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<!-- Cover zoom overlay -->
<div id="cover-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
onclick="this.classList.add('hidden');this.classList.remove('flex');document.getElementById('cover-zoom-img').src='';">
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
</div>
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
<div>
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
</div>
<div class="flex gap-2 mt-1 flex-wrap items-center">
<a href="/ranking/view"
hx-get="/ranking/view"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
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">
View Markdown
</a>
</div>
</div>
<div id="ranking-refresh-status" class="mt-2"></div>
<!-- Filter bar -->
<div class="mt-4 mb-2 flex gap-2 items-center">
<div class="flex-1 relative">
<input id="ranking-filter" type="search" placeholder="Filter by title, author…"
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" />
</div>
</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>
<!-- Display pagination: browse cached items -->
{{if gt .TotalPages 1}}
<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}}
{{if .Local}}
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
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">
{{if .Cover}}
<img src="{{.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)">
{{end}}
<div class="min-w-0 flex-1">
<div class="flex items-start gap-1.5 flex-wrap">
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">{{.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>
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
<div class="flex gap-1.5 mt-1.5 flex-wrap">
{{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}}
</div>
</a>
{{else}}
<div data-filter="{{.Title}} {{.Author}}"
data-status="{{.Status}}"
data-genres="{{range .Genres}}{{.}}|{{end}}"
data-local="0"
{{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}}">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 cursor-zoom-in"
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)">
{{end}}
<div class="min-w-0 flex-1">
<div class="flex items-start gap-1.5 flex-wrap">
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
<h2 class="font-semibold text-zinc-100 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
</div>
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
<div class="flex gap-1.5 mt-1.5 flex-wrap">
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400">{{.}}</span>{{end}}
</div>
{{if .SourceURL}}
<div class="mt-2 flex items-center gap-2">
<span class="scrape-hint text-xs text-zinc-500 hidden">Click again to scrape</span>
<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">
<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>
</div>
{{end}}
</div>
</div>
{{end}}
{{else}}
<p class="text-zinc-500 col-span-2">No ranking data. Use the page buttons above to fetch from novelfire.net.</p>
{{end}}
</div>
</div>
<script>
(function () {
var ALL_ITEMS = {{.AllItemsJSON}};
var input = document.getElementById('ranking-filter');
var countEl = document.getElementById('ranking-filter-count');
var clearBtn = document.getElementById('ranking-clear-filters');
var pagination = document.getElementById('ranking-pagination');
var grid = document.getElementById('ranking-grid');
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> }
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;
}
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function applyFilters() {
var q = input ? input.value.trim().toLowerCase() : '';
var filtering = isFiltering();
if (!filtering) {
// Restore paginated server-rendered HTML.
grid.innerHTML = pagedHTML;
htmx.process(grid);
countEl.classList.add('hidden');
clearBtn.classList.add('hidden');
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
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();
});
}
/* ── two-click scrape on ranking cards ─────────────────────────────────── */
var pendingCard = null;
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');
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.setAttribute('hx-get', '/ui/scrape/status');
badge.setAttribute('hx-trigger', 'every 3s');
badge.setAttribute('hx-target', 'this');
badge.setAttribute('hx-swap', 'outerHTML');
card.innerHTML = '';
card.appendChild(badge);
card.style.borderColor = '';
card.style.background = '';
card.style.cursor = 'default';
card.classList.remove('cursor-pointer');
htmx.process(badge);
var body = new URLSearchParams();
body.set('url', url);
fetch('/ui/scrape/book', { method: 'POST', body: body });
}
grid.addEventListener('click', function (e) {
var card = e.target.closest('.ranking-card[data-scrape-url]');
if (!card) return;
if (e.target.closest('button')) return;
if (pendingCard && pendingCard !== card) cancelPending();
if (pendingCard === card) {
clearTimeout(pendingTimer);
pendingTimer = null;
pendingCard = null;
submitScrape(card);
return;
}
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();
});
}());
</script>`
// rankingViewItem enriches a RankingItem with whether it is present in the
// local book library, so the template can highlight it differently.
type rankingViewItem struct {
writer.RankingItem
Local bool
}
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
out := make([]rankingViewItem, len(items))
for i, it := range items {
out[i] = rankingViewItem{
RankingItem: it,
Local: localSlugs[it.Slug],
}
}
return out
}
// pageNum is one entry in the ranking pagination bar.
// Num == 0 is a sentinel that renders as an ellipsis gap.
type pageNum struct {
Num int
}
// rankingPageNums builds a pagination list with smart ellipsis.
// It always shows: first 2, last 2, and a ±2 window around current.
// Gaps between non-consecutive runs are filled with a sentinel (Num==0) for "…".
// Pass current=0 when there is no concept of a current page (e.g. fetch bar).
func rankingPageNums(total, current int) []pageNum {
if total <= 0 {
return nil
}
show := make(map[int]bool)
// First 2 and last 2.
for i := 1; i <= 2 && i <= total; i++ {
show[i] = true
}
for i := total - 1; i <= total; i++ {
if i >= 1 {
show[i] = true
}
}
// ±2 window around current page.
if current > 0 {
for i := current - 2; i <= current+2; i++ {
if i >= 1 && i <= total {
show[i] = true
}
}
}
// Collect and sort.
pages := make([]int, 0, len(show))
for p := range show {
pages = append(pages, p)
}
for i := 0; i < len(pages); i++ {
for j := i + 1; j < len(pages); j++ {
if pages[j] < pages[i] {
pages[i], pages[j] = pages[j], pages[i]
}
}
}
// Build output with ellipsis sentinels between non-consecutive pages.
out := make([]pageNum, 0, len(pages)*2)
for i, p := range pages {
if i > 0 && p > pages[i-1]+1 {
out = append(out, pageNum{0})
}
out = append(out, pageNum{p})
}
return out
}
const rankingPageSize = 20
// handleRanking serves the ranking page from the cached ranking.json file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
// Supports ?page=N for browsing through cached items (20 per page).
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
rankingItems, err := s.writer.ReadRankingItems()
if err != nil {
s.log.Error("failed to read cached ranking", "err", err)
}
cachedAt := ""
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
}
// Parse requested display page (1-indexed).
currentPage := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err2 := strconv.Atoi(p); err2 == nil && n > 0 {
currentPage = n
}
}
totalItems := len(rankingItems)
totalPages := 1
if totalItems > 0 {
totalPages = (totalItems + rankingPageSize - 1) / rankingPageSize
}
if currentPage > totalPages {
currentPage = totalPages
}
// Slice items for the current display page.
start := (currentPage - 1) * rankingPageSize
end := start + rankingPageSize
if end > totalItems {
end = totalItems
}
pageItems := rankingItems
if totalItems > 0 {
pageItems = rankingItems[start:end]
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
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)
// 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 {
Books interface{}
CachedAt string
FetchNums []pageNum
DisplayNums []pageNum
CurrentPage int
TotalPages int
TotalItems int
AllGenres []string
AllStatuses []string
AllItemsJSON template.JS
}{
Books: toRankingViewItems(pageItems, localSlugs),
CachedAt: cachedAt,
FetchNums: rankingPageNums(100, 0),
DisplayNums: rankingPageNums(totalPages, currentPage),
CurrentPage: currentPage,
TotalPages: totalPages,
TotalItems: totalItems,
AllGenres: allGenres,
AllStatuses: allStatuses,
AllItemsJSON: template.JS(allItemsJSON),
})
s.respond(w, r, "Rankings", buf.String())
}
// handleRankingRefresh starts an async scrape of novelfire.net/ranking and
// immediately returns a polling badge. The browser polls /ui/ranking/status
// until the job finishes, then follows an HX-Redirect back to /ranking.
//
// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means
// fetch all pages; otherwise at most that many pages are scraped.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
maxPages := 0
if p := strings.TrimSpace(r.FormValue("pages")); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
maxPages = n
}
}
s.mu.Lock()
if s.rankingRunning {
s.mu.Unlock()
renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…"))
return
}
s.rankingRunning = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.rankingRunning = false
s.mu.Unlock()
}()
// Allow ~90 s per page; minimum 120 s for a single page.
timeout := 120 * time.Second
if maxPages > 1 {
timeout = time.Duration(maxPages) * 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)
var rankingItems []writer.RankingItem
for rankingCh != nil || errCh != nil {
select {
case meta, ok := <-rankingCh:
if !ok {
rankingCh = nil
} else {
rankingItems = append(rankingItems, writer.RankingItem{
Rank: meta.Ranking,
Slug: meta.Slug,
Title: meta.Title,
Author: meta.Author,
Cover: meta.Cover,
Status: meta.Status,
Genres: meta.Genres,
SourceURL: meta.SourceURL,
})
}
case err, ok := <-errCh:
if !ok {
errCh = nil
} else if err != nil {
s.log.Error("ranking scrape error", "err", err)
}
}
}
if len(rankingItems) > 0 {
if err := s.writer.WriteRanking(rankingItems); err != nil {
s.log.Error("failed to save ranking", "err", err)
}
}
}()
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
}
// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
// While running it returns a self-replacing badge; when done it issues an
// HX-Redirect so the browser navigates to /ranking.
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.rankingRunning
s.mu.Unlock()
if running {
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
return
}
// Job done — redirect the HTMX request to the ranking page.
w.Header().Set("HX-Redirect", "/ranking")
w.WriteHeader(http.StatusOK)
}
// rankingStatusHTML returns a self-replacing polling badge for the ranking
// refresh job. state is "running" or "done".
func rankingStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
default:
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}
// ─── GET /ranking/view — view ranking markdown ─────────────────────────────────
const rankingViewTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<a href="/ranking"
hx-get="/ranking"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← Back to Rankings
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
<pre class="text-xs text-zinc-300 bg-zinc-900 border border-zinc-700 rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-words">{{.JSON}}</pre>
</div>`
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
items, err := s.writer.ReadRankingItems()
if err != nil {
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
return
}
if len(items) == 0 {
http.NotFound(w, r)
return
}
pretty, err := json.MarshalIndent(items, "", " ")
if err != nil {
http.Error(w, "json marshal error: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct{ JSON string }{JSON: string(pretty)})
s.respond(w, r, "Ranking Data", buf.String())
}
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
const chapterPageSize = 50
const bookTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<!-- Cover zoom overlay -->
<div id="cover-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
onclick="document.getElementById('cover-zoom-overlay').classList.add('hidden');document.getElementById('cover-zoom-overlay').classList.remove('flex');">
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
</div>
<!-- Summary zoom overlay -->
<div id="summary-zoom-overlay"
class="fixed inset-0 z-50 hidden items-start justify-center bg-black/80 cursor-zoom-out p-4 pt-8 overflow-y-auto"
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()">
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Summary</h2>
<div id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed space-y-3"></div>
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
class="mt-5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
</div>
</div>
<div class="flex gap-5 mb-8 mt-4">
{{if .Meta.Cover}}
<img src="{{.Meta.Cover}}" alt="cover"
id="cover-thumb"
class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg cursor-zoom-in"
onclick="(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)">
{{end}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
{{if .Meta.SourceURL}}
<a href="{{.Meta.SourceURL}}" target="_blank" rel="noopener noreferrer"
title="View on novelfire.net"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded text-zinc-400 hover:text-zinc-200 transition-colors">
<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
</a>
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
<button type="submit"
class="text-xs px-2 py-1 rounded text-amber-400 hover:text-amber-300 transition-colors border-0 bg-transparent cursor-pointer"
title="Re-scrape from source">
Refresh
</button>
</form>
{{end}}
</div>
{{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
onclick="openSummary(this)"
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}}
</div>
</div>
<!-- Resume reading button — populated by JS from localStorage -->
<div id="resume-bar" class="mb-4 hidden">
<a id="resume-link"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer">
▶ Resume — Chapter <span id="resume-chapter-num"></span>
</a>
</div>
<!-- Latest chapter button — shown by JS when book has not been started -->
{{if .LastChapter}}
<div id="latest-bar" class="mb-4 hidden">
<a href="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-get="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-zinc-300 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer border border-zinc-700 hover:border-amber-500">
↓ Latest — Chapter {{.LastChapter}}
</a>
</div>
{{end}}
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
<ul id="chapter-list" class="space-y-1">
{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
</a>
</li>
{{else}}
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
{{end}}
</ul>
{{if gt .TotalPages 1}}
<div id="chapter-pagination" class="mt-6 flex justify-center gap-1 flex-wrap">
{{if gt .CurrentPage 1}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
{{end}}
{{range pages .TotalPages}}
{{if eq . $.CurrentPage}}
<span class="px-3 py-1.5 rounded-lg text-amber-400 text-sm font-semibold">{{.}}</span>
{{else}}
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{$.Slug}}?page={{.}}"
hx-on::after-request="applyProgress('{{$.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">{{.}}</a>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalPages}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
{{end}}
</div>
{{end}}
</div>
<script>
window.openSummary = function(el) {
var t = el.dataset.full || '';
var o = document.getElementById('summary-zoom-overlay');
var c = document.getElementById('summary-zoom-text');
c.innerHTML = '';
var paras = t.split(/\n+/).filter(function(s) { return s.trim().length > 0; });
if (paras.length <= 1) {
paras = t.split(/(?<=[.!?][\u201d\u2019"']?)\s{2,}/).filter(function(s) { return s.trim().length > 0; });
}
if (paras.length <= 1) { paras = [t]; }
paras.forEach(function(p) {
var el = document.createElement('p');
el.textContent = p.trim();
c.appendChild(el);
});
o.classList.remove('hidden');
o.classList.add('flex');
};
(function () {
var SLUG = '{{.Slug}}';
var LS_KEY = 'reading_progress';
function getProgress() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
}
window.applyProgress = function(slug) {
var progress = getProgress();
var saved = progress[slug];
// Show/hide latest bar (only when no progress recorded for this book).
var latestBar = document.getElementById('latest-bar');
if (latestBar) {
if (!saved) {
latestBar.classList.remove('hidden');
} else {
latestBar.classList.add('hidden');
}
}
if (!saved) return;
// Highlight the saved chapter row.
document.querySelectorAll('.chapter-row').forEach(function (a) {
var dot = a.querySelector('.progress-dot');
if (!dot) return;
if (parseInt(a.dataset.chapter) === saved) {
dot.classList.remove('hidden');
a.classList.add('bg-zinc-800/50');
}
});
// Show resume bar.
var bar = document.getElementById('resume-bar');
var link = document.getElementById('resume-link');
var num = document.getElementById('resume-chapter-num');
if (bar && link && num) {
var href = '/books/' + slug + '/chapters/' + saved;
num.textContent = saved;
link.setAttribute('href', href);
link.setAttribute('hx-get', href);
htmx.process(link);
bar.classList.remove('hidden');
}
};
applyProgress(SLUG);
}());
</script>`
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
meta, ok, err := s.writer.ReadMetadata(slug)
if err != nil {
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
total := len(chapters)
totalPages := (total + chapterPageSize - 1) / chapterPageSize
if totalPages < 1 {
totalPages = 1
}
currentPage := 1
page := chapters
if total > chapterPageSize {
page = chapters[:chapterPageSize]
}
funcMap := template.FuncMap{
"pages": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"prev": func(n int) int { return n - 1 },
"next": func(n int) int { return n + 1 },
}
t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl))
var buf bytes.Buffer
lastChapter := 0
if total > 0 {
lastChapter = chapters[total-1].Number
}
_ = t.Execute(&buf, struct {
Slug string
Meta interface{}
Chapters interface{}
TotalDownloaded int
TotalPages int
CurrentPage int
LastChapter int
}{
Slug: slug,
Meta: meta,
Chapters: page,
TotalDownloaded: total,
TotalPages: totalPages,
CurrentPage: currentPage,
LastChapter: lastChapter,
})
s.respond(w, r, meta.Title, buf.String())
}
// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ────────
const chapterPageTmpl = `{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
</a>
</li>
{{end}}
<div id="chapter-pagination" hx-swap-oob="true" class="mt-6 flex justify-center gap-1 flex-wrap">
{{if gt .CurrentPage 1}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
{{end}}
{{range pages .TotalPages}}
{{if eq . $.CurrentPage}}
<span class="px-3 py-1.5 rounded-lg bg-amber-700 text-white text-sm font-semibold">{{.}}</span>
{{else}}
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{$.Slug}}?page={{.}}"
hx-on::after-request="applyProgress('{{$.Slug}}')"
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">{{.}}</a>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalPages}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
{{end}}
</div>`
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
currentPage := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
currentPage = n
}
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
total := len(chapters)
totalPages := (total + chapterPageSize - 1) / chapterPageSize
if totalPages < 1 {
totalPages = 1
}
start := (currentPage - 1) * chapterPageSize
if start >= total {
w.WriteHeader(http.StatusNoContent)
return
}
end := start + chapterPageSize
if end > total {
end = total
}
funcMap := template.FuncMap{
"pages": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"prev": func(n int) int { return n - 1 },
"next": func(n int) int { return n + 1 },
}
t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Chapters interface{}
TotalPages int
CurrentPage int
}{
Slug: slug,
Chapters: chapters[start:end],
TotalPages: totalPages,
CurrentPage: currentPage,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
<!-- ─── Sticky reader toolbar ────────────────────────────────────────────────
Uses <nav> with a descriptive aria-label so screen readers expose it as a
landmark. aria-hidden was previously misused here — removed. -->
<nav id="reader-header" aria-label="Reader controls"
class="sticky top-0 z-50 bg-zinc-950 border-b border-zinc-800">
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-14">
<!-- Back to book list -->
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Back to chapter list"
aria-label="Back to chapter list"
class="flex-shrink-0 px-2 py-1.5 rounded-lg text-zinc-400 hover:text-amber-400 transition-colors text-xl leading-none no-underline">
&#9776;
</a>
<!-- Chapter title button — opens chapter list drawer -->
<button id="chapter-list-btn"
type="button"
onclick="toggleChapterList()"
aria-haspopup="listbox"
aria-expanded="false"
title="Jump to chapter"
class="flex-1 min-w-0 flex items-center justify-center gap-1.5 overflow-hidden bg-transparent border-none cursor-pointer py-1 px-1">
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem] text-zinc-400 max-w-full">{{.Title}}</span>
</button>
</div>
<!-- TTS status strip -->
<div id="tts-status-bar" hidden
class="px-4 py-0.5 bg-zinc-900 text-xs text-zinc-500 text-center"
aria-live="polite" aria-atomic="true">
<span id="tts-status"></span>
</div>
<!-- Chapter list drawer -->
<div id="chapter-list-panel"
role="listbox"
aria-label="Chapter list"
hidden
class="absolute left-0 right-0 top-full max-h-[60vh] overflow-y-auto bg-zinc-900 border-b border-zinc-800 shadow-2xl z-[99]">
<div class="max-w-2xl mx-auto py-2">
{{range .AllChapters}}
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
role="option"
aria-selected="{{if eq .Number $.ChapterN}}true{{else}}false{{end}}"
onclick="closeChapterList()"
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
class="flex items-baseline gap-3 px-4 py-2 no-underline transition-colors hover:bg-zinc-800 {{if eq .Number $.ChapterN}}bg-zinc-800{{end}}">
<span class="flex-shrink-0 text-xs text-zinc-600 w-10 text-right" aria-hidden="true">{{.Number}}</span>
<span class="text-sm {{if eq .Number $.ChapterN}}text-amber-400 font-medium{{else}}text-zinc-300{{end}}">
{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}
</span>
{{if eq .Number $.ChapterN}}<span class="ml-auto flex-shrink-0 text-[0.7rem] text-amber-500" aria-hidden="true">&#9654; now</span>{{end}}
</a>
{{end}}
</div>
</div>
</div>
</nav>
<!-- ─── Chapter content ───────────────────────────────────────────────────── -->
<main class="max-w-2xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-zinc-100 mb-6 leading-snug">{{.Title}}</h1>
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
{{.HTML}}
</article>
</main>
<!-- ─── Compact audio player (fixed bottom) ─────────────────────────────── -->
<aside id="mini-player"
aria-label="Audio player"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 text-zinc-100">
<!-- Seek bar sits flush on top edge, full width -->
<div id="seek-track"
class="relative w-full cursor-pointer touch-none select-none"
style="display:none">
<div id="seek-rail" class="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full overflow-hidden pointer-events-none">
<div id="seek-fill" class="absolute left-0 top-0 h-full bg-amber-500" style="width:0%"></div>
</div>
<div id="seek-thumb" class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-amber-400 shadow-md pointer-events-none -translate-x-1/2 transition-transform hover:scale-125" style="left:0%"></div>
</div>
<!-- Time row (only when playing) -->
<div id="seek-times" style="display:none"
class="max-w-2xl mx-auto px-4 pt-1 flex items-center justify-between">
<span id="seek-cur" class="text-[0.65rem] tabular-nums text-zinc-500">0:00</span>
<span id="seek-tot" class="text-[0.65rem] tabular-nums text-zinc-600">0:00</span>
</div>
<div class="border-t border-zinc-800"></div>
<!-- Settings panel (opens upward) -->
<div id="settings-panel"
role="dialog"
aria-label="Reader settings"
hidden
class="fixed inset-x-2 bottom-[4.5rem] top-4 overflow-y-auto sm:absolute sm:inset-x-auto sm:top-auto sm:bottom-[calc(100%+0.25rem)] sm:right-[max(0.5rem,calc(50%-32rem+0.5rem))] sm:w-80 sm:max-h-[80vh] bg-zinc-900 border border-zinc-800 rounded-xl p-4 shadow-2xl z-[100]">
<!-- hidden native select keeps existing JS working unchanged -->
<select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
{{range .Voices}}
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<div class="mb-3.5">
<span class="block text-xs text-zinc-500 mb-2">Voice</span>
<div id="voice-grid" class="grid grid-cols-2 gap-1.5 max-h-64 sm:max-h-48 overflow-y-auto pr-0.5">
{{range .Voices}}
<button type="button"
data-voice="{{.ID}}"
onclick="selectVoice(this)"
class="voice-btn flex items-center gap-2 px-2.5 py-1.5 rounded-lg border text-left transition-colors
{{if eq .ID $.DefaultVoice}}border-amber-500 bg-amber-500/10 text-amber-300{{else}}border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100{{end}}">
<span class="flex-1 min-w-0">
<span class="block text-[0.8rem] font-medium leading-tight truncate">{{.Name}}</span>
<span class="block text-[0.65rem] text-zinc-500 leading-tight">{{.Lang}} · {{.Gender}}</span>
</span>
</button>
{{end}}
</div>
</div>
<label class="block mb-3.5">
<span class="block text-xs text-zinc-500 mb-1.5">Speed — <span id="tts-speed-label">1.0×</span></span>
<input id="tts-speed" type="range"
min="0.5" max="2" step="0.1" value="1"
class="w-full accent-amber-500 cursor-pointer" />
</label>
<label class="flex items-center justify-between gap-3 cursor-pointer select-none mb-2.5">
<span class="text-sm text-zinc-300">Auto-play next chapter</span>
<span class="toggle-switch">
<input id="tts-autoplay" type="checkbox" />
<span class="toggle-track"></span>
</span>
</label>
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
<span class="text-sm text-zinc-300">Auto-scroll to paragraph</span>
<span class="toggle-switch">
<input id="tts-autoscroll" type="checkbox" checked />
<span class="toggle-track"></span>
</span>
</label>
</div>
<div class="max-w-2xl mx-auto px-3 py-2 flex flex-col gap-0">
<!-- Single control row -->
<div class="flex items-center gap-1.5 h-12">
<!-- Prev chapter -->
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Previous chapter"
aria-label="Previous chapter"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
&#8592;
</a>
{{else}}
<span class="flex-shrink-0 w-10"></span>
{{end}}
<!-- Play/Pause -->
<button id="player-play-btn"
type="button"
onclick="ttsToggle()"
aria-label="Play/Pause"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-base border-none cursor-pointer transition-colors">
<span id="player-play-icon" aria-hidden="true">&#9654;</span>
</button>
<!-- Chapter title + state badge -->
<div class="flex-1 min-w-0 flex items-center gap-2 px-1">
<span id="player-title" class="text-[0.8rem] font-medium text-zinc-300 truncate">Ch.&#x00A0;{{.ChapterN}}</span>
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
</div>
<!-- Next prefetch badge -->
<div id="player-next-row" hidden class="flex-shrink-0 flex items-center gap-1">
<span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
</div>
<!-- Settings -->
<button id="settings-btn"
type="button"
onclick="toggleSettings()"
aria-label="Reader settings"
aria-haspopup="dialog"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-transparent border-none cursor-pointer text-zinc-500 hover:text-amber-400 transition-colors">
&#9881;
</button>
<!-- Next chapter -->
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Next chapter"
aria-label="Next chapter"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
&#8594;
</a>
{{else}}
<span class="flex-shrink-0 w-10"></span>
{{end}}
</div>
</div>
<!-- Hidden native audio (no controls — we drive it entirely from JS) -->
<audio id="tts-audio-native" preload="none" style="display:none"></audio>
</aside>
<style>
/* Paragraph TTS highlight */
#chapter-article p {
border-radius: 0.375rem;
margin-left: -0.5rem;
padding-left: 0.5rem;
transition: background 0.15s;
}
#chapter-article p.tts-active {
background: rgba(251,191,36,0.13);
border-left: 2px solid #f59e0b;
padding-left: calc(0.5rem - 2px);
}
/* Prevent sticky nav from obscuring anchor targets */
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 7rem; }
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; }
/* Audio state badges */
.queue-badge {
display: inline-block;
padding: 0.1em 0.5em;
border-radius: 9999px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.queue-badge-idle { background: #27272a; color: #71717a; }
.queue-badge-generating { background: #78350f; color: #fbbf24; }
.queue-badge-ready { background: #14532d; color: #4ade80; }
.queue-badge-playing { background: #1e3a5f; color: #60a5fa; }
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
.queue-badge-error { background: #450a0a; color: #f87171; }
/* Seek bar — tall hit-area, thin visual rail via inner element */
#seek-track {
height: 2.5rem;
}
/* Safe-area inset for notched phones */
#mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }
/* Toggle switch */
.toggle-switch { position: relative; display: inline-block; width: 2.25rem; height: 1.25rem; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0;
background: #3f3f46; border-radius: 9999px;
transition: background 0.2s;
cursor: pointer;
}
.toggle-track::after {
content: '';
position: absolute; left: 0.2rem; top: 50%; transform: translateY(-50%);
width: 0.85rem; height: 0.85rem;
background: #fff; border-radius: 50%;
transition: left 0.2s;
}
.toggle-switch input:checked + .toggle-track { background: #f59e0b; }
.toggle-switch input:checked + .toggle-track::after { left: calc(100% - 0.2rem - 0.85rem); }
.toggle-switch input:focus-visible + .toggle-track { outline: 2px solid #f59e0b; outline-offset: 2px; }
</style>
<script>
(function () {
var NEXT_N = {{.NextN}};
var PREV_N = {{.PrevN}};
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
var COVER_URL = '{{.Cover}}';
// ── reading progress ─────────────────────────────────────────────────────────
(function saveProgress() {
try {
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
p[SLUG] = CHAPTER_N;
localStorage.setItem('reading_progress', JSON.stringify(p));
} catch(_) {}
})();
// ── Teardown previous chapter's script instance ───────────────────────────────
// Each HTMX swap re-runs this IIFE on the same persistent audio element.
// We use a global generation counter so stale closures become no-ops immediately.
window.__ttsGen = (window.__ttsGen || 0) + 1;
var myGen = window.__ttsGen;
function stale() { return window.__ttsGen !== myGen; }
// Remove the previous chapter's htmx:beforeSwap listener before adding our own.
if (window.__ttsBeforeSwap) {
document.body.removeEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
}
// ── DOM refs ─────────────────────────────────────────────────────────────────
var audio = document.getElementById('tts-audio-native');
var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice');
var speedSlider = document.getElementById('tts-speed');
var speedLabel = document.getElementById('tts-speed-label');
var autoplayChk = document.getElementById('tts-autoplay');
var autoscrollChk = document.getElementById('tts-autoscroll');
var article = document.getElementById('chapter-article');
var playerPlayBtn = document.getElementById('player-play-btn');
var playerPlayIcon = document.getElementById('player-play-icon');
var playerTitle = document.getElementById('player-title');
var playerStateBadge= document.getElementById('player-state-badge');
var playerNextRow = document.getElementById('player-next-row');
var playerNextBadge = document.getElementById('player-next-badge');
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel= document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
var seekTrack = document.getElementById('seek-track');
var seekRail = document.getElementById('seek-rail');
var seekFill = document.getElementById('seek-fill');
var seekThumb = document.getElementById('seek-thumb');
var seekCur = document.getElementById('seek-cur');
var seekTot = document.getElementById('seek-tot');
var seekTimes = document.getElementById('seek-times');
// ── badge helper ─────────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error'];
function setBadge(el, state) {
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
el.classList.add('queue-badge-' + state);
el.textContent = state;
}
// ── time formatter ────────────────────────────────────────────────────────────
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '0:00';
var m = Math.floor(s / 60);
var sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
// ── seek bar ──────────────────────────────────────────────────────────────────
function showSeek() { seekTrack.style.display = ''; seekTimes.style.display = ''; }
function hideSeek() { seekTrack.style.display = 'none'; seekTimes.style.display = 'none'; }
function updateSeek() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
var ps = pct.toFixed(2) + '%';
seekFill.style.width = ps;
seekThumb.style.left = ps;
seekCur.textContent = fmtTime(cur);
seekTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
}
function seekFromEvent(e) {
var rect = seekRail.getBoundingClientRect();
var clientX = (e.touches ? e.touches[0] : e).clientX;
var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
updateSeek();
}
}
(function attachSeek() {
var dragging = false;
seekTrack.addEventListener('mousedown', function(e) {
dragging = true;
seekFromEvent(e);
e.preventDefault();
});
seekTrack.addEventListener('touchstart', function(e) {
dragging = true;
seekFromEvent(e);
}, { passive: true });
document.addEventListener('mousemove', function(e) {
if (dragging) seekFromEvent(e);
});
document.addEventListener('touchmove', function(e) {
if (dragging) { seekFromEvent(e); e.preventDefault(); }
}, { passive: false });
document.addEventListener('mouseup', function(e) {
if (dragging) { seekFromEvent(e); dragging = false; }
});
document.addEventListener('touchend', function() { dragging = false; });
})();
// ── status strip ─────────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
// ── panel toggles (chapter list / settings) ───────────────────────────────────
window.closeChapterList = function () {
chapterListPanel.style.display = 'none';
chapterListBtn.querySelector('span:last-child').innerHTML = '&#9660;';
};
window.toggleChapterList = function () {
var open = chapterListPanel.style.display !== 'none';
settingsPanel.style.display = 'none';
if (open) {
closeChapterList();
} else {
chapterListPanel.style.display = 'block';
chapterListBtn.querySelector('span:last-child').innerHTML = '&#9650;';
var cur = chapterListPanel.querySelector('[data-current="1"]');
if (cur) cur.scrollIntoView({ block: 'center' });
}
};
window.toggleSettings = function () {
var open = settingsPanel.style.display !== 'none';
closeChapterList();
settingsPanel.style.display = open ? 'none' : 'block';
};
// Re-register the document click-outside handler each swap by replacing via a named ref.
if (window.__ttsClickOutside) {
document.removeEventListener('click', window.__ttsClickOutside);
}
window.__ttsClickOutside = function (e) {
if (stale()) { document.removeEventListener('click', window.__ttsClickOutside); return; }
var sb = document.getElementById('settings-btn');
if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) {
settingsPanel.style.display = 'none';
}
if (chapterListPanel.style.display !== 'none' &&
!chapterListPanel.contains(e.target) &&
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
};
document.addEventListener('click', window.__ttsClickOutside);
// ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_AUTONEXT = 'tts_autonext';
var LS_AUTOSCROLL = 'tts_autoscroll';
(function loadSettings() {
var spd = localStorage.getItem(LS_SPEED);
if (spd !== null) {
speedSlider.value = spd;
speedLabel.textContent = parseFloat(spd).toFixed(1) + '\u00D7';
}
var vc = localStorage.getItem(LS_VOICE);
if (vc !== null) {
var opt = voiceSel.querySelector('option[value="' + vc + '"]');
if (opt) voiceSel.value = vc;
}
var an = localStorage.getItem(LS_AUTONEXT);
if (an !== null) autoplayChk.checked = an === 'true';
var as = localStorage.getItem(LS_AUTOSCROLL);
if (as !== null) autoscrollChk.checked = as !== 'false';
})();
speedSlider.addEventListener('input', function () {
if (stale()) return;
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false;
});
voiceSel.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false;
});
autoplayChk.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
});
autoscrollChk.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_AUTOSCROLL, autoscrollChk.checked ? 'true' : 'false');
});
// ── paragraph indexing (highlight only) ──────────────────────────────────────
var paras = Array.prototype.slice.call((article || {querySelectorAll: function(){return[];}}).querySelectorAll('p'));
var activePara = null;
// ── auto-scroll pause on user interaction ────────────────────────────────────
var autoScrollEnabled = true;
var autoScrollTimer = null;
function resetAutoScrollTimer() {
autoScrollEnabled = false;
clearTimeout(autoScrollTimer);
autoScrollTimer = setTimeout(function () { autoScrollEnabled = true; }, 3000);
}
document.addEventListener('wheel', resetAutoScrollTimer, { passive: true });
document.addEventListener('touchmove', resetAutoScrollTimer, { passive: true });
document.addEventListener('keydown', function (e) {
var scrollKeys = { ArrowUp: 1, ArrowDown: 1, PageUp: 1, PageDown: 1, Home: 1, End: 1, ' ': 1 };
if (scrollKeys[e.key]) resetAutoScrollTimer();
});
function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
if (activePara) {
activePara.classList.add('tts-active');
if (autoscrollChk.checked && autoScrollEnabled) activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ── UI state helpers ──────────────────────────────────────────────────────────
function setPlayIcon(icon) { playerPlayIcon.innerHTML = icon; }
function setGenerating() {
setPlayIcon('\u231B');
playerPlayBtn.disabled = true;
voiceSel.disabled = true;
speedSlider.disabled = true;
setBadge(playerStateBadge, 'generating');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
setStatus('Generating audio\u2026');
hideSeek();
}
function setPlaying() {
if (stale()) return;
setPlayIcon('&#9646;&#9646;');
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
setBadge(playerStateBadge, 'playing');
playerStateBadge.textContent = '0%';
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
}
function setPaused() {
if (stale()) return;
setPlayIcon('&#9654;');
setBadge(playerStateBadge, 'paused');
// Keep showing current % while paused.
var pct = (audio.duration && isFinite(audio.duration))
? Math.round((audio.currentTime / audio.duration) * 100) : 0;
playerStateBadge.textContent = pct + '%';
setStatus('');
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
}
function setStopped() {
highlightPara(-1);
setPlayIcon('&#9654;');
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
setBadge(playerStateBadge, 'idle');
// Show how long TTS generation took, if we have a timestamp.
if (genStartTime > 0) {
var secs = ((Date.now() - genStartTime) / 1000).toFixed(1);
playerStateBadge.textContent = secs + 's';
}
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
hideSeek();
}
function setError(msg) {
if (stale()) return;
highlightPara(-1);
setPlayIcon('&#9654;');
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
hideSeek();
}
// ── next-chapter prefetch display ────────────────────────────────────────────
function setNextState(state) {
if (!NEXT_N) { playerNextRow.hidden = true; return; }
playerNextRow.hidden = false;
setBadge(playerNextBadge, state);
}
// ── server-side audio generation ─────────────────────────────────────────────
var currentAudioCtrl = null;
var genStartTime = 0; // Date.now() when generation began
function generateAudio(chapterN, cb) {
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
genStartTime = Date.now();
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
voice: voiceSel.value,
speed: parseFloat(speedSlider.value)
}),
signal: ctrl.signal
})
.then(function (res) {
if (!res.ok) return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
return res.json();
})
.then(function (data) {
if (stale()) return;
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
})
.catch(function (e) {
if (e.name === 'AbortError' || stale()) return;
setError(e.message);
});
}
// ── next-chapter prefetch at 80% ─────────────────────────────────────────────
var prefetchFired = false;
var prefetchedUrl = null; // resolved proxy URL for the next chapter, if ready
// ── audio event handlers (named so they can be removed on next swap) ──────────
function onTimeUpdate() {
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
updateSeek();
// Live % progress in the badge while playing.
if (audio.duration && isFinite(audio.duration) && !audio.paused) {
playerStateBadge.textContent = Math.round((audio.currentTime / audio.duration) * 100) + '%';
}
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) {
} else if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true;
setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
})
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function (data) {
if (!stale()) {
prefetchedUrl = (data && data.url) ? data.url : null;
setNextState('ready');
}
})
.catch(function () { if (!stale()) setNextState('error'); });
}
// paragraph highlight
if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length),
paras.length - 1
);
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
}
function onCanPlay() {
if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; }
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
}
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
function onPlaying() { if (!stale()) setPlaying(); }
function onPause() { if (!stale() && !audio.ended) setPaused(); }
function onAudioError() { if (!stale()) setError('audio error'); }
function onEnded() {
if (stale()) { audio.removeEventListener('ended', onEnded); return; }
audio.src = '';
prefetchFired = false;
if (autoplayChk.checked && NEXT_N) {
goNextChapter();
} else {
setStopped();
}
}
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('waiting', onWaiting);
audio.addEventListener('playing', onPlaying);
audio.addEventListener('pause', onPause);
audio.addEventListener('play', onPlaying);
audio.addEventListener('error', onAudioError);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
// ── auto-next ────────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
// If audio was already pre-generated, play it directly without a page
// navigation. This keeps the existing audio context alive so the browser
// doesn't block autoplay on a locked screen where a user interaction was
// already granted for the current chapter.
if (prefetchedUrl) {
var url = prefetchedUrl;
prefetchedUrl = null;
audio.src = url;
audio.load();
// Update Media Session so lock-screen shows the next chapter.
if ('mediaSession' in navigator && navigator.mediaSession.metadata) {
navigator.mediaSession.metadata.title = 'Ch.\u00a0' + NEXT_N;
}
// audio.play() is called by onCanPlay once the element is ready.
return;
}
// Fall back to HTMX navigation (audio not ready yet).
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
htmx.ajax('GET', nextURL, {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
}
// ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
audio.pause();
audio.src = '';
prefetchFired = false;
prefetchedUrl = null;
setStopped();
}
// ── Media Session API (lock-screen controls + background playback) ────────────
function registerMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Ch.\u00a0' + CHAPTER_N,
artist: SLUG.replace(/-/g, ' '),
album: 'libnovel',
artwork: COVER_URL ? [{ src: COVER_URL, sizes: '512x512', type: 'image/jpeg' }] : []
});
navigator.mediaSession.setActionHandler('play', function() {
audio.play().then(setPlaying).catch(function(){});
});
navigator.mediaSession.setActionHandler('pause', function() {
audio.pause(); setPaused();
});
navigator.mediaSession.setActionHandler('stop', function() { stop(); });
if (NEXT_N) {
navigator.mediaSession.setActionHandler('nexttrack', function() {
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
});
}
if (PREV_N) {
navigator.mediaSession.setActionHandler('previoustrack', function() {
window.location.href = '/books/' + SLUG + '/chapters/' + PREV_N;
});
}
// seekto handler so lock-screen scrubbing works
navigator.mediaSession.setActionHandler('seekto', function(d) {
if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); }
});
}
// ── main entry point ─────────────────────────────────────────────────────────
function startAudio() {
setGenerating();
highlightPara(0);
generateAudio(CHAPTER_N, function (url) {
if (stale()) return;
audio.src = url;
audio.load();
registerMediaSession();
});
}
window.ttsToggle = function () {
if (audio.src) {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
} else {
audio.pause();
}
return;
}
startAudio();
};
// ── cleanup on next HTMX swap ────────────────────────────────────────────────
window.__ttsBeforeSwap = function () {
// Invalidate this generation so all stale() checks short-circuit.
window.__ttsGen++;
stop();
};
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
// ── auto-start: localStorage signal (from auto-next) or ?autoplay=1 ──────────
var _autostart = false;
try { _autostart = localStorage.getItem('tts_autostart') === '1'; } catch(_) {}
if (_autostart) {
try { localStorage.removeItem('tts_autostart'); } catch(_) {}
autoplayChk.checked = true;
setTimeout(startAudio, 100);
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true;
setTimeout(startAudio, 100);
}
// ── init next-chapter display ────────────────────────────────────────────────
if (NEXT_N) { setNextState('idle'); }
// ── double-tap left/right to navigate chapters ───────────────────────────────
if (window.__ttsDoubleTap) {
document.removeEventListener('touchend', window.__ttsDoubleTap);
}
(function initDoubleTap() {
var lastTap = 0;
var lastSide = '';
var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
window.__ttsDoubleTap = function (e) {
if (stale()) { document.removeEventListener('touchend', window.__ttsDoubleTap); return; }
var el = e.target;
while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
el = el.parentElement;
}
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) {
lastTap = 0; lastSide = '';
if (side === 'right' && NEXT_N) window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
else if (side === 'left' && PREV_N) window.location.href = '/books/' + SLUG + '/chapters/' + PREV_N;
} else {
lastTap = now; lastSide = side;
}
};
document.addEventListener('touchend', window.__ttsDoubleTap, { passive: true });
}());
}());
// ── Voice card picker ─────────────────────────────────────────────────────────
window.selectVoice = function (btn) {
var voiceSel = document.getElementById('tts-voice');
var grid = document.getElementById('voice-grid');
if (!voiceSel || !grid) return;
// Update hidden select so voiceSel.value works in the existing TTS code.
voiceSel.value = btn.dataset.voice;
// Persist to localStorage using same key as the TTS IIFE.
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
// Swap active styling across all cards.
grid.querySelectorAll('.voice-btn').forEach(function (b) {
var active = b === btn;
b.classList.toggle('border-amber-500', active);
b.classList.toggle('bg-amber-500/10', active);
b.classList.toggle('text-amber-300', active);
b.classList.toggle('border-zinc-700', !active);
b.classList.toggle('bg-zinc-800', !active);
b.classList.toggle('text-zinc-300', !active);
});
};
// On page load, sync voice grid selection to the restored localStorage value.
(function syncVoiceGrid() {
var voiceSel = document.getElementById('tts-voice');
var grid = document.getElementById('voice-grid');
if (!voiceSel || !grid) return;
var saved = null;
try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
if (!saved) return;
var btn = grid.querySelector('[data-voice="' + saved + '"]');
if (btn) window.selectVoice(btn);
})();
</script>`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.writer.ReadChapter(slug, n)
if err != nil {
http.NotFound(w, r)
return
}
// Strip the first heading line so it isn't rendered as a duplicate <h1>
// inside the article (the template already renders an explicit <h1>).
rawForHTML := stripFirstHeadingLine(raw)
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
chapters, _ := s.writer.ListChapters(slug)
prevN, nextN := adjacentChapters(chapters, n)
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
chapterTitle, chapterDate := writer.SplitChapterTitle(title)
// Load cover URL for Media Session artwork (best-effort; ignore errors).
var coverURL string
if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
coverURL = meta.Cover
}
t := template.Must(template.New("chapter").Parse(chapterTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
HTML template.HTML
PrevN int
NextN int
ChapterN int
Title string
ChapterDate string
AllChapters interface{}
Voices []voiceInfo
DefaultVoice string
Cover string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: chapterTitle,
ChapterDate: chapterDate,
AllChapters: chapters,
Voices: parseVoices(s.voices()),
DefaultVoice: s.kokoroVoice,
Cover: coverURL,
})
s.respond(w, r, chapterTitle, buf.String())
}
// ─── 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.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
for i, ch := range chapters {
if ch.Number == n {
if i > 0 {
prev = chapters[i-1].Number
}
if i < len(chapters)-1 {
next = chapters[i+1].Number
}
return
}
}
return
}
// stripFirstHeadingLine removes the first non-empty line if it is a markdown
// heading (starts with one or more "#"). This prevents the heading from being
// rendered as a duplicate <h1> inside the article when the template already
// renders an explicit title above the article.
func stripFirstHeadingLine(src string) string {
lines := strings.SplitN(src, "\n", -1)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
// Remove this line and return the rest.
rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n")
return strings.TrimLeft(rest, "\n")
}
// First non-empty line is not a heading — nothing to strip.
break
}
return src
}
// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
for _, line := range strings.SplitN(md, "\n", 20) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fallback
}
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
bookURL := strings.TrimSpace(r.FormValue("url"))
if bookURL == "" {
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
return
}
s.mu.Lock()
already := s.running
if !already {
s.running = true
}
s.mu.Unlock()
if already {
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
return
}
cfg := s.oCfg
cfg.SingleBookURL = bookURL
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
defer cancel()
o := orchestrator.New(cfg, s.novel, s.log)
if err := o.Run(ctx); err != nil {
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
}
}()
// Return a status badge that polls until the job finishes.
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
// Keep polling every 3 s while the job is in progress.
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
return
}
// Job finished — show a done badge and stop polling.
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}
// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
case "done":
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
case "busy":
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
default: // error
colour = "text-red-300 bg-red-950 border-red-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}