Files
libnovel/scraper/internal/server/ui.go
Admin 0aba23de1f
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
feat: switch ranking storage from markdown to JSON, fix round-trip test
- WriteRanking now writes ranking.json via json.MarshalIndent (replaces markdown table)
- ReadRankingItems uses json.Unmarshal (no fragile pipe-split parsing)
- ReadRanking() (raw string reader) removed; handleRankingView uses ReadRankingItems
- handleRankingView renders a <pre> JSON block instead of goldmark markdown
- RankingItem gains json: struct tags; slug and genres now survive the round-trip exactly
- TestWriteRanking_RoundTrip updated: checks ranking.json, verifies Slug/Genres/SourceURL
- Add RankingPageCacher interface and per-page HTML disk cache support in scraper
2026-03-01 21:44:39 +05:00

2255 lines
86 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/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>
<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 bg-amber-700 hover:bg-amber-600 text-white">Browse Rankings</a>
</div>
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
<!-- 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>
<!-- Scrape form -->
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
<form id="scrape-form"
hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML"
class="flex gap-2">
<div class="flex-1 relative" 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 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 bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors whitespace-nowrap">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-3"></div>
</div>
<!-- Continue reading section (populated by JS) -->
<div id="continue-reading-section" class="hidden mb-10">
<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>
<!-- 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}}
</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);
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);
/* ── ranking search / scrape autocomplete ──────────────────────────────── */
(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;
// Sync the hidden url field whenever the visible input looks like a URL.
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;
}
// Select an item: put the URL in the visible input so the user can see/copy it.
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 || '';
// Cover image
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);
}
// Text block
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);
}
// Show the URL so the user knows what will land in the field.
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) {
// Preview the URL in the input while navigating with keyboard.
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(); // keep focus on input
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 it looks like a URL, no autocomplete needed.
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();
}
});
// Validate before submit: if url field empty, treat visible input as raw URL.
form.addEventListener('htmx:configRequest', function (e) {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
// Also handle plain form submit (non-HTMX fallback).
form.addEventListener('submit', function () {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
// Close dropdown when clicking outside.
document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop();
}
});
}());
}());
</script>`
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
}
// Load ranking items for the scrape-search autocomplete.
// Failures are non-fatal — the form degrades to a plain URL input.
rankingItems, _ := s.writer.ReadRankingItems()
// Encode ranking items as JSON for embedding in the template.
rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
RankingJSON template.JS
}{
Books: books,
RankingJSON: template.JS(rankingJSON),
})
s.respond(w, r, "Home", buf.String())
}
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
const rankingTmpl = `
<div class="max-w-4xl 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>
<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 bg-zinc-700 hover:bg-zinc-600 text-white 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-4 flex gap-2 items-center">
<div class="flex-1 relative">
<input id="ranking-filter" type="search" placeholder="Filter by title, author, genre, status…"
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>
<p id="ranking-filter-count" class="text-xs text-zinc-500 whitespace-nowrap hidden"></p>
</div>
<!-- Pagination: fetch pages from novelfire -->
<div class="mb-6">
<div class="flex items-center gap-1.5 mb-2 flex-wrap">
<span class="text-xs text-zinc-500 mr-1">Fetch up to page:</span>
{{range .PageNums}}
{{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 bg-zinc-800 hover:bg-amber-700 border border-zinc-700 hover:border-amber-600 text-zinc-300 hover:text-white 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>
<!-- 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}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
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">
{{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">
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white 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>
</div>
{{end}}
</div>
</a>
{{else}}
<div data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
class="group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 hover:border-amber-500 transition-colors min-w-0">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
{{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-amber-400 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 flex-wrap">
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
<input type="hidden" name="url" value="{{.SourceURL}}">
<button type="submit"
class="text-xs px-2 py-0.5 rounded bg-amber-700 hover:bg-amber-600 text-white">
Scrape
</button>
</form>
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white 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>
</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 input = document.getElementById('ranking-filter');
var countEl = document.getElementById('ranking-filter-count');
var grid = document.getElementById('ranking-grid');
if (!input || !grid) return;
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var cards = grid.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.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
countEl.classList.remove('hidden');
} else {
countEl.classList.add('hidden');
}
});
}());
</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 the dynamic pagination list for 100 pages with
// smart ellipsis: always show the first 3, last 3, and a sliding window of
// 3 around the ends — compressed with "…" separators between runs.
//
// For a flat 100-page list it produces:
//
// 1 2 3 … 6 7 8 … 93 94 95 … 98 99 100
//
// (The middle range is omitted intentionally; users click individual pages.)
func rankingPageNums(total int) []pageNum {
if total <= 0 {
return nil
}
// Build the set of visible page numbers using a sliding-window approach.
show := make(map[int]bool)
// Always show first 3 and last 3.
for i := 1; i <= 3 && i <= total; i++ {
show[i] = true
}
for i := total - 2; i <= total; i++ {
if i >= 1 {
show[i] = true
}
}
// For large ranges, show a few pages near the 1/4 and 3/4 marks.
if total > 12 {
q1 := total / 4
q3 := total * 3 / 4
for _, p := range []int{q1 - 1, q1, q1 + 1, q3 - 1, q3, q3 + 1} {
if p >= 1 && p <= total {
show[p] = true
}
}
}
// Collect sorted visible pages.
pages := make([]int, 0, len(show))
for p := range show {
pages = append(pages, p)
}
// Sort pages slice.
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 (Num==0) 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}) // ellipsis
}
out = append(out, pageNum{p})
}
return out
}
// handleRanking serves the ranking page from the cached ranking.md file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
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")
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
PageNums []pageNum
}{
Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()),
CachedAt: cachedAt,
PageNums: rankingPageNums(100),
})
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>
<div class="flex gap-5 mb-8 mt-4">
{{if .Meta.Cover}}
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
{{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 bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white 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 bg-amber-700 hover:bg-amber-600 text-white"
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">{{.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 bg-amber-700 hover:bg-amber-600 text-white text-sm font-medium transition-colors cursor-pointer">
▶ Resume — Chapter <span id="resume-chapter-num"></span>
</a>
</div>
<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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 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];
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
_ = t.Execute(&buf, struct {
Slug string
Meta interface{}
Chapters interface{}
TotalDownloaded int
TotalPages int
CurrentPage int
}{
Slug: slug,
Meta: meta,
Chapters: page,
TotalDownloaded: total,
TotalPages: totalPages,
CurrentPage: currentPage,
})
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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 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 (nav, not header, so Reader Mode ignores it) ─── -->
<!-- aria-hidden keeps it out of accessibility trees / Reader Mode heuristics -->
<nav id="reader-header" aria-hidden="true"
style="position:sticky;top:0;z-index:50;background:#09090b;border-bottom:1px solid #27272a;">
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-12">
<!-- Back to book -->
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Chapter list"
aria-label="Back to chapter list"
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;color:#a1a1aa;font-size:0.875rem;text-decoration:none;transition:color 0.15s;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
&#8592;
</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"
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
&#8592; Prev
</a>
{{else}}
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;" aria-hidden="true">&#8592; Prev</span>
{{end}}
<!-- Chapter title button — opens chapter list drawer -->
<button id="chapter-list-btn"
onclick="toggleChapterList()"
title="Jump to chapter"
aria-label="Jump to chapter"
style="flex:1;min-width:0;display:flex;align-items:center;justify-content:center;gap:0.375rem;overflow:hidden;background:transparent;border:none;cursor:pointer;padding:0.375rem 0.25rem;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8125rem;color:#a1a1aa;">{{.Title}}</span>
<span style="flex-shrink:0;font-size:0.625rem;color:#52525b;" 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"
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
Next &#8594;
</a>
{{else}}
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;" aria-hidden="true">Next &#8594;</span>
{{end}}
<!-- TTS play/pause button -->
<button id="tts-btn"
onclick="ttsToggle()"
title="Listen"
aria-label="Listen"
style="flex-shrink:0;display:flex;align-items:center;gap:0.375rem;padding:0.375rem 0.75rem;border-radius:0.5rem;background:#d97706;color:#fff;font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:background 0.15s;"
onmouseover="this.style.background='#b45309'" onmouseout="this.style.background='#d97706'">
<span id="tts-icon" aria-hidden="true">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Settings gear button -->
<button id="settings-btn"
onclick="toggleSettings()"
title="Settings"
aria-label="Settings"
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:1rem;border:none;cursor:pointer;transition:color 0.15s;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
&#9881;
</button>
</div>
<!-- TTS status strip -->
<div id="tts-status-bar" style="display:none;padding:0.125rem 1rem;background:#18181b;font-size:0.75rem;color:#71717a;text-align:center;" aria-live="polite">
<span id="tts-status"></span>
</div>
<!-- Chapter list drawer -->
<div id="chapter-list-panel"
style="display:none;position:absolute;left:0;right:0;top:100%;max-height:60vh;overflow-y:auto;background:#18181b;border-bottom:1px solid #27272a;box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:99;">
<div style="max-width:42rem;margin:0 auto;padding:0.5rem 0;">
{{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"
onclick="closeChapterList()"
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
style="display:flex;align-items:baseline;gap:0.75rem;padding:0.5rem 1rem;text-decoration:none;transition:background 0.1s;{{if eq .Number $.ChapterN}}background:#27272a;{{end}}"
onmouseover="this.style.background='#27272a'" onmouseout="this.style.background='{{if eq .Number $.ChapterN}}#27272a{{else}}transparent{{end}}'">
<span style="flex-shrink:0;font-size:0.75rem;color:#52525b;width:2.5rem;text-align:right;" aria-hidden="true">{{.Number}}</span>
<span style="font-size:0.875rem;{{if eq .Number $.ChapterN}}color:#fbbf24;font-weight:500;{{else}}color:#d4d4d8;{{end}}">{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}</span>
{{if eq .Number $.ChapterN}}<span style="flex-shrink:0;font-size:0.7rem;color:#f59e0b;margin-left:auto;" aria-hidden="true">&#9654; now</span>{{end}}
</a>
{{end}}
</div>
</div>
<!-- Settings dropdown panel -->
<div id="settings-panel"
style="display:none;position:absolute;right:max(0.5rem,calc(50% - 32rem + 0.5rem));top:calc(100% + 0.25rem);min-width:260px;background:#18181b;border:1px solid #27272a;border-radius:0.75rem;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:100;">
<!-- Voice -->
<label style="display:block;margin-bottom:0.875rem;">
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Voice</span>
<select id="tts-voice"
style="width:100%;border-radius:0.5rem;background:#27272a;border:1px solid #3f3f46;padding:0.375rem 0.5rem;font-size:0.875rem;color:#e4e4e7;outline:none;">
{{range .Voices}}
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</label>
<!-- Speed -->
<label style="display:block;margin-bottom:0.875rem;">
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">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"
style="width:100%;accent-color:#f59e0b;cursor:pointer;" />
</label>
<!-- Auto-next -->
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;user-select:none;">
<input id="tts-autoplay" type="checkbox" style="accent-color:#f59e0b;cursor:pointer;width:1rem;height:1rem;" />
<span style="font-size:0.875rem;color:#d4d4d8;">Auto-play next chapter</span>
</label>
</div>
</nav>
<!-- Audio element outside nav/header so Reader Mode never sees it -->
<audio id="tts-audio" aria-hidden="true" style="display:none"></audio>
<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
<!-- role="main" + <article> give Reader Mode a strong content anchor -->
<div role="main" class="max-w-2xl mx-auto px-4 py-10">
<!-- Visible h1 inside the content area — Reader Mode anchors on this title -->
<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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 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 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
Next chapter →
</a>
{{else}}<span></span>{{end}}
</nav>
</div>
<!-- ─── Audio queue panel (sticky bottom) ───────────────────────────────────── -->
<div id="audio-queue-bar"
role="region"
aria-label="Audio queue"
style="position:fixed;bottom:0;left:0;right:0;z-index:60;background:#09090b;border-top:1px solid #27272a;font-size:0.75rem;color:#a1a1aa;">
<!-- Toggle handle ─────────────────────────────────────────────────────────── -->
<button id="queue-toggle"
onclick="window.toggleQueuePanel()"
aria-expanded="false"
style="width:100%;display:flex;align-items:center;justify-content:between;gap:0.5rem;padding:0.4rem 1rem;background:transparent;border:none;cursor:pointer;color:#a1a1aa;font-size:0.7rem;text-align:left;">
<span style="flex:1;display:flex;align-items:center;gap:0.5rem;">
<span id="queue-toggle-icon" style="font-size:0.65rem;">&#9650;</span>
<span style="font-weight:600;letter-spacing:0.05em;text-transform:uppercase;">Audio Queue</span>
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
</span>
<!-- Mini scrubber always visible in the handle row -->
<span style="flex:2;display:flex;align-items:center;gap:0.5rem;padding:0 0.5rem;">
<span id="queue-time-cur" style="min-width:2.5rem;text-align:right;font-variant-numeric:tabular-nums;">0:00</span>
<span style="flex:1;position:relative;height:3px;background:#3f3f46;border-radius:2px;overflow:hidden;">
<span id="queue-scrubber-fill" style="position:absolute;left:0;top:0;height:100%;width:0%;background:#f59e0b;transition:width 0.3s linear;"></span>
</span>
<span id="queue-time-tot" style="min-width:2.5rem;font-variant-numeric:tabular-nums;">0:00</span>
</span>
</button>
<!-- Expanded panel ────────────────────────────────────────────────────────── -->
<div id="queue-panel-body" style="display:none;border-top:1px solid #27272a;">
<!-- Now playing row -->
<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 row (hidden if no next chapter) -->
<div class="queue-row" id="queue-row-next" style="display:none;">
<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 info row -->
<div class="queue-row queue-row-debug" id="queue-row-debug">
<span class="queue-row-label">dbg</span>
<span id="queue-debug-text" style="font-family:monospace;word-break:break-all;"></span>
</div>
</div>
</div>
<style>
/* Paragraph TTS highlight — no cursor change so paragraphs look editorial */
#chapter-article p {
border-radius: 0.375rem;
margin-left: -0.5rem;
padding-left: 0.5rem;
transition: background 0.15s;
}
/* Subtle tap affordance only on touch/pointer devices that support hover */
@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 covering anchor targets */
#main-content { scroll-padding-top: 3.5rem; }
/* Improve base reading typography */
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; }
/* ── Audio queue bar ─────────────────────────────────────────────────────── */
/* Extra bottom padding so chapter text is never hidden behind the sticky bar */
#main-content { padding-bottom: 4rem; }
.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-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))
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
AllChapters interface{}
Voices []string
DefaultVoice string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: title,
AllChapters: chapters,
Voices: kokoroVoices,
DefaultVoice: s.kokoroVoice,
})
s.respond(w, r, title, buf.String())
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// 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),
)
}