Files
libnovel/scraper/internal/server/ui.go
Admin fb5d22eb8d
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Move scrape form to dedicated /scrape page, add '+ Add' link in home header
- Extract scrape form and autocomplete JS from homeTmpl into new scrapeTmpl
- Add handleScrape GET handler serving /scrape with ranking autocomplete
- Register GET /scrape route in server.go
- Replace inline scrape form on home page with '+ Add' flat button in header
- handleHome no longer loads ranking items (only needed on /scrape)
2026-03-01 22:26:50 +05:00

2494 lines
94 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>
<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>
<!-- 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>
<!-- 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);
}());
</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
}
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
}{
Books: books,
})
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">
<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">
{{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"
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 text-amber-400 hover:text-amber-300 transition-colors border-0 bg-transparent cursor-pointer">
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 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>
</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 clearBtn = document.getElementById('ranking-clear-filters');
var pagination = document.getElementById('ranking-pagination');
var grid = document.getElementById('ranking-grid');
if (!grid) return;
// 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;
}
function applyFilters() {
var q = input ? input.value.trim().toLowerCase() : '';
var cards = grid.querySelectorAll('[data-filter]');
var shown = 0;
var filtering = isFiltering();
cards.forEach(function (card) {
var match = true;
// Text search against title/author
if (q && card.dataset.filter.toLowerCase().indexOf(q) === -1) match = false;
// Status facet (AND: all selected statuses must be present — but realistically
// a book has one status, so selecting two statuses shows books matching either;
// treat multiple status selections as OR within the facet, AND between facets)
if (match && active.status.size > 0) {
var cardStatus = (card.dataset.status || '').toLowerCase();
var hit = false;
active.status.forEach(function (v) { if (cardStatus === v.toLowerCase()) hit = true; });
if (!hit) match = false;
}
// Genre facet — all selected genres must be present on the card (AND)
if (match && active.genre.size > 0) {
var cardGenres = (card.dataset.genres || '').toLowerCase();
active.genre.forEach(function (v) {
if (cardGenres.indexOf(v.toLowerCase() + '|') === -1) match = false;
});
}
// Local facet
if (match && active.local.size > 0) {
if (card.dataset.local !== '1') match = false;
}
card.style.display = match ? '' : 'none';
if (match) shown++;
});
// Show/hide result count
if (filtering) {
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
countEl.classList.remove('hidden');
clearBtn.classList.remove('hidden');
if (pagination) pagination.style.display = 'none';
} else {
countEl.classList.add('hidden');
clearBtn.classList.add('hidden');
if (pagination) pagination.style.display = '';
}
}
// 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();
});
}
}());
</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)
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
FetchNums []pageNum
DisplayNums []pageNum
CurrentPage int
TotalPages int
TotalItems int
AllGenres []string
AllStatuses []string
}{
Books: toRankingViewItems(pageItems, s.writer.LocalSlugs()),
CachedAt: cachedAt,
FetchNums: rankingPageNums(100, 0),
DisplayNums: rankingPageNums(totalPages, currentPage),
CurrentPage: currentPage,
TotalPages: totalPages,
TotalItems: totalItems,
AllGenres: allGenres,
AllStatuses: allStatuses,
})
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 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">{{.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>
<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];
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 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 (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-14">
<!-- Back to book (book/list icon, visually distinct from ← Prev) -->
<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"
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;color:#a1a1aa;font-size:1rem;text-decoration:none;transition:color 0.15s;line-height:1;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
&#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"
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:0.8125rem;text-decoration:none;transition:color 0.15s;"
onmouseover="this.style.color='#f4f4f5'" onmouseout="this.style.color='#a1a1aa'">
&#8592; Prev
</a>
{{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.25rem 0.25rem;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8125rem;color:#a1a1aa;max-width:100%;">{{.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:transparent;color:#a1a1aa;font-size:0.8125rem;text-decoration:none;transition:color 0.15s;"
onmouseover="this.style.color='#f4f4f5'" onmouseout="this.style.color='#a1a1aa'">
Next &#8594;
</a>
{{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.625rem;border-radius:0.5rem;background:transparent;color:#f59e0b;font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:color 0.15s;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#f59e0b'">
<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 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>
</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))
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),
)
}