- Cards now use flex layout with break-words/min-w-0 so long titles wrap instead of stretching the column; grid uses gap-3 for tighter density - Added filter bar (title/author/genre/status) with live result count, consistent with the home page filter - Replaced four big refresh buttons with compact pagination buttons (p.1, p.1-3, p.1-5, p.1-10, All) labelled with page ranges; source link points to novelfire.net/genre-all/sort-popular/status-all/all-novel - Template data field renamed from RefreshPages to Pages; pagination entries updated to reflect 100 novels per page
2190 lines
85 KiB
Go
2190 lines
85 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/libnovel/scraper/internal/orchestrator"
|
||
"github.com/libnovel/scraper/internal/writer"
|
||
"github.com/yuin/goldmark"
|
||
"github.com/yuin/goldmark/extension"
|
||
goldhtml "github.com/yuin/goldmark/renderer/html"
|
||
)
|
||
|
||
// md is the shared goldmark instance used for all markdown→HTML conversions.
|
||
var md = goldmark.New(
|
||
goldmark.WithExtensions(extension.Typographer, extension.Table),
|
||
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
||
)
|
||
|
||
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
|
||
// grouped loosely by language prefix:
|
||
//
|
||
// af_ / am_ American English female / male
|
||
// bf_ / bm_ British English female / male
|
||
// ef_ / em_ Spanish female / male
|
||
// ff_ French female
|
||
// hf_ / hm_ Hindi female / male
|
||
// if_ / im_ Italian female / male
|
||
// jf_ / jm_ Japanese female / male
|
||
// pf_ / pm_ Portuguese female / male
|
||
// zf_ / zm_ Chinese female / male
|
||
var kokoroVoices = []string{
|
||
// American English
|
||
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
|
||
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
|
||
"af_sarah", "af_sky",
|
||
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
|
||
"am_michael", "am_onyx", "am_puck",
|
||
// British English
|
||
"bf_alice", "bf_emma", "bf_lily",
|
||
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
|
||
// Spanish
|
||
"ef_dora", "em_alex",
|
||
// French
|
||
"ff_siwis",
|
||
// Hindi
|
||
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
|
||
// Italian
|
||
"if_sara", "im_nicola",
|
||
// Japanese
|
||
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
|
||
// Portuguese
|
||
"pf_dora", "pm_alex",
|
||
// Chinese
|
||
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
|
||
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
|
||
}
|
||
|
||
// ─── shared layout ────────────────────────────────────────────────────────────
|
||
|
||
const layoutHead = `<!DOCTYPE html>
|
||
<html lang="en" class="bg-zinc-950 text-zinc-100">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{.Title}} — libnovel</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
|
||
<style>
|
||
.prose p { margin-bottom: 1em; }
|
||
.prose h1,.prose h2,
|
||
.prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
|
||
.prose h4 { font-size: 1.05rem; }
|
||
.prose h3 { font-size: 1.2rem; }
|
||
.prose h2 { font-size: 1.4rem; }
|
||
.prose h1 { font-size: 1.7rem; }
|
||
.prose em { font-style: italic; }
|
||
.prose strong { font-weight: 700; }
|
||
.prose hr { border-color: #3f3f46; margin: 2em 0; }
|
||
.prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">`
|
||
|
||
const layoutFoot = `</body></html>`
|
||
|
||
func renderPage(w http.ResponseWriter, title, body string) {
|
||
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = t.Execute(w, struct{ Title string }{Title: title})
|
||
}
|
||
|
||
func renderFragment(w http.ResponseWriter, body string) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprint(w, body)
|
||
}
|
||
|
||
func isHTMX(r *http.Request) bool {
|
||
return r.Header.Get("HX-Request") == "true"
|
||
}
|
||
|
||
// respond writes either a full page or an HTMX fragment depending on the request.
|
||
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
|
||
if isHTMX(r) {
|
||
renderFragment(w, fragment)
|
||
return
|
||
}
|
||
renderPage(w, title,
|
||
`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
|
||
}
|
||
|
||
// ─── GET / — book catalogue ───────────────────────────────────────────────────
|
||
|
||
const homeTmpl = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10" hx-history="false">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
|
||
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg bg-amber-700 hover:bg-amber-600 text-white">Browse Rankings</a>
|
||
</div>
|
||
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
||
|
||
<!-- Filter bar -->
|
||
<div class="mb-6">
|
||
<input id="filter-input" type="search" placeholder="Filter by title, author, genre…"
|
||
autocomplete="off" spellcheck="false"
|
||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||
<p id="filter-count" class="text-xs text-zinc-500 mt-1 hidden"></p>
|
||
</div>
|
||
|
||
<!-- Scrape form -->
|
||
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
|
||
<form id="scrape-form"
|
||
hx-post="/ui/scrape/book"
|
||
hx-target="#scrape-status"
|
||
hx-swap="innerHTML"
|
||
class="flex gap-2">
|
||
<div class="flex-1 relative" id="scrape-search-wrap">
|
||
<input type="text"
|
||
id="scrape-search"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
placeholder="Search rankings or paste a URL…"
|
||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||
<!-- hidden url field submitted to HTMX -->
|
||
<input type="url" name="url" id="scrape-url" required class="hidden" />
|
||
<!-- dropdown -->
|
||
<ul id="scrape-dropdown"
|
||
class="hidden absolute z-50 left-0 right-0 top-full mt-1 rounded-xl border border-zinc-700 bg-zinc-900 shadow-xl max-h-80 overflow-y-auto">
|
||
</ul>
|
||
</div>
|
||
<button type="submit"
|
||
class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors whitespace-nowrap">
|
||
Scrape
|
||
</button>
|
||
</form>
|
||
<div id="scrape-status" class="mt-3"></div>
|
||
</div>
|
||
|
||
<!-- Continue reading section (populated by JS) -->
|
||
<div id="continue-reading-section" class="hidden mb-10">
|
||
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Continue reading</h2>
|
||
<div id="continue-reading-grid" class="grid gap-4 sm:grid-cols-2"></div>
|
||
</div>
|
||
|
||
<!-- All books grid -->
|
||
<div id="available-section">
|
||
<h2 id="available-heading" class="text-lg font-semibold text-zinc-200 mb-3{{if eq (len .Books) 0}} hidden{{end}}">Available</h2>
|
||
<div id="books-grid" class="grid gap-4 sm:grid-cols-2">
|
||
{{range .Books}}
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
data-slug="{{.Slug}}"
|
||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||
class="book-card group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer">
|
||
<div class="flex gap-4">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0">
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
|
||
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
||
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
{{else}}
|
||
<p class="text-zinc-500 col-span-2">No books scraped yet.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
/* ── continue-reading ──────────────────────────────────────────────────── */
|
||
var progress = {};
|
||
try { progress = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
|
||
|
||
var inProgress = Object.keys(progress);
|
||
var continueSection = document.getElementById('continue-reading-section');
|
||
var continueGrid = document.getElementById('continue-reading-grid');
|
||
|
||
// Always clear before re-populating so back-navigation never duplicates cards.
|
||
if (continueGrid) continueGrid.innerHTML = '';
|
||
|
||
if (inProgress.length > 0 && continueGrid) {
|
||
var moved = 0;
|
||
inProgress.forEach(function(slug) {
|
||
// Find the source card in #books-grid by data-slug.
|
||
var card = document.querySelector('#books-grid [data-slug="' + slug + '"]');
|
||
if (!card) return;
|
||
|
||
var chapterNum = progress[slug];
|
||
|
||
// Build a fresh card element rather than cloning + mutating,
|
||
// so repeated back-navigations cannot accumulate injected nodes.
|
||
var a = document.createElement('a');
|
||
a.href = card.getAttribute('href');
|
||
a.setAttribute('hx-get', card.getAttribute('hx-get'));
|
||
a.setAttribute('hx-target', card.getAttribute('hx-target'));
|
||
a.setAttribute('hx-push-url', card.getAttribute('hx-push-url'));
|
||
a.setAttribute('hx-swap', card.getAttribute('hx-swap'));
|
||
a.setAttribute('data-slug', slug);
|
||
a.className = card.className;
|
||
// Copy inner HTML then inject the chapter-progress line.
|
||
a.innerHTML = card.innerHTML;
|
||
if (chapterNum) {
|
||
var meta = a.querySelector('.min-w-0');
|
||
if (meta) {
|
||
// Remove any previously injected progress line (defensive).
|
||
var old = meta.querySelector('.chapter-progress-line');
|
||
if (old) old.parentNode.removeChild(old);
|
||
|
||
var chLine = document.createElement('p');
|
||
chLine.className = 'chapter-progress-line text-xs text-amber-400 mt-1';
|
||
chLine.textContent = 'Chapter ' + chapterNum;
|
||
var author = meta.querySelector('p');
|
||
if (author) author.insertAdjacentElement('afterend', chLine);
|
||
else {
|
||
var title = meta.querySelector('h2');
|
||
if (title) title.insertAdjacentElement('afterend', chLine);
|
||
}
|
||
}
|
||
}
|
||
htmx.process(a);
|
||
continueGrid.appendChild(a);
|
||
// Keep the original card visible in #books-grid — do not hide it.
|
||
// Books should always appear in Available regardless of reading progress.
|
||
moved++;
|
||
});
|
||
if (moved > 0) {
|
||
continueSection.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
/* ── filter ────────────────────────────────────────────────────────────── */
|
||
var input = document.getElementById('filter-input');
|
||
var countEl = document.getElementById('filter-count');
|
||
var booksGrid = document.getElementById('books-grid');
|
||
|
||
function filterCards() {
|
||
var q = input.value.trim().toLowerCase();
|
||
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
|
||
var shown = 0;
|
||
cards.forEach(function(card) {
|
||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||
card.style.display = match ? '' : 'none';
|
||
if (match) shown++;
|
||
});
|
||
if (q && countEl) {
|
||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||
countEl.classList.remove('hidden');
|
||
} else if (countEl) {
|
||
countEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
if (input) input.addEventListener('input', filterCards);
|
||
|
||
/* ── ranking search / scrape autocomplete ──────────────────────────────── */
|
||
(function () {
|
||
var RANKING = {{.RankingJSON}};
|
||
if (!RANKING || !RANKING.length) return;
|
||
|
||
var searchInput = document.getElementById('scrape-search');
|
||
var urlInput = document.getElementById('scrape-url');
|
||
var dropdown = document.getElementById('scrape-dropdown');
|
||
var form = document.getElementById('scrape-form');
|
||
if (!searchInput || !urlInput || !dropdown || !form) return;
|
||
|
||
var activeIdx = -1;
|
||
|
||
// Sync the hidden url field whenever the visible input looks like a URL.
|
||
function syncURLField(val) {
|
||
val = val.trim();
|
||
if (/^https?:\/\//i.test(val)) {
|
||
urlInput.value = val;
|
||
} else {
|
||
urlInput.value = '';
|
||
}
|
||
}
|
||
|
||
function closeDrop() {
|
||
dropdown.classList.add('hidden');
|
||
dropdown.innerHTML = '';
|
||
activeIdx = -1;
|
||
}
|
||
|
||
function buildItem(item, q) {
|
||
var li = document.createElement('li');
|
||
li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-zinc-800 transition-colors';
|
||
li.dataset.url = item.source_url || '';
|
||
|
||
// Cover image
|
||
if (item.cover) {
|
||
var img = document.createElement('img');
|
||
img.src = item.cover;
|
||
img.alt = '';
|
||
img.className = 'w-10 h-14 object-cover rounded flex-shrink-0 bg-zinc-800';
|
||
li.appendChild(img);
|
||
} else {
|
||
var ph = document.createElement('div');
|
||
ph.className = 'w-10 h-14 rounded flex-shrink-0 bg-zinc-800';
|
||
li.appendChild(ph);
|
||
}
|
||
|
||
// Text block
|
||
var txt = document.createElement('div');
|
||
txt.className = 'min-w-0 flex-1';
|
||
|
||
var title = document.createElement('p');
|
||
title.className = 'text-sm font-medium text-zinc-100 truncate';
|
||
title.textContent = item.title || '';
|
||
txt.appendChild(title);
|
||
|
||
if (item.author) {
|
||
var author = document.createElement('p');
|
||
author.className = 'text-xs text-zinc-400 truncate mt-0.5';
|
||
author.textContent = item.author;
|
||
txt.appendChild(author);
|
||
}
|
||
|
||
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) {
|
||
searchInput.value = items[idx].title || '';
|
||
urlInput.value = items[idx].source_url || '';
|
||
}
|
||
}
|
||
|
||
function showDrop(items, q) {
|
||
dropdown.innerHTML = '';
|
||
activeIdx = -1;
|
||
if (!items.length) { closeDrop(); return; }
|
||
items.forEach(function (item, i) {
|
||
var li = buildItem(item, q);
|
||
li.addEventListener('mousedown', function (e) {
|
||
e.preventDefault(); // keep focus on input
|
||
searchInput.value = item.title || item.source_url || '';
|
||
urlInput.value = item.source_url || '';
|
||
closeDrop();
|
||
});
|
||
dropdown.appendChild(li);
|
||
});
|
||
dropdown.classList.remove('hidden');
|
||
}
|
||
|
||
searchInput.addEventListener('input', function () {
|
||
var q = searchInput.value.trim().toLowerCase();
|
||
syncURLField(searchInput.value);
|
||
if (!q) { closeDrop(); return; }
|
||
// If it looks like a URL, no autocomplete needed.
|
||
if (/^https?:\/\//i.test(q)) { closeDrop(); return; }
|
||
var results = RANKING.filter(function (item) {
|
||
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '')).toLowerCase();
|
||
return haystack.indexOf(q) !== -1;
|
||
}).slice(0, 8);
|
||
showDrop(results, q);
|
||
});
|
||
|
||
searchInput.addEventListener('keydown', function (e) {
|
||
var lis = dropdown.querySelectorAll('li');
|
||
var items = RANKING.filter(function (item) {
|
||
var q = searchInput.value.trim().toLowerCase();
|
||
var haystack = ((item.title || '') + ' ' + (item.author || '') + ' ' + (item.status || '')).toLowerCase();
|
||
return haystack.indexOf(q) !== -1;
|
||
}).slice(0, 8);
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setActive(Math.min(activeIdx + 1, lis.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]) {
|
||
searchInput.value = items[activeIdx].title || items[activeIdx].source_url || '';
|
||
urlInput.value = items[activeIdx].source_url || '';
|
||
}
|
||
closeDrop();
|
||
} else if (e.key === 'Escape') {
|
||
closeDrop();
|
||
}
|
||
});
|
||
|
||
// Validate before submit: if url field empty, treat visible input as raw URL.
|
||
form.addEventListener('htmx:configRequest', function (e) {
|
||
var val = searchInput.value.trim();
|
||
if (!urlInput.value && /^https?:\/\//i.test(val)) {
|
||
urlInput.value = val;
|
||
}
|
||
});
|
||
|
||
// Also handle plain form submit (non-HTMX fallback).
|
||
form.addEventListener('submit', function () {
|
||
var val = searchInput.value.trim();
|
||
if (!urlInput.value && /^https?:\/\//i.test(val)) {
|
||
urlInput.value = val;
|
||
}
|
||
});
|
||
|
||
// Close dropdown when clicking outside.
|
||
document.addEventListener('mousedown', function (e) {
|
||
if (!document.getElementById('scrape-search-wrap').contains(e.target)) {
|
||
closeDrop();
|
||
}
|
||
});
|
||
}());
|
||
|
||
}());
|
||
</script>`
|
||
|
||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
books, err := s.writer.ListBooks()
|
||
if err != nil {
|
||
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Load ranking items for the scrape-search autocomplete.
|
||
// Failures are non-fatal — the form degrades to a plain URL input.
|
||
rankingItems, _ := s.writer.ReadRankingItems()
|
||
|
||
// Encode ranking items as JSON for embedding in the template.
|
||
rankingJSON, _ := json.Marshal(rankingItems)
|
||
|
||
t := template.Must(template.New("home").Parse(homeTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Books interface{}
|
||
RankingJSON template.JS
|
||
}{
|
||
Books: books,
|
||
RankingJSON: template.JS(rankingJSON),
|
||
})
|
||
|
||
s.respond(w, r, "Home", buf.String())
|
||
}
|
||
|
||
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
|
||
|
||
const rankingTmpl = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||
<a href="/"
|
||
hx-get="/"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← All books
|
||
</a>
|
||
|
||
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
|
||
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
|
||
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
|
||
</div>
|
||
<div class="flex gap-2 mt-1 flex-wrap items-center">
|
||
<a href="/ranking/view"
|
||
hx-get="/ranking/view"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-700 hover:bg-zinc-600 text-white inline-flex items-center gap-1">
|
||
View Markdown
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div id="ranking-refresh-status" class="mt-2"></div>
|
||
|
||
<!-- Filter bar -->
|
||
<div class="mt-4 mb-4 flex gap-2 items-center">
|
||
<div class="flex-1 relative">
|
||
<input id="ranking-filter" type="search" placeholder="Filter by title, author, genre, status…"
|
||
autocomplete="off" spellcheck="false"
|
||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||
</div>
|
||
<p id="ranking-filter-count" class="text-xs text-zinc-500 whitespace-nowrap hidden"></p>
|
||
</div>
|
||
|
||
<!-- Pagination: fetch pages from novelfire -->
|
||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||
<span class="text-xs text-zinc-500">Fetch pages:</span>
|
||
{{range .Pages}}
|
||
<form hx-post="/ranking/refresh"
|
||
hx-target="#ranking-refresh-status"
|
||
hx-swap="innerHTML">
|
||
<input type="hidden" name="pages" value="{{.Pages}}">
|
||
<button type="submit"
|
||
class="text-xs px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-amber-700 border border-zinc-700 hover:border-amber-600 text-zinc-300 hover:text-white transition-colors">
|
||
{{.Label}}
|
||
</button>
|
||
</form>
|
||
{{end}}
|
||
<span class="text-xs text-zinc-600 ml-1">
|
||
(source:
|
||
<a href="https://novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1"
|
||
target="_blank" rel="noopener noreferrer"
|
||
class="text-zinc-500 hover:text-amber-400 underline underline-offset-2">novelfire.net</a>)
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Book grid -->
|
||
<div id="ranking-grid" class="grid gap-3 sm:grid-cols-2 mt-2">
|
||
{{range .Books}}
|
||
{{if .Local}}
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||
class="group flex gap-3 rounded-xl border border-teal-700 bg-teal-950 p-3 hover:border-teal-400 transition-colors min-w-0">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-1.5 flex-wrap">
|
||
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
|
||
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>
|
||
</div>
|
||
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-1.5 mt-1.5 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300">{{.Status}}</span>{{end}}
|
||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
||
</div>
|
||
{{if .SourceURL}}
|
||
<div class="mt-2">
|
||
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||
</svg>
|
||
Source
|
||
</a>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</a>
|
||
{{else}}
|
||
<div data-filter="{{.Title}} {{.Author}} {{.Status}} {{range .Genres}}{{.}} {{end}}"
|
||
class="group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 hover:border-amber-500 transition-colors min-w-0">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-1.5 flex-wrap">
|
||
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
|
||
</div>
|
||
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-1.5 mt-1.5 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400">{{.}}</span>{{end}}
|
||
</div>
|
||
{{if .SourceURL}}
|
||
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||
<input type="hidden" name="url" value="{{.SourceURL}}">
|
||
<button type="submit"
|
||
class="text-xs px-2 py-0.5 rounded bg-amber-700 hover:bg-amber-600 text-white">
|
||
Scrape
|
||
</button>
|
||
</form>
|
||
<a href="{{.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||
</svg>
|
||
Source
|
||
</a>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
{{else}}
|
||
<p class="text-zinc-500 col-span-2">No ranking data. Use the page buttons above to fetch from novelfire.net.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
var input = document.getElementById('ranking-filter');
|
||
var countEl = document.getElementById('ranking-filter-count');
|
||
var grid = document.getElementById('ranking-grid');
|
||
if (!input || !grid) return;
|
||
|
||
input.addEventListener('input', function () {
|
||
var q = input.value.trim().toLowerCase();
|
||
var cards = grid.querySelectorAll('[data-filter]');
|
||
var shown = 0;
|
||
cards.forEach(function (card) {
|
||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||
card.style.display = match ? '' : 'none';
|
||
if (match) shown++;
|
||
});
|
||
if (q) {
|
||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||
countEl.classList.remove('hidden');
|
||
} else {
|
||
countEl.classList.add('hidden');
|
||
}
|
||
});
|
||
}());
|
||
</script>`
|
||
|
||
// rankingViewItem enriches a RankingItem with whether it is present in the
|
||
// local book library, so the template can highlight it differently.
|
||
type rankingViewItem struct {
|
||
writer.RankingItem
|
||
Local bool
|
||
}
|
||
|
||
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
|
||
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
|
||
out := make([]rankingViewItem, len(items))
|
||
for i, it := range items {
|
||
out[i] = rankingViewItem{
|
||
RankingItem: it,
|
||
Local: localSlugs[it.Slug],
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// refreshPage is one entry in the ranking refresh pagination bar.
|
||
type refreshPage struct {
|
||
Label string
|
||
Pages int // 0 means "all pages"
|
||
}
|
||
|
||
// rankingRefreshPages defines the pagination buttons shown on the ranking page.
|
||
// Each entry fetches that many pages from novelfire.net (100 novels per page).
|
||
// Pages == 0 means fetch all pages.
|
||
var rankingRefreshPages = []refreshPage{
|
||
{"p.1 (top 100)", 1},
|
||
{"p.1–3 (top 300)", 3},
|
||
{"p.1–5 (top 500)", 5},
|
||
{"p.1–10 (top 1000)", 10},
|
||
{"All", 0},
|
||
}
|
||
|
||
// handleRanking serves the ranking page from the cached ranking.md file.
|
||
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
|
||
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||
rankingItems, err := s.writer.ReadRankingItems()
|
||
if err != nil {
|
||
s.log.Error("failed to read cached ranking", "err", err)
|
||
}
|
||
|
||
cachedAt := ""
|
||
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
|
||
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
|
||
}
|
||
|
||
t := template.Must(template.New("ranking").Parse(rankingTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Books interface{}
|
||
CachedAt string
|
||
Pages []refreshPage
|
||
}{
|
||
Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()),
|
||
CachedAt: cachedAt,
|
||
Pages: rankingRefreshPages,
|
||
})
|
||
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>
|
||
|
||
<div class="prose prose-invert max-w-none">
|
||
{{.HTML}}
|
||
</div>
|
||
</div>`
|
||
|
||
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
|
||
markdown, err := s.writer.ReadRanking()
|
||
if err != nil {
|
||
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if markdown == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
var htmlBuf bytes.Buffer
|
||
if err := md.Convert([]byte(markdown), &htmlBuf); err != nil {
|
||
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct{ HTML template.HTML }{HTML: template.HTML(htmlBuf.String())})
|
||
|
||
s.respond(w, r, "Ranking Data", buf.String())
|
||
}
|
||
|
||
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
|
||
|
||
const chapterPageSize = 50
|
||
|
||
const bookTmpl = `
|
||
<div class="max-w-2xl mx-auto px-4 py-10">
|
||
<a href="/"
|
||
hx-get="/"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← All books
|
||
</a>
|
||
|
||
<div class="flex gap-5 mb-8 mt-4">
|
||
{{if .Meta.Cover}}
|
||
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
|
||
{{end}}
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
||
{{if .Meta.SourceURL}}
|
||
<a href="{{.Meta.SourceURL}}" target="_blank" rel="noopener noreferrer"
|
||
title="View on novelfire.net"
|
||
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 hover:text-white transition-colors">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||
</svg>
|
||
Source
|
||
</a>
|
||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
|
||
<button type="submit"
|
||
class="text-xs px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white"
|
||
title="Re-scrape from source">
|
||
Refresh
|
||
</button>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
{{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
|
||
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
|
||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
|
||
</div>
|
||
{{if .Meta.Summary}}
|
||
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resume reading button — populated by JS from localStorage -->
|
||
<div id="resume-bar" class="mb-4 hidden">
|
||
<a id="resume-link"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-700 hover:bg-amber-600 text-white text-sm font-medium transition-colors cursor-pointer">
|
||
▶ Resume — Chapter <span id="resume-chapter-num"></span>
|
||
</a>
|
||
</div>
|
||
|
||
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
|
||
<ul id="chapter-list" class="space-y-1">
|
||
{{range .Chapters}}
|
||
<li>
|
||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
|
||
data-chapter="{{.Number}}">
|
||
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
|
||
<div class="min-w-0 flex-1">
|
||
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
|
||
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
|
||
</div>
|
||
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
|
||
</a>
|
||
</li>
|
||
{{else}}
|
||
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
|
||
{{end}}
|
||
</ul>
|
||
|
||
{{if gt .TotalPages 1}}
|
||
<div id="chapter-pagination" class="mt-6 flex justify-center gap-1 flex-wrap">
|
||
{{if gt .CurrentPage 1}}
|
||
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">«</a>
|
||
{{end}}
|
||
{{range pages .TotalPages}}
|
||
{{if eq . $.CurrentPage}}
|
||
<span class="px-3 py-1.5 rounded-lg bg-amber-700 text-white text-sm font-semibold">{{.}}</span>
|
||
{{else}}
|
||
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{$.Slug}}?page={{.}}"
|
||
hx-on::after-request="applyProgress('{{$.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">{{.}}</a>
|
||
{{end}}
|
||
{{end}}
|
||
{{if lt .CurrentPage .TotalPages}}
|
||
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">»</a>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
var SLUG = '{{.Slug}}';
|
||
var LS_KEY = 'reading_progress';
|
||
|
||
function getProgress() {
|
||
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
|
||
}
|
||
|
||
window.applyProgress = function(slug) {
|
||
var progress = getProgress();
|
||
var saved = progress[slug];
|
||
if (!saved) return;
|
||
|
||
// Highlight the saved chapter row.
|
||
document.querySelectorAll('.chapter-row').forEach(function (a) {
|
||
var dot = a.querySelector('.progress-dot');
|
||
if (!dot) return;
|
||
if (parseInt(a.dataset.chapter) === saved) {
|
||
dot.classList.remove('hidden');
|
||
a.classList.add('bg-zinc-800/50');
|
||
}
|
||
});
|
||
|
||
// Show resume bar.
|
||
var bar = document.getElementById('resume-bar');
|
||
var link = document.getElementById('resume-link');
|
||
var num = document.getElementById('resume-chapter-num');
|
||
if (bar && link && num) {
|
||
var href = '/books/' + slug + '/chapters/' + saved;
|
||
num.textContent = saved;
|
||
link.setAttribute('href', href);
|
||
link.setAttribute('hx-get', href);
|
||
htmx.process(link);
|
||
bar.classList.remove('hidden');
|
||
}
|
||
};
|
||
|
||
applyProgress(SLUG);
|
||
}());
|
||
</script>`
|
||
|
||
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
|
||
meta, ok, err := s.writer.ReadMetadata(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
chapters, err := s.writer.ListChapters(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
total := len(chapters)
|
||
totalPages := (total + chapterPageSize - 1) / chapterPageSize
|
||
if totalPages < 1 {
|
||
totalPages = 1
|
||
}
|
||
|
||
currentPage := 1
|
||
page := chapters
|
||
if total > chapterPageSize {
|
||
page = chapters[:chapterPageSize]
|
||
}
|
||
|
||
funcMap := template.FuncMap{
|
||
"pages": func(n int) []int {
|
||
out := make([]int, n)
|
||
for i := range out {
|
||
out[i] = i + 1
|
||
}
|
||
return out
|
||
},
|
||
"prev": func(n int) int { return n - 1 },
|
||
"next": func(n int) int { return n + 1 },
|
||
}
|
||
|
||
t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
Meta interface{}
|
||
Chapters interface{}
|
||
TotalDownloaded int
|
||
TotalPages int
|
||
CurrentPage int
|
||
}{
|
||
Slug: slug,
|
||
Meta: meta,
|
||
Chapters: page,
|
||
TotalDownloaded: total,
|
||
TotalPages: totalPages,
|
||
CurrentPage: currentPage,
|
||
})
|
||
|
||
s.respond(w, r, meta.Title, buf.String())
|
||
}
|
||
|
||
// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ────────
|
||
|
||
const chapterPageTmpl = `{{range .Chapters}}
|
||
<li>
|
||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
|
||
data-chapter="{{.Number}}">
|
||
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
|
||
<div class="min-w-0 flex-1">
|
||
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
|
||
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
|
||
</div>
|
||
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0 w-2 h-2 rounded-full bg-amber-400"></span>
|
||
</a>
|
||
</li>
|
||
{{end}}
|
||
<div id="chapter-pagination" hx-swap-oob="true" class="mt-6 flex justify-center gap-1 flex-wrap">
|
||
{{if gt .CurrentPage 1}}
|
||
<a hx-get="/books/{{.Slug}}/chapters-page?page={{prev .CurrentPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{.Slug}}?page={{prev .CurrentPage}}"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">«</a>
|
||
{{end}}
|
||
{{range pages .TotalPages}}
|
||
{{if eq . $.CurrentPage}}
|
||
<span class="px-3 py-1.5 rounded-lg bg-amber-700 text-white text-sm font-semibold">{{.}}</span>
|
||
{{else}}
|
||
<a hx-get="/books/{{$.Slug}}/chapters-page?page={{.}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{$.Slug}}?page={{.}}"
|
||
hx-on::after-request="applyProgress('{{$.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">{{.}}</a>
|
||
{{end}}
|
||
{{end}}
|
||
{{if lt .CurrentPage .TotalPages}}
|
||
<a hx-get="/books/{{.Slug}}/chapters-page?page={{next .CurrentPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="innerHTML"
|
||
hx-push-url="/books/{{.Slug}}?page={{next .CurrentPage}}"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors cursor-pointer">»</a>
|
||
{{end}}
|
||
</div>`
|
||
|
||
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
currentPage := 1
|
||
if p := r.URL.Query().Get("page"); p != "" {
|
||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||
currentPage = n
|
||
}
|
||
}
|
||
|
||
chapters, err := s.writer.ListChapters(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
total := len(chapters)
|
||
totalPages := (total + chapterPageSize - 1) / chapterPageSize
|
||
if totalPages < 1 {
|
||
totalPages = 1
|
||
}
|
||
|
||
start := (currentPage - 1) * chapterPageSize
|
||
if start >= total {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
end := start + chapterPageSize
|
||
if end > total {
|
||
end = total
|
||
}
|
||
|
||
funcMap := template.FuncMap{
|
||
"pages": func(n int) []int {
|
||
out := make([]int, n)
|
||
for i := range out {
|
||
out[i] = i + 1
|
||
}
|
||
return out
|
||
},
|
||
"prev": func(n int) int { return n - 1 },
|
||
"next": func(n int) int { return n + 1 },
|
||
}
|
||
|
||
t := template.Must(template.New("chapterPage").Funcs(funcMap).Parse(chapterPageTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
Chapters interface{}
|
||
TotalPages int
|
||
CurrentPage int
|
||
}{
|
||
Slug: slug,
|
||
Chapters: chapters[start:end],
|
||
TotalPages: totalPages,
|
||
CurrentPage: currentPage,
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = buf.WriteTo(w)
|
||
}
|
||
|
||
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
|
||
|
||
const chapterTmpl = `
|
||
<!-- ─── Sticky reader toolbar (nav, not header, so Reader Mode ignores it) ─── -->
|
||
<!-- aria-hidden keeps it out of accessibility trees / Reader Mode heuristics -->
|
||
<nav id="reader-header" aria-hidden="true"
|
||
style="position:sticky;top:0;z-index:50;background:#09090b;border-bottom:1px solid #27272a;">
|
||
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-12">
|
||
|
||
<!-- Back to book -->
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
title="Chapter list"
|
||
aria-label="Back to chapter list"
|
||
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;color:#a1a1aa;font-size:0.875rem;text-decoration:none;transition:color 0.15s;"
|
||
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
|
||
←
|
||
</a>
|
||
|
||
<!-- Prev chapter -->
|
||
{{if .PrevN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
title="Previous chapter"
|
||
aria-label="Previous chapter"
|
||
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
|
||
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
|
||
← Prev
|
||
</a>
|
||
{{else}}
|
||
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;" aria-hidden="true">← Prev</span>
|
||
{{end}}
|
||
|
||
<!-- Chapter title button — opens chapter list drawer -->
|
||
<button id="chapter-list-btn"
|
||
onclick="toggleChapterList()"
|
||
title="Jump to chapter"
|
||
aria-label="Jump to chapter"
|
||
style="flex:1;min-width:0;display:flex;align-items:center;justify-content:center;gap:0.375rem;overflow:hidden;background:transparent;border:none;cursor:pointer;padding:0.375rem 0.25rem;">
|
||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8125rem;color:#a1a1aa;">{{.Title}}</span>
|
||
<span style="flex-shrink:0;font-size:0.625rem;color:#52525b;" aria-hidden="true">▼</span>
|
||
</button>
|
||
|
||
<!-- Next chapter -->
|
||
{{if .NextN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
title="Next chapter"
|
||
aria-label="Next chapter"
|
||
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
|
||
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
|
||
Next →
|
||
</a>
|
||
{{else}}
|
||
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;" aria-hidden="true">Next →</span>
|
||
{{end}}
|
||
|
||
<!-- TTS play/pause button -->
|
||
<button id="tts-btn"
|
||
onclick="ttsToggle()"
|
||
title="Listen"
|
||
aria-label="Listen"
|
||
style="flex-shrink:0;display:flex;align-items:center;gap:0.375rem;padding:0.375rem 0.75rem;border-radius:0.5rem;background:#d97706;color:#fff;font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:background 0.15s;"
|
||
onmouseover="this.style.background='#b45309'" onmouseout="this.style.background='#d97706'">
|
||
<span id="tts-icon" aria-hidden="true">▶</span>
|
||
<span id="tts-label">Listen</span>
|
||
</button>
|
||
|
||
<!-- Settings gear button -->
|
||
<button id="settings-btn"
|
||
onclick="toggleSettings()"
|
||
title="Settings"
|
||
aria-label="Settings"
|
||
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:1rem;border:none;cursor:pointer;transition:color 0.15s;"
|
||
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
|
||
⚙
|
||
</button>
|
||
</div>
|
||
|
||
<!-- TTS status strip -->
|
||
<div id="tts-status-bar" style="display:none;padding:0.125rem 1rem;background:#18181b;font-size:0.75rem;color:#71717a;text-align:center;" aria-live="polite">
|
||
<span id="tts-status"></span>
|
||
</div>
|
||
|
||
<!-- Chapter list drawer -->
|
||
<div id="chapter-list-panel"
|
||
style="display:none;position:absolute;left:0;right:0;top:100%;max-height:60vh;overflow-y:auto;background:#18181b;border-bottom:1px solid #27272a;box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:99;">
|
||
<div style="max-width:42rem;margin:0 auto;padding:0.5rem 0;">
|
||
{{range .AllChapters}}
|
||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
onclick="closeChapterList()"
|
||
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
|
||
style="display:flex;align-items:baseline;gap:0.75rem;padding:0.5rem 1rem;text-decoration:none;transition:background 0.1s;{{if eq .Number $.ChapterN}}background:#27272a;{{end}}"
|
||
onmouseover="this.style.background='#27272a'" onmouseout="this.style.background='{{if eq .Number $.ChapterN}}#27272a{{else}}transparent{{end}}'">
|
||
<span style="flex-shrink:0;font-size:0.75rem;color:#52525b;width:2.5rem;text-align:right;" aria-hidden="true">{{.Number}}</span>
|
||
<span style="font-size:0.875rem;{{if eq .Number $.ChapterN}}color:#fbbf24;font-weight:500;{{else}}color:#d4d4d8;{{end}}">{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}</span>
|
||
{{if eq .Number $.ChapterN}}<span style="flex-shrink:0;font-size:0.7rem;color:#f59e0b;margin-left:auto;" aria-hidden="true">▶ now</span>{{end}}
|
||
</a>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings dropdown panel -->
|
||
<div id="settings-panel"
|
||
style="display:none;position:absolute;right:max(0.5rem,calc(50% - 32rem + 0.5rem));top:calc(100% + 0.25rem);min-width:260px;background:#18181b;border:1px solid #27272a;border-radius:0.75rem;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:100;">
|
||
<!-- Voice -->
|
||
<label style="display:block;margin-bottom:0.875rem;">
|
||
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Voice</span>
|
||
<select id="tts-voice"
|
||
style="width:100%;border-radius:0.5rem;background:#27272a;border:1px solid #3f3f46;padding:0.375rem 0.5rem;font-size:0.875rem;color:#e4e4e7;outline:none;">
|
||
{{range .Voices}}
|
||
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
|
||
{{end}}
|
||
</select>
|
||
</label>
|
||
<!-- Speed -->
|
||
<label style="display:block;margin-bottom:0.875rem;">
|
||
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Speed — <span id="tts-speed-label">1.0×</span></span>
|
||
<input id="tts-speed" type="range"
|
||
min="0.5" max="2" step="0.1" value="1"
|
||
style="width:100%;accent-color:#f59e0b;cursor:pointer;" />
|
||
</label>
|
||
<!-- Auto-next -->
|
||
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;user-select:none;">
|
||
<input id="tts-autoplay" type="checkbox" style="accent-color:#f59e0b;cursor:pointer;width:1rem;height:1rem;" />
|
||
<span style="font-size:0.875rem;color:#d4d4d8;">Auto-play next chapter</span>
|
||
</label>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Audio element outside nav/header so Reader Mode never sees it -->
|
||
<audio id="tts-audio" aria-hidden="true" style="display:none"></audio>
|
||
|
||
<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
|
||
<!-- role="main" + <article> give Reader Mode a strong content anchor -->
|
||
<div role="main" class="max-w-2xl mx-auto px-4 py-10">
|
||
|
||
<!-- Visible h1 inside the content area — Reader Mode anchors on this title -->
|
||
<h1 class="text-2xl font-bold text-zinc-100 mb-6 leading-snug">{{.Title}}</h1>
|
||
|
||
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
|
||
{{.HTML}}
|
||
</article>
|
||
|
||
<nav aria-label="Chapter pagination" class="flex justify-between mt-12 pt-6 border-t border-zinc-800">
|
||
{{if .PrevN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
← Previous chapter
|
||
</a>
|
||
{{else}}<span></span>{{end}}
|
||
{{if .NextN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
Next chapter →
|
||
</a>
|
||
{{else}}<span></span>{{end}}
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- ─── Audio queue panel (sticky bottom) ───────────────────────────────────── -->
|
||
<div id="audio-queue-bar"
|
||
role="region"
|
||
aria-label="Audio queue"
|
||
style="position:fixed;bottom:0;left:0;right:0;z-index:60;background:#09090b;border-top:1px solid #27272a;font-size:0.75rem;color:#a1a1aa;">
|
||
|
||
<!-- Toggle handle ─────────────────────────────────────────────────────────── -->
|
||
<button id="queue-toggle"
|
||
onclick="window.toggleQueuePanel()"
|
||
aria-expanded="false"
|
||
style="width:100%;display:flex;align-items:center;justify-content:between;gap:0.5rem;padding:0.4rem 1rem;background:transparent;border:none;cursor:pointer;color:#a1a1aa;font-size:0.7rem;text-align:left;">
|
||
<span style="flex:1;display:flex;align-items:center;gap:0.5rem;">
|
||
<span id="queue-toggle-icon" style="font-size:0.65rem;">▲</span>
|
||
<span style="font-weight:600;letter-spacing:0.05em;text-transform:uppercase;">Audio Queue</span>
|
||
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
|
||
</span>
|
||
<!-- Mini scrubber always visible in the handle row -->
|
||
<span style="flex:2;display:flex;align-items:center;gap:0.5rem;padding:0 0.5rem;">
|
||
<span id="queue-time-cur" style="min-width:2.5rem;text-align:right;font-variant-numeric:tabular-nums;">0:00</span>
|
||
<span style="flex:1;position:relative;height:3px;background:#3f3f46;border-radius:2px;overflow:hidden;">
|
||
<span id="queue-scrubber-fill" style="position:absolute;left:0;top:0;height:100%;width:0%;background:#f59e0b;transition:width 0.3s linear;"></span>
|
||
</span>
|
||
<span id="queue-time-tot" style="min-width:2.5rem;font-variant-numeric:tabular-nums;">0:00</span>
|
||
</span>
|
||
</button>
|
||
|
||
<!-- Expanded panel ────────────────────────────────────────────────────────── -->
|
||
<div id="queue-panel-body" style="display:none;border-top:1px solid #27272a;">
|
||
|
||
<!-- Now playing row -->
|
||
<div class="queue-row" id="queue-row-current">
|
||
<span class="queue-row-label">Now</span>
|
||
<span class="queue-row-title" id="queue-cur-title">—</span>
|
||
<span class="queue-badge" id="queue-cur-badge">idle</span>
|
||
</div>
|
||
|
||
<!-- Up next row (hidden if no next chapter) -->
|
||
<div class="queue-row" id="queue-row-next" style="display:none;">
|
||
<span class="queue-row-label">Next</span>
|
||
<span class="queue-row-title" id="queue-next-title">—</span>
|
||
<span class="queue-badge" id="queue-next-badge">—</span>
|
||
</div>
|
||
|
||
<!-- Debug info row -->
|
||
<div class="queue-row queue-row-debug" id="queue-row-debug">
|
||
<span class="queue-row-label">dbg</span>
|
||
<span id="queue-debug-text" style="font-family:monospace;word-break:break-all;"></span>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* Paragraph TTS highlight — no cursor change so paragraphs look editorial */
|
||
#chapter-article p {
|
||
border-radius: 0.375rem;
|
||
margin-left: -0.5rem;
|
||
padding-left: 0.5rem;
|
||
transition: background 0.15s;
|
||
}
|
||
/* Subtle tap affordance only on touch/pointer devices that support hover */
|
||
@media (hover: hover) {
|
||
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
|
||
}
|
||
#chapter-article p.tts-active {
|
||
background: rgba(251,191,36,0.13);
|
||
border-left: 2px solid #f59e0b;
|
||
padding-left: calc(0.5rem - 2px);
|
||
}
|
||
/* Prevent sticky nav from covering anchor targets */
|
||
#main-content { scroll-padding-top: 3.5rem; }
|
||
/* Improve base reading typography */
|
||
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
|
||
#chapter-article p + p { margin-top: 0; }
|
||
|
||
/* ── Audio queue bar ─────────────────────────────────────────────────────── */
|
||
/* Extra bottom padding so chapter text is never hidden behind the sticky bar */
|
||
#main-content { padding-bottom: 4rem; }
|
||
|
||
.queue-badge {
|
||
display: inline-block;
|
||
padding: 0.1em 0.5em;
|
||
border-radius: 9999px;
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
.queue-badge-idle { background: #27272a; color: #71717a; }
|
||
.queue-badge-generating { background: #78350f; color: #fbbf24; }
|
||
.queue-badge-ready { background: #14532d; color: #4ade80; }
|
||
.queue-badge-playing { background: #1e3a5f; color: #60a5fa; }
|
||
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
||
.queue-badge-error { background: #450a0a; color: #f87171; }
|
||
|
||
.queue-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 1rem;
|
||
border-bottom: 1px solid #18181b;
|
||
}
|
||
.queue-row-label {
|
||
width: 2.5rem;
|
||
flex-shrink: 0;
|
||
font-weight: 700;
|
||
font-size: 0.65rem;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: #52525b;
|
||
}
|
||
.queue-row-title {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #d4d4d8;
|
||
}
|
||
.queue-row-debug {
|
||
background: #0c0c0e;
|
||
color: #52525b;
|
||
font-size: 0.65rem;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
(function () {
|
||
var NEXT_N = {{.NextN}};
|
||
var SLUG = '{{.Slug}}';
|
||
var CHAPTER_N = {{.ChapterN}};
|
||
|
||
// ── reading progress ──────────────────────────────────────────────────────────
|
||
(function saveProgress() {
|
||
try {
|
||
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
|
||
p[SLUG] = CHAPTER_N;
|
||
localStorage.setItem('reading_progress', JSON.stringify(p));
|
||
} catch(_) {}
|
||
})();
|
||
|
||
var audio = document.getElementById('tts-audio');
|
||
var btn = document.getElementById('tts-btn');
|
||
var icon = document.getElementById('tts-icon');
|
||
var label = document.getElementById('tts-label');
|
||
var statusEl = document.getElementById('tts-status');
|
||
var statusBar = document.getElementById('tts-status-bar');
|
||
var voiceSel = document.getElementById('tts-voice');
|
||
var speedSlider = document.getElementById('tts-speed');
|
||
var speedLabel = document.getElementById('tts-speed-label');
|
||
var autoplayChk = document.getElementById('tts-autoplay');
|
||
var article = document.getElementById('chapter-article');
|
||
|
||
// ── audio queue bar DOM refs ───────────────────────────────────────────────────
|
||
var queuePanelBody = document.getElementById('queue-panel-body');
|
||
var queueToggleBtn = document.getElementById('queue-toggle');
|
||
var queueToggleIcon = document.getElementById('queue-toggle-icon');
|
||
var queueNowBadge = document.getElementById('queue-now-badge');
|
||
var queueTimeCur = document.getElementById('queue-time-cur');
|
||
var queueTimeTot = document.getElementById('queue-time-tot');
|
||
var queueFill = document.getElementById('queue-scrubber-fill');
|
||
var queueRowNext = document.getElementById('queue-row-next');
|
||
var queueCurTitle = document.getElementById('queue-cur-title');
|
||
var queueCurBadge = document.getElementById('queue-cur-badge');
|
||
var queueNextTitle = document.getElementById('queue-next-title');
|
||
var queueNextBadge = document.getElementById('queue-next-badge');
|
||
var queueDebugText = document.getElementById('queue-debug-text');
|
||
|
||
// ── audio queue helpers ────────────────────────────────────────────────────────
|
||
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
|
||
'queue-badge-playing','queue-badge-paused','queue-badge-error'];
|
||
|
||
function setBadge(el, state) {
|
||
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
|
||
el.classList.add('queue-badge-' + state);
|
||
el.textContent = state;
|
||
}
|
||
|
||
function fmtTime(secs) {
|
||
if (!isFinite(secs) || secs < 0) return '0:00';
|
||
var m = Math.floor(secs / 60);
|
||
var s = Math.floor(secs % 60);
|
||
return m + ':' + (s < 10 ? '0' : '') + s;
|
||
}
|
||
|
||
function queueUpdateScrubber() {
|
||
var cur = audio.currentTime || 0;
|
||
var tot = audio.duration;
|
||
queueTimeCur.textContent = fmtTime(cur);
|
||
queueTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
|
||
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
|
||
queueFill.style.width = pct.toFixed(1) + '%';
|
||
}
|
||
|
||
function queueSetCurrent(state, debugMsg) {
|
||
setBadge(queueNowBadge, state);
|
||
setBadge(queueCurBadge, state);
|
||
queueCurTitle.textContent = 'Ch. ' + CHAPTER_N;
|
||
if (debugMsg !== undefined) {
|
||
queueDebugText.textContent = debugMsg;
|
||
}
|
||
queueUpdateScrubber();
|
||
}
|
||
|
||
function queueSetNext(state, debugMsg) {
|
||
if (!NEXT_N) { queueRowNext.style.display = 'none'; return; }
|
||
queueRowNext.style.display = '';
|
||
setBadge(queueNextBadge, state);
|
||
queueNextTitle.textContent = 'Ch. ' + NEXT_N;
|
||
if (debugMsg !== undefined) {
|
||
queueDebugText.textContent = debugMsg;
|
||
}
|
||
}
|
||
|
||
// Toggle expand/collapse.
|
||
window.toggleQueuePanel = function () {
|
||
var open = queuePanelBody.style.display !== 'none';
|
||
queuePanelBody.style.display = open ? 'none' : '';
|
||
queueToggleIcon.innerHTML = open ? '▲' : '▼';
|
||
queueToggleBtn.setAttribute('aria-expanded', String(!open));
|
||
};
|
||
|
||
// ── panel toggles ─────────────────────────────────────────────────────────────
|
||
var settingsPanel = document.getElementById('settings-panel');
|
||
var chapterListPanel = document.getElementById('chapter-list-panel');
|
||
var chapterListBtn = document.getElementById('chapter-list-btn');
|
||
|
||
window.closeChapterList = function () {
|
||
chapterListPanel.style.display = 'none';
|
||
chapterListBtn.querySelector('span:last-child').innerHTML = '▼';
|
||
};
|
||
window.toggleChapterList = function () {
|
||
var open = chapterListPanel.style.display !== 'none';
|
||
settingsPanel.style.display = 'none';
|
||
if (open) {
|
||
closeChapterList();
|
||
} else {
|
||
chapterListPanel.style.display = 'block';
|
||
chapterListBtn.querySelector('span:last-child').innerHTML = '▲';
|
||
var cur = chapterListPanel.querySelector('[data-current="1"]');
|
||
if (cur) cur.scrollIntoView({ block: 'center' });
|
||
}
|
||
};
|
||
window.toggleSettings = function () {
|
||
var open = settingsPanel.style.display !== 'none';
|
||
closeChapterList();
|
||
settingsPanel.style.display = open ? 'none' : 'block';
|
||
};
|
||
document.addEventListener('click', function (e) {
|
||
var sb = document.getElementById('settings-btn');
|
||
if (settingsPanel.style.display !== 'none' &&
|
||
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) {
|
||
settingsPanel.style.display = 'none';
|
||
}
|
||
if (chapterListPanel.style.display !== 'none' &&
|
||
!chapterListPanel.contains(e.target) &&
|
||
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
|
||
closeChapterList();
|
||
}
|
||
});
|
||
|
||
// ── status strip ──────────────────────────────────────────────────────────────
|
||
function setStatus(text) {
|
||
statusEl.textContent = text;
|
||
statusBar.style.display = text ? '' : 'none';
|
||
}
|
||
|
||
// ── localStorage settings ─────────────────────────────────────────────────────
|
||
var LS_SPEED = 'tts_speed';
|
||
var LS_VOICE = 'tts_voice';
|
||
var LS_AUTONEXT = 'tts_autonext';
|
||
|
||
(function loadSettings() {
|
||
var spd = localStorage.getItem(LS_SPEED);
|
||
if (spd !== null) {
|
||
speedSlider.value = spd;
|
||
speedLabel.textContent = parseFloat(spd).toFixed(1) + '\u00D7';
|
||
}
|
||
var vc = localStorage.getItem(LS_VOICE);
|
||
if (vc !== null) {
|
||
var opt = voiceSel.querySelector('option[value="' + vc + '"]');
|
||
if (opt) voiceSel.value = vc;
|
||
}
|
||
var an = localStorage.getItem(LS_AUTONEXT);
|
||
if (an !== null) autoplayChk.checked = an === 'true';
|
||
})();
|
||
|
||
speedSlider.addEventListener('input', function () {
|
||
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
|
||
localStorage.setItem(LS_SPEED, speedSlider.value);
|
||
prefetchFired = false; // voice/speed changed — invalidate prefetch
|
||
});
|
||
voiceSel.addEventListener('change', function () {
|
||
localStorage.setItem(LS_VOICE, voiceSel.value);
|
||
prefetchFired = false; // voice/speed changed — invalidate prefetch
|
||
});
|
||
autoplayChk.addEventListener('change', function () {
|
||
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
|
||
});
|
||
|
||
// ── paragraph indexing ───────────────────────────────────────────────────────
|
||
// Clicking a paragraph seeks to the proportional position in the audio file.
|
||
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
|
||
var activePara = null;
|
||
|
||
paras.forEach(function (p, i) {
|
||
p.dataset.paraIdx = i;
|
||
p.addEventListener('click', function () { seekToPara(i); });
|
||
});
|
||
|
||
function highlightPara(idx) {
|
||
if (activePara) activePara.classList.remove('tts-active');
|
||
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
|
||
if (activePara) {
|
||
activePara.classList.add('tts-active');
|
||
activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
// Seek to the proportional position in the loaded audio corresponding to para idx.
|
||
function seekToPara(idx) {
|
||
if (!audio.src || !audio.duration || !isFinite(audio.duration)) {
|
||
// Audio not loaded yet — generate it first, then seek once ready.
|
||
generateAudio(CHAPTER_N, function (url) {
|
||
audio.src = url;
|
||
audio.addEventListener('loadedmetadata', function onMeta() {
|
||
audio.removeEventListener('loadedmetadata', onMeta);
|
||
doSeek(idx);
|
||
});
|
||
audio.load();
|
||
});
|
||
return;
|
||
}
|
||
doSeek(idx);
|
||
}
|
||
|
||
function doSeek(idx) {
|
||
if (!audio.duration || !isFinite(audio.duration)) return;
|
||
var ratio = paras.length > 1 ? idx / (paras.length - 1) : 0;
|
||
audio.currentTime = ratio * audio.duration;
|
||
highlightPara(idx);
|
||
if (audio.paused) audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
|
||
}
|
||
|
||
// ── UI state helpers ──────────────────────────────────────────────────────────
|
||
function setGenerating() {
|
||
icon.textContent = '\u231B';
|
||
label.textContent = 'Generating\u2026';
|
||
setStatus('Generating audio on server\u2026');
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.6';
|
||
btn.style.cursor = 'not-allowed';
|
||
voiceSel.disabled = true;
|
||
speedSlider.disabled = true;
|
||
queueSetCurrent('generating', 'POST /ui/audio/' + SLUG + '/' + CHAPTER_N);
|
||
}
|
||
function setPlaying() {
|
||
icon.innerHTML = '▮▮';
|
||
label.textContent = 'Pause';
|
||
setStatus('Playing');
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.style.cursor = 'pointer';
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
queueSetCurrent('playing', 'audio.src set, playing ch.' + CHAPTER_N);
|
||
}
|
||
function setPaused() {
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Resume';
|
||
setStatus('Paused');
|
||
queueSetCurrent('paused');
|
||
}
|
||
function setStopped() {
|
||
highlightPara(-1);
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Listen';
|
||
setStatus('');
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.style.cursor = 'pointer';
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
queueSetCurrent('idle', 'stopped');
|
||
queueSetNext('idle');
|
||
queueUpdateScrubber();
|
||
}
|
||
function setError(msg) {
|
||
highlightPara(-1);
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Listen';
|
||
setStatus('Error: ' + msg);
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.style.cursor = 'pointer';
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
queueSetCurrent('error', 'err: ' + msg);
|
||
}
|
||
|
||
// ── server-side audio generation ─────────────────────────────────────────────
|
||
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}.
|
||
// When merged=false, url is part-0; polling /ui/audio/{slug}/{n}/status
|
||
// detects when the full merged file is ready and swaps audio.src seamlessly.
|
||
// cb(url) is called with the initial (part-0 or merged) URL on success.
|
||
|
||
var currentAudioCtrl = null; // AbortController for the active generateAudio fetch
|
||
var mergePoller = null; // setInterval id for polling merged status
|
||
|
||
function clearMergePoller() {
|
||
if (mergePoller !== null) { clearInterval(mergePoller); mergePoller = null; }
|
||
}
|
||
|
||
function generateAudio(chapterN, cb) {
|
||
// Cancel any previous in-flight generation and polling.
|
||
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
|
||
clearMergePoller();
|
||
var ctrl = new AbortController();
|
||
currentAudioCtrl = ctrl;
|
||
|
||
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
voice: voiceSel.value,
|
||
speed: parseFloat(speedSlider.value)
|
||
}),
|
||
signal: ctrl.signal
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
|
||
return res.json();
|
||
})
|
||
.then(function (data) {
|
||
if (!data || !data.url) throw new Error('no url in response');
|
||
cb(data.url);
|
||
// If the server is still generating remaining parts, start polling.
|
||
if (!data.merged && data.parts > 1) {
|
||
var statusURL = '/ui/audio/' + SLUG + '/' + chapterN + '/status'
|
||
+ '?voice=' + encodeURIComponent(voiceSel.value)
|
||
+ '&speed=' + parseFloat(speedSlider.value);
|
||
mergePoller = setInterval(function () {
|
||
fetch(statusURL)
|
||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||
.then(function (s) {
|
||
if (s.merged) {
|
||
clearMergePoller();
|
||
// Seamlessly swap to the full merged file.
|
||
var ratio = (audio.duration && isFinite(audio.duration))
|
||
? audio.currentTime / audio.duration : 0;
|
||
audio.addEventListener('loadedmetadata', function onMeta() {
|
||
audio.removeEventListener('loadedmetadata', onMeta);
|
||
if (audio.duration && isFinite(audio.duration)) {
|
||
audio.currentTime = ratio * audio.duration;
|
||
}
|
||
}, { once: true });
|
||
audio.src = s.url;
|
||
audio.load();
|
||
}
|
||
})
|
||
.catch(function () { /* ignore transient poll errors */ });
|
||
}, 3000);
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
if (e.name === 'AbortError') return; // silently cancelled
|
||
setError(e.message);
|
||
});
|
||
}
|
||
|
||
// ── next-chapter prefetch ─────────────────────────────────────────────────────
|
||
// At 80 % playback we silently POST to generate the next chapter's audio so
|
||
// it is cached by the time the current chapter ends.
|
||
|
||
var prefetchFired = false; // ensure we only fire once per chapter
|
||
|
||
audio.addEventListener('timeupdate', function () {
|
||
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return;
|
||
if (audio.currentTime / audio.duration >= 0.8) {
|
||
prefetchFired = true;
|
||
queueSetNext('generating', 'POST /ui/audio/' + SLUG + '/' + NEXT_N + ' (prefetch)');
|
||
// Fire-and-forget: idempotent on server.
|
||
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
voice: voiceSel.value,
|
||
speed: parseFloat(speedSlider.value)
|
||
})
|
||
})
|
||
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
|
||
.then(function () { queueSetNext('ready', 'prefetch complete: ch.' + NEXT_N + ' cached'); })
|
||
.catch(function (e) { queueSetNext('error', 'prefetch failed: ' + e); });
|
||
}
|
||
});
|
||
|
||
// ── stop / cleanup ────────────────────────────────────────────────────────────
|
||
function stop() {
|
||
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
|
||
clearMergePoller();
|
||
audio.pause();
|
||
audio.src = '';
|
||
prefetchFired = false;
|
||
setStopped();
|
||
}
|
||
|
||
// ── audio events ──────────────────────────────────────────────────────────────
|
||
audio.addEventListener('canplay', function () {
|
||
if (audio.paused) {
|
||
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
|
||
}
|
||
});
|
||
audio.addEventListener('waiting', function () { setStatus('Buffering\u2026'); });
|
||
audio.addEventListener('playing', setPlaying);
|
||
audio.addEventListener('pause', function () { if (!audio.ended) setPaused(); });
|
||
audio.addEventListener('play', setPlaying);
|
||
audio.addEventListener('error', function () { setError('audio error'); });
|
||
|
||
// Sync paragraph highlight and scrubber while playing (via timeupdate).
|
||
audio.addEventListener('timeupdate', function () {
|
||
queueUpdateScrubber();
|
||
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
|
||
var idx = Math.min(
|
||
Math.floor((audio.currentTime / audio.duration) * paras.length),
|
||
paras.length - 1
|
||
);
|
||
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
|
||
});
|
||
|
||
audio.addEventListener('ended', function () {
|
||
audio.src = '';
|
||
prefetchFired = false;
|
||
if (autoplayChk.checked && NEXT_N) {
|
||
goNextChapter();
|
||
} else {
|
||
setStopped();
|
||
}
|
||
});
|
||
|
||
// ── auto-next ────────────────────────────────────────────────────────────────
|
||
function goNextChapter() {
|
||
if (!NEXT_N) return;
|
||
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
|
||
htmx.ajax('GET', nextURL + '?autoplay=1', {
|
||
target: '#main-content',
|
||
swap: 'innerHTML',
|
||
pushURL: nextURL
|
||
});
|
||
}
|
||
|
||
// ── main entry point ─────────────────────────────────────────────────────────
|
||
function startAudio() {
|
||
setGenerating();
|
||
highlightPara(0);
|
||
generateAudio(CHAPTER_N, function (url) {
|
||
audio.src = url;
|
||
audio.load();
|
||
// canplay event triggers play(); setPlaying() re-enables controls.
|
||
});
|
||
}
|
||
|
||
window.ttsToggle = function () {
|
||
// Already have audio loaded — just play/pause.
|
||
if (audio.src) {
|
||
if (audio.paused) {
|
||
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
|
||
} else {
|
||
audio.pause();
|
||
}
|
||
return;
|
||
}
|
||
startAudio();
|
||
};
|
||
|
||
// Stop cleanly when HTMX navigates away.
|
||
document.body.addEventListener('htmx:beforeSwap', stop);
|
||
|
||
// ── auto-start on ?autoplay=1 ─────────────────────────────────────────────────
|
||
if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
|
||
autoplayChk.checked = true;
|
||
setTimeout(startAudio, 100);
|
||
}
|
||
|
||
// ── initialise queue bar on page load ────────────────────────────────────────
|
||
queueSetCurrent('idle', 'ready — ch.' + CHAPTER_N);
|
||
if (NEXT_N) { queueSetNext('idle', 'waiting — ch.' + NEXT_N); }
|
||
}());
|
||
</script>`
|
||
|
||
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
n, err := strconv.Atoi(r.PathValue("n"))
|
||
if err != nil || n < 1 {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
raw, err := s.writer.ReadChapter(slug, n)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
var htmlBuf bytes.Buffer
|
||
if err := md.Convert([]byte(raw), &htmlBuf); err != nil {
|
||
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
chapters, _ := s.writer.ListChapters(slug)
|
||
prevN, nextN := adjacentChapters(chapters, n)
|
||
|
||
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
|
||
|
||
t := template.Must(template.New("chapter").Parse(chapterTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
HTML template.HTML
|
||
PrevN int
|
||
NextN int
|
||
ChapterN int
|
||
Title string
|
||
AllChapters interface{}
|
||
Voices []string
|
||
DefaultVoice string
|
||
}{
|
||
Slug: slug,
|
||
HTML: template.HTML(htmlBuf.String()),
|
||
PrevN: prevN,
|
||
NextN: nextN,
|
||
ChapterN: n,
|
||
Title: title,
|
||
AllChapters: chapters,
|
||
Voices: kokoroVoices,
|
||
DefaultVoice: s.kokoroVoice,
|
||
})
|
||
|
||
s.respond(w, r, title, buf.String())
|
||
}
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
// stripMarkdown removes Markdown syntax and returns clean plain text.
|
||
func stripMarkdown(src string) string {
|
||
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
|
||
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
|
||
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||
return strings.TrimSpace(src)
|
||
}
|
||
|
||
// adjacentChapters returns the chapter numbers immediately before and after n
|
||
// in the sorted chapters list. 0 means "does not exist".
|
||
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
|
||
for i, ch := range chapters {
|
||
if ch.Number == n {
|
||
if i > 0 {
|
||
prev = chapters[i-1].Number
|
||
}
|
||
if i < len(chapters)-1 {
|
||
next = chapters[i+1].Number
|
||
}
|
||
return
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// firstHeading returns the text of the first non-empty line, stripping a
|
||
// leading "# " markdown heading marker. Falls back to fallback.
|
||
func firstHeading(md, fallback string) string {
|
||
for _, line := range strings.SplitN(md, "\n", 20) {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
return strings.TrimPrefix(line, "# ")
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
|
||
|
||
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||
bookURL := strings.TrimSpace(r.FormValue("url"))
|
||
if bookURL == "" {
|
||
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
|
||
return
|
||
}
|
||
|
||
s.mu.Lock()
|
||
already := s.running
|
||
if !already {
|
||
s.running = true
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
if already {
|
||
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
|
||
return
|
||
}
|
||
|
||
cfg := s.oCfg
|
||
cfg.SingleBookURL = bookURL
|
||
|
||
go func() {
|
||
defer func() {
|
||
s.mu.Lock()
|
||
s.running = false
|
||
s.mu.Unlock()
|
||
}()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
|
||
defer cancel()
|
||
|
||
o := orchestrator.New(cfg, s.novel, s.log)
|
||
if err := o.Run(ctx); err != nil {
|
||
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
|
||
}
|
||
}()
|
||
|
||
// Return a status badge that polls until the job finishes.
|
||
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
|
||
}
|
||
|
||
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
|
||
|
||
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
|
||
s.mu.Lock()
|
||
running := s.running
|
||
s.mu.Unlock()
|
||
|
||
if running {
|
||
// Keep polling every 3 s while the job is in progress.
|
||
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
|
||
return
|
||
}
|
||
// Job finished — show a done badge and stop polling.
|
||
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
|
||
}
|
||
|
||
// scrapeStatusHTML returns a self-contained status badge fragment.
|
||
// state is one of: "running" | "done" | "busy" | "error".
|
||
func scrapeStatusHTML(state, msg string) string {
|
||
var colour, dot, poll string
|
||
switch state {
|
||
case "running":
|
||
colour = "text-amber-300 bg-amber-950 border-amber-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
|
||
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
|
||
case "done":
|
||
colour = "text-green-300 bg-green-950 border-green-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
|
||
case "busy":
|
||
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
|
||
default: // error
|
||
colour = "text-red-300 bg-red-950 border-red-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
|
||
}
|
||
return fmt.Sprintf(
|
||
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
|
||
colour, poll, dot, template.HTMLEscapeString(msg),
|
||
)
|
||
}
|