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