Files
libnovel/scraper/internal/server/ui.go

2767 lines
105 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",
}
// ─── 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-center justify-center bg-black/80 cursor-zoom-out p-6"
onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
<div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
<p id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed whitespace-pre-wrap"></p>
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
class="mt-4 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
</div>
</div>
<div class="flex gap-5 mb-8 mt-4">
{{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="(function(t){var o=document.getElementById('summary-zoom-overlay');document.getElementById('summary-zoom-text').textContent=t;o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}}
</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>
(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-base leading-none no-underline">
&#9776;
</a>
<!-- 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 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
&#8592; Prev
</a>
{{end}}
<!-- 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>
<span class="flex-shrink-0 text-[0.625rem] text-zinc-600" aria-hidden="true">&#9660;</span>
</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 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
Next &#8594;
</a>
{{end}}
<!-- TTS play/pause -->
<button id="tts-btn"
type="button"
onclick="ttsToggle()"
aria-label="Listen"
class="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-transparent text-amber-500 hover:text-amber-400 text-[0.8125rem] font-medium border-none cursor-pointer transition-colors">
<span id="tts-icon" aria-hidden="true">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Settings -->
<button id="settings-btn"
type="button"
onclick="toggleSettings()"
aria-label="Reader settings"
aria-haspopup="dialog"
class="flex-shrink-0 px-2 py-1.5 rounded-lg bg-transparent text-zinc-400 hover:text-amber-400 text-base border-none cursor-pointer transition-colors">
&#9881;
</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>
<!-- Settings panel -->
<div id="settings-panel"
role="dialog"
aria-label="Reader settings"
hidden
class="absolute right-[max(0.5rem,calc(50%-32rem+0.5rem))] top-[calc(100%+0.25rem)] min-w-[260px] bg-zinc-900 border border-zinc-800 rounded-xl p-4 shadow-2xl z-[100]">
<label class="block mb-3.5">
<span class="block text-xs text-zinc-500 mb-1.5">Voice</span>
<select id="tts-voice"
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-sm text-zinc-200 outline-none">
{{range .Voices}}
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</label>
<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 gap-2 cursor-pointer select-none">
<input id="tts-autoplay" type="checkbox" class="accent-amber-500 cursor-pointer w-4 h-4" />
<span class="text-sm text-zinc-300">Auto-play next chapter</span>
</label>
</div>
</nav>
<!-- Hidden audio element -->
<audio id="tts-audio" aria-hidden="true" hidden></audio>
<!-- ─── 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>
<nav aria-label="Chapter pagination" class="flex justify-between mt-12 pt-6 border-t border-zinc-800">
{{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"
class="text-sm px-4 py-2 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">
← Previous chapter
</a>
{{else}}<span></span>{{end}}
{{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"
class="text-sm px-4 py-2 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">
Next chapter →
</a>
{{else}}<span></span>{{end}}
</nav>
</main>
<!-- ─── Audio queue bar (sticky bottom) ──────────────────────────────────── -->
<aside id="audio-queue-bar"
aria-label="Audio queue"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-xs text-zinc-400">
<!-- Toggle handle -->
<button id="queue-toggle"
type="button"
onclick="window.toggleQueuePanel()"
aria-expanded="false"
class="w-full flex items-center gap-2 px-4 py-1.5 bg-transparent border-none cursor-pointer text-zinc-400 text-[0.7rem] text-left">
<span class="flex-1 flex items-center gap-2">
<span id="queue-toggle-icon" aria-hidden="true" class="text-[0.65rem]">&#9650;</span>
<span class="font-semibold tracking-widest uppercase">Audio Queue</span>
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
</span>
<!-- Mini scrubber -->
<span class="flex-[2] flex items-center gap-2 px-2" aria-hidden="true">
<span id="queue-time-cur" class="min-w-[2.5rem] text-right tabular-nums">0:00</span>
<span class="flex-1 relative h-[3px] bg-zinc-700 rounded-sm overflow-hidden">
<span id="queue-scrubber-fill" class="absolute left-0 top-0 h-full w-0 bg-amber-500 transition-[width] duration-300 linear"></span>
</span>
<span id="queue-time-tot" class="min-w-[2.5rem] tabular-nums">0:00</span>
</span>
</button>
<!-- Expanded panel -->
<div id="queue-panel-body" hidden class="border-t border-zinc-800">
<!-- Now playing -->
<div class="queue-row" id="queue-row-current">
<span class="queue-row-label">Now</span>
<span class="queue-row-title" id="queue-cur-title">—</span>
<span class="queue-badge" id="queue-cur-badge">idle</span>
</div>
<!-- Up next -->
<div class="queue-row" id="queue-row-next" hidden>
<span class="queue-row-label">Next</span>
<span class="queue-row-title" id="queue-next-title">—</span>
<span class="queue-badge" id="queue-next-badge">—</span>
</div>
<!-- Debug row -->
<div class="queue-row queue-row-debug" id="queue-row-debug">
<span class="queue-row-label">dbg</span>
<span id="queue-debug-text" class="font-mono break-all"></span>
</div>
</div>
</aside>
<style>
/* Paragraph TTS highlight */
#chapter-article p {
border-radius: 0.375rem;
margin-left: -0.5rem;
padding-left: 0.5rem;
transition: background 0.15s;
}
@media (hover: hover) {
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
}
#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: 4rem; }
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; }
/* Audio queue 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; }
/* Queue rows */
.queue-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid #18181b;
}
.queue-row-label {
width: 2.5rem;
flex-shrink: 0;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #52525b;
}
.queue-row-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #d4d4d8;
}
.queue-row-debug { background: #0c0c0e; color: #52525b; font-size: 0.65rem; }
</style>
<script>
(function () {
var NEXT_N = {{.NextN}};
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
// ── 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(_) {}
})();
var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn');
var icon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
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 article = document.getElementById('chapter-article');
// ── audio queue bar DOM refs ───────────────────────────────────────────────────
var queuePanelBody = document.getElementById('queue-panel-body');
var queueToggleBtn = document.getElementById('queue-toggle');
var queueToggleIcon = document.getElementById('queue-toggle-icon');
var queueNowBadge = document.getElementById('queue-now-badge');
var queueTimeCur = document.getElementById('queue-time-cur');
var queueTimeTot = document.getElementById('queue-time-tot');
var queueFill = document.getElementById('queue-scrubber-fill');
var queueRowNext = document.getElementById('queue-row-next');
var queueCurTitle = document.getElementById('queue-cur-title');
var queueCurBadge = document.getElementById('queue-cur-badge');
var queueNextTitle = document.getElementById('queue-next-title');
var queueNextBadge = document.getElementById('queue-next-badge');
var queueDebugText = document.getElementById('queue-debug-text');
// ── audio queue helpers ────────────────────────────────────────────────────────
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;
}
function fmtTime(secs) {
if (!isFinite(secs) || secs < 0) return '0:00';
var m = Math.floor(secs / 60);
var s = Math.floor(secs % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function queueUpdateScrubber() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
queueTimeCur.textContent = fmtTime(cur);
queueTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
queueFill.style.width = pct.toFixed(1) + '%';
}
function queueSetCurrent(state, debugMsg) {
setBadge(queueNowBadge, state);
setBadge(queueCurBadge, state);
queueCurTitle.textContent = 'Ch. ' + CHAPTER_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
queueUpdateScrubber();
}
function queueSetNext(state, debugMsg) {
if (!NEXT_N) { queueRowNext.style.display = 'none'; return; }
queueRowNext.style.display = '';
setBadge(queueNextBadge, state);
queueNextTitle.textContent = 'Ch. ' + NEXT_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
}
// Toggle expand/collapse.
window.toggleQueuePanel = function () {
var open = queuePanelBody.style.display !== 'none';
queuePanelBody.style.display = open ? 'none' : '';
queueToggleIcon.innerHTML = open ? '&#9650;' : '&#9660;';
queueToggleBtn.setAttribute('aria-expanded', String(!open));
};
// ── panel toggles ─────────────────────────────────────────────────────────────
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel = document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
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';
};
document.addEventListener('click', function (e) {
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();
}
});
// ── status strip ──────────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
// ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_AUTONEXT = 'tts_autonext';
(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';
})();
speedSlider.addEventListener('input', function () {
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
});
voiceSel.addEventListener('change', function () {
localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
});
autoplayChk.addEventListener('change', function () {
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
});
// ── paragraph indexing ───────────────────────────────────────────────────────
// Clicking a paragraph seeks to the proportional position in the audio file.
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
var activePara = null;
paras.forEach(function (p, i) {
p.dataset.paraIdx = i;
p.addEventListener('click', function () { seekToPara(i); });
});
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');
activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// Seek to the proportional position in the loaded audio corresponding to para idx.
function seekToPara(idx) {
if (!audio.src || !audio.duration || !isFinite(audio.duration)) {
// Audio not loaded yet — generate it first, then seek once ready.
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
doSeek(idx);
});
audio.load();
});
return;
}
doSeek(idx);
}
function doSeek(idx) {
if (!audio.duration || !isFinite(audio.duration)) return;
var ratio = paras.length > 1 ? idx / (paras.length - 1) : 0;
audio.currentTime = ratio * audio.duration;
highlightPara(idx);
if (audio.paused) audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
// ── UI state helpers ──────────────────────────────────────────────────────────
function setGenerating() {
icon.textContent = '\u231B';
label.textContent = 'Generating\u2026';
setStatus('Generating audio on server\u2026');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
voiceSel.disabled = true;
speedSlider.disabled = true;
queueSetCurrent('generating', 'POST /ui/audio/' + SLUG + '/' + CHAPTER_N);
}
function setPlaying() {
icon.innerHTML = '&#9646;&#9646;';
label.textContent = 'Pause';
setStatus('Playing');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('playing', 'audio.src set, playing ch.' + CHAPTER_N);
}
function setPaused() {
icon.innerHTML = '&#9654;';
label.textContent = 'Resume';
setStatus('Paused');
queueSetCurrent('paused');
}
function setStopped() {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('idle', 'stopped');
queueSetNext('idle');
queueUpdateScrubber();
}
function setError(msg) {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('Error: ' + msg);
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('error', 'err: ' + msg);
}
// ── server-side audio generation ─────────────────────────────────────────────
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}.
// When merged=false, url is part-0; polling /ui/audio/{slug}/{n}/status
// detects when the full merged file is ready and swaps audio.src seamlessly.
// cb(url) is called with the initial (part-0 or merged) URL on success.
var currentAudioCtrl = null; // AbortController for the active generateAudio fetch
var mergePoller = null; // setInterval id for polling merged status
function clearMergePoller() {
if (mergePoller !== null) { clearInterval(mergePoller); mergePoller = null; }
}
function generateAudio(chapterN, cb) {
// Cancel any previous in-flight generation and polling.
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
clearMergePoller();
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
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 (!data || !data.url) throw new Error('no url in response');
cb(data.url);
// If the server is still generating remaining parts, start polling.
if (!data.merged && data.parts > 1) {
var statusURL = '/ui/audio/' + SLUG + '/' + chapterN + '/status'
+ '?voice=' + encodeURIComponent(voiceSel.value)
+ '&speed=' + parseFloat(speedSlider.value);
mergePoller = setInterval(function () {
fetch(statusURL)
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (s) {
if (s.merged) {
clearMergePoller();
// Seamlessly swap to the full merged file.
var ratio = (audio.duration && isFinite(audio.duration))
? audio.currentTime / audio.duration : 0;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
}
}, { once: true });
audio.src = s.url;
audio.load();
}
})
.catch(function () { /* ignore transient poll errors */ });
}, 3000);
}
})
.catch(function (e) {
if (e.name === 'AbortError') return; // silently cancelled
setError(e.message);
});
}
// ── next-chapter prefetch ─────────────────────────────────────────────────────
// At 80 % playback we silently POST to generate the next chapter's audio so
// it is cached by the time the current chapter ends.
var prefetchFired = false; // ensure we only fire once per chapter
audio.addEventListener('timeupdate', function () {
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return;
if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true;
queueSetNext('generating', 'POST /ui/audio/' + SLUG + '/' + NEXT_N + ' (prefetch)');
// Fire-and-forget: idempotent on server.
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 () { queueSetNext('ready', 'prefetch complete: ch.' + NEXT_N + ' cached'); })
.catch(function (e) { queueSetNext('error', 'prefetch failed: ' + e); });
}
});
// ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
clearMergePoller();
audio.pause();
audio.src = '';
prefetchFired = false;
setStopped();
}
// ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
});
audio.addEventListener('waiting', function () { setStatus('Buffering\u2026'); });
audio.addEventListener('playing', setPlaying);
audio.addEventListener('pause', function () { if (!audio.ended) setPaused(); });
audio.addEventListener('play', setPlaying);
audio.addEventListener('error', function () { setError('audio error'); });
// Sync paragraph highlight and scrubber while playing (via timeupdate).
audio.addEventListener('timeupdate', function () {
queueUpdateScrubber();
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length),
paras.length - 1
);
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
});
audio.addEventListener('ended', function () {
audio.src = '';
prefetchFired = false;
if (autoplayChk.checked && NEXT_N) {
goNextChapter();
} else {
setStopped();
}
});
// ── auto-next ────────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
htmx.ajax('GET', nextURL + '?autoplay=1', {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
}
// ── main entry point ─────────────────────────────────────────────────────────
function startAudio() {
setGenerating();
highlightPara(0);
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.load();
// canplay event triggers play(); setPlaying() re-enables controls.
});
}
window.ttsToggle = function () {
// Already have audio loaded — just play/pause.
if (audio.src) {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
} else {
audio.pause();
}
return;
}
startAudio();
};
// Stop cleanly when HTMX navigates away.
document.body.addEventListener('htmx:beforeSwap', stop);
// ── auto-start on ?autoplay=1 ─────────────────────────────────────────────────
if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true;
setTimeout(startAudio, 100);
}
// ── initialise queue bar on page load ────────────────────────────────────────
queueSetCurrent('idle', 'ready — ch.' + CHAPTER_N);
if (NEXT_N) { queueSetNext('idle', 'waiting — ch.' + NEXT_N); }
}());
</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
}
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(raw), &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)
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 []string
DefaultVoice string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: chapterTitle,
ChapterDate: chapterDate,
AllChapters: chapters,
Voices: kokoroVoices,
DefaultVoice: s.kokoroVoice,
})
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
}
// 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),
)
}