3023 lines
116 KiB
Go
3023 lines
116 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/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 = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10" hx-history="false">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
|
||
<div class="flex items-center gap-1">
|
||
<a href="/scrape" hx-get="/scrape" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">+ Add</a>
|
||
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg text-amber-400 hover:text-amber-300 transition-colors">Browse Rankings</a>
|
||
</div>
|
||
</div>
|
||
<p class="text-zinc-400 mb-2">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
||
|
||
<!-- Continue reading section (populated by JS) -->
|
||
<div id="continue-reading-section" class="hidden mb-6">
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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}}
|
||
{{if .Downloaded}}<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.Downloaded}} downloaded</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);
|
||
|
||
// Add dismiss button (swipe-left to remove from continue reading).
|
||
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));
|
||
}
|
||
// Slide left and fade out, then remove.
|
||
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();
|
||
// Hide section if no cards remain.
|
||
var remaining = continueGrid.querySelectorAll('[data-slug]');
|
||
if (remaining.length === 0) {
|
||
continueSection.classList.add('hidden');
|
||
}
|
||
}, 260);
|
||
});
|
||
// Make card position:relative so the absolute button positions correctly.
|
||
a.style.position = 'relative';
|
||
a.appendChild(dismissBtn);
|
||
|
||
continueGrid.appendChild(a);
|
||
// Keep the original card visible in #books-grid — do not hide it.
|
||
// Books should always appear in Available regardless of reading progress.
|
||
moved++;
|
||
});
|
||
if (moved > 0) {
|
||
continueSection.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
/* ── filter ────────────────────────────────────────────────────────────── */
|
||
var input = document.getElementById('filter-input');
|
||
var countEl = document.getElementById('filter-count');
|
||
var booksGrid = document.getElementById('books-grid');
|
||
|
||
function filterCards() {
|
||
var q = input.value.trim().toLowerCase();
|
||
var cards = booksGrid ? booksGrid.querySelectorAll('[data-filter]') : [];
|
||
var shown = 0;
|
||
cards.forEach(function(card) {
|
||
var match = !q || card.dataset.filter.toLowerCase().indexOf(q) !== -1;
|
||
card.style.display = match ? '' : 'none';
|
||
if (match) shown++;
|
||
});
|
||
if (q && countEl) {
|
||
countEl.textContent = shown + ' result' + (shown !== 1 ? 's' : '');
|
||
countEl.classList.remove('hidden');
|
||
} else if (countEl) {
|
||
countEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
if (input) input.addEventListener('input', filterCards);
|
||
|
||
}());
|
||
</script>`
|
||
|
||
// homeBookItem wraps BookMeta with the count of chapters already on disk.
|
||
type homeBookItem struct {
|
||
scraper.BookMeta
|
||
Downloaded int
|
||
}
|
||
|
||
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),
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||
|
||
<!-- 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>
|
||
|
||
<a href="/"
|
||
hx-get="/"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← All books
|
||
</a>
|
||
|
||
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
|
||
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
|
||
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
|
||
</div>
|
||
<div class="flex gap-2 mt-1 flex-wrap items-center">
|
||
<a href="/ranking/view"
|
||
hx-get="/ranking/view"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-3 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors inline-flex items-center gap-1">
|
||
View Markdown
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div id="ranking-refresh-status" class="mt-2"></div>
|
||
|
||
<!-- Filter bar -->
|
||
<div class="mt-4 mb-2 flex gap-2 items-center">
|
||
<div class="flex-1 relative">
|
||
<input id="ranking-filter" type="search" placeholder="Filter by title, author…"
|
||
autocomplete="off" spellcheck="false"
|
||
class="w-full rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Facet filters -->
|
||
<div id="ranking-facets" class="mb-4 space-y-2">
|
||
{{if .AllStatuses}}
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Status</span>
|
||
{{range .AllStatuses}}
|
||
<button type="button"
|
||
data-facet="status" data-value="{{.}}"
|
||
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||
{{.}}
|
||
</button>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{if .AllGenres}}
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Genre</span>
|
||
{{range .AllGenres}}
|
||
<button type="button"
|
||
data-facet="genre" data-value="{{.}}"
|
||
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||
{{.}}
|
||
</button>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<span class="text-xs text-zinc-500 w-12 flex-shrink-0">Library</span>
|
||
<button type="button"
|
||
data-facet="local" data-value="1"
|
||
class="facet-btn text-xs px-2 py-0.5 rounded-full border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors">
|
||
In library
|
||
</button>
|
||
<button type="button" id="ranking-clear-filters"
|
||
class="hidden text-xs px-2 py-0.5 rounded-full border border-zinc-800 text-zinc-600 hover:text-zinc-300 hover:border-zinc-600 transition-colors ml-2">
|
||
✕ Clear filters
|
||
</button>
|
||
</div>
|
||
<p id="ranking-filter-count" class="text-xs text-zinc-500 hidden"></p>
|
||
</div>
|
||
|
||
<!-- Display pagination: browse cached items -->
|
||
{{if gt .TotalPages 1}}
|
||
<div id="ranking-pagination" class="mb-4">
|
||
<div class="flex items-center gap-1.5 flex-wrap">
|
||
<span class="text-xs text-zinc-500 mr-1">Page:</span>
|
||
{{$cur := .CurrentPage}}
|
||
{{range .DisplayNums}}
|
||
{{if eq .Num 0}}
|
||
<span class="text-xs text-zinc-600 px-1 select-none">…</span>
|
||
{{else if eq .Num $cur}}
|
||
<span class="text-xs w-8 h-7 rounded-lg text-amber-400 font-semibold flex items-center justify-center">{{.Num}}</span>
|
||
{{else}}
|
||
<a href="/ranking?page={{.Num}}"
|
||
hx-get="/ranking?page={{.Num}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-xs w-8 h-7 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors flex items-center justify-center">
|
||
{{.Num}}
|
||
</a>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
<p class="text-xs text-zinc-600 mt-1">Showing {{.TotalItems}} cached novels · page {{.CurrentPage}} of {{.TotalPages}}</p>
|
||
</div>
|
||
{{end}}
|
||
|
||
<!-- Fetch: pull more pages from novelfire -->
|
||
<details class="mb-6 group">
|
||
<summary class="text-xs text-zinc-500 cursor-pointer select-none hover:text-zinc-300 transition-colors list-none flex items-center gap-1">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||
Fetch more from novelfire.net
|
||
</summary>
|
||
<div class="mt-2 pl-4 border-l border-zinc-800">
|
||
<div class="flex items-center gap-1.5 mb-1 flex-wrap">
|
||
<span class="text-xs text-zinc-500 mr-1">Fetch up to page:</span>
|
||
{{range .FetchNums}}
|
||
{{if eq .Num 0}}
|
||
<span class="text-xs text-zinc-600 px-1 select-none">…</span>
|
||
{{else}}
|
||
<form hx-post="/ranking/refresh"
|
||
hx-target="#ranking-refresh-status"
|
||
hx-swap="innerHTML">
|
||
<input type="hidden" name="pages" value="{{.Num}}">
|
||
<button type="submit"
|
||
class="text-xs w-8 h-7 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors text-center">
|
||
{{.Num}}
|
||
</button>
|
||
</form>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
<p class="text-xs text-zinc-600">
|
||
Each page ≈ 20 novels from
|
||
<a href="https://novelfire.net/genre-all/sort-popular/status-all/all-novel?page=1"
|
||
target="_blank" rel="noopener noreferrer"
|
||
class="text-zinc-500 hover:text-amber-400 underline underline-offset-2">novelfire.net popular</a>
|
||
</p>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Book grid -->
|
||
<div id="ranking-grid" class="grid gap-3 sm:grid-cols-2 mt-2">
|
||
{{range .Books}}
|
||
{{if .Local}}
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
data-filter="{{.Title}} {{.Author}}"
|
||
data-status="{{.Status}}"
|
||
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||
data-local="1"
|
||
class="group flex gap-3 rounded-xl border border-teal-700 bg-teal-950 p-3 hover:border-teal-400 transition-colors min-w-0">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 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)">
|
||
{{end}}
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-start gap-1.5 flex-wrap">
|
||
{{if .Rank}}<span class="text-xs font-bold text-amber-400 flex-shrink-0">#{{.Rank}}</span>{{end}}
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">{{.Title}}</h2>
|
||
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>
|
||
</div>
|
||
{{if .Author}}<p class="text-xs text-zinc-400 mt-0.5 truncate">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-1.5 mt-1.5 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-1.5 py-0.5 rounded-full bg-teal-900 text-teal-300">{{.Status}}</span>{{end}}
|
||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
||
</div>
|
||
{{if .SourceURL}}
|
||
<div class="mt-2">
|
||
<button onclick="event.preventDefault();event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
|
||
class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded text-zinc-400 hover:text-zinc-200 transition-colors cursor-pointer border-0">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||
</svg>
|
||
Source
|
||
</button>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</a>
|
||
{{else}}
|
||
<div data-filter="{{.Title}} {{.Author}}"
|
||
data-status="{{.Status}}"
|
||
data-genres="{{range .Genres}}{{.}}|{{end}}"
|
||
data-local="0"
|
||
{{if .SourceURL}}data-scrape-url="{{.SourceURL}}"{{end}}
|
||
class="ranking-card group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 transition-colors min-w-0{{if .SourceURL}} cursor-pointer hover:border-zinc-600{{end}}">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 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)">
|
||
{{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 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">
|
||
<span class="scrape-hint text-xs text-zinc-500 hidden">Click again to scrape</span>
|
||
<button onclick="event.stopPropagation();window.open('{{.SourceURL}}','_blank','noopener,noreferrer')"
|
||
class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded text-zinc-600 hover:text-zinc-300 transition-colors cursor-pointer border-0 bg-transparent ml-auto">
|
||
<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>
|
||
</button>
|
||
</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 ALL_ITEMS = {{.AllItemsJSON}};
|
||
var input = document.getElementById('ranking-filter');
|
||
var countEl = document.getElementById('ranking-filter-count');
|
||
var clearBtn = document.getElementById('ranking-clear-filters');
|
||
var pagination = document.getElementById('ranking-pagination');
|
||
var grid = document.getElementById('ranking-grid');
|
||
if (!grid) return;
|
||
|
||
// Snapshot of the server-rendered paginated HTML so we can restore it.
|
||
var pagedHTML = grid.innerHTML;
|
||
|
||
// active facets: { status: Set<string>, genre: Set<string>, local: Set<string> }
|
||
var active = { status: new Set(), genre: new Set(), local: new Set() };
|
||
|
||
function isFiltering() {
|
||
return (input && input.value.trim() !== '') ||
|
||
active.status.size > 0 ||
|
||
active.genre.size > 0 ||
|
||
active.local.size > 0;
|
||
}
|
||
|
||
// Build an HTML card string from a full-dataset item (mirrors server-rendered HTML).
|
||
function buildCard(it) {
|
||
var genres = (it.genres || []);
|
||
var genreStr = genres.join('|') + (genres.length ? '|' : '');
|
||
var coverHtml = it.cover
|
||
? '<img src="' + it.cover + '" alt="cover" class="w-12 h-[4.5rem] object-cover rounded flex-shrink-0 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)">'
|
||
: '';
|
||
var statusBadge = it.status
|
||
? '<span class="text-xs px-1.5 py-0.5 rounded-full ' + (it.local ? 'bg-teal-900 text-teal-300' : 'bg-zinc-800 text-zinc-300') + '">' + esc(it.status) + '</span>'
|
||
: '';
|
||
var genreBadges = genres.map(function(g) {
|
||
return '<span class="text-xs px-1.5 py-0.5 rounded ' + (it.local ? 'bg-teal-900 text-teal-400' : 'bg-zinc-800 text-zinc-400') + '">' + esc(g) + '</span>';
|
||
}).join('');
|
||
var rankBadge = it.rank ? '<span class="text-xs font-bold text-amber-400 flex-shrink-0">#' + it.rank + '</span>' : '';
|
||
var sourceBtn = it.source_url
|
||
? '<button onclick="event.stopPropagation();window.open(\'' + it.source_url + '\',\'_blank\',\'noopener,noreferrer\')"' +
|
||
' class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded text-zinc-600 hover:text-zinc-300 transition-colors cursor-pointer border-0 bg-transparent ml-auto">' +
|
||
'<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>' +
|
||
'</button>'
|
||
: '';
|
||
|
||
if (it.local) {
|
||
return '<a href="/books/' + it.slug + '"' +
|
||
' hx-get="/books/' + it.slug + '"' +
|
||
' hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML"' +
|
||
' data-filter="' + esc(it.title) + ' ' + esc(it.author || '') + '"' +
|
||
' data-status="' + esc(it.status || '') + '"' +
|
||
' data-genres="' + esc(genreStr) + '"' +
|
||
' data-local="1"' +
|
||
' class="group flex gap-3 rounded-xl border border-teal-700 bg-teal-950 p-3 hover:border-teal-400 transition-colors min-w-0">' +
|
||
coverHtml +
|
||
'<div class="min-w-0 flex-1">' +
|
||
'<div class="flex items-start gap-1.5 flex-wrap">' + rankBadge +
|
||
'<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 break-words leading-snug flex-1 min-w-0">' + esc(it.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>' +
|
||
(it.author ? '<p class="text-xs text-zinc-400 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||
'<div class="flex gap-1.5 mt-1.5 flex-wrap">' + statusBadge + genreBadges + '</div>' +
|
||
(it.source_url ? '<div class="mt-2">' + sourceBtn + '</div>' : '') +
|
||
'</div>' +
|
||
'</a>';
|
||
} else {
|
||
return '<div' +
|
||
' data-filter="' + esc(it.title) + ' ' + esc(it.author || '') + '"' +
|
||
' data-status="' + esc(it.status || '') + '"' +
|
||
' data-genres="' + esc(genreStr) + '"' +
|
||
' data-local="0"' +
|
||
(it.source_url ? ' data-scrape-url="' + esc(it.source_url) + '"' : '') +
|
||
' class="ranking-card group flex gap-3 rounded-xl border border-zinc-800 bg-zinc-900 p-3 transition-colors min-w-0' + (it.source_url ? ' cursor-pointer hover:border-zinc-600' : '') + '">' +
|
||
coverHtml +
|
||
'<div class="min-w-0 flex-1">' +
|
||
'<div class="flex items-start gap-1.5 flex-wrap">' + rankBadge +
|
||
'<h2 class="font-semibold text-zinc-100 break-words leading-snug flex-1 min-w-0">' + esc(it.title) + '</h2>' +
|
||
'</div>' +
|
||
(it.author ? '<p class="text-xs text-zinc-400 mt-0.5 truncate">' + esc(it.author) + '</p>' : '') +
|
||
'<div class="flex gap-1.5 mt-1.5 flex-wrap">' + statusBadge + genreBadges + '</div>' +
|
||
(it.source_url ? '<div class="mt-2 flex items-center gap-2"><span class="scrape-hint text-xs text-zinc-500 hidden">Click again to scrape</span>' + sourceBtn + '</div>' : '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function applyFilters() {
|
||
var q = input ? input.value.trim().toLowerCase() : '';
|
||
var filtering = isFiltering();
|
||
|
||
if (!filtering) {
|
||
// Restore paginated server-rendered HTML.
|
||
grid.innerHTML = pagedHTML;
|
||
htmx.process(grid);
|
||
countEl.classList.add('hidden');
|
||
clearBtn.classList.add('hidden');
|
||
if (pagination) pagination.style.display = '';
|
||
return;
|
||
}
|
||
|
||
// Filter across ALL items from JSON.
|
||
var matched = ALL_ITEMS.filter(function(it) {
|
||
var filterStr = ((it.title || '') + ' ' + (it.author || '')).toLowerCase();
|
||
if (q && filterStr.indexOf(q) === -1) return false;
|
||
|
||
if (active.status.size > 0) {
|
||
var cs = (it.status || '').toLowerCase();
|
||
var hit = false;
|
||
active.status.forEach(function(v) { if (cs === v.toLowerCase()) hit = true; });
|
||
if (!hit) return false;
|
||
}
|
||
|
||
if (active.genre.size > 0) {
|
||
var genres = (it.genres || []).map(function(g){ return g.toLowerCase(); });
|
||
var ok = true;
|
||
active.genre.forEach(function(v) {
|
||
if (genres.indexOf(v.toLowerCase()) === -1) ok = false;
|
||
});
|
||
if (!ok) return false;
|
||
}
|
||
|
||
if (active.local.size > 0 && !it.local) return false;
|
||
|
||
return true;
|
||
});
|
||
|
||
grid.innerHTML = matched.map(buildCard).join('');
|
||
htmx.process(grid);
|
||
|
||
countEl.textContent = matched.length + ' result' + (matched.length !== 1 ? 's' : '') + ' across all pages';
|
||
countEl.classList.remove('hidden');
|
||
clearBtn.classList.remove('hidden');
|
||
if (pagination) pagination.style.display = 'none';
|
||
}
|
||
|
||
// Wire up text input
|
||
if (input) input.addEventListener('input', applyFilters);
|
||
|
||
// Wire up facet chips
|
||
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var facet = btn.dataset.facet;
|
||
var val = btn.dataset.value;
|
||
if (!active[facet]) return;
|
||
if (active[facet].has(val)) {
|
||
active[facet].delete(val);
|
||
btn.style.color = '';
|
||
btn.style.borderColor = '';
|
||
btn.style.background = '';
|
||
} else {
|
||
active[facet].add(val);
|
||
btn.style.color = '#f59e0b';
|
||
btn.style.borderColor = '#f59e0b';
|
||
btn.style.background = 'rgba(245,158,11,0.08)';
|
||
}
|
||
applyFilters();
|
||
});
|
||
});
|
||
|
||
// Clear all filters
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', function () {
|
||
if (input) input.value = '';
|
||
['status','genre','local'].forEach(function (facet) { active[facet].clear(); });
|
||
document.querySelectorAll('.facet-btn').forEach(function (btn) {
|
||
btn.style.color = '';
|
||
btn.style.borderColor = '';
|
||
btn.style.background = '';
|
||
});
|
||
applyFilters();
|
||
});
|
||
}
|
||
|
||
/* ── two-click scrape on ranking cards ─────────────────────────────────── */
|
||
var pendingCard = null;
|
||
var pendingTimer = null;
|
||
|
||
function cancelPending() {
|
||
if (!pendingCard) return;
|
||
pendingCard.style.borderColor = '';
|
||
pendingCard.style.background = '';
|
||
var hint = pendingCard.querySelector('.scrape-hint');
|
||
if (hint) hint.classList.add('hidden');
|
||
pendingCard = null;
|
||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
|
||
}
|
||
|
||
function submitScrape(card) {
|
||
var url = card.dataset.scrapeUrl;
|
||
if (!url) return;
|
||
var badge = document.createElement('div');
|
||
badge.className = 'flex items-center text-sm px-3 py-2 rounded-lg border text-amber-300 bg-amber-950 border-amber-800';
|
||
badge.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>Scraping…';
|
||
badge.setAttribute('hx-get', '/ui/scrape/status');
|
||
badge.setAttribute('hx-trigger', 'every 3s');
|
||
badge.setAttribute('hx-target', 'this');
|
||
badge.setAttribute('hx-swap', 'outerHTML');
|
||
card.innerHTML = '';
|
||
card.appendChild(badge);
|
||
card.style.borderColor = '';
|
||
card.style.background = '';
|
||
card.style.cursor = 'default';
|
||
card.classList.remove('cursor-pointer');
|
||
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 card = e.target.closest('.ranking-card[data-scrape-url]');
|
||
if (!card) return;
|
||
if (e.target.closest('button')) return;
|
||
|
||
if (pendingCard && pendingCard !== card) cancelPending();
|
||
|
||
if (pendingCard === card) {
|
||
clearTimeout(pendingTimer);
|
||
pendingTimer = null;
|
||
pendingCard = null;
|
||
submitScrape(card);
|
||
return;
|
||
}
|
||
|
||
pendingCard = card;
|
||
card.style.borderColor = '#f59e0b';
|
||
card.style.background = 'rgba(245,158,11,0.05)';
|
||
var hint = card.querySelector('.scrape-hint');
|
||
if (hint) hint.classList.remove('hidden');
|
||
pendingTimer = setTimeout(cancelPending, 3000);
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
if (pendingCard && !e.target.closest('.ranking-card')) cancelPending();
|
||
});
|
||
|
||
}());
|
||
</script>`
|
||
|
||
// rankingViewItem enriches a RankingItem with whether it is present in the
|
||
// local book library, so the template can highlight it differently.
|
||
type rankingViewItem struct {
|
||
writer.RankingItem
|
||
Local bool
|
||
}
|
||
|
||
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
|
||
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
|
||
out := make([]rankingViewItem, len(items))
|
||
for i, it := range items {
|
||
out[i] = rankingViewItem{
|
||
RankingItem: it,
|
||
Local: localSlugs[it.Slug],
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pageNum is one entry in the ranking pagination bar.
|
||
// Num == 0 is a sentinel that renders as an ellipsis gap.
|
||
type pageNum struct {
|
||
Num int
|
||
}
|
||
|
||
// rankingPageNums builds a pagination list with smart ellipsis.
|
||
// It always shows: first 2, last 2, and a ±2 window around current.
|
||
// Gaps between non-consecutive runs are filled with a sentinel (Num==0) for "…".
|
||
// Pass current=0 when there is no concept of a current page (e.g. fetch bar).
|
||
func rankingPageNums(total, current int) []pageNum {
|
||
if total <= 0 {
|
||
return nil
|
||
}
|
||
show := make(map[int]bool)
|
||
// First 2 and last 2.
|
||
for i := 1; i <= 2 && i <= total; i++ {
|
||
show[i] = true
|
||
}
|
||
for i := total - 1; i <= total; i++ {
|
||
if i >= 1 {
|
||
show[i] = true
|
||
}
|
||
}
|
||
// ±2 window around current page.
|
||
if current > 0 {
|
||
for i := current - 2; i <= current+2; i++ {
|
||
if i >= 1 && i <= total {
|
||
show[i] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Collect and sort.
|
||
pages := make([]int, 0, len(show))
|
||
for p := range show {
|
||
pages = append(pages, p)
|
||
}
|
||
for i := 0; i < len(pages); i++ {
|
||
for j := i + 1; j < len(pages); j++ {
|
||
if pages[j] < pages[i] {
|
||
pages[i], pages[j] = pages[j], pages[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build output with ellipsis sentinels between non-consecutive pages.
|
||
out := make([]pageNum, 0, len(pages)*2)
|
||
for i, p := range pages {
|
||
if i > 0 && p > pages[i-1]+1 {
|
||
out = append(out, pageNum{0})
|
||
}
|
||
out = append(out, pageNum{p})
|
||
}
|
||
return out
|
||
}
|
||
|
||
const rankingPageSize = 20
|
||
|
||
// handleRanking serves the ranking page from the cached ranking.json file.
|
||
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
|
||
// Supports ?page=N for browsing through cached items (20 per page).
|
||
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||
rankingItems, err := s.writer.ReadRankingItems()
|
||
if err != nil {
|
||
s.log.Error("failed to read cached ranking", "err", err)
|
||
}
|
||
|
||
cachedAt := ""
|
||
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
|
||
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
|
||
}
|
||
|
||
// Parse requested display page (1-indexed).
|
||
currentPage := 1
|
||
if p := r.URL.Query().Get("page"); p != "" {
|
||
if n, err2 := strconv.Atoi(p); err2 == nil && n > 0 {
|
||
currentPage = n
|
||
}
|
||
}
|
||
|
||
totalItems := len(rankingItems)
|
||
totalPages := 1
|
||
if totalItems > 0 {
|
||
totalPages = (totalItems + rankingPageSize - 1) / rankingPageSize
|
||
}
|
||
if currentPage > totalPages {
|
||
currentPage = totalPages
|
||
}
|
||
|
||
// Slice items for the current display page.
|
||
start := (currentPage - 1) * rankingPageSize
|
||
end := start + rankingPageSize
|
||
if end > totalItems {
|
||
end = totalItems
|
||
}
|
||
pageItems := rankingItems
|
||
if totalItems > 0 {
|
||
pageItems = rankingItems[start:end]
|
||
}
|
||
|
||
t := template.Must(template.New("ranking").Parse(rankingTmpl))
|
||
var buf bytes.Buffer
|
||
|
||
// Collect distinct genres and statuses across ALL items for facet filters.
|
||
genreSet := map[string]bool{}
|
||
statusSet := map[string]bool{}
|
||
for _, it := range rankingItems {
|
||
if it.Status != "" {
|
||
statusSet[it.Status] = true
|
||
}
|
||
for _, g := range it.Genres {
|
||
genreSet[g] = true
|
||
}
|
||
}
|
||
allGenres := sortedKeys(genreSet)
|
||
allStatuses := sortedKeys(statusSet)
|
||
|
||
// 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
|
||
AllGenres []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,
|
||
AllGenres: allGenres,
|
||
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 = `
|
||
<!-- ─── Sticky reader toolbar ────────────────────────────────────────────────
|
||
Uses <nav> with a descriptive aria-label so screen readers expose it as a
|
||
landmark. aria-hidden was previously misused here — removed. -->
|
||
<nav id="reader-header" aria-label="Reader controls"
|
||
class="sticky top-0 z-50 bg-zinc-950 border-b border-zinc-800">
|
||
|
||
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-14">
|
||
|
||
<!-- Back to book list -->
|
||
<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 px-2 py-1.5 rounded-lg text-zinc-400 hover:text-amber-400 transition-colors text-xl leading-none no-underline">
|
||
☰
|
||
</a>
|
||
|
||
<!-- Chapter title button — opens chapter list drawer -->
|
||
<button id="chapter-list-btn"
|
||
type="button"
|
||
onclick="toggleChapterList()"
|
||
aria-haspopup="listbox"
|
||
aria-expanded="false"
|
||
title="Jump to chapter"
|
||
class="flex-1 min-w-0 flex items-center justify-center gap-1.5 overflow-hidden bg-transparent border-none cursor-pointer py-1 px-1">
|
||
<span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem] text-zinc-400 max-w-full">{{.Title}}</span>
|
||
</button>
|
||
|
||
</div>
|
||
|
||
<!-- TTS status strip -->
|
||
<div id="tts-status-bar" hidden
|
||
class="px-4 py-0.5 bg-zinc-900 text-xs text-zinc-500 text-center"
|
||
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-10 text-right" aria-hidden="true">{{.Number}}</span>
|
||
<span class="text-sm {{if eq .Number $.ChapterN}}text-amber-400 font-medium{{else}}text-zinc-300{{end}}">
|
||
{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}
|
||
</span>
|
||
{{if eq .Number $.ChapterN}}<span class="ml-auto flex-shrink-0 text-[0.7rem] text-amber-500" aria-hidden="true">▶ now</span>{{end}}
|
||
</a>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- ─── Chapter content ───────────────────────────────────────────────────── -->
|
||
<main class="max-w-2xl mx-auto px-4 py-10">
|
||
|
||
<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>
|
||
|
||
</main>
|
||
|
||
<!-- ─── Compact audio player (fixed bottom) ─────────────────────────────── -->
|
||
<aside id="mini-player"
|
||
aria-label="Audio player"
|
||
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 text-zinc-100">
|
||
|
||
<!-- Seek bar sits flush on top edge, full width -->
|
||
<div id="seek-track"
|
||
class="relative w-full cursor-pointer touch-none select-none"
|
||
style="display:none">
|
||
<div id="seek-rail" class="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full overflow-hidden pointer-events-none">
|
||
<div id="seek-fill" class="absolute left-0 top-0 h-full bg-amber-500" style="width:0%"></div>
|
||
</div>
|
||
<div id="seek-thumb" class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-amber-400 shadow-md pointer-events-none -translate-x-1/2 transition-transform hover:scale-125" style="left:0%"></div>
|
||
</div>
|
||
|
||
<!-- Time row (only when playing) -->
|
||
<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.65rem] tabular-nums text-zinc-500">0:00</span>
|
||
<span id="seek-tot" class="text-[0.65rem] tabular-nums text-zinc-600">0:00</span>
|
||
</div>
|
||
|
||
<div class="border-t border-zinc-800"></div>
|
||
|
||
<!-- Settings panel (opens upward) -->
|
||
<div id="settings-panel"
|
||
role="dialog"
|
||
aria-label="Reader settings"
|
||
hidden
|
||
class="fixed inset-x-2 bottom-[4.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-3.5">
|
||
<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-3.5">
|
||
<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-2.5">
|
||
<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>
|
||
|
||
<div class="max-w-2xl mx-auto px-3 py-2 flex flex-col gap-0">
|
||
|
||
<!-- Single control row -->
|
||
<div class="flex items-center gap-1.5 h-12">
|
||
|
||
<!-- Prev chapter -->
|
||
{{if .PrevN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
title="Previous chapter"
|
||
aria-label="Previous chapter"
|
||
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
|
||
←
|
||
</a>
|
||
{{else}}
|
||
<span class="flex-shrink-0 w-10"></span>
|
||
{{end}}
|
||
|
||
<!-- Play/Pause -->
|
||
<button id="player-play-btn"
|
||
type="button"
|
||
onclick="ttsToggle()"
|
||
aria-label="Play/Pause"
|
||
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-base border-none cursor-pointer transition-colors">
|
||
<span id="player-play-icon" aria-hidden="true">▶</span>
|
||
</button>
|
||
|
||
<!-- Chapter title + state badge -->
|
||
<div class="flex-1 min-w-0 flex items-center gap-2 px-1">
|
||
<span id="player-title" class="text-[0.8rem] font-medium text-zinc-300 truncate">Ch. {{.ChapterN}}</span>
|
||
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
|
||
</div>
|
||
|
||
<!-- Next prefetch badge -->
|
||
<div id="player-next-row" hidden class="flex-shrink-0 flex items-center gap-1">
|
||
<span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<button id="settings-btn"
|
||
type="button"
|
||
onclick="toggleSettings()"
|
||
aria-label="Reader settings"
|
||
aria-haspopup="dialog"
|
||
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-transparent border-none cursor-pointer text-zinc-500 hover:text-amber-400 transition-colors">
|
||
⚙
|
||
</button>
|
||
|
||
<!-- Next chapter -->
|
||
{{if .NextN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
title="Next chapter"
|
||
aria-label="Next chapter"
|
||
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
|
||
→
|
||
</a>
|
||
{{else}}
|
||
<span class="flex-shrink-0 w-10"></span>
|
||
{{end}}
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Hidden native audio (no controls — we drive it entirely from JS) -->
|
||
<audio id="tts-audio-native" preload="none" style="display:none"></audio>
|
||
|
||
</aside>
|
||
|
||
<style>
|
||
/* Paragraph TTS highlight */
|
||
#chapter-article p {
|
||
border-radius: 0.375rem;
|
||
margin-left: -0.5rem;
|
||
padding-left: 0.5rem;
|
||
transition: background 0.15s;
|
||
}
|
||
#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 obscuring anchor targets */
|
||
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 7rem; }
|
||
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
|
||
#chapter-article p + p { margin-top: 0; }
|
||
|
||
/* Audio state badges */
|
||
.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; }
|
||
|
||
/* Seek bar — tall hit-area, thin visual rail via inner element */
|
||
#seek-track {
|
||
height: 2.5rem;
|
||
}
|
||
/* Safe-area inset for notched phones */
|
||
#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>
|
||
|
||
<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));
|
||
} catch(_) {}
|
||
})();
|
||
|
||
// ── Teardown previous chapter's script instance ───────────────────────────────
|
||
// Each HTMX swap re-runs this IIFE on the same persistent audio element.
|
||
// We use a global generation counter so stale closures become no-ops immediately.
|
||
window.__ttsGen = (window.__ttsGen || 0) + 1;
|
||
var myGen = window.__ttsGen;
|
||
function stale() { return window.__ttsGen !== myGen; }
|
||
|
||
// Remove the previous chapter's htmx:beforeSwap listener before adding our own.
|
||
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'];
|
||
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 (chapter list / settings) ───────────────────────────────────
|
||
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';
|
||
};
|
||
// Re-register the document click-outside handler each swap by replacing via a named ref.
|
||
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';
|
||
var LS_VOICE = 'tts_voice';
|
||
var LS_AUTONEXT = 'tts_autonext';
|
||
var 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 (highlight only) ──────────────────────────────────────
|
||
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 ──────────────────────────────────────────────────────────
|
||
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('▮▮');
|
||
playerPlayBtn.disabled = false;
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
setBadge(playerStateBadge, 'playing');
|
||
playerStateBadge.textContent = '0%';
|
||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||
setStatus('');
|
||
showSeek();
|
||
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
|
||
}
|
||
function setPaused() {
|
||
if (stale()) return;
|
||
setPlayIcon('▶');
|
||
setBadge(playerStateBadge, 'paused');
|
||
// Keep showing current % while 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('▶');
|
||
playerPlayBtn.disabled = false;
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
setBadge(playerStateBadge, 'idle');
|
||
// Show how long TTS generation took, if we have a timestamp.
|
||
if (genStartTime > 0) {
|
||
var secs = ((Date.now() - genStartTime) / 1000).toFixed(1);
|
||
playerStateBadge.textContent = secs + 's';
|
||
}
|
||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||
setStatus('');
|
||
hideSeek();
|
||
}
|
||
function setError(msg) {
|
||
if (stale()) return;
|
||
highlightPara(-1);
|
||
setPlayIcon('▶');
|
||
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; // Date.now() when generation began
|
||
|
||
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; // resolved proxy URL for the next chapter, if ready
|
||
|
||
// ── audio event handlers (named so they can be removed on next swap) ──────────
|
||
function onTimeUpdate() {
|
||
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
|
||
updateSeek();
|
||
// Live % progress in the badge while playing.
|
||
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)) {
|
||
} else if (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'); });
|
||
}
|
||
// paragraph highlight
|
||
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 audio was already pre-generated, play it directly without a page
|
||
// navigation. This keeps the existing audio context alive so the browser
|
||
// doesn't block autoplay on a locked screen where a user interaction was
|
||
// already granted for the current chapter.
|
||
if (prefetchedUrl) {
|
||
var url = prefetchedUrl;
|
||
prefetchedUrl = null;
|
||
audio.src = url;
|
||
audio.load();
|
||
// Update Media Session so lock-screen shows the next chapter.
|
||
if ('mediaSession' in navigator && navigator.mediaSession.metadata) {
|
||
navigator.mediaSession.metadata.title = 'Ch.\u00a0' + NEXT_N;
|
||
}
|
||
// audio.play() is called by onCanPlay once the element is ready.
|
||
return;
|
||
}
|
||
|
||
// Fall back to HTMX navigation (audio not ready yet).
|
||
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
|
||
});
|
||
}
|
||
|
||
// ── stop / cleanup ────────────────────────────────────────────────────────────
|
||
function stop() {
|
||
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
|
||
audio.pause();
|
||
audio.src = '';
|
||
prefetchFired = false;
|
||
prefetchedUrl = null;
|
||
setStopped();
|
||
}
|
||
|
||
// ── Media Session API (lock-screen controls + background playback) ────────────
|
||
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;
|
||
});
|
||
}
|
||
// seekto handler so lock-screen scrubbing works
|
||
navigator.mediaSession.setActionHandler('seekto', function(d) {
|
||
if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); }
|
||
});
|
||
}
|
||
|
||
// ── main entry point ─────────────────────────────────────────────────────────
|
||
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();
|
||
};
|
||
|
||
// ── cleanup on next HTMX swap ────────────────────────────────────────────────
|
||
window.__ttsBeforeSwap = function () {
|
||
// Invalidate this generation so all stale() checks short-circuit.
|
||
window.__ttsGen++;
|
||
stop();
|
||
};
|
||
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
|
||
|
||
// ── auto-start: localStorage signal (from auto-next) or ?autoplay=1 ──────────
|
||
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);
|
||
}
|
||
|
||
// ── init next-chapter display ────────────────────────────────────────────────
|
||
if (NEXT_N) { setNextState('idle'); }
|
||
|
||
// ── double-tap left/right to navigate chapters ───────────────────────────────
|
||
if (window.__ttsDoubleTap) {
|
||
document.removeEventListener('touchend', window.__ttsDoubleTap);
|
||
}
|
||
(function initDoubleTap() {
|
||
var lastTap = 0;
|
||
var 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;
|
||
|
||
// Update hidden select so voiceSel.value works in the existing TTS code.
|
||
voiceSel.value = btn.dataset.voice;
|
||
// Persist to localStorage using same key as the TTS IIFE.
|
||
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
|
||
|
||
// Swap active styling across all cards.
|
||
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);
|
||
});
|
||
};
|
||
|
||
// On page load, sync voice grid selection to the restored localStorage value.
|
||
(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),
|
||
)
|
||
}
|