Files
Admin 09e90d2ab0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
feat: play cached TTS audio immediately on auto-start if already generated
2026-03-03 13:46:37 +05:00

3302 lines
137 KiB
Go
Raw Permalink 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"
"sort"
"strconv"
"strings"
"time"
"github.com/libnovel/scraper/internal/orchestrator"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
goldhtml "github.com/yuin/goldmark/renderer/html"
)
// md is the shared goldmark instance used for all markdown→HTML conversions.
var md = goldmark.New(
goldmark.WithExtensions(extension.Typographer, extension.Table),
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
// grouped loosely by language prefix:
//
// af_ / am_ American English female / male
// bf_ / bm_ British English female / male
// ef_ / em_ Spanish female / male
// ff_ French female
// hf_ / hm_ Hindi female / male
// if_ / im_ Italian female / male
// jf_ / jm_ Japanese female / male
// pf_ / pm_ Portuguese female / male
// zf_ / zm_ Chinese female / male
var kokoroVoices = []string{
// American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky",
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
"am_michael", "am_onyx", "am_puck",
// British English
"bf_alice", "bf_emma", "bf_lily",
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
// Spanish
"ef_dora", "em_alex",
// French
"ff_siwis",
// Hindi
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
// Italian
"if_sara", "im_nicola",
// Japanese
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
// Portuguese
"pf_dora", "pm_alex",
// Chinese
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}
// voiceInfo holds the parsed display metadata for a single Kokoro voice.
type voiceInfo struct {
ID string // raw voice ID, e.g. "af_bella"
Name string // display name, e.g. "Bella"
Lang string // language label, e.g. "EN-US"
Gender string // "F" or "M"
}
// langLabel maps the two-letter prefix to a human-readable language tag.
var langLabel = map[string]string{
"a": "EN-US",
"b": "EN-GB",
"e": "ES",
"f": "FR",
"h": "HI",
"i": "IT",
"j": "JA",
"p": "PT",
"z": "ZH",
}
// parseVoice decodes a Kokoro voice ID into display metadata.
// IDs follow the pattern {lang}{gender}_{name} e.g. "af_bella".
func parseVoice(id string) voiceInfo {
v := voiceInfo{ID: id, Name: id, Lang: "?", Gender: "?"}
if len(id) < 3 || id[2] != '_' {
return v
}
lc := string(id[0])
gc := string(id[1])
name := id[3:]
if l, ok := langLabel[lc]; ok {
v.Lang = l
}
switch gc {
case "f":
v.Gender = "F"
case "m":
v.Gender = "M"
}
// Capitalise name, replace underscores with spaces.
if len(name) > 0 {
runes := []rune(name)
runes[0] -= 'a' - 'A'
v.Name = strings.ReplaceAll(string(runes), "_", " ")
}
return v
}
// parseVoices converts a slice of raw voice IDs to voiceInfo structs.
func parseVoices(ids []string) []voiceInfo {
out := make([]voiceInfo, len(ids))
for i, id := range ids {
out[i] = parseVoice(id)
}
return out
}
// ─── shared layout ────────────────────────────────────────────────────────────
const layoutHead = `<!DOCTYPE html>
<html lang="en" class="bg-zinc-950 text-zinc-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — libnovel</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
<style>
.prose p { margin-bottom: 1em; }
.prose h1,.prose h2,
.prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
.prose h4 { font-size: 1.05rem; }
.prose h3 { font-size: 1.2rem; }
.prose h2 { font-size: 1.4rem; }
.prose h1 { font-size: 1.7rem; }
.prose em { font-style: italic; }
.prose strong { font-weight: 700; }
.prose hr { border-color: #3f3f46; margin: 2em 0; }
.prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
</style>
</head>
<body class="min-h-screen">`
const layoutFoot = `</body></html>`
func renderPage(w http.ResponseWriter, title, body string) {
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = t.Execute(w, struct{ Title string }{Title: title})
}
func renderFragment(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, body)
}
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// respond writes either a full page or an HTMX fragment depending on the request.
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
if isHTMX(r) {
renderFragment(w, fragment)
return
}
renderPage(w, title,
`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
}
// ─── GET / — book catalogue ───────────────────────────────────────────────────
const homeTmpl = `
<style>
.home-progress-bar { transition: width 0.4s ease; }
.sort-btn { transition: background 0.15s, color 0.15s; }
.sort-btn.active { background: #78350f; color: #fbbf24; }
.genre-chip { transition: background 0.15s, color 0.15s, border-color 0.15s; }
.genre-chip.active { background: #78350f; color: #fbbf24; border-color: #92400e; }
</style>
<div class="max-w-4xl mx-auto px-4 py-8 overflow-hidden" hx-history="false">
<!-- ── Header ── -->
<div class="flex items-center justify-between mb-1">
<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>
<!-- ── Stats strip ── -->
<div id="stats-strip" class="hidden flex items-center gap-4 text-xs text-zinc-500 mb-5 flex-wrap">
<span id="stat-books"></span>
<span class="text-zinc-700">·</span>
<span id="stat-chapters"></span>
<span class="text-zinc-700">·</span>
<span id="stat-time"></span>
<span id="stat-completed-wrap" class="hidden">
<span class="text-zinc-700">·</span>
<span id="stat-completed"></span>
</span>
</div>
<p id="stats-fallback" class="text-zinc-400 text-sm mb-5">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
<!-- ── Hero: last-read book (populated by JS) ── -->
<div id="hero-section" class="hidden mb-6"></div>
<!-- ── Continue reading (populated by JS) ── -->
<div id="continue-reading-section" class="hidden mb-6">
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Continue reading</h2>
<div id="continue-reading-grid" class="grid gap-3 sm:grid-cols-2"></div>
</div>
<!-- ── Recently added (populated by JS) ── -->
<div id="recently-added-section" class="hidden mb-6">
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3">Recently added</h2>
<div id="recently-added-grid" class="grid gap-3 sm:grid-cols-2"></div>
</div>
<!-- ── Filter + Sort + Genre chips ── -->
<div class="mb-4 space-y-3">
<div class="flex gap-2">
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
autocomplete="off" spellcheck="false"
class="flex-1 min-w-0 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>
<!-- Sort bar -->
<div class="flex gap-1.5 flex-wrap" id="sort-bar">
<button class="sort-btn active text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="alpha">AZ</button>
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="recent">Recently read</button>
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="added">Recently added</button>
<button class="sort-btn text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-300" data-sort="downloaded">Most downloaded</button>
</div>
<!-- Genre chips (populated by JS) -->
<div id="genre-chips" class="flex gap-1.5 flex-wrap hidden"></div>
<p id="filter-count" class="text-xs text-zinc-500 hidden"></p>
</div>
<!-- ── All books grid ── -->
<div id="available-section">
<h2 id="available-heading" class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-3{{if eq (len .Books) 0}} hidden{{end}}">All books</h2>
<div id="books-grid" class="grid gap-3 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-title="{{.Title}}"
data-downloaded="{{.Downloaded}}"
data-added="{{.AddedAt}}"
data-total="{{.TotalChapters}}"
data-cover="{{.Cover}}"
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
data-genres="{{range .Genres}}{{.}}|{{end}}"
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors cursor-pointer min-w-0 overflow-hidden">
<div class="flex gap-3">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" loading="lazy" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
{{end}}
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate text-sm leading-snug">{{.Title}}</h2>
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
<!-- progress bar injected by JS -->
<div class="book-progress-wrap hidden mt-2">
<div class="h-0.5 rounded-full bg-zinc-700 overflow-hidden">
<div class="book-progress-bar home-progress-bar h-full bg-amber-500 rounded-full" style="width:0%"></div>
</div>
<p class="book-progress-label text-[0.65rem] text-zinc-500 mt-0.5"></p>
</div>
<div class="flex gap-1.5 mt-2 flex-wrap items-center">
{{if .Status}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
{{if .TotalChapters}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.TotalChapters}} ch</span>{{end}}
{{if .Downloaded}}<span class="text-[0.65rem] px-1.5 py-0.5 rounded-full bg-amber-900/60 text-amber-400">{{.Downloaded}} dl</span>{{end}}
<span class="book-unread-badge hidden text-[0.65rem] px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300 font-medium"></span>
</div>
</div>
</div>
</a>
{{else}}
<p class="text-zinc-500 col-span-2">No books scraped yet.</p>
{{end}}
</div>
</div>
</div>
<script>
(function () {
/* ── state ─────────────────────────────────────────────────────────────── */
var progress = {};
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
// progress = { slug: chapterNum, ... }
var progressTs = {};
try { progressTs = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
// progressTs = { slug: timestampMs, ... }
var booksGrid = document.getElementById('books-grid');
var continueSection = document.getElementById('continue-reading-section');
var continueGrid = document.getElementById('continue-reading-grid');
var recentlyAddedSec = document.getElementById('recently-added-section');
var recentlyAddedGrid = document.getElementById('recently-added-grid');
var heroSection = document.getElementById('hero-section');
var statsStrip = document.getElementById('stats-strip');
var statsFallback = document.getElementById('stats-fallback');
var genreChipsEl = document.getElementById('genre-chips');
var allCards = booksGrid ? Array.from(booksGrid.querySelectorAll('[data-slug]')) : [];
/* ── reading_progress_ts: record timestamps for "recently read" sort ───── */
// On first load after upgrade, seed timestamps for existing progress entries.
var tsUpdated = false;
Object.keys(progress).forEach(function(slug) {
if (!progressTs[slug]) { progressTs[slug] = Date.now(); tsUpdated = true; }
});
if (tsUpdated) {
try { localStorage.setItem('reading_progress_ts', JSON.stringify(progressTs)); } catch(_) {}
}
/* ── progress bars + unread badges ─────────────────────────────────────── */
allCards.forEach(function(card) {
var slug = card.dataset.slug;
var total = parseInt(card.dataset.total, 10) || 0;
var downloaded = parseInt(card.dataset.downloaded, 10) || 0;
var chapterNum = progress[slug] ? parseInt(progress[slug], 10) : 0;
var base = downloaded || total;
if (chapterNum > 0 && base > 0) {
var pct = Math.min(100, Math.round(chapterNum / base * 100));
var wrap = card.querySelector('.book-progress-wrap');
var bar = card.querySelector('.book-progress-bar');
var lbl = card.querySelector('.book-progress-label');
if (wrap && bar && lbl) {
bar.style.width = pct + '%';
lbl.textContent = 'Ch.\u00a0' + chapterNum + (base ? '\u00a0/\u00a0' + base : '') + '\u00a0\u2014\u00a0' + pct + '%';
wrap.classList.remove('hidden');
}
}
// Unread badge: downloaded > last-read chapter
if (downloaded > 0 && chapterNum > 0 && downloaded > chapterNum) {
var unread = downloaded - chapterNum;
var badge = card.querySelector('.book-unread-badge');
if (badge) {
badge.textContent = '+' + unread + ' new';
badge.classList.remove('hidden');
}
}
});
/* ── stats strip ─────────────────────────────────────────────────────────── */
var totalChaptersRead = 0;
var booksCompleted = 0;
Object.keys(progress).forEach(function(slug) {
var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
var ch = parseInt(progress[slug], 10) || 0;
totalChaptersRead += ch;
if (card) {
var total = parseInt(card.dataset.total, 10) || 0;
var dl = parseInt(card.dataset.downloaded, 10) || 0;
var base = dl || total;
if (base > 0 && ch >= base) booksCompleted++;
}
});
if (totalChaptersRead > 0) {
var estMins = Math.round(totalChaptersRead * 12); // ~12 min/chapter avg
var timeLabel = estMins >= 60
? Math.floor(estMins / 60) + 'h ' + (estMins % 60) + 'm'
: estMins + 'm';
document.getElementById('stat-books').textContent = allCards.length + ' book' + (allCards.length !== 1 ? 's' : '');
document.getElementById('stat-chapters').textContent = totalChaptersRead + ' ch read';
document.getElementById('stat-time').textContent = '~' + timeLabel + ' read';
if (booksCompleted > 0) {
document.getElementById('stat-completed').textContent = booksCompleted + ' completed';
document.getElementById('stat-completed-wrap').classList.remove('hidden');
}
statsStrip.classList.remove('hidden');
statsStrip.style.display = 'flex';
if (statsFallback) statsFallback.classList.add('hidden');
}
/* ── hero card: most recently read ─────────────────────────────────────── */
var heroSlug = null, heroTs = 0;
Object.keys(progressTs).forEach(function(slug) {
if (progressTs[slug] > heroTs && progress[slug]) {
heroTs = progressTs[slug]; heroSlug = slug;
}
});
if (heroSlug && heroSection) {
var heroCard = booksGrid ? booksGrid.querySelector('[data-slug="' + heroSlug + '"]') : null;
if (heroCard) {
var heroChapter = parseInt(progress[heroSlug], 10) || 0;
var heroTotal = parseInt(heroCard.dataset.total, 10) || parseInt(heroCard.dataset.downloaded, 10) || 0;
var heroPct = heroTotal > 0 ? Math.min(100, Math.round(heroChapter / heroTotal * 100)) : 0;
var heroCover = heroCard.dataset.cover || '';
var heroTitle = heroCard.dataset.title || '';
var heroHref = heroCard.getAttribute('href');
var heroGet = heroCard.getAttribute('hx-get');
heroSection.innerHTML =
'<a href="' + heroHref + '"' +
' hx-get="' + heroGet + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
' class="group relative block rounded-2xl overflow-hidden border border-zinc-700 hover:border-amber-500 transition-colors cursor-pointer"' +
' style="' + (heroCover ? 'background-image:linear-gradient(to right,#09090b 40%,rgba(9,9,11,0.4)),url(' + heroCover + ');background-size:cover;background-position:right center;' : 'background:#18181b;') + '">' +
'<div class="relative z-10 p-5 sm:p-6">' +
'<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-amber-500 mb-1">Continue reading</p>' +
'<h2 class="text-xl sm:text-2xl font-bold text-zinc-100 group-hover:text-amber-400 transition-colors leading-snug mb-2 max-w-sm">' + escHtml(heroTitle) + '</h2>' +
'<p class="text-sm text-zinc-400 mb-4">Chapter\u00a0' + heroChapter + (heroTotal ? '\u00a0/\u00a0' + heroTotal : '') + (heroPct ? '\u00a0\u2014\u00a0' + heroPct + '%' : '') + '</p>' +
'<div class="w-48 h-1 rounded-full bg-zinc-700 overflow-hidden mb-4">' +
'<div class="h-full bg-amber-500 rounded-full" style="width:' + heroPct + '%"></div>' +
'</div>' +
'<span class="inline-block text-sm font-semibold px-4 py-2 rounded-xl bg-amber-500 text-zinc-900 group-hover:bg-amber-400 transition-colors">Resume</span>' +
'</div>' +
'</a>';
heroSection.classList.remove('hidden');
htmx.process(heroSection);
}
}
/* ── continue reading (excluding hero) ──────────────────────────────────── */
if (continueGrid) continueGrid.innerHTML = '';
var inProgress = Object.keys(progress).sort(function(a,b) {
return (progressTs[b]||0) - (progressTs[a]||0);
});
var moved = 0;
inProgress.forEach(function(slug) {
if (slug === heroSlug) return; // hero already shown
var card = booksGrid ? booksGrid.querySelector('[data-slug="' + slug + '"]') : null;
if (!card) return;
var a = buildContinueCard(card, progress[slug]);
continueGrid.appendChild(a);
moved++;
});
if (moved > 0) continueSection.classList.remove('hidden');
/* ── recently added (top 4, excluding books already in continue reading) ── */
if (recentlyAddedGrid) recentlyAddedGrid.innerHTML = '';
var progressSlugs = new Set(Object.keys(progress));
var byAdded = allCards.slice().sort(function(a, b) {
return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
});
var addedShown = 0;
byAdded.forEach(function(card) {
if (addedShown >= 4) return;
if (progressSlugs.has(card.dataset.slug)) return; // already in continue reading
recentlyAddedGrid.appendChild(buildSimpleCard(card));
addedShown++;
});
if (addedShown > 0) recentlyAddedSec.classList.remove('hidden');
/* ── genre chips ──────────────────────────────────────────────────────── */
var allGenres = {};
allCards.forEach(function(card) {
(card.dataset.genres || '').split('|').forEach(function(g) {
g = g.trim();
if (g) allGenres[g] = (allGenres[g] || 0) + 1;
});
});
var sortedGenres = Object.keys(allGenres).sort(function(a,b) { return allGenres[b]-allGenres[a]; }).slice(0, 12);
var activeGenres = new Set();
if (sortedGenres.length > 1 && genreChipsEl) {
sortedGenres.forEach(function(g) {
var btn = document.createElement('button');
btn.className = 'genre-chip text-xs px-2.5 py-1 rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400';
btn.textContent = g;
btn.addEventListener('click', function() {
if (activeGenres.has(g)) { activeGenres.delete(g); btn.classList.remove('active'); }
else { activeGenres.add(g); btn.classList.add('active'); }
applyFilters();
});
genreChipsEl.appendChild(btn);
});
genreChipsEl.classList.remove('hidden');
}
/* ── sort ─────────────────────────────────────────────────────────────── */
var currentSort = 'alpha';
document.querySelectorAll('.sort-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.sort-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
currentSort = btn.dataset.sort;
applySortAndFilter();
});
});
/* ── filter ───────────────────────────────────────────────────────────── */
var input = document.getElementById('filter-input');
var countEl = document.getElementById('filter-count');
if (input) input.addEventListener('input', applyFilters);
function applyFilters() { applySortAndFilter(); }
function applySortAndFilter() {
var q = input ? input.value.trim().toLowerCase() : '';
// Sort cards
var sorted = allCards.slice().sort(function(a, b) {
if (currentSort === 'alpha') return (a.dataset.title||'').localeCompare(b.dataset.title||'');
if (currentSort === 'downloaded') return (parseInt(b.dataset.downloaded,10)||0) - (parseInt(a.dataset.downloaded,10)||0);
if (currentSort === 'added') return (parseInt(b.dataset.added,10)||0) - (parseInt(a.dataset.added,10)||0);
if (currentSort === 'recent') {
var ta = progressTs[a.dataset.slug] || 0;
var tb = progressTs[b.dataset.slug] || 0;
if (tb !== ta) return tb - ta;
return (a.dataset.title||'').localeCompare(b.dataset.title||'');
}
return 0;
});
// Re-append in sorted order, apply visibility
var shown = 0;
sorted.forEach(function(card) {
var matchText = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
var matchGenre = activeGenres.size === 0 || (function() {
var cardGenres = (card.dataset.genres||'').split('|').map(function(g){return g.trim();});
return Array.from(activeGenres).every(function(g) { return cardGenres.indexOf(g) !== -1; });
})();
var match = matchText && matchGenre;
card.style.display = match ? '' : 'none';
if (match) shown++;
booksGrid.appendChild(card); // re-order in DOM
});
if ((q || activeGenres.size > 0) && countEl) {
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
countEl.classList.remove('hidden');
} else if (countEl) {
countEl.classList.add('hidden');
}
}
/* ── helpers ──────────────────────────────────────────────────────────── */
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function buildSimpleCard(card) {
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', card.dataset.slug);
a.className = card.className;
a.innerHTML = card.innerHTML;
htmx.process(a);
return a;
}
function buildContinueCard(card, chapterNum) {
var a = buildSimpleCard(card);
if (chapterNum) {
var meta = a.querySelector('.min-w-0');
if (meta) {
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);
}
}
}
// dismiss button
var dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.title = 'Remove from continue reading';
dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
dismissBtn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var cardEl = this.closest('[data-slug]');
var cardSlug = cardEl ? cardEl.dataset.slug : null;
if (cardSlug) {
var p = {}; try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
delete p[cardSlug];
localStorage.setItem('reading_progress', JSON.stringify(p));
var ts = {}; try { ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}'); } catch(_) {}
delete ts[cardSlug];
localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
}
var target = cardEl || this.parentElement;
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
target.style.transform = 'translateX(-60px)';
target.style.opacity = '0';
setTimeout(function() {
target.remove();
var remaining = continueGrid.querySelectorAll('[data-slug]');
if (remaining.length === 0) continueSection.classList.add('hidden');
}, 260);
});
a.style.position = 'relative';
a.appendChild(dismissBtn);
return a;
}
}());
</script>`
// homeBookItem wraps BookMeta with the count of chapters already on disk
// and the Unix timestamp of when the book was added (metadata.yaml mtime).
type homeBookItem struct {
scraper.BookMeta
Downloaded int
AddedAt int64
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
books, err := s.writer.ListBooks()
if err != nil {
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
return
}
items := make([]homeBookItem, len(books))
for i, b := range books {
items[i] = homeBookItem{
BookMeta: b,
Downloaded: s.writer.CountChapters(b.Slug),
AddedAt: s.writer.MetadataMtime(b.Slug),
}
}
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
}{
Books: items,
})
s.respond(w, r, "Home", buf.String())
}
// ─── GET /scrape — add a new book ─────────────────────────────────────────────
const scrapeTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-1">Add a book</h1>
<p class="text-zinc-400 text-sm mb-8">Search the rankings or paste a novelfire.net URL to scrape a new book.</p>
<form id="scrape-form"
hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML">
<div class="relative mb-3" id="scrape-search-wrap">
<input type="text"
id="scrape-search"
autocomplete="off"
spellcheck="false"
placeholder="Search rankings or paste a URL…"
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
<!-- hidden url field submitted to HTMX -->
<input type="url" name="url" id="scrape-url" required class="hidden" />
<!-- dropdown -->
<ul id="scrape-dropdown"
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
</ul>
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-4"></div>
</div>
<script>
(function () {
var RANKING = {{.RankingJSON}};
if (!RANKING || !RANKING.length) return;
var searchInput = document.getElementById('scrape-search');
var urlInput = document.getElementById('scrape-url');
var dropdown = document.getElementById('scrape-dropdown');
var form = document.getElementById('scrape-form');
if (!searchInput || !urlInput || !dropdown || !form) return;
var activeIdx = -1;
function syncURLField(val) {
val = val.trim();
if (/^https?:\/\//i.test(val)) {
urlInput.value = val;
} else {
urlInput.value = '';
}
}
function closeDrop() {
dropdown.classList.add('hidden');
dropdown.innerHTML = '';
activeIdx = -1;
}
function selectItem(item) {
var url = item.source_url || '';
searchInput.value = url;
urlInput.value = url;
closeDrop();
}
function buildItem(item, q) {
var li = document.createElement('li');
li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
li.dataset.url = item.source_url || '';
if (item.cover) {
var img = document.createElement('img');
img.src = item.cover;
img.alt = '';
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
li.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
li.appendChild(ph);
}
var txt = document.createElement('div');
txt.className = 'min-w-0 flex-1';
var title = document.createElement('p');
title.className = 'text-sm font-medium text-zinc-100 truncate';
title.textContent = item.title || '';
txt.appendChild(title);
if (item.author) {
var author = document.createElement('p');
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
author.textContent = item.author;
txt.appendChild(author);
}
if (item.source_url) {
var urlHint = document.createElement('p');
urlHint.className = 'text-xs text-zinc-500 truncate mt-0.5';
urlHint.textContent = item.source_url;
txt.appendChild(urlHint);
}
var meta = document.createElement('div');
meta.className = 'flex gap-1.5 mt-1 flex-wrap';
if (item.status) {
var s = document.createElement('span');
s.className = 'text-xs px-1.5 py-0.5 rounded-full bg-zinc-700 text-zinc-300';
s.textContent = item.status;
meta.appendChild(s);
}
if (item.rank) {
var r = document.createElement('span');
r.className = 'text-xs px-1.5 py-0.5 rounded-full bg-amber-900 text-amber-300';
r.textContent = '#' + item.rank;
meta.appendChild(r);
}
txt.appendChild(meta);
li.appendChild(txt);
return li;
}
function setActive(idx, items) {
var lis = dropdown.querySelectorAll('li');
lis.forEach(function (li, i) {
if (i === idx) li.classList.add('bg-zinc-800');
else li.classList.remove('bg-zinc-800');
});
activeIdx = idx;
if (idx >= 0 && idx < items.length) {
var url = items[idx].source_url || '';
searchInput.value = url;
urlInput.value = url;
}
}
function showDrop(items, q) {
dropdown.innerHTML = '';
activeIdx = -1;
if (!items.length) { closeDrop(); return; }
items.forEach(function (item, i) {
var li = buildItem(item, q);
li.addEventListener('mousedown', function (e) {
e.preventDefault();
selectItem(item);
});
dropdown.appendChild(li);
});
dropdown.classList.remove('hidden');
}
function filterRanking(q) {
return RANKING.filter(function (item) {
var genres = (item.genres || []).join(' ');
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '') + ' ' + genres).toLowerCase();
return haystack.indexOf(q) !== -1;
}).slice(0, 10);
}
searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
syncURLField(searchInput.value);
if (!q) { closeDrop(); return; }
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
showDrop(filterRanking(q), q);
});
searchInput.addEventListener('keydown', function (e) {
var q = searchInput.value.trim().toLowerCase();
var items = /^https?:\/\//i.test(q) ? [] : filterRanking(q);
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(Math.min(activeIdx + 1, items.length - 1), items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(Math.max(activeIdx - 1, 0), items);
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
if (items[activeIdx]) selectItem(items[activeIdx]);
} else if (e.key === 'Escape') {
closeDrop();
}
});
form.addEventListener('htmx:configRequest', function (e) {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
form.addEventListener('submit', function () {
var val = searchInput.value.trim();
if (!urlInput.value && /^https?:\/\//i.test(val)) {
urlInput.value = val;
}
});
document.addEventListener('mousedown', function (e) {
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
closeDrop();
}
});
}());
</script>`
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
rankingItems, _ := s.writer.ReadRankingItems()
rankingJSON, _ := json.Marshal(rankingItems)
t := template.Must(template.New("scrape").Parse(scrapeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
RankingJSON template.JS
}{
RankingJSON: template.JS(rankingJSON),
})
s.respond(w, r, "Add a book", buf.String())
}
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
const rankingTmpl = `
<style>
.facet-btn.active { background: rgba(245,158,11,0.12); color: #f59e0b; border-color: #92400e; }
.ranking-row { transition: background 0.12s, border-color 0.12s; }
.ranking-row:hover { background: rgba(255,255,255,0.03); }
.add-btn { transition: background 0.15s, color 0.15s; }
#ranking-filter-bar { position: sticky; top: 0; z-index: 20; background: #09090b; border-bottom: 1px solid #27272a; }
</style>
<div class="max-w-3xl mx-auto overflow-hidden">
<!-- Cover zoom overlay -->
<div id="cover-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
onclick="this.classList.add('hidden');this.classList.remove('flex');document.getElementById('cover-zoom-img').src='';">
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
</div>
<!-- Header (non-sticky) -->
<div class="px-4 pt-6 pb-4">
<a href="/" hx-get="/" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
class="text-xs text-zinc-500 hover:text-amber-400 mb-4 inline-flex items-center gap-1">
← All books
</a>
<div class="flex items-end justify-between gap-3 mt-3 flex-wrap">
<div>
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">Novel Rankings</h1>
<div class="flex items-center gap-2 mt-1 flex-wrap text-xs text-zinc-500">
{{if .TotalItems}}<span id="ranking-shown-count">{{.TotalItems}} novels</span>{{end}}
{{if .CachedAt}}<span>· cached {{.CachedAt}}</span>{{end}}
<span id="ranking-lib-count" class="text-teal-500 hidden"></span>
</div>
</div>
<form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
<input type="hidden" name="pages" value="5">
<button type="submit"
class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-400 hover:border-amber-600 hover:text-amber-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
</form>
</div>
<div id="ranking-refresh-status" class="mt-2"></div>
</div>
<!-- Sticky filter bar -->
<div id="ranking-filter-bar" class="px-4 py-3 space-y-2.5">
<!-- Search -->
<div class="relative">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 105 11a6 6 0 0012 0z"/>
</svg>
<input id="ranking-filter" type="search" placeholder="Search title or author…"
autocomplete="off" spellcheck="false"
class="w-full rounded-lg bg-zinc-900 border border-zinc-700 pl-8 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-amber-500 transition-colors" />
</div>
<!-- Filter chips row -->
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-[0.65rem] text-zinc-600 uppercase tracking-wide mr-0.5">Filter:</span>
<button type="button" data-facet="local" data-value="1"
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors font-medium">
In library
</button>
{{range .AllStatuses}}
<button type="button" data-facet="status" data-value="{{.}}"
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
{{.}}
</button>
{{end}}
{{range .TopGenres}}
<button type="button" data-facet="genre" data-value="{{.}}"
class="facet-btn text-xs px-2.5 py-0.5 rounded-full border border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-zinc-200 transition-colors">
{{.}}
</button>
{{end}}
<button type="button" id="ranking-clear-filters"
class="hidden text-xs px-2 py-0.5 rounded-full text-zinc-600 hover:text-zinc-300 transition-colors">
✕ clear
</button>
</div>
</div>
<!-- List -->
<div id="ranking-grid" class="divide-y divide-zinc-800/60">
{{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}} {{range .Genres}}{{.}} {{end}}"
data-status="{{.Status}}"
data-genres="{{range .Genres}}{{.}}|{{end}}"
data-local="1"
class="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">
<!-- Rank -->
<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
{{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
</div>
<!-- Cover -->
<div class="flex-shrink-0">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" loading="lazy"
class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
onclick="event.preventDefault();event.stopPropagation();(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
{{else}}
<div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
{{end}}
</div>
<!-- Info -->
<div class="min-w-0 flex-1 py-0.5">
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">{{.Title}}</h2>
<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>
</div>
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
<div class="flex gap-1 flex-wrap items-center mt-1.5">
{{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-teal-900/60 text-teal-400">{{.Status}}</span>{{end}}
{{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
</div>
</div>
</a>
{{else}}
<div data-filter="{{.Title}} {{.Author}} {{range .Genres}}{{.}} {{end}}"
data-status="{{.Status}}"
data-genres="{{range .Genres}}{{.}}|{{end}}"
data-local="0"
{{if .SourceURL}}data-scrape-url="{{.SourceURL}}"{{end}}
class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">
<!-- Rank -->
<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5">
{{if .Rank}}<span class="text-sm font-bold text-amber-400">#{{.Rank}}</span>{{end}}
</div>
<!-- Cover -->
<div class="flex-shrink-0">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" loading="lazy"
class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"
onclick="event.stopPropagation();(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
{{else}}
<div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">N/A</div>
{{end}}
</div>
<!-- Info -->
<div class="min-w-0 flex-1 py-0.5">
<h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">{{.Title}}</h2>
{{if .Author}}<p class="text-xs text-zinc-500 mt-0.5 truncate">{{.Author}}</p>{{end}}
<div class="flex gap-1 flex-wrap items-center mt-1.5">
{{if .Status}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-400">{{.Status}}</span>{{end}}
{{range .Genres}}<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">{{.}}</span>{{end}}
</div>
{{if .SourceURL}}
<div class="flex items-center gap-2 mt-2">
<button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors"
data-action="add" data-url="{{.SourceURL}}">
+ Add to library
</button>
<button onclick="event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">
source ↗
</button>
</div>
{{end}}
</div>
</div>
{{end}}
{{else}}
<div class="px-4 py-16 text-center">
<p class="text-zinc-500 mb-4 text-sm">No ranking data cached yet.</p>
<form hx-post="/ranking/refresh" hx-target="#ranking-refresh-status" hx-swap="innerHTML">
<input type="hidden" name="pages" value="5">
<button type="submit" class="text-sm px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-zinc-900 font-semibold transition-colors">
Fetch rankings from novelfire.net
</button>
</form>
</div>
{{end}}
</div>
<!-- Load more -->
<div id="load-more-wrap" class="px-4 py-6 text-center hidden">
<button id="load-more-btn"
class="text-sm px-5 py-2 rounded-lg border border-zinc-700 bg-zinc-900 text-zinc-300 hover:border-amber-600 hover:text-amber-400 transition-colors">
Show more
</button>
<p id="load-more-label" class="text-xs text-zinc-600 mt-2"></p>
</div>
</div>
<script>
(function () {
var ALL_ITEMS = {{.AllItemsJSON}};
var PAGE_SIZE = 40;
var input = document.getElementById('ranking-filter');
var clearBtn = document.getElementById('ranking-clear-filters');
var grid = document.getElementById('ranking-grid');
var loadWrap = document.getElementById('load-more-wrap');
var loadBtn = document.getElementById('load-more-btn');
var loadLabel = document.getElementById('load-more-label');
var libCount = document.getElementById('ranking-lib-count');
var shownCount = document.getElementById('ranking-shown-count');
if (!grid) return;
// Show library count
var inLib = ALL_ITEMS.filter(function(it){ return it.local; }).length;
if (inLib > 0 && libCount) {
libCount.textContent = '· ' + inLib + ' in library';
libCount.classList.remove('hidden');
}
var active = { status: new Set(), genre: new Set(), local: new Set() };
var visibleCount = 0;
var currentItems = [];
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function buildRow(it) {
var genres = (it.genres || []).slice(0, 5);
var genreStr = genres.join('|') + (genres.length ? '|' : '');
var rankHtml = it.rank
? '<div class="w-8 flex-shrink-0 flex items-start justify-end pt-0.5"><span class="text-sm font-bold text-amber-400">#' + it.rank + '</span></div>'
: '<div class="w-8 flex-shrink-0"></div>';
var coverHtml = it.cover
? '<div class="flex-shrink-0"><img src="' + esc(it.cover) + '" alt="cover" loading="lazy" class="w-10 h-[3.75rem] object-cover rounded shadow cursor-zoom-in"' +
' onclick="event.preventDefault();event.stopPropagation();(function(src){var o=document.getElementById(\'cover-zoom-overlay\');document.getElementById(\'cover-zoom-img\').src=src;o.classList.remove(\'hidden\');o.classList.add(\'flex\');})(this.src)"></div>'
: '<div class="flex-shrink-0"><div class="w-10 h-[3.75rem] rounded bg-zinc-800 flex items-center justify-center text-zinc-600 text-[0.55rem]">' + esc(it.rank ? '#'+it.rank : '?') + '</div></div>';
var statusBadge = it.status
? '<span class="text-[0.6rem] px-1.5 py-0.5 rounded-full ' + (it.local ? 'bg-teal-900/60 text-teal-400' : 'bg-zinc-800 text-zinc-400') + '">' + esc(it.status) + '</span>'
: '';
var genreBadges = genres.map(function(g) {
return '<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-500">' + esc(g) + '</span>';
}).join('');
if (it.local) {
return '<a href="/books/' + esc(it.slug) + '"' +
' hx-get="/books/' + esc(it.slug) + '" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
' data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="1"' +
' class="ranking-row group flex gap-3 px-4 py-3 min-w-0 overflow-hidden border-l-2 border-teal-600 bg-teal-950/20 hover:bg-teal-950/40 transition-colors">' +
rankHtml + coverHtml +
'<div class="min-w-0 flex-1 py-0.5">' +
'<div class="flex items-start justify-between gap-2">' +
'<h2 class="font-semibold text-sm text-zinc-100 group-hover:text-teal-300 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
'<span class="text-[0.6rem] px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 font-medium flex-shrink-0 mt-0.5">In library</span>' +
'</div>' +
(it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
'<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
'</div></a>';
} else {
var addBtn = it.source_url
? '<button class="add-btn text-[0.65rem] px-2.5 py-1 rounded-md bg-zinc-800 hover:bg-amber-600 text-zinc-400 hover:text-zinc-900 font-medium border border-zinc-700 hover:border-amber-600 transition-colors" data-action="add" data-url="' + esc(it.source_url) + '">+ Add to library</button>'
: '';
var srcBtn = it.source_url
? '<button onclick="event.stopPropagation();window.open(\'' + it.source_url.replace(/'/g,"\\'") + '\',\'_blank\',\'noopener,noreferrer\')" class="text-[0.6rem] text-zinc-600 hover:text-zinc-400 transition-colors underline underline-offset-2">source ↗</button>'
: '';
var actionRow = (addBtn || srcBtn)
? '<div class="flex items-center gap-2 mt-2">' + addBtn + srcBtn + '</div>'
: '';
return '<div data-filter="' + esc(it.title + ' ' + (it.author||'') + ' ' + (it.genres||[]).join(' ')) + '"' +
' data-status="' + esc(it.status||'') + '" data-genres="' + esc(genreStr) + '" data-local="0"' +
(it.source_url ? ' data-scrape-url="' + esc(it.source_url) + '"' : '') +
' class="ranking-row ranking-card group flex gap-3 px-4 py-3 min-w-0 overflow-hidden">' +
rankHtml + coverHtml +
'<div class="min-w-0 flex-1 py-0.5">' +
'<h2 class="font-semibold text-sm text-zinc-100 leading-snug min-w-0 break-words">' + esc(it.title) + '</h2>' +
(it.author ? '<p class="text-xs text-zinc-500 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
'<div class="flex gap-1 flex-wrap items-center mt-1.5">' + statusBadge + genreBadges + '</div>' +
actionRow +
'</div></div>';
}
}
function isFiltering() {
return (input && input.value.trim() !== '') ||
active.status.size > 0 || active.genre.size > 0 || active.local.size > 0;
}
function updateShownCount(n, total) {
if (!shownCount) return;
if (isFiltering()) {
shownCount.textContent = n + ' of ' + total + ' matched';
} else {
shownCount.textContent = total + ' novels';
}
}
function applyFilters() {
var q = input ? input.value.trim().toLowerCase() : '';
currentItems = ALL_ITEMS.filter(function(it) {
var txt = ((it.title||'') + ' ' + (it.author||'')).toLowerCase();
if (q && txt.indexOf(q) === -1) return false;
if (active.status.size > 0) {
var hit = false;
active.status.forEach(function(v){ if ((it.status||'').toLowerCase() === v.toLowerCase()) hit = true; });
if (!hit) return false;
}
if (active.genre.size > 0) {
var gs = (it.genres||[]).map(function(g){ return g.toLowerCase(); });
var ok = true;
active.genre.forEach(function(v){ if (gs.indexOf(v.toLowerCase()) === -1) ok = false; });
if (!ok) return false;
}
if (active.local.size > 0 && !it.local) return false;
return true;
});
visibleCount = 0;
grid.innerHTML = '';
renderMore();
if (clearBtn) {
if (isFiltering()) clearBtn.classList.remove('hidden');
else clearBtn.classList.add('hidden');
}
}
function renderMore() {
var batch = currentItems.slice(visibleCount, visibleCount + PAGE_SIZE);
var frag = document.createDocumentFragment();
batch.forEach(function(it) {
var tmp = document.createElement('div');
tmp.innerHTML = buildRow(it);
var el = tmp.firstElementChild;
frag.appendChild(el);
});
grid.appendChild(frag);
htmx.process(grid);
visibleCount += batch.length;
var remaining = currentItems.length - visibleCount;
updateShownCount(visibleCount, currentItems.length);
if (remaining > 0) {
loadWrap.classList.remove('hidden');
loadLabel.textContent = visibleCount + ' of ' + currentItems.length + ' shown · ' + remaining + ' more';
} else {
loadWrap.classList.add('hidden');
}
}
if (loadBtn) loadBtn.addEventListener('click', renderMore);
// Initial render from ALL_ITEMS
currentItems = ALL_ITEMS;
grid.innerHTML = '';
renderMore();
// Facet chips
document.querySelectorAll('.facet-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var facet = btn.dataset.facet;
var val = btn.dataset.value;
if (!active[facet]) return;
if (active[facet].has(val)) {
active[facet].delete(val);
btn.classList.remove('active');
} else {
active[facet].add(val);
btn.classList.add('active');
}
applyFilters();
});
});
if (input) input.addEventListener('input', applyFilters);
if (clearBtn) {
clearBtn.addEventListener('click', function() {
if (input) input.value = '';
['status','genre','local'].forEach(function(f){ active[f].clear(); });
document.querySelectorAll('.facet-btn').forEach(function(b){ b.classList.remove('active'); });
applyFilters();
});
}
/* ── Add to library ─────────────────────────────────────────────────────── */
function submitScrape(url, cardEl) {
var badge = document.createElement('div');
badge.className = 'flex items-center text-sm px-4 py-3 text-amber-300 bg-amber-950/60 border-l-2 border-amber-600';
badge.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>Adding to library…';
badge.setAttribute('hx-get', '/ui/scrape/status');
badge.setAttribute('hx-trigger', 'every 3s');
badge.setAttribute('hx-target', 'this');
badge.setAttribute('hx-swap', 'outerHTML');
if (cardEl) {
cardEl.replaceWith(badge);
} else {
grid.prepend(badge);
}
htmx.process(badge);
var body = new URLSearchParams();
body.set('url', url);
fetch('/ui/scrape/book', { method: 'POST', body: body });
}
grid.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action="add"]');
if (!btn) return;
e.stopPropagation();
var url = btn.dataset.url;
if (!url) return;
var card = btn.closest('.ranking-card');
if (btn.dataset.confirm === '1') {
submitScrape(url, card);
} else {
btn.dataset.confirm = '1';
btn.textContent = 'Confirm?';
btn.classList.add('bg-amber-600','text-zinc-900','border-amber-600');
btn.classList.remove('bg-zinc-800','text-zinc-400','border-zinc-700');
setTimeout(function() {
if (btn.dataset.confirm === '1') {
btn.dataset.confirm = '';
btn.textContent = '+ Add to library';
btn.classList.remove('bg-amber-600','text-zinc-900','border-amber-600');
btn.classList.add('bg-zinc-800','text-zinc-400','border-zinc-700');
}
}, 3000);
}
});
}());
</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.
genreFreq := map[string]int{}
statusSet := map[string]bool{}
for _, it := range rankingItems {
if it.Status != "" {
statusSet[it.Status] = true
}
for _, g := range it.Genres {
genreFreq[g]++
}
}
allStatuses := sortedKeys(statusSet)
// Build top-10 genres sorted by frequency descending.
type genreCount struct {
name string
count int
}
gcSlice := make([]genreCount, 0, len(genreFreq))
for g, c := range genreFreq {
gcSlice = append(gcSlice, genreCount{g, c})
}
sort.Slice(gcSlice, func(i, j int) bool {
if gcSlice[i].count != gcSlice[j].count {
return gcSlice[i].count > gcSlice[j].count
}
return gcSlice[i].name < gcSlice[j].name
})
topN := 10
if len(gcSlice) < topN {
topN = len(gcSlice)
}
topGenres := make([]string, topN)
for i := 0; i < topN; i++ {
topGenres[i] = gcSlice[i].name
}
// Encode full dataset + local slugs for client-side cross-page filtering.
localSlugs := s.writer.LocalSlugs()
type rankingJSONItem struct {
Rank int `json:"rank"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Local bool `json:"local"`
}
allItemsForJS := make([]rankingJSONItem, len(rankingItems))
for i, it := range rankingItems {
allItemsForJS[i] = rankingJSONItem{
Rank: it.Rank,
Slug: it.Slug,
Title: it.Title,
Author: it.Author,
Cover: it.Cover,
Status: it.Status,
Genres: it.Genres,
SourceURL: it.SourceURL,
Local: localSlugs[it.Slug],
}
}
allItemsJSON, _ := json.Marshal(allItemsForJS)
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
FetchNums []pageNum
DisplayNums []pageNum
CurrentPage int
TotalPages int
TotalItems int
TopGenres []string
AllStatuses []string
AllItemsJSON template.JS
}{
Books: toRankingViewItems(pageItems, localSlugs),
CachedAt: cachedAt,
FetchNums: rankingPageNums(100, 0),
DisplayNums: rankingPageNums(totalPages, currentPage),
CurrentPage: currentPage,
TotalPages: totalPages,
TotalItems: totalItems,
TopGenres: topGenres,
AllStatuses: allStatuses,
AllItemsJSON: template.JS(allItemsJSON),
})
s.respond(w, r, "Rankings", buf.String())
}
// handleRankingRefresh starts an async scrape of novelfire.net/ranking and
// immediately returns a polling badge. The browser polls /ui/ranking/status
// until the job finishes, then follows an HX-Redirect back to /ranking.
//
// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means
// fetch all pages; otherwise at most that many pages are scraped.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
maxPages := 0
if p := strings.TrimSpace(r.FormValue("pages")); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
maxPages = n
}
}
s.mu.Lock()
if s.rankingRunning {
s.mu.Unlock()
renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…"))
return
}
s.rankingRunning = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.rankingRunning = false
s.mu.Unlock()
}()
// Allow ~90 s per page; minimum 120 s for a single page.
timeout := 120 * time.Second
if maxPages > 1 {
timeout = time.Duration(maxPages) * 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)
var rankingItems []writer.RankingItem
for rankingCh != nil || errCh != nil {
select {
case meta, ok := <-rankingCh:
if !ok {
rankingCh = nil
} else {
rankingItems = append(rankingItems, writer.RankingItem{
Rank: meta.Ranking,
Slug: meta.Slug,
Title: meta.Title,
Author: meta.Author,
Cover: meta.Cover,
Status: meta.Status,
Genres: meta.Genres,
SourceURL: meta.SourceURL,
})
}
case err, ok := <-errCh:
if !ok {
errCh = nil
} else if err != nil {
s.log.Error("ranking scrape error", "err", err)
}
}
}
if len(rankingItems) > 0 {
if err := s.writer.WriteRanking(rankingItems); err != nil {
s.log.Error("failed to save ranking", "err", err)
}
}
}()
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
}
// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
// While running it returns a self-replacing badge; when done it issues an
// HX-Redirect so the browser navigates to /ranking.
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.rankingRunning
s.mu.Unlock()
if running {
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
return
}
// Job done — redirect the HTMX request to the ranking page.
w.Header().Set("HX-Redirect", "/ranking")
w.WriteHeader(http.StatusOK)
}
// rankingStatusHTML returns a self-replacing polling badge for the ranking
// refresh job. state is "running" or "done".
func rankingStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
default:
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}
// ─── GET /ranking/view — view ranking markdown ─────────────────────────────────
const rankingViewTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<a href="/ranking"
hx-get="/ranking"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← Back to Rankings
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
<pre class="text-xs text-zinc-300 bg-zinc-900 border border-zinc-700 rounded-xl p-4 overflow-x-auto whitespace-pre-wrap break-words">{{.JSON}}</pre>
</div>`
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
items, err := s.writer.ReadRankingItems()
if err != nil {
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
return
}
if len(items) == 0 {
http.NotFound(w, r)
return
}
pretty, err := json.MarshalIndent(items, "", " ")
if err != nil {
http.Error(w, "json marshal error: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct{ JSON string }{JSON: string(pretty)})
s.respond(w, r, "Ranking Data", buf.String())
}
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
const chapterPageSize = 50
const bookTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<!-- Cover zoom overlay -->
<div id="cover-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
onclick="document.getElementById('cover-zoom-overlay').classList.add('hidden');document.getElementById('cover-zoom-overlay').classList.remove('flex');">
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
</div>
<!-- Summary zoom overlay -->
<div id="summary-zoom-overlay"
class="fixed inset-0 z-50 hidden items-start justify-center bg-black/80 cursor-zoom-out p-4 pt-8 overflow-y-auto"
onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
<div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Summary</h2>
<div id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed space-y-3"></div>
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
class="mt-5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
</div>
</div>
<div class="flex gap-5 mb-8 mt-4">
{{if .Meta.Cover}}
<img src="{{.Meta.Cover}}" alt="cover"
id="cover-thumb"
class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg cursor-zoom-in"
onclick="(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
{{end}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
{{if .Meta.SourceURL}}
<a href="{{.Meta.SourceURL}}" target="_blank" rel="noopener noreferrer"
title="View on novelfire.net"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded text-zinc-400 hover:text-zinc-200 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
Source
</a>
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
<button type="submit"
class="text-xs px-2 py-1 rounded text-amber-400 hover:text-amber-300 transition-colors border-0 bg-transparent cursor-pointer"
title="Re-scrape from source">
Refresh
</button>
</form>
{{end}}
</div>
{{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
onclick="openSummary(this)"
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}}
</div>
</div>
<!-- Resume reading button — populated by JS from localStorage -->
<div id="resume-bar" class="mb-4 hidden">
<a id="resume-link"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-amber-400 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer">
▶ Resume — Chapter <span id="resume-chapter-num"></span>
</a>
</div>
<!-- Latest chapter button — shown by JS when book has not been started -->
{{if .LastChapter}}
<div id="latest-bar" class="mb-4 hidden">
<a href="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-get="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-zinc-300 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer border border-zinc-700 hover:border-amber-500">
↓ Latest — Chapter {{.LastChapter}}
</a>
</div>
{{end}}
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
<ul id="chapter-list" class="space-y-1">
{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
</a>
</li>
{{else}}
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
{{end}}
</ul>
{{if gt .TotalPages 1}}
<div id="chapter-pagination" class="mt-6 flex justify-center gap-1 flex-wrap">
{{if gt .CurrentPage 1}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
{{end}}
{{range pages .TotalPages}}
{{if eq . $.CurrentPage}}
<span class="px-3 py-1.5 rounded-lg text-amber-400 text-sm font-semibold">{{.}}</span>
{{else}}
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{$.Slug}}?page={{.}}"
hx-on::after-request="applyProgress('{{$.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">{{.}}</a>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalPages}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
{{end}}
</div>
{{end}}
</div>
<script>
window.openSummary = function(el) {
var t = el.dataset.full || '';
var o = document.getElementById('summary-zoom-overlay');
var c = document.getElementById('summary-zoom-text');
c.innerHTML = '';
var paras = t.split(/\n+/).filter(function(s) { return s.trim().length > 0; });
if (paras.length <= 1) {
paras = t.split(/(?<=[.!?][\u201d\u2019"']?)\s{2,}/).filter(function(s) { return s.trim().length > 0; });
}
if (paras.length <= 1) { paras = [t]; }
paras.forEach(function(p) {
var el = document.createElement('p');
el.textContent = p.trim();
c.appendChild(el);
});
o.classList.remove('hidden');
o.classList.add('flex');
};
(function () {
var SLUG = '{{.Slug}}';
var LS_KEY = 'reading_progress';
function getProgress() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
}
window.applyProgress = function(slug) {
var progress = getProgress();
var saved = progress[slug];
// Show/hide latest bar (only when no progress recorded for this book).
var latestBar = document.getElementById('latest-bar');
if (latestBar) {
if (!saved) {
latestBar.classList.remove('hidden');
} else {
latestBar.classList.add('hidden');
}
}
if (!saved) return;
// Highlight the saved chapter row.
document.querySelectorAll('.chapter-row').forEach(function (a) {
var dot = a.querySelector('.progress-dot');
if (!dot) return;
if (parseInt(a.dataset.chapter) === saved) {
dot.classList.remove('hidden');
a.classList.add('bg-zinc-800/50');
}
});
// Show resume bar.
var bar = document.getElementById('resume-bar');
var link = document.getElementById('resume-link');
var num = document.getElementById('resume-chapter-num');
if (bar && link && num) {
var href = '/books/' + slug + '/chapters/' + saved;
num.textContent = saved;
link.setAttribute('href', href);
link.setAttribute('hx-get', href);
htmx.process(link);
bar.classList.remove('hidden');
}
};
applyProgress(SLUG);
}());
</script>`
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
meta, ok, err := s.writer.ReadMetadata(slug)
if err != nil {
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
total := len(chapters)
totalPages := (total + chapterPageSize - 1) / chapterPageSize
if totalPages < 1 {
totalPages = 1
}
currentPage := 1
page := chapters
if total > chapterPageSize {
page = chapters[:chapterPageSize]
}
funcMap := template.FuncMap{
"pages": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"prev": func(n int) int { return n - 1 },
"next": func(n int) int { return n + 1 },
}
t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl))
var buf bytes.Buffer
lastChapter := 0
if total > 0 {
lastChapter = chapters[total-1].Number
}
_ = t.Execute(&buf, struct {
Slug string
Meta interface{}
Chapters interface{}
TotalDownloaded int
TotalPages int
CurrentPage int
LastChapter int
}{
Slug: slug,
Meta: meta,
Chapters: page,
TotalDownloaded: total,
TotalPages: totalPages,
CurrentPage: currentPage,
LastChapter: lastChapter,
})
s.respond(w, r, meta.Title, buf.String())
}
// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ────────
const chapterPageTmpl = `{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
</a>
</li>
{{end}}
<div id="chapter-pagination" hx-swap-oob="true" class="mt-6 flex justify-center gap-1 flex-wrap">
{{if gt .CurrentPage 1}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">«</a>
{{end}}
{{range pages .TotalPages}}
{{if eq . $.CurrentPage}}
<span class="px-3 py-1.5 rounded-lg bg-amber-700 text-white text-sm font-semibold">{{.}}</span>
{{else}}
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{$.Slug}}?page={{.}}"
hx-on::after-request="applyProgress('{{$.Slug}}')"
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">{{.}}</a>
{{end}}
{{end}}
{{if lt .CurrentPage .TotalPages}}
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
hx-target="#chapter-list"
hx-swap="innerHTML"
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 text-sm transition-colors cursor-pointer">»</a>
{{end}}
</div>`
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
currentPage := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
currentPage = n
}
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
total := len(chapters)
totalPages := (total + chapterPageSize - 1) / chapterPageSize
if totalPages < 1 {
totalPages = 1
}
start := (currentPage - 1) * chapterPageSize
if start >= total {
w.WriteHeader(http.StatusNoContent)
return
}
end := start + chapterPageSize
if end > total {
end = total
}
funcMap := template.FuncMap{
"pages": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"prev": func(n int) int { return n - 1 },
"next": func(n int) int { return n + 1 },
}
t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Chapters interface{}
TotalPages int
CurrentPage int
}{
Slug: slug,
Chapters: chapters[start:end],
TotalPages: totalPages,
CurrentPage: currentPage,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
<style>
/* ── Reader typography ──────────────────────────────────────────────────── */
#reader-body {
font-size: 1.0625rem;
line-height: 1.85;
color: #d4d4d8;
}
#reader-body p {
margin: 0 0 0.9em 0;
}
#reader-body p.tts-active {
background: rgba(251,191,36,0.10);
border-left: 2px solid #f59e0b;
margin-left: -0.75rem;
padding-left: calc(0.75rem - 2px);
border-radius: 0 4px 4px 0;
transition: background 0.15s;
}
/* ── Prevent sticky nav obscuring anchors ───────────────────────────────── */
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 6rem; }
/* ── Audio state badges ─────────────────────────────────────────────────── */
.queue-badge {
display: inline-flex; align-items: center;
padding: 0.15em 0.55em;
border-radius: 9999px;
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
}
.queue-badge-idle { background: #27272a; color: #52525b; }
.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: #a8a29e; }
.queue-badge-error { background: #450a0a; color: #f87171; }
.queue-badge-time { text-transform: none; letter-spacing: 0; }
/* ── Seek bar ───────────────────────────────────────────────────────────── */
#seek-track { height: 1.25rem; cursor: pointer; touch-action: none; user-select: none; width: min(38rem, 90%); margin: auto; }
#seek-times { width: min(38rem, 95%); margin: auto; }
#seek-rail { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%); height: 2px; background: #3f3f46; border-radius: 9999px; overflow: hidden; pointer-events: none; }
#seek-fill { position: absolute; left: 0; top: 0; height: 100%; background: #f59e0b; }
#seek-thumb { position: absolute; top: 50%; transform: translateY(-50%) translateX(-50%); width: 10px; height: 10px; border-radius: 50%; background: #fbbf24; box-shadow: 0 1px 4px rgba(0,0,0,.5); pointer-events: none; transition: transform 0.1s; }
#seek-thumb:hover { transform: translateY(-50%) translateX(-50%) scale(1.3); }
/* ── Safe-area inset ────────────────────────────────────────────────────── */
#mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }
/* ── Settings bottom sheet (mobile) ────────────────────────────────────── */
#settings-backdrop {
display: none;
position: fixed; inset: 0; z-index: 90;
background: rgba(0,0,0,0.5);
}
#settings-panel {
/* mobile: bottom sheet */
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 100;
max-height: 92dvh;
overflow-y: auto;
overscroll-behavior: contain;
background: #18181b;
border-top: 1px solid #27272a;
border-radius: 1rem 1rem 0 0;
box-shadow: 0 -4px 32px rgba(0,0,0,.5);
transform: translateY(100%);
transition: transform 0.32s cubic-bezier(0.32,0.72,0,1);
will-change: transform;
padding-bottom: env(safe-area-inset-bottom, 0);
}
#settings-panel.sheet-open {
transform: translateY(0);
}
.sheet-handle {
width: 2.5rem; height: 4px;
background: #3f3f46; border-radius: 9999px;
margin: 0.75rem auto 0;
flex-shrink: 0;
}
@media (min-width: 640px) {
#settings-backdrop { display: none !important; }
#settings-panel {
position: absolute;
left: auto; right: max(0.5rem, calc(50% - 32rem + 0.5rem));
bottom: calc(100% + 0.25rem);
top: auto;
width: 20rem;
max-height: 80vh;
border-radius: 0.75rem;
border: 1px solid #27272a;
transform: none !important;
transition: none !important;
}
#settings-panel.sheet-open { transform: none !important; }
.sheet-handle { display: none; }
}
/* ── Toggle switch ──────────────────────────────────────────────────────── */
.toggle-switch { position: relative; display: inline-block; width: 2.25rem; height: 1.25rem; flex-shrink: 0; }
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track { position: absolute; inset: 0; background: #3f3f46; border-radius: 9999px; transition: background 0.2s; cursor: pointer; }
.toggle-track::after { content: ''; position: absolute; left: 0.2rem; top: 50%; transform: translateY(-50%); width: 0.85rem; height: 0.85rem; background: #fff; border-radius: 50%; transition: left 0.2s; }
.toggle-switch input:checked + .toggle-track { background: #f59e0b; }
.toggle-switch input:checked + .toggle-track::after { left: calc(100% - 0.2rem - 0.85rem); }
.toggle-switch input:focus-visible + .toggle-track { outline: 2px solid #f59e0b; outline-offset: 2px; }
</style>
<!-- ─── Sticky top toolbar ─────────────────────────────────────────────────── -->
<nav id="reader-header" aria-label="Reader controls"
class="sticky top-0 z-50 bg-zinc-950/95 backdrop-blur border-b border-zinc-800/60">
<div class="max-w-2xl mx-auto px-3 flex items-center gap-1 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="Back to chapter list"
aria-label="Back to chapter list"
class="flex-shrink-0 w-9 h-9 flex items-center justify-center rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors text-lg no-underline">
&#9776;
</a>
<!-- Chapter title (tappable — opens chapter list) -->
<button id="chapter-list-btn" type="button" onclick="toggleChapterList()"
aria-haspopup="listbox" aria-expanded="false"
class="flex-1 min-w-0 h-9 flex items-center justify-center px-2 bg-transparent border-none cursor-pointer">
<span class="text-sm text-zinc-200 truncate max-w-full leading-none">{{.Title}}</span>
</button>
<!-- Chapter position -->
<span class="flex-shrink-0 text-xs text-zinc-600 tabular-nums">
{{.ChapterN}}{{if .AllChapters}} / {{len .AllChapters}}{{end}}
</span>
</div>
<!-- TTS status strip (generating / buffering messages) -->
<div id="tts-status-bar" hidden
class="px-4 py-0.5 bg-zinc-900/80 text-[0.7rem] text-zinc-500 text-center border-t border-zinc-800/40"
aria-live="polite" aria-atomic="true">
<span id="tts-status"></span>
</div>
<!-- Chapter list drawer -->
<div id="chapter-list-panel"
role="listbox"
aria-label="Chapter list"
hidden
class="absolute left-0 right-0 top-full max-h-[60vh] overflow-y-auto bg-zinc-900 border-b border-zinc-800 shadow-2xl z-[99]">
<div class="max-w-2xl mx-auto py-2">
{{range .AllChapters}}
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
role="option"
aria-selected="{{if eq .Number $.ChapterN}}true{{else}}false{{end}}"
onclick="closeChapterList()"
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
class="flex items-baseline gap-3 px-4 py-2 no-underline transition-colors hover:bg-zinc-800 {{if eq .Number $.ChapterN}}bg-zinc-800{{end}}">
<span class="flex-shrink-0 text-xs text-zinc-600 w-8 text-right tabular-nums">{{.Number}}</span>
<span class="text-sm {{if eq .Number $.ChapterN}}text-amber-400 font-medium{{else}}text-zinc-300{{end}} leading-snug">
{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}
</span>
{{if eq .Number $.ChapterN}}<span class="ml-auto flex-shrink-0 text-[0.65rem] text-amber-500/80">now</span>{{end}}
</a>
{{end}}
</div>
</div>
</nav>
<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
<main class="max-w-[38rem] mx-auto px-5 pt-8 pb-4">
<!-- Chapter heading — smaller and secondary, not a huge h1 -->
<div class="mb-6">
<p class="text-xs font-medium text-amber-500/80 uppercase tracking-widest mb-1.5">Chapter {{.ChapterN}}</p>
<h1 class="text-xl font-bold text-zinc-100 leading-snug">{{.Title}}</h1>
{{if .ChapterDate}}<p class="text-xs text-zinc-600 mt-1">{{.ChapterDate}}</p>{{end}}
</div>
<article id="chapter-article" id="reader-body">
{{.HTML}}
</article>
</main>
<!-- ─── Fixed bottom player ───────────────────────────────────────────────── -->
<aside id="mini-player" aria-label="Audio player"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950/95 backdrop-blur border-t border-zinc-800/60">
<!-- Seek track (full width, sits flush at top of player) -->
<div id="seek-track" style="display:none" class="relative w-full">
<div id="seek-rail">
<div id="seek-fill"></div>
</div>
<div id="seek-thumb" style="left:0%"></div>
</div>
<!-- Time row -->
<div id="seek-times" style="display:none"
class="px-1 pt-1 flex items-center justify-between">
<span id="seek-cur" class="text-[0.6rem] tabular-nums text-zinc-600">0:00</span>
<span id="seek-tot" class="text-[0.6rem] tabular-nums text-zinc-700">0:00</span>
</div>
<!-- Main control row -->
<div class="max-w-2xl mx-auto px-3 py-2">
<div class="flex items-center gap-2 h-11">
<!-- Prev chapter -->
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
title="Previous chapter" aria-label="Previous chapter"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors no-underline">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><polyline points="15 18 9 12 15 6"/></svg>
</a>
{{else}}<span class="flex-shrink-0 w-10"></span>{{end}}
<!-- Play / Pause -->
<button id="player-play-btn" type="button" onclick="ttsToggle()"
aria-label="Play / Pause"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors shadow-sm">
<span id="player-play-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M8 5v14l11-7z"/></svg></span>
</button>
<!-- Centre info -->
<div class="flex-1 min-w-0 flex items-center gap-2 px-1">
<span id="player-title" class="text-xs font-medium text-zinc-300 truncate">Ch.&#x00A0;{{.ChapterN}}</span>
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0" hidden></span>
<!-- next-chapter prefetch badge — shown only during/after prefetch -->
<span id="player-next-row" hidden class="flex-shrink-0">
<span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
</span>
</div>
<!-- Settings -->
<button id="settings-btn" type="button" onclick="toggleSettings()"
aria-label="Reader settings" aria-haspopup="dialog"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-xl text-zinc-400 hover:text-amber-400 hover:bg-zinc-800 border-none cursor-pointer bg-transparent transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<!-- Next chapter -->
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
title="Next chapter" aria-label="Next chapter"
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors no-underline">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}<span class="flex-shrink-0 w-10"></span>{{end}}
</div>
</div>
<!-- Backdrop (mobile only) -->
<div id="settings-backdrop" onclick="closeSettings()"></div>
<!-- Settings panel — bottom sheet on mobile, popover on sm+ -->
<div id="settings-panel"
role="dialog"
aria-label="Reader settings"
aria-modal="true"
style="display:none">
<!-- Drag handle -->
<div class="sheet-handle" id="sheet-handle"></div>
<div class="p-4 space-y-4">
<!-- Speed -->
<div>
<span class="block text-xs text-zinc-500 mb-1.5">Speed</span>
<div id="speed-btns" class="flex gap-1.5">
<button type="button" data-speed="1" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1×</button>
<button type="button" data-speed="1.25" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.25×</button>
<button type="button" data-speed="1.5" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-amber-500 bg-amber-500/10 text-amber-300">1.5×</button>
<button type="button" data-speed="1.75" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.75×</button>
<button type="button" data-speed="2" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">2×</button>
</div>
</div>
<!-- Toggles -->
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
<span class="text-sm text-zinc-300">Auto-play next chapter</span>
<span class="toggle-switch"><input id="tts-autoplay" type="checkbox" /><span class="toggle-track"></span></span>
</label>
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
<span class="text-sm text-zinc-300">Auto-scroll to paragraph</span>
<span class="toggle-switch"><input id="tts-autoscroll" type="checkbox" checked /><span class="toggle-track"></span></span>
</label>
<!-- Divider -->
<div class="border-t border-zinc-800"></div>
<!-- Voice selector -->
<div>
<span class="block text-xs text-zinc-500 mb-1.5">Voice</span>
<!-- hidden native select — JS reads/writes .value here -->
<select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
{{range .Voices}}
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<!-- custom dropdown trigger -->
<button type="button" id="voice-trigger"
onclick="toggleVoiceDropdown()"
class="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-200 text-sm hover:border-zinc-500 transition-colors">
<span id="voice-trigger-label" class="truncate">{{range .Voices}}{{if eq .ID $.DefaultVoice}}{{.Name}} ({{.Lang}} · {{.Gender}}){{end}}{{end}}</span>
<svg id="voice-trigger-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" class="flex-shrink-0 text-zinc-500 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<!-- dropdown list -->
<div id="voice-dropdown" hidden
class="mt-1 rounded-lg border border-zinc-700 bg-zinc-900 overflow-hidden shadow-xl">
<div class="max-h-48 overflow-y-auto overscroll-contain">
{{range .Voices}}
<button type="button"
data-voice="{{.ID}}"
data-label="{{.Name}} ({{.Lang}} · {{.Gender}})"
onclick="selectVoiceOption(this)"
class="voice-opt w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 transition-colors
{{if eq .ID $.DefaultVoice}}bg-zinc-800/60 text-zinc-100{{end}}">
<span class="truncate">{{.Name}}</span>
<span class="text-xs text-zinc-500 flex-shrink-0">{{.Lang}} · {{.Gender}}</span>
</button>
{{end}}
</div>
</div>
</div>
</div>
</div>
<!-- Hidden native audio -->
<audio id="tts-audio-native" preload="none" style="display:none"></audio>
</aside>
<script>
(function () {
var NEXT_N = {{.NextN}};
var PREV_N = {{.PrevN}};
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
var COVER_URL = '{{.Cover}}';
// ── reading progress ──────────────────────────────────────────────────────
(function saveProgress() {
try {
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
p[SLUG] = CHAPTER_N;
localStorage.setItem('reading_progress', JSON.stringify(p));
var ts = JSON.parse(localStorage.getItem('reading_progress_ts') || '{}');
ts[SLUG] = Date.now();
localStorage.setItem('reading_progress_ts', JSON.stringify(ts));
} catch(_) {}
})();
// ── generation counter (stale-closure guard) ──────────────────────────────
window.__ttsGen = (window.__ttsGen || 0) + 1;
var myGen = window.__ttsGen;
function stale() { return window.__ttsGen !== myGen; }
if (window.__ttsBeforeSwap) {
document.body.removeEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
}
// ── DOM refs ──────────────────────────────────────────────────────────────
var audio = document.getElementById('tts-audio-native');
var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice');
var speedBtns = document.querySelectorAll('.speed-btn');
var autoplayChk = document.getElementById('tts-autoplay');
var autoscrollChk = document.getElementById('tts-autoscroll');
var article = document.getElementById('chapter-article');
var playerPlayBtn = document.getElementById('player-play-btn');
var playerPlayIcon = document.getElementById('player-play-icon');
var playerTitle = document.getElementById('player-title');
var playerStateBadge= document.getElementById('player-state-badge');
var seekTrack = document.getElementById('seek-track');
var seekRail = document.getElementById('seek-rail');
var seekFill = document.getElementById('seek-fill');
var seekThumb = document.getElementById('seek-thumb');
var seekCur = document.getElementById('seek-cur');
var seekTot = document.getElementById('seek-tot');
var seekTimes = document.getElementById('seek-times');
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel= document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
// ── badge helper ──────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error',
'queue-badge-time'];
function setBadge(el, state) {
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
el.classList.add('queue-badge-' + state);
el.textContent = state;
}
// ── time formatter ────────────────────────────────────────────────────────
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '0:00';
var m = Math.floor(s / 60);
var sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
// ── seek bar ──────────────────────────────────────────────────────────────
function showSeek() { seekTrack.style.display = ''; seekTimes.style.display = ''; }
function hideSeek() { seekTrack.style.display = 'none'; seekTimes.style.display = 'none'; }
function updateSeek() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
var ps = pct.toFixed(2) + '%';
seekFill.style.width = ps;
seekThumb.style.left = ps;
seekCur.textContent = fmtTime(cur);
seekTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
}
function seekFromEvent(e) {
var rect = seekRail.getBoundingClientRect();
var clientX = (e.touches ? e.touches[0] : e).clientX;
var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
updateSeek();
}
}
(function attachSeek() {
var dragging = false;
seekTrack.addEventListener('mousedown', function(e) { dragging = true; seekFromEvent(e); e.preventDefault(); });
seekTrack.addEventListener('touchstart', function(e) { dragging = true; seekFromEvent(e); }, { passive: true });
document.addEventListener('mousemove', function(e) { if (dragging) seekFromEvent(e); });
document.addEventListener('touchmove', function(e) { if (dragging) { seekFromEvent(e); e.preventDefault(); } }, { passive: false });
document.addEventListener('mouseup', function(e) { if (dragging) { seekFromEvent(e); dragging = false; } });
document.addEventListener('touchend', function() { dragging = false; });
})();
// ── status strip ──────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
// ── panel toggles ─────────────────────────────────────────────────────────
window.closeChapterList = function () { chapterListPanel.style.display = 'none'; };
window.toggleChapterList = function () {
var open = chapterListPanel.style.display !== 'none';
closeSettings();
if (open) { closeChapterList(); }
else {
chapterListPanel.style.display = 'block';
var cur = chapterListPanel.querySelector('[data-current="1"]');
if (cur) cur.scrollIntoView({ block: 'center' });
}
};
var settingsBackdrop = document.getElementById('settings-backdrop');
function openSettings() {
closeChapterList();
settingsPanel.style.display = 'block';
// rAF so display:block is painted before the transition fires
requestAnimationFrame(function () {
settingsPanel.classList.add('sheet-open');
});
settingsBackdrop.style.display = 'block';
}
function closeSettings() {
settingsPanel.classList.remove('sheet-open');
settingsBackdrop.style.display = 'none';
// hide after slide-down transition; fallback for desktop (no transition)
var done = false;
function hide() { if (!done) { done = true; settingsPanel.style.display = 'none'; } }
settingsPanel.addEventListener('transitionend', function h() {
settingsPanel.removeEventListener('transitionend', h);
hide();
});
setTimeout(hide, 400); // fallback if transitionend never fires
}
window.closeSettings = closeSettings;
window.toggleSettings = function () {
if (settingsPanel.style.display !== 'none') closeSettings();
else openSettings();
};
// ── Bottom sheet drag-to-dismiss / drag-to-expand ─────────────────────────
(function sheetDrag() {
var handle = document.getElementById('sheet-handle');
if (!handle) return;
var startY = 0, startH = 0, dragging = false;
var DISMISS_THRESHOLD = 80; // px swipe down to dismiss
function onStart(clientY) {
if (window.innerWidth >= 640) return; // desktop: no drag
startY = clientY;
startH = settingsPanel.getBoundingClientRect().height;
dragging = true;
settingsPanel.style.transition = 'none';
}
function onMove(clientY) {
if (!dragging) return;
var dy = clientY - startY; // positive = dragging down
if (dy < 0) {
// stretching up — increase height, clamped to 92dvh
var newH = Math.min(startH - dy, window.innerHeight * 0.92);
settingsPanel.style.maxHeight = newH + 'px';
settingsPanel.style.transform = 'translateY(0)';
} else {
// dragging down — translate panel down
settingsPanel.style.transform = 'translateY(' + dy + 'px)';
settingsPanel.style.maxHeight = '';
}
}
function onEnd(clientY) {
if (!dragging) return;
dragging = false;
settingsPanel.style.transition = '';
var dy = clientY - startY;
if (dy > DISMISS_THRESHOLD) {
closeSettings();
settingsPanel.style.maxHeight = '';
} else {
settingsPanel.style.transform = '';
// snap: if we dragged up, keep the new height
if (dy < 0) {
var snapped = Math.min(startH - dy, window.innerHeight * 0.92);
settingsPanel.style.maxHeight = snapped + 'px';
}
}
}
handle.addEventListener('touchstart', function (e) { onStart(e.touches[0].clientY); }, { passive: true });
handle.addEventListener('touchmove', function (e) { onMove(e.touches[0].clientY); }, { passive: true });
handle.addEventListener('touchend', function (e) { onEnd(e.changedTouches[0].clientY); }, { passive: true });
}());
if (window.__ttsClickOutside) document.removeEventListener('click', window.__ttsClickOutside);
window.__ttsClickOutside = function (e) {
if (stale()) { document.removeEventListener('click', window.__ttsClickOutside); return; }
var sb = document.getElementById('settings-btn');
if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target) &&
e.target !== settingsBackdrop) {
closeSettings();
}
if (chapterListPanel.style.display !== 'none' &&
!chapterListPanel.contains(e.target) &&
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
// close voice dropdown if clicking outside it
var dd = document.getElementById('voice-dropdown');
var trigger = document.getElementById('voice-trigger');
if (dd && !dd.hidden && trigger &&
!dd.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
dd.hidden = true;
var chevron = document.getElementById('voice-trigger-chevron');
if (chevron) chevron.style.transform = '';
}
};
document.addEventListener('click', window.__ttsClickOutside);
// ── localStorage settings ─────────────────────────────────────────────────
var LS_SPEED = 'tts_speed', LS_VOICE = 'tts_voice', LS_AUTONEXT = 'tts_autonext', LS_AUTOSCROLL = 'tts_autoscroll';
function selectSpeed(btn) {
speedBtns.forEach(function (b) {
var active = b === btn;
b.classList.toggle('speed-active', active);
b.classList.toggle('border-amber-500', active);
b.classList.toggle('bg-amber-500/10', active);
b.classList.toggle('text-amber-300', active);
b.classList.toggle('border-zinc-700', !active);
b.classList.toggle('bg-zinc-800', !active);
b.classList.toggle('text-zinc-300', !active);
});
try { localStorage.setItem(LS_SPEED, btn.dataset.speed); } catch(_) {}
if (audio.src) audio.playbackRate = parseFloat(btn.dataset.speed);
}
window.selectSpeed = selectSpeed;
(function loadSettings() {
var spd = localStorage.getItem(LS_SPEED);
var activeSpd = spd || '1.5';
var btn = document.querySelector('.speed-btn[data-speed="' + activeSpd + '"]');
if (btn) selectSpeed(btn);
var vc = localStorage.getItem(LS_VOICE);
if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
var an = localStorage.getItem(LS_AUTONEXT);
if (an !== null) autoplayChk.checked = an === 'true';
var as = localStorage.getItem(LS_AUTOSCROLL);
if (as !== null) autoscrollChk.checked = as !== 'false';
})();
voiceSel.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_VOICE, voiceSel.value); });
autoplayChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); });
autoscrollChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTOSCROLL, autoscrollChk.checked ? 'true' : 'false'); });
// ── paragraph indexing ────────────────────────────────────────────────────
var paras = Array.prototype.slice.call((article || {querySelectorAll: function(){return[];}}).querySelectorAll('p'));
var activePara = null;
// ── auto-scroll pause on user interaction ─────────────────────────────────
var autoScrollEnabled = true;
var autoScrollTimer = null;
function resetAutoScrollTimer() {
autoScrollEnabled = false;
clearTimeout(autoScrollTimer);
autoScrollTimer = setTimeout(function () { autoScrollEnabled = true; }, 3000);
}
document.addEventListener('wheel', resetAutoScrollTimer, { passive: true });
document.addEventListener('touchmove', resetAutoScrollTimer, { passive: true });
document.addEventListener('keydown', function (e) {
var scrollKeys = { ArrowUp:1, ArrowDown:1, PageUp:1, PageDown:1, Home:1, End:1, ' ':1 };
if (scrollKeys[e.key]) resetAutoScrollTimer();
});
function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
if (activePara) {
activePara.classList.add('tts-active');
if (autoscrollChk.checked && autoScrollEnabled) activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ── UI state helpers ──────────────────────────────────────────────────────
var SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M8 5v14l11-7z"/></svg>';
var SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
function setPlayIcon(icon) { playerPlayIcon.innerHTML = icon; }
function getSpeed() {
var b = document.querySelector('.speed-btn.speed-active');
return b ? parseFloat(b.dataset.speed) : 1.5;
}
function setGenerating() {
setPlayIcon('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M12 3a9 9 0 0 1 9 9" style="animation:tts-spin 0.8s linear infinite"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.8s" repeatCount="indefinite"/></path></svg>');
playerPlayBtn.disabled = true;
voiceSel.disabled = true; speedBtns.forEach(function(b){b.disabled=true;});
setBadge(playerStateBadge, 'generating');
playerStateBadge.hidden = false;
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('Generating audio\u2026');
hideSeek();
}
function setPlaying() {
if (stale()) return;
setPlayIcon(SVG_PAUSE);
playerPlayBtn.disabled = false;
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
setBadge(playerStateBadge, 'playing');
playerStateBadge.hidden = false;
var pct = (audio.duration && isFinite(audio.duration))
? Math.round((audio.currentTime / audio.duration) * 100) : 0;
playerStateBadge.textContent = pct + '%';
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
}
function setPaused() {
if (stale()) return;
setPlayIcon(SVG_PLAY);
setBadge(playerStateBadge, 'paused');
playerStateBadge.hidden = false;
var pct = (audio.duration && isFinite(audio.duration))
? Math.round((audio.currentTime / audio.duration) * 100) : 0;
playerStateBadge.textContent = pct + '%';
setStatus('');
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
}
function setStopped() {
highlightPara(-1);
setPlayIcon(SVG_PLAY);
playerPlayBtn.disabled = false;
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
if (genStartTime > 0) {
var elapsed = Math.round((Date.now() - genStartTime) / 1000);
var label = elapsed >= 60 ? Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's' : elapsed + 's';
setBadge(playerStateBadge, 'idle');
playerStateBadge.textContent = label;
playerStateBadge.classList.add('queue-badge-time');
playerStateBadge.hidden = false;
genStartTime = 0;
} else {
playerStateBadge.hidden = true;
}
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
hideSeek();
}
function setError(msg) {
if (stale()) return;
highlightPara(-1);
setPlayIcon(SVG_PLAY);
playerPlayBtn.disabled = false;
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
setBadge(playerStateBadge, 'error');
playerStateBadge.hidden = false;
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
hideSeek();
}
// ── audio generation ─────────────────────────────────────────────────────
var currentAudioCtrl = null;
var genStartTime = 0;
function generateAudio(chapterN, cb) {
if (currentAudioCtrl) currentAudioCtrl.abort();
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
genStartTime = Date.now();
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceSel.value, speed: 1 }),
signal: ctrl.signal
})
.then(function (res) {
if (!res.ok) return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
return res.json();
})
.then(function (data) {
if (stale()) return;
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
})
.catch(function (e) {
if (e.name === 'AbortError' || stale()) return;
setError(e.message);
});
}
// ── audio events ──────────────────────────────────────────────────────────
function onTimeUpdate() {
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
updateSeek();
if (audio.duration && isFinite(audio.duration) && !audio.paused) {
playerStateBadge.textContent = Math.round((audio.currentTime / audio.duration) * 100) + '%';
}
if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
var idx = Math.min(Math.floor((audio.currentTime / audio.duration) * paras.length), paras.length - 1);
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
}
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } audio.autoplay = false; if (audio.paused) audio.play().then(setPlaying).catch(function(e){ if (e.name === 'NotAllowedError') setPaused(); else setError(e.message); }); }
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
function onPlaying() { if (!stale()) setPlaying(); }
function onPause() { if (!stale() && !audio.ended) setPaused(); }
function onAudioError() { if (!stale()) setError('audio error'); }
function onEnded() {
if (stale()) { audio.removeEventListener('ended', onEnded); return; }
audio.src = '';
if (autoplayChk.checked && NEXT_N) goNextChapter(); else setStopped();
}
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('waiting', onWaiting);
audio.addEventListener('playing', onPlaying);
audio.addEventListener('pause', onPause);
audio.addEventListener('play', onPlaying);
audio.addEventListener('error', onAudioError);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
// ── auto-next ─────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
}
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
audio.pause(); audio.src = '';
setStopped();
}
// ── Media Session ─────────────────────────────────────────────────────────
function registerMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Ch.\u00a0' + CHAPTER_N,
artist: SLUG.replace(/-/g, ' '),
album: 'libnovel',
artwork: COVER_URL ? [{ src: COVER_URL, sizes: '512x512', type: 'image/jpeg' }] : []
});
navigator.mediaSession.setActionHandler('play', function() { audio.play().then(setPlaying).catch(function(){}); });
navigator.mediaSession.setActionHandler('pause', function() { audio.pause(); setPaused(); });
navigator.mediaSession.setActionHandler('stop', function() { stop(); });
if (NEXT_N) navigator.mediaSession.setActionHandler('nexttrack', function() { try { localStorage.setItem('tts_autostart','1'); } catch(_){} window.location.href = '/books/'+SLUG+'/chapters/'+NEXT_N; });
if (PREV_N) navigator.mediaSession.setActionHandler('previoustrack', function() { window.location.href = '/books/'+SLUG+'/chapters/'+PREV_N; });
navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
}
function loadAndPlay(url) {
// Always set autoplay=true before load(): the browser needs this hint set
// synchronously before load() regardless of whether we got here via a user
// gesture or a programmatic trigger (e.g. chapter transition). The canplay
// handler will call play() as well, but autoplay on the element is the only
// reliable path after an async fetch where the gesture stack is gone.
audio.autoplay = true;
audio.src = url; audio.load();
audio.playbackRate = getSpeed();
registerMediaSession();
}
function startAudio(autoplay) {
setGenerating();
highlightPara(0);
// Check if audio was already generated for this chapter this session.
// If so, play it directly without re-generating.
fetch('/ui/audio/' + SLUG + '/' + CHAPTER_N + '?voice=' + encodeURIComponent(voiceSel.value) + '&speed=1')
.then(function(res) {
if (stale()) return;
if (res.ok) {
return res.json().then(function(data) {
if (stale()) return;
if (data && data.url) { loadAndPlay(data.url); return; }
throw new Error('no url in response');
});
}
// Not cached yet — fall through to generation.
generateAudio(CHAPTER_N, function(url) {
if (stale()) return;
loadAndPlay(url);
});
})
.catch(function(e) {
if (stale()) return;
setError(e.message);
});
}
window.ttsToggle = function () {
if (audio.src) {
if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); });
else audio.pause();
return;
}
startAudio();
};
window.__ttsBeforeSwap = function () {
// If TTS is active (generating or has audio loaded), persist the autostart
// flag so the incoming chapter's init code will restart TTS automatically.
var wasActive = currentAudioCtrl !== null || (audio.src && !audio.ended);
if (wasActive) {
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
}
window.__ttsGen++; stop();
};
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
var _autostart = false;
try { _autostart = localStorage.getItem('tts_autostart') === '1'; } catch(_) {}
if (_autostart) {
try { localStorage.removeItem('tts_autostart'); } catch(_) {}
autoplayChk.checked = true;
setTimeout(function() { startAudio(true); }, 100);
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true;
setTimeout(function() { startAudio(true); }, 100);
}
// ── double-tap left/right to navigate ────────────────────────────────────
if (window.__ttsDoubleTap) document.removeEventListener('touchend', window.__ttsDoubleTap);
(function initDoubleTap() {
var lastTap = 0, lastSide = '';
var THRESHOLD = 300;
var INTERACTIVE = ['A','BUTTON','INPUT','SELECT','TEXTAREA','LABEL'];
window.__ttsDoubleTap = function (e) {
if (stale()) { document.removeEventListener('touchend', window.__ttsDoubleTap); return; }
var el = e.target;
while (el && el !== document.body) { if (INTERACTIVE.indexOf(el.tagName) !== -1) return; el = el.parentElement; }
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) {
lastTap = 0; lastSide = '';
if (side === 'right' && NEXT_N) window.location.href = '/books/'+SLUG+'/chapters/'+NEXT_N;
else if (side === 'left' && PREV_N) window.location.href = '/books/'+SLUG+'/chapters/'+PREV_N;
} else { lastTap = now; lastSide = side; }
};
document.addEventListener('touchend', window.__ttsDoubleTap, { passive: true });
}());
}());
// ── Custom voice dropdown ─────────────────────────────────────────────────
window.toggleVoiceDropdown = function () {
var dd = document.getElementById('voice-dropdown');
var chevron = document.getElementById('voice-trigger-chevron');
if (!dd) return;
var opening = dd.hidden;
dd.hidden = !opening;
if (chevron) chevron.style.transform = opening ? 'rotate(180deg)' : '';
};
window.selectVoiceOption = function (btn) {
var voiceSel = document.getElementById('tts-voice');
var label = document.getElementById('voice-trigger-label');
var dd = document.getElementById('voice-dropdown');
var chevron = document.getElementById('voice-trigger-chevron');
if (!voiceSel) return;
// update hidden select
voiceSel.value = btn.dataset.voice;
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
// update trigger label
if (label) label.textContent = btn.dataset.label;
// highlight active row
if (dd) {
dd.querySelectorAll('.voice-opt').forEach(function (b) {
var active = b === btn;
b.classList.toggle('bg-zinc-800/60', active);
b.classList.toggle('text-zinc-100', active);
});
dd.hidden = true;
}
if (chevron) chevron.style.transform = '';
// fire change event so existing voice listener persists
voiceSel.dispatchEvent(new Event('change'));
};
// sync trigger label from localStorage on load
(function syncVoiceTrigger() {
var saved = null;
try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
if (!saved) return;
var btn = document.querySelector('.voice-opt[data-voice="' + saved + '"]');
if (btn) window.selectVoiceOption(btn);
})();
</script>`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.writer.ReadChapter(slug, n)
if err != nil {
http.NotFound(w, r)
return
}
// Strip the first heading line so it isn't rendered as a duplicate <h1>
// inside the article (the template already renders an explicit <h1>).
rawForHTML := stripFirstHeadingLine(raw)
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
chapters, _ := s.writer.ListChapters(slug)
prevN, nextN := adjacentChapters(chapters, n)
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
chapterTitle, chapterDate := writer.SplitChapterTitle(title)
// Load cover URL for Media Session artwork (best-effort; ignore errors).
var coverURL string
if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
coverURL = meta.Cover
}
t := template.Must(template.New("chapter").Parse(chapterTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
HTML template.HTML
PrevN int
NextN int
ChapterN int
Title string
ChapterDate string
AllChapters interface{}
Voices []voiceInfo
DefaultVoice string
Cover string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: chapterTitle,
ChapterDate: chapterDate,
AllChapters: chapters,
Voices: parseVoices(s.voices()),
DefaultVoice: s.kokoroVoice,
Cover: coverURL,
})
s.respond(w, r, chapterTitle, buf.String())
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// sortedKeys returns the keys of a string-bool map in sorted order.
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Simple insertion sort — sets are small (< 100 items).
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// stripMarkdown removes Markdown syntax and returns clean plain text.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
for i, ch := range chapters {
if ch.Number == n {
if i > 0 {
prev = chapters[i-1].Number
}
if i < len(chapters)-1 {
next = chapters[i+1].Number
}
return
}
}
return
}
// stripFirstHeadingLine removes the first non-empty line if it is a markdown
// heading (starts with one or more "#"). This prevents the heading from being
// rendered as a duplicate <h1> inside the article when the template already
// renders an explicit title above the article.
func stripFirstHeadingLine(src string) string {
lines := strings.SplitN(src, "\n", -1)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
// Remove this line and return the rest.
rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n")
return strings.TrimLeft(rest, "\n")
}
// First non-empty line is not a heading — nothing to strip.
break
}
return src
}
// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
for _, line := range strings.SplitN(md, "\n", 20) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fallback
}
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
bookURL := strings.TrimSpace(r.FormValue("url"))
if bookURL == "" {
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
return
}
s.mu.Lock()
already := s.running
if !already {
s.running = true
}
s.mu.Unlock()
if already {
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
return
}
cfg := s.oCfg
cfg.SingleBookURL = bookURL
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
defer cancel()
o := orchestrator.New(cfg, s.novel, s.log)
if err := o.Run(ctx); err != nil {
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
}
}()
// Return a status badge that polls until the job finishes.
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
// Keep polling every 3 s while the job is in progress.
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
return
}
// Job finished — show a done badge and stop polling.
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}
// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
case "done":
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
case "busy":
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
default: // error
colour = "text-red-300 bg-red-950 border-red-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}