Files
libnovel/scraper/internal/server/ui.go
Admin 2546a5774e
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
fix: increase settings panel bottom offset to clear full player height
2026-03-02 12:52:16 +05:00

3112 lines
128 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
"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: 2.5rem; cursor: pointer; touch-action: none; user-select: none; }
#seek-rail { position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%); height: 3px; 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: 13px; height: 13px; 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); }
/* ── 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-xs text-zinc-500 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>
<!-- Bottom chapter nav (inline, below content) -->
<div class="flex items-center justify-between gap-4 mt-10 pt-6 border-t border-zinc-800/50">
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
class="flex items-center gap-1.5 text-sm text-zinc-400 hover:text-amber-400 no-underline transition-colors">
← Prev
</a>
{{else}}<span></span>{{end}}
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
class="flex items-center gap-1.5 text-sm text-zinc-400 hover:text-amber-400 no-underline transition-colors">
Next →
</a>
{{else}}<span></span>{{end}}
</div>
</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="max-w-2xl mx-auto px-4 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 w-8 h-8 flex items-center justify-center rounded-lg text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors no-underline text-sm">
</a>
{{else}}<span class="flex-shrink-0 w-8"></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-1.5 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">idle</span>
<!-- next-chapter prefetch badge -->
<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-8 h-8 flex items-center justify-center rounded-lg text-zinc-600 hover:text-amber-400 border-none cursor-pointer bg-transparent transition-colors text-base">
&#9881;
</button>
<!-- Next chapter -->
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"
title="Next chapter" aria-label="Next chapter"
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors no-underline text-sm">
</a>
{{else}}<span class="flex-shrink-0 w-8"></span>{{end}}
</div>
</div>
<!-- Settings panel (opens upward) -->
<div id="settings-panel"
role="dialog"
aria-label="Reader settings"
hidden
class="fixed inset-x-2 bottom-[6.5rem] top-4 overflow-y-auto sm:absolute sm:inset-x-auto sm:top-auto sm:bottom-[calc(100%+0.25rem)] sm:right-[max(0.5rem,calc(50%-32rem+0.5rem))] sm:w-80 sm:max-h-[80vh] bg-zinc-900 border border-zinc-800 rounded-xl p-4 shadow-2xl z-[100]">
<!-- hidden native select keeps existing JS working unchanged -->
<select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
{{range .Voices}}
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<div class="mb-4">
<span class="block text-xs text-zinc-500 mb-2">Voice</span>
<div id="voice-grid" class="grid grid-cols-2 gap-1.5 max-h-64 sm:max-h-48 overflow-y-auto pr-0.5">
{{range .Voices}}
<button type="button"
data-voice="{{.ID}}"
onclick="selectVoice(this)"
class="voice-btn flex items-center gap-2 px-2.5 py-1.5 rounded-lg border text-left transition-colors
{{if eq .ID $.DefaultVoice}}border-amber-500 bg-amber-500/10 text-amber-300{{else}}border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100{{end}}">
<span class="flex-1 min-w-0">
<span class="block text-[0.8rem] font-medium leading-tight truncate">{{.Name}}</span>
<span class="block text-[0.65rem] text-zinc-500 leading-tight">{{.Lang}} · {{.Gender}}</span>
</span>
</button>
{{end}}
</div>
</div>
<label class="block mb-4">
<span class="block text-xs text-zinc-500 mb-1.5">Speed — <span id="tts-speed-label">1.0×</span></span>
<input id="tts-speed" type="range" min="0.5" max="2" step="0.1" value="1"
class="w-full accent-amber-500 cursor-pointer" />
</label>
<label class="flex items-center justify-between gap-3 cursor-pointer select-none mb-3">
<span class="text-sm text-zinc-300">Auto-play next chapter</span>
<span class="toggle-switch"><input id="tts-autoplay" type="checkbox" /><span class="toggle-track"></span></span>
</label>
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
<span class="text-sm text-zinc-300">Auto-scroll to paragraph</span>
<span class="toggle-switch"><input id="tts-autoscroll" type="checkbox" checked /><span class="toggle-track"></span></span>
</label>
</div>
<!-- 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 speedSlider = document.getElementById('tts-speed');
var speedLabel = document.getElementById('tts-speed-label');
var autoplayChk = document.getElementById('tts-autoplay');
var autoscrollChk = document.getElementById('tts-autoscroll');
var article = document.getElementById('chapter-article');
var playerPlayBtn = document.getElementById('player-play-btn');
var playerPlayIcon = document.getElementById('player-play-icon');
var playerTitle = document.getElementById('player-title');
var playerStateBadge= document.getElementById('player-state-badge');
var playerNextRow = document.getElementById('player-next-row');
var playerNextBadge = document.getElementById('player-next-badge');
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel= document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
var seekTrack = document.getElementById('seek-track');
var seekRail = document.getElementById('seek-rail');
var seekFill = document.getElementById('seek-fill');
var seekThumb = document.getElementById('seek-thumb');
var seekCur = document.getElementById('seek-cur');
var seekTot = document.getElementById('seek-tot');
var seekTimes = document.getElementById('seek-times');
// ── badge helper ──────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error',
'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';
settingsPanel.style.display = 'none';
if (open) { closeChapterList(); }
else {
chapterListPanel.style.display = 'block';
var cur = chapterListPanel.querySelector('[data-current="1"]');
if (cur) cur.scrollIntoView({ block: 'center' });
}
};
window.toggleSettings = function () {
var open = settingsPanel.style.display !== 'none';
closeChapterList();
settingsPanel.style.display = open ? 'none' : 'block';
};
if (window.__ttsClickOutside) document.removeEventListener('click', window.__ttsClickOutside);
window.__ttsClickOutside = function (e) {
if (stale()) { document.removeEventListener('click', window.__ttsClickOutside); return; }
var sb = document.getElementById('settings-btn');
if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) {
settingsPanel.style.display = 'none';
}
if (chapterListPanel.style.display !== 'none' &&
!chapterListPanel.contains(e.target) &&
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
};
document.addEventListener('click', window.__ttsClickOutside);
// ── localStorage settings ─────────────────────────────────────────────────
var LS_SPEED = 'tts_speed', LS_VOICE = 'tts_voice', LS_AUTONEXT = 'tts_autonext', LS_AUTOSCROLL = 'tts_autoscroll';
(function loadSettings() {
var spd = localStorage.getItem(LS_SPEED);
if (spd !== null) { speedSlider.value = spd; speedLabel.textContent = parseFloat(spd).toFixed(1) + '\u00D7'; }
var vc = localStorage.getItem(LS_VOICE);
if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
var an = localStorage.getItem(LS_AUTONEXT);
if (an !== null) autoplayChk.checked = an === 'true';
var as = localStorage.getItem(LS_AUTOSCROLL);
if (as !== null) autoscrollChk.checked = as !== 'false';
})();
speedSlider.addEventListener('input', function () {
if (stale()) return;
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false;
});
voiceSel.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_VOICE, voiceSel.value); prefetchFired = false; });
autoplayChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); });
autoscrollChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTOSCROLL, autoscrollChk.checked ? 'true' : 'false'); });
// ── paragraph indexing ────────────────────────────────────────────────────
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 setGenerating() {
setPlayIcon('\u231B');
playerPlayBtn.disabled = true;
voiceSel.disabled = true; speedSlider.disabled = true;
setBadge(playerStateBadge, 'generating');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
setStatus('Generating audio\u2026');
hideSeek();
}
function setPlaying() {
if (stale()) return;
setPlayIcon(SVG_PAUSE);
playerPlayBtn.disabled = false;
voiceSel.disabled = false; speedSlider.disabled = false;
setBadge(playerStateBadge, 'playing');
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');
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; speedSlider.disabled = false;
setBadge(playerStateBadge, 'idle');
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';
playerStateBadge.textContent = label;
playerStateBadge.classList.add('queue-badge-time');
genStartTime = 0;
}
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; speedSlider.disabled = false;
setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
hideSeek();
}
// ── next-chapter prefetch display ─────────────────────────────────────────
function setNextState(state) {
if (!NEXT_N) { playerNextRow.hidden = true; return; }
playerNextRow.hidden = false;
setBadge(playerNextBadge, state);
}
// ── server-side audio generation ──────────────────────────────────────────
var currentAudioCtrl = null;
var genStartTime = 0;
function generateAudio(chapterN, cb) {
if (currentAudioCtrl) currentAudioCtrl.abort();
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
genStartTime = Date.now();
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) }),
signal: ctrl.signal
})
.then(function (res) {
if (!res.ok) return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
return res.json();
})
.then(function (data) {
if (stale()) return;
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
})
.catch(function (e) {
if (e.name === 'AbortError' || stale()) return;
setError(e.message);
});
}
// ── next-chapter prefetch at 80% ──────────────────────────────────────────
var prefetchFired = false;
var prefetchedUrl = null;
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 (NEXT_N && !prefetchFired && audio.duration && isFinite(audio.duration) &&
audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true;
setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
})
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function (data) { if (!stale()) { prefetchedUrl = (data && data.url) ? data.url : null; setNextState('ready'); } })
.catch(function () { if (!stale()) setNextState('error'); });
}
if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
var idx = Math.min(Math.floor((audio.currentTime / audio.duration) * paras.length), paras.length - 1);
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
}
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); }); }
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
function onPlaying() { if (!stale()) setPlaying(); }
function onPause() { if (!stale() && !audio.ended) setPaused(); }
function onAudioError() { if (!stale()) setError('audio error'); }
function onEnded() {
if (stale()) { audio.removeEventListener('ended', onEnded); return; }
audio.src = '';
prefetchFired = false;
if (autoplayChk.checked && NEXT_N) goNextChapter(); else setStopped();
}
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('waiting', onWaiting);
audio.addEventListener('playing', onPlaying);
audio.addEventListener('pause', onPause);
audio.addEventListener('play', onPlaying);
audio.addEventListener('error', onAudioError);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
// ── auto-next ─────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
if (prefetchedUrl) {
var url = prefetchedUrl; prefetchedUrl = null;
audio.src = url; audio.load();
if ('mediaSession' in navigator && navigator.mediaSession.metadata)
navigator.mediaSession.metadata.title = 'Ch.\u00a0' + NEXT_N;
return;
}
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
htmx.ajax('GET', nextURL, { target: '#main-content', swap: 'innerHTML', pushURL: nextURL });
}
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
audio.pause(); audio.src = '';
prefetchFired = false; prefetchedUrl = null;
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 startAudio() {
setGenerating();
highlightPara(0);
generateAudio(CHAPTER_N, function (url) {
if (stale()) return;
audio.src = url; audio.load();
registerMediaSession();
});
}
window.ttsToggle = function () {
if (audio.src) {
if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); });
else audio.pause();
return;
}
startAudio();
};
window.__ttsBeforeSwap = function () { 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(startAudio, 100);
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true;
setTimeout(startAudio, 100);
}
if (NEXT_N) setNextState('idle');
// ── 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 });
}());
}());
// ── Voice card picker ─────────────────────────────────────────────────────
window.selectVoice = function (btn) {
var voiceSel = document.getElementById('tts-voice');
var grid = document.getElementById('voice-grid');
if (!voiceSel || !grid) return;
voiceSel.value = btn.dataset.voice;
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
grid.querySelectorAll('.voice-btn').forEach(function (b) {
var active = b === btn;
b.classList.toggle('border-amber-500', active);
b.classList.toggle('bg-amber-500/10', active);
b.classList.toggle('text-amber-300', active);
b.classList.toggle('border-zinc-700', !active);
b.classList.toggle('bg-zinc-800', !active);
b.classList.toggle('text-zinc-300', !active);
});
};
(function syncVoiceGrid() {
var voiceSel = document.getElementById('tts-voice');
var grid = document.getElementById('voice-grid');
if (!voiceSel || !grid) return;
var saved = null;
try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
if (!saved) return;
var btn = grid.querySelector('[data-voice="' + saved + '"]');
if (btn) window.selectVoice(btn);
})();
</script>`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.writer.ReadChapter(slug, n)
if err != nil {
http.NotFound(w, r)
return
}
// Strip the first heading line so it isn't rendered as a duplicate <h1>
// inside the article (the template already renders an explicit <h1>).
rawForHTML := stripFirstHeadingLine(raw)
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(rawForHTML), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
chapters, _ := s.writer.ListChapters(slug)
prevN, nextN := adjacentChapters(chapters, n)
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
chapterTitle, chapterDate := writer.SplitChapterTitle(title)
// Load cover URL for Media Session artwork (best-effort; ignore errors).
var coverURL string
if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
coverURL = meta.Cover
}
t := template.Must(template.New("chapter").Parse(chapterTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
HTML template.HTML
PrevN int
NextN int
ChapterN int
Title string
ChapterDate string
AllChapters interface{}
Voices []voiceInfo
DefaultVoice string
Cover string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
Title: chapterTitle,
ChapterDate: chapterDate,
AllChapters: chapters,
Voices: parseVoices(s.voices()),
DefaultVoice: s.kokoroVoice,
Cover: coverURL,
})
s.respond(w, r, chapterTitle, buf.String())
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// sortedKeys returns the keys of a string-bool map in sorted order.
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Simple insertion sort — sets are small (< 100 items).
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// stripMarkdown removes Markdown syntax and returns clean plain text.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
for i, ch := range chapters {
if ch.Number == n {
if i > 0 {
prev = chapters[i-1].Number
}
if i < len(chapters)-1 {
next = chapters[i+1].Number
}
return
}
}
return
}
// stripFirstHeadingLine removes the first non-empty line if it is a markdown
// heading (starts with one or more "#"). This prevents the heading from being
// rendered as a duplicate <h1> inside the article when the template already
// renders an explicit title above the article.
func stripFirstHeadingLine(src string) string {
lines := strings.SplitN(src, "\n", -1)
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
// Remove this line and return the rest.
rest := strings.Join(append(lines[:i], lines[i+1:]...), "\n")
return strings.TrimLeft(rest, "\n")
}
// First non-empty line is not a heading — nothing to strip.
break
}
return src
}
// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
for _, line := range strings.SplitN(md, "\n", 20) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fallback
}
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
bookURL := strings.TrimSpace(r.FormValue("url"))
if bookURL == "" {
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
return
}
s.mu.Lock()
already := s.running
if !already {
s.running = true
}
s.mu.Unlock()
if already {
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
return
}
cfg := s.oCfg
cfg.SingleBookURL = bookURL
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
defer cancel()
o := orchestrator.New(cfg, s.novel, s.log)
if err := o.Run(ctx); err != nil {
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
}
}()
// Return a status badge that polls until the job finishes.
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
// Keep polling every 3 s while the job is in progress.
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
return
}
// Job finished — show a done badge and stop polling.
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}
// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
case "done":
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
case "busy":
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
default: // error
colour = "text-red-300 bg-red-950 border-red-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}