- ScrapeRanking now accepts a maxPages int parameter (0 = all pages). Each page is fetched strictly sequentially; the next page is only requested after every entry from the current page has been sent, so there is no pre-fetching or look-ahead. Pagination stops automatically when no next-page link is present or when the rank-novels container is absent/empty. - The ranking URL pattern follows the existing catalogue convention: /ranking?page=N (next-page link detection as the stop condition). - Server: handleRankingRefresh reads an optional 'pages' form field and passes it to ScrapeRanking. Timeout scales at 90 s/page. - UI: Refresh Rankings button is now a small form with a numeric 'Pages' input (default 1), letting the user choose how many pages to pull in one refresh without touching the server config.
1675 lines
59 KiB
Go
1675 lines
59 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"html/template"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/libnovel/scraper/internal/orchestrator"
|
||
"github.com/libnovel/scraper/internal/writer"
|
||
"github.com/yuin/goldmark"
|
||
"github.com/yuin/goldmark/extension"
|
||
goldhtml "github.com/yuin/goldmark/renderer/html"
|
||
)
|
||
|
||
// md is the shared goldmark instance used for all markdown→HTML conversions.
|
||
var md = goldmark.New(
|
||
goldmark.WithExtensions(extension.Typographer, extension.Table),
|
||
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
||
)
|
||
|
||
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
|
||
// grouped loosely by language prefix:
|
||
//
|
||
// af_ / am_ American English female / male
|
||
// bf_ / bm_ British English female / male
|
||
// ef_ / em_ Spanish female / male
|
||
// ff_ French female
|
||
// hf_ / hm_ Hindi female / male
|
||
// if_ / im_ Italian female / male
|
||
// jf_ / jm_ Japanese female / male
|
||
// pf_ / pm_ Portuguese female / male
|
||
// zf_ / zm_ Chinese female / male
|
||
var kokoroVoices = []string{
|
||
// American English
|
||
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
|
||
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
|
||
"af_sarah", "af_sky",
|
||
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
|
||
"am_michael", "am_onyx", "am_puck",
|
||
// British English
|
||
"bf_alice", "bf_emma", "bf_lily",
|
||
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
|
||
// Spanish
|
||
"ef_dora", "em_alex",
|
||
// French
|
||
"ff_siwis",
|
||
// Hindi
|
||
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
|
||
// Italian
|
||
"if_sara", "im_nicola",
|
||
// Japanese
|
||
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
|
||
// Portuguese
|
||
"pf_dora", "pm_alex",
|
||
// Chinese
|
||
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
|
||
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
|
||
}
|
||
|
||
// ─── shared layout ────────────────────────────────────────────────────────────
|
||
|
||
const layoutHead = `<!DOCTYPE html>
|
||
<html lang="en" class="bg-zinc-950 text-zinc-100">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{.Title}} — libnovel</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
|
||
<style>
|
||
.prose p { margin-bottom: 1em; }
|
||
.prose h1,.prose h2,
|
||
.prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
|
||
.prose h4 { font-size: 1.05rem; }
|
||
.prose h3 { font-size: 1.2rem; }
|
||
.prose h2 { font-size: 1.4rem; }
|
||
.prose h1 { font-size: 1.7rem; }
|
||
.prose em { font-style: italic; }
|
||
.prose strong { font-weight: 700; }
|
||
.prose hr { border-color: #3f3f46; margin: 2em 0; }
|
||
.prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
|
||
</style>
|
||
</head>
|
||
<body class="min-h-screen">`
|
||
|
||
const layoutFoot = `</body></html>`
|
||
|
||
func renderPage(w http.ResponseWriter, title, body string) {
|
||
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = t.Execute(w, struct{ Title string }{Title: title})
|
||
}
|
||
|
||
func renderFragment(w http.ResponseWriter, body string) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprint(w, body)
|
||
}
|
||
|
||
func isHTMX(r *http.Request) bool {
|
||
return r.Header.Get("HX-Request") == "true"
|
||
}
|
||
|
||
// respond writes either a full page or an HTMX fragment depending on the request.
|
||
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
|
||
if isHTMX(r) {
|
||
renderFragment(w, fragment)
|
||
return
|
||
}
|
||
renderPage(w, title,
|
||
`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
|
||
}
|
||
|
||
// ─── GET / — book catalogue ───────────────────────────────────────────────────
|
||
|
||
const homeTmpl = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
|
||
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg bg-amber-700 hover:bg-amber-600 text-white">Browse Rankings</a>
|
||
</div>
|
||
<p class="text-zinc-400 mb-8">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
|
||
|
||
<!-- Scrape form -->
|
||
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
|
||
<form hx-post="/ui/scrape/book"
|
||
hx-target="#scrape-status"
|
||
hx-swap="innerHTML"
|
||
class="flex gap-2">
|
||
<input type="url"
|
||
name="url"
|
||
required
|
||
placeholder="https://novelfire.net/book/some-book"
|
||
class="flex-1 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" />
|
||
<button type="submit"
|
||
class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors whitespace-nowrap">
|
||
Scrape
|
||
</button>
|
||
</form>
|
||
<div id="scrape-status" class="mt-3"></div>
|
||
</div>
|
||
|
||
<!-- Book grid -->
|
||
<div class="grid gap-4 sm:grid-cols-2">
|
||
{{range .Books}}
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer">
|
||
<div class="flex gap-4">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0">
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
|
||
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
||
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
{{else}}
|
||
<p class="text-zinc-500 col-span-2">No books scraped yet.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>`
|
||
|
||
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
|
||
}
|
||
|
||
t := template.Must(template.New("home").Parse(homeTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct{ Books interface{} }{Books: books})
|
||
|
||
s.respond(w, r, "Home", buf.String())
|
||
}
|
||
|
||
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
|
||
|
||
const rankingTmpl = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||
<a href="/"
|
||
hx-get="/"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← All books
|
||
</a>
|
||
|
||
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
|
||
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
|
||
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
|
||
</div>
|
||
<div class="flex gap-2 mt-1">
|
||
<a href="/ranking/view"
|
||
hx-get="/ranking/view"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-700 hover:bg-zinc-600 text-white inline-flex items-center gap-1">
|
||
View Markdown
|
||
</a>
|
||
<form
|
||
hx-post="/ranking/refresh"
|
||
hx-target="#ranking-refresh-status"
|
||
hx-swap="innerHTML"
|
||
class="flex items-center gap-2">
|
||
<label class="flex items-center gap-1.5">
|
||
<span class="text-xs text-zinc-400 whitespace-nowrap">Pages</span>
|
||
<input type="number" name="pages" value="1" min="1"
|
||
class="w-16 rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1 text-sm text-zinc-100 text-center focus:outline-none focus:border-amber-500 transition-colors" />
|
||
</label>
|
||
<button type="submit"
|
||
class="text-sm px-3 py-1.5 rounded-lg bg-amber-700 hover:bg-amber-600 text-white inline-flex items-center gap-2">
|
||
Refresh Rankings
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div id="ranking-refresh-status" class="mt-2"></div>
|
||
|
||
<!-- Book grid -->
|
||
<div class="grid gap-4 sm:grid-cols-2 mt-8">
|
||
{{range .Books}}
|
||
{{if .Local}}
|
||
{{/* Book is in local library — wrap entire card in a clickable link */}}
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="group block rounded-xl border border-teal-700 bg-teal-950 p-4 hover:border-teal-400 transition-colors">
|
||
<div class="flex gap-4">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
{{if .Rank}}<span class="text-xs font-bold text-amber-400">#{{.Rank}}</span>{{end}}
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 truncate">{{.Title}}</h2>
|
||
<span class="ml-auto text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>
|
||
</div>
|
||
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-teal-900 text-teal-300">{{.Status}}</span>{{end}}
|
||
</div>
|
||
{{if .Genres}}
|
||
<div class="flex gap-1 mt-2 flex-wrap">
|
||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</a>
|
||
{{else}}
|
||
{{/* Book not yet in local library */}}
|
||
<div class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors">
|
||
<div class="flex gap-4">
|
||
{{if .Cover}}
|
||
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
|
||
{{end}}
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
{{if .Rank}}<span class="text-xs font-bold text-amber-400">#{{.Rank}}</span>{{end}}
|
||
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
|
||
</div>
|
||
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
|
||
</div>
|
||
{{if .Genres}}
|
||
<div class="flex gap-1 mt-2 flex-wrap">
|
||
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400">{{.}}</span>{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{if .SourceURL}}
|
||
<div class="mt-3">
|
||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||
<input type="hidden" name="url" value="{{.SourceURL}}">
|
||
<button type="submit"
|
||
class="text-xs px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white"
|
||
title="Scrape full content">
|
||
Scrape
|
||
</button>
|
||
</form>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
{{else}}
|
||
<p class="text-zinc-500 col-span-2">No ranking data available. Click "Refresh Rankings" to fetch from novelfire.net.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>`
|
||
|
||
// 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
|
||
}
|
||
|
||
// handleRanking serves the ranking page from the cached ranking.md file.
|
||
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
|
||
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||
rankingItems, err := s.writer.ReadRankingItems()
|
||
if err != nil {
|
||
s.log.Error("failed to read cached ranking", "err", err)
|
||
}
|
||
|
||
cachedAt := ""
|
||
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
|
||
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
|
||
}
|
||
|
||
t := template.Must(template.New("ranking").Parse(rankingTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Books interface{}
|
||
CachedAt string
|
||
}{Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()), CachedAt: cachedAt})
|
||
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 {
|
||
select {
|
||
case meta, ok := <-rankingCh:
|
||
if !ok {
|
||
rankingCh = nil
|
||
continue
|
||
}
|
||
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
|
||
continue
|
||
}
|
||
if err != nil {
|
||
s.log.Error("ranking scrape error", "err", err)
|
||
}
|
||
}
|
||
if rankingCh == nil && errCh == nil {
|
||
break
|
||
}
|
||
}
|
||
|
||
if len(rankingItems) > 0 {
|
||
if err := s.writer.WriteRanking(rankingItems); err != nil {
|
||
s.log.Error("failed to save ranking", "err", err)
|
||
}
|
||
}
|
||
}()
|
||
|
||
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
|
||
}
|
||
|
||
// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
|
||
// While running it returns a self-replacing badge; when done it issues an
|
||
// HX-Redirect so the browser navigates to /ranking.
|
||
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
|
||
s.mu.Lock()
|
||
running := s.rankingRunning
|
||
s.mu.Unlock()
|
||
|
||
if running {
|
||
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
|
||
return
|
||
}
|
||
// Job done — redirect the HTMX request to the ranking page.
|
||
w.Header().Set("HX-Redirect", "/ranking")
|
||
w.WriteHeader(http.StatusOK)
|
||
}
|
||
|
||
// rankingStatusHTML returns a self-replacing polling badge for the ranking
|
||
// refresh job. state is "running" or "done".
|
||
func rankingStatusHTML(state, msg string) string {
|
||
var colour, dot, poll string
|
||
switch state {
|
||
case "running":
|
||
colour = "text-amber-300 bg-amber-950 border-amber-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
|
||
poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
|
||
default:
|
||
colour = "text-green-300 bg-green-950 border-green-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
|
||
}
|
||
return fmt.Sprintf(
|
||
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
|
||
colour, poll, dot, template.HTMLEscapeString(msg),
|
||
)
|
||
}
|
||
|
||
// ─── GET /ranking/view — view ranking markdown ─────────────────────────────────
|
||
|
||
const rankingViewTmpl = `
|
||
<div class="max-w-4xl mx-auto px-4 py-10">
|
||
<a href="/ranking"
|
||
hx-get="/ranking"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← Back to Rankings
|
||
</a>
|
||
|
||
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
|
||
|
||
<div class="prose prose-invert max-w-none">
|
||
{{.HTML}}
|
||
</div>
|
||
</div>`
|
||
|
||
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
|
||
markdown, err := s.writer.ReadRanking()
|
||
if err != nil {
|
||
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if markdown == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
var htmlBuf bytes.Buffer
|
||
if err := md.Convert([]byte(markdown), &htmlBuf); err != nil {
|
||
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct{ HTML template.HTML }{HTML: template.HTML(htmlBuf.String())})
|
||
|
||
s.respond(w, r, "Ranking Data", buf.String())
|
||
}
|
||
|
||
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
|
||
|
||
const chapterPageSize = 50
|
||
|
||
const bookTmpl = `
|
||
<div class="max-w-2xl mx-auto px-4 py-10">
|
||
<a href="/"
|
||
hx-get="/"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
|
||
← All books
|
||
</a>
|
||
|
||
<div class="flex gap-5 mb-8 mt-4">
|
||
{{if .Meta.Cover}}
|
||
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
|
||
{{end}}
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
||
{{if .Meta.SourceURL}}
|
||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
||
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
|
||
<button type="submit"
|
||
class="text-xs px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white"
|
||
title="Re-scrape from source">
|
||
Refresh
|
||
</button>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
{{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
|
||
<div class="flex gap-2 mt-2 flex-wrap">
|
||
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
|
||
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
|
||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
|
||
</div>
|
||
{{if .Meta.Summary}}
|
||
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resume reading button — populated by JS from localStorage -->
|
||
<div id="resume-bar" class="mb-4 hidden">
|
||
<a id="resume-link"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-700 hover:bg-amber-600 text-white text-sm font-medium transition-colors cursor-pointer">
|
||
▶ Resume — Chapter <span id="resume-chapter-num"></span>
|
||
</a>
|
||
</div>
|
||
|
||
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
|
||
<ul id="chapter-list" class="space-y-1">
|
||
{{range .Chapters}}
|
||
<li>
|
||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
|
||
data-chapter="{{.Number}}">
|
||
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
|
||
<div class="min-w-0 flex-1">
|
||
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
|
||
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
|
||
</div>
|
||
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0">●</span>
|
||
</a>
|
||
</li>
|
||
{{else}}
|
||
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
|
||
{{end}}
|
||
</ul>
|
||
|
||
{{if .HasMore}}
|
||
<div id="load-more-wrap" class="mt-4 text-center">
|
||
<button
|
||
hx-get="/books/{{.Slug}}/chapters-page?page={{.NextPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="beforeend"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors">
|
||
Load more chapters
|
||
</button>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
var SLUG = '{{.Slug}}';
|
||
var LS_KEY = 'reading_progress';
|
||
|
||
function getProgress() {
|
||
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
|
||
}
|
||
|
||
window.applyProgress = function(slug) {
|
||
var progress = getProgress();
|
||
var saved = progress[slug];
|
||
if (!saved) return;
|
||
|
||
// Highlight the saved chapter row.
|
||
document.querySelectorAll('.chapter-row').forEach(function (a) {
|
||
var dot = a.querySelector('.progress-dot');
|
||
if (!dot) return;
|
||
if (parseInt(a.dataset.chapter) === saved) {
|
||
dot.classList.remove('hidden');
|
||
a.classList.add('bg-zinc-800/50');
|
||
}
|
||
});
|
||
|
||
// Show resume bar.
|
||
var bar = document.getElementById('resume-bar');
|
||
var link = document.getElementById('resume-link');
|
||
var num = document.getElementById('resume-chapter-num');
|
||
if (bar && link && num) {
|
||
var href = '/books/' + slug + '/chapters/' + saved;
|
||
num.textContent = saved;
|
||
link.setAttribute('href', href);
|
||
link.setAttribute('hx-get', href);
|
||
htmx.process(link);
|
||
bar.classList.remove('hidden');
|
||
}
|
||
};
|
||
|
||
applyProgress(SLUG);
|
||
}());
|
||
</script>`
|
||
|
||
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
|
||
meta, ok, err := s.writer.ReadMetadata(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
chapters, err := s.writer.ListChapters(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
total := len(chapters)
|
||
page := chapters
|
||
hasMore := false
|
||
if total > chapterPageSize {
|
||
page = chapters[:chapterPageSize]
|
||
hasMore = true
|
||
}
|
||
|
||
t := template.Must(template.New("book").Parse(bookTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
Meta interface{}
|
||
Chapters interface{}
|
||
TotalDownloaded int
|
||
HasMore bool
|
||
NextPage int
|
||
}{
|
||
Slug: slug,
|
||
Meta: meta,
|
||
Chapters: page,
|
||
TotalDownloaded: total,
|
||
HasMore: hasMore,
|
||
NextPage: 2,
|
||
})
|
||
|
||
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">●</span>
|
||
</a>
|
||
</li>
|
||
{{end}}
|
||
{{if .HasMore}}
|
||
<li id="load-more-wrap" hx-swap-oob="true">
|
||
<div class="mt-4 text-center">
|
||
<button
|
||
hx-get="/books/{{.Slug}}/chapters-page?page={{.NextPage}}"
|
||
hx-target="#chapter-list"
|
||
hx-swap="beforeend"
|
||
hx-on::after-request="applyProgress('{{.Slug}}')"
|
||
class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors">
|
||
Load more chapters
|
||
</button>
|
||
</div>
|
||
</li>
|
||
{{else}}
|
||
<li id="load-more-wrap" hx-swap-oob="true"></li>
|
||
{{end}}`
|
||
|
||
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
page := 1
|
||
if p := r.URL.Query().Get("page"); p != "" {
|
||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||
page = n
|
||
}
|
||
}
|
||
|
||
chapters, err := s.writer.ListChapters(slug)
|
||
if err != nil {
|
||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
start := (page - 1) * chapterPageSize
|
||
if start >= len(chapters) {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
end := start + chapterPageSize
|
||
hasMore := end < len(chapters)
|
||
if end > len(chapters) {
|
||
end = len(chapters)
|
||
}
|
||
|
||
t := template.Must(template.New("chapterPage").Parse(chapterPageTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
Chapters interface{}
|
||
HasMore bool
|
||
NextPage int
|
||
}{
|
||
Slug: slug,
|
||
Chapters: chapters[start:end],
|
||
HasMore: hasMore,
|
||
NextPage: page + 1,
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = buf.WriteTo(w)
|
||
}
|
||
|
||
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
|
||
|
||
const chapterTmpl = `
|
||
<div class="max-w-2xl mx-auto px-4 py-10">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<a href="/books/{{.Slug}}"
|
||
hx-get="/books/{{.Slug}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm text-zinc-400 hover:text-amber-400 inline-flex items-center gap-1">
|
||
← Chapter list
|
||
</a>
|
||
<div class="flex gap-2">
|
||
{{if .PrevN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
← Prev
|
||
</a>
|
||
{{end}}
|
||
{{if .NextN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
Next →
|
||
</a>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TTS player bar — calls Kokoro directly from the browser -->
|
||
<div id="tts-bar" class="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
|
||
<!-- Play/pause button -->
|
||
<button id="tts-btn"
|
||
onclick="ttsToggle()"
|
||
class="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-medium transition-colors">
|
||
<span id="tts-icon">▶</span>
|
||
<span id="tts-label">Listen</span>
|
||
</button>
|
||
|
||
<!-- Voice selector -->
|
||
<select id="tts-voice"
|
||
class="rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-amber-500 transition-colors">
|
||
{{range .Voices}}
|
||
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
|
||
{{end}}
|
||
</select>
|
||
|
||
<!-- Speed control -->
|
||
<div class="flex items-center gap-1.5">
|
||
<span class="text-xs text-zinc-500 whitespace-nowrap">Speed</span>
|
||
<input id="tts-speed" type="range"
|
||
min="0.5" max="2" step="0.1" value="1"
|
||
class="w-20 accent-amber-500 cursor-pointer" />
|
||
<span id="tts-speed-label" class="text-xs text-zinc-400 w-7 text-right">1.0×</span>
|
||
</div>
|
||
|
||
<div id="tts-status" class="text-xs text-zinc-500 ml-1"></div>
|
||
|
||
<!-- Auto-play next chapter toggle -->
|
||
<label class="flex items-center gap-1.5 ml-auto cursor-pointer select-none" title="Automatically start the next chapter when this one finishes">
|
||
<input id="tts-autoplay" type="checkbox" class="accent-amber-500 cursor-pointer" />
|
||
<span class="text-xs text-zinc-400 whitespace-nowrap">Auto-next</span>
|
||
</label>
|
||
</div>
|
||
<audio id="tts-audio" style="display:none"></audio>
|
||
</div>
|
||
|
||
<!-- Paragraphs get data-para-idx injected by JS after render -->
|
||
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
|
||
{{.HTML}}
|
||
</article>
|
||
|
||
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800">
|
||
{{if .PrevN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
← Previous chapter
|
||
</a>
|
||
{{else}}<span></span>{{end}}
|
||
{{if .NextN}}
|
||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||
hx-target="#main-content"
|
||
hx-push-url="true"
|
||
hx-swap="innerHTML"
|
||
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||
Next chapter →
|
||
</a>
|
||
{{else}}<span></span>{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* Paragraph click-to-play affordance */
|
||
#chapter-article p {
|
||
cursor: pointer;
|
||
border-radius: 0.375rem;
|
||
margin-left: -0.5rem;
|
||
padding-left: 0.5rem;
|
||
transition: background 0.15s;
|
||
}
|
||
#chapter-article p:hover {
|
||
background: rgba(251,191,36,0.07);
|
||
}
|
||
#chapter-article p.tts-active {
|
||
background: rgba(251,191,36,0.13);
|
||
border-left: 2px solid #f59e0b;
|
||
padding-left: calc(0.5rem - 2px);
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
(function () {
|
||
var KOKORO_URL = '{{.KokoroURL}}';
|
||
var NEXT_N = {{.NextN}};
|
||
var SLUG = '{{.Slug}}';
|
||
var CHAPTER_N = {{.ChapterN}};
|
||
|
||
// ── reading progress ──────────────────────────────────────────────────────────
|
||
// Save current chapter to localStorage so the book page can show a resume button.
|
||
(function saveProgress() {
|
||
var LS_KEY = 'reading_progress';
|
||
try {
|
||
var progress = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
|
||
progress[SLUG] = CHAPTER_N;
|
||
localStorage.setItem(LS_KEY, JSON.stringify(progress));
|
||
} catch(_) {}
|
||
})();
|
||
|
||
var audio = document.getElementById('tts-audio');
|
||
var btn = document.getElementById('tts-btn');
|
||
var icon = document.getElementById('tts-icon');
|
||
var label = document.getElementById('tts-label');
|
||
var status = document.getElementById('tts-status');
|
||
var voiceSel = document.getElementById('tts-voice');
|
||
var speedSlider = document.getElementById('tts-speed');
|
||
var speedLabel = document.getElementById('tts-speed-label');
|
||
var autoplayChk = document.getElementById('tts-autoplay');
|
||
var article = document.getElementById('chapter-article');
|
||
|
||
var abortCtrl = null;
|
||
var mse = null;
|
||
var srcBuf = null;
|
||
var queue = [];
|
||
var appending = false;
|
||
var fetchDone = false;
|
||
var activePara = null; // currently highlighted <p> element
|
||
|
||
// ── next-chapter prefetch state ───────────────────────────────────────────────
|
||
// When auto-play is on we begin fetching the next chapter's audio in the
|
||
// background as soon as Kokoro finishes streaming the current chapter.
|
||
// The result is held in a second <audio>/MSE pipeline so that when the
|
||
// current chapter ends we can play it without any buffering wait.
|
||
|
||
var prefetchAudio = null; // hidden <audio> element used for prefetch
|
||
var prefetchMSE = null;
|
||
var prefetchBuf = null;
|
||
var prefetchQueue = [];
|
||
var prefetchAppending = false;
|
||
var prefetchDone = false;
|
||
var prefetchAbort = null;
|
||
var prefetchReady = false; // true once canplay fired on prefetchAudio
|
||
|
||
// ── localStorage persistence ─────────────────────────────────────────────────
|
||
// Settings saved: tts_speed, tts_voice, tts_autonext
|
||
// Loaded once on page init; written on every change.
|
||
|
||
var LS_SPEED = 'tts_speed';
|
||
var LS_VOICE = 'tts_voice';
|
||
var LS_AUTONEXT = 'tts_autonext';
|
||
|
||
(function loadSettings() {
|
||
var savedSpeed = localStorage.getItem(LS_SPEED);
|
||
if (savedSpeed !== null) {
|
||
speedSlider.value = savedSpeed;
|
||
speedLabel.textContent = parseFloat(savedSpeed).toFixed(1) + '\u00D7';
|
||
}
|
||
var savedVoice = localStorage.getItem(LS_VOICE);
|
||
if (savedVoice !== null) {
|
||
var opt = voiceSel.querySelector('option[value="' + savedVoice + '"]');
|
||
if (opt) voiceSel.value = savedVoice;
|
||
}
|
||
var savedAutoNext = localStorage.getItem(LS_AUTONEXT);
|
||
if (savedAutoNext !== null) {
|
||
autoplayChk.checked = savedAutoNext === 'true';
|
||
}
|
||
})();
|
||
|
||
// ── speed slider ─────────────────────────────────────────────────────────────
|
||
|
||
speedSlider.addEventListener('input', function () {
|
||
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
|
||
localStorage.setItem(LS_SPEED, speedSlider.value);
|
||
});
|
||
|
||
voiceSel.addEventListener('change', function () {
|
||
localStorage.setItem(LS_VOICE, voiceSel.value);
|
||
});
|
||
|
||
autoplayChk.addEventListener('change', function () {
|
||
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
|
||
});
|
||
|
||
// ── paragraph indexing ───────────────────────────────────────────────────────
|
||
// Collect all <p> elements inside the article, assign data-para-idx,
|
||
// and wire click handlers so any paragraph starts TTS from that point.
|
||
|
||
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
|
||
paras.forEach(function (p, i) {
|
||
p.dataset.paraIdx = i;
|
||
p.title = 'Click to read from here';
|
||
p.addEventListener('click', function () {
|
||
startFromPara(i);
|
||
});
|
||
});
|
||
|
||
function textFromPara(startIdx) {
|
||
return paras
|
||
.slice(startIdx)
|
||
.map(function (p) { return p.innerText.trim(); })
|
||
.filter(function (t) { return t.length > 0; })
|
||
.join('\n\n');
|
||
}
|
||
|
||
function highlightPara(idx) {
|
||
if (activePara) activePara.classList.remove('tts-active');
|
||
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
|
||
if (activePara) {
|
||
activePara.classList.add('tts-active');
|
||
activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
// ── state helpers ─────────────────────────────────────────────────────────────
|
||
|
||
function setLoading(paraIdx) {
|
||
highlightPara(paraIdx);
|
||
icon.textContent = '\u231B';
|
||
label.textContent = 'Buffering';
|
||
status.textContent = 'Fetching from Kokoro\u2026';
|
||
btn.disabled = true;
|
||
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||
voiceSel.disabled = true;
|
||
speedSlider.disabled = true;
|
||
}
|
||
|
||
function setPlaying() {
|
||
icon.innerHTML = '▮▮';
|
||
label.textContent = 'Pause';
|
||
status.textContent = 'Playing';
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||
}
|
||
|
||
function setPaused() {
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Resume';
|
||
status.textContent = 'Paused';
|
||
}
|
||
|
||
function setStopped() {
|
||
highlightPara(-1);
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Listen';
|
||
status.textContent = '';
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
}
|
||
|
||
function setError(msg) {
|
||
highlightPara(-1);
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Listen';
|
||
status.textContent = 'Error: ' + msg;
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||
voiceSel.disabled = false;
|
||
speedSlider.disabled = false;
|
||
}
|
||
|
||
// ── capability detection ──────────────────────────────────────────────────────
|
||
// MSE streaming is not supported on iOS Safari (or is unreliable on older
|
||
// versions). Fall back to a full-download blob URL on those platforms so
|
||
// audio works on all devices. The downside is playback starts only after the
|
||
// full chapter audio has been downloaded, but it is universally compatible.
|
||
|
||
var USE_MSE = (function () {
|
||
if (typeof MediaSource === 'undefined') return false;
|
||
// iOS Safari reports MediaSource but does not support it reliably for audio/mpeg
|
||
var ua = navigator.userAgent;
|
||
var isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
|
||
if (isIOS) return false;
|
||
try { return MediaSource.isTypeSupported('audio/mpeg'); } catch(_) { return false; }
|
||
})();
|
||
|
||
// ── MSE helpers (desktop / non-iOS) ──────────────────────────────────────────
|
||
|
||
function flushQueue() {
|
||
if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return;
|
||
appending = true;
|
||
var chunk = queue.shift();
|
||
try {
|
||
srcBuf.appendBuffer(chunk);
|
||
} catch (e) {
|
||
appending = false;
|
||
setError(e.message);
|
||
}
|
||
}
|
||
|
||
function endStream() {
|
||
if (mse && mse.readyState === 'open' && srcBuf && !srcBuf.updating) {
|
||
try { mse.endOfStream(); } catch(_) {}
|
||
}
|
||
// Current chapter audio is fully received — good time to start prefetching
|
||
// the next chapter so it's ready by the time playback ends.
|
||
if (autoplayChk.checked && NEXT_N && !prefetchAbort) {
|
||
startPrefetch();
|
||
}
|
||
}
|
||
|
||
// ── next-chapter prefetch ─────────────────────────────────────────────────────
|
||
|
||
function stopPrefetch() {
|
||
if (prefetchAbort) { prefetchAbort.abort(); prefetchAbort = null; }
|
||
if (prefetchAudio) {
|
||
if (prefetchAudio.src && prefetchAudio.src.startsWith('blob:')) {
|
||
URL.revokeObjectURL(prefetchAudio.src);
|
||
}
|
||
prefetchAudio.src = '';
|
||
prefetchAudio = null;
|
||
}
|
||
prefetchMSE = null;
|
||
prefetchBuf = null;
|
||
prefetchQueue = [];
|
||
prefetchAppending = false;
|
||
prefetchDone = false;
|
||
prefetchReady = false;
|
||
}
|
||
|
||
function flushPrefetchQueue() {
|
||
if (prefetchAppending || !prefetchBuf || prefetchBuf.updating || prefetchQueue.length === 0) return;
|
||
prefetchAppending = true;
|
||
var chunk = prefetchQueue.shift();
|
||
try {
|
||
prefetchBuf.appendBuffer(chunk);
|
||
} catch (e) {
|
||
prefetchAppending = false;
|
||
}
|
||
}
|
||
|
||
function startPrefetch() {
|
||
if (!KOKORO_URL || !NEXT_N) return;
|
||
|
||
fetch('/ui/chapter-text/' + SLUG + '/' + NEXT_N)
|
||
.then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); })
|
||
.then(function (text) {
|
||
text = text.trim();
|
||
if (!text) return;
|
||
|
||
prefetchAbort = new AbortController();
|
||
prefetchAudio = document.createElement('audio');
|
||
prefetchAudio.style.display = 'none';
|
||
prefetchReady = false;
|
||
|
||
prefetchAudio.addEventListener('canplay', function () { prefetchReady = true; });
|
||
|
||
if (USE_MSE) {
|
||
// ── MSE prefetch path ────────────────────────────────────────────────
|
||
prefetchMSE = new MediaSource();
|
||
prefetchQueue = [];
|
||
prefetchAppending = false;
|
||
prefetchDone = false;
|
||
|
||
prefetchAudio.src = URL.createObjectURL(prefetchMSE);
|
||
|
||
prefetchMSE.addEventListener('sourceopen', function () {
|
||
try {
|
||
prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg');
|
||
} catch (e) { return; }
|
||
|
||
prefetchBuf.addEventListener('updateend', function () {
|
||
prefetchAppending = false;
|
||
if (prefetchDone && prefetchQueue.length === 0) {
|
||
if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) {
|
||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||
}
|
||
} else {
|
||
flushPrefetchQueue();
|
||
}
|
||
});
|
||
|
||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||
method: 'POST',
|
||
signal: prefetchAbort.signal,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true
|
||
})
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) return;
|
||
var reader = res.body.getReader();
|
||
function pump() {
|
||
reader.read().then(function (ref) {
|
||
if (ref.done) {
|
||
prefetchDone = true;
|
||
if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) {
|
||
if (prefetchMSE && prefetchMSE.readyState === 'open') {
|
||
try { prefetchMSE.endOfStream(); } catch(_) {}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
prefetchQueue.push(ref.value);
|
||
flushPrefetchQueue();
|
||
pump();
|
||
}).catch(function () {});
|
||
}
|
||
pump();
|
||
})
|
||
.catch(function () {});
|
||
});
|
||
|
||
prefetchAudio.load();
|
||
} else {
|
||
// ── Blob prefetch path (iOS / no MSE) ────────────────────────────────
|
||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||
method: 'POST',
|
||
signal: prefetchAbort.signal,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
|
||
})
|
||
})
|
||
.then(function (res) { return res.ok ? res.blob() : Promise.reject(res.status); })
|
||
.then(function (blob) {
|
||
prefetchAudio.src = URL.createObjectURL(blob);
|
||
prefetchDone = true;
|
||
prefetchAudio.load();
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
// ── core stream logic ─────────────────────────────────────────────────────────
|
||
|
||
function stop() {
|
||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
||
if (audio.src && audio.src.startsWith('blob:')) {
|
||
URL.revokeObjectURL(audio.src);
|
||
}
|
||
audio.src = '';
|
||
mse = null;
|
||
srcBuf = null;
|
||
queue = [];
|
||
appending = false;
|
||
fetchDone = false;
|
||
stopPrefetch();
|
||
setStopped();
|
||
}
|
||
|
||
function startStream(text, paraIdx) {
|
||
abortCtrl = new AbortController();
|
||
|
||
if (USE_MSE) {
|
||
// ── MSE streaming path (desktop) ─────────────────────────────────────────
|
||
mse = new MediaSource();
|
||
queue = [];
|
||
appending = false;
|
||
fetchDone = false;
|
||
|
||
audio.src = URL.createObjectURL(mse);
|
||
|
||
mse.addEventListener('sourceopen', function () {
|
||
try {
|
||
srcBuf = mse.addSourceBuffer('audio/mpeg');
|
||
} catch (e) {
|
||
setError('MSE: ' + e.message);
|
||
return;
|
||
}
|
||
|
||
srcBuf.addEventListener('updateend', function () {
|
||
appending = false;
|
||
if (fetchDone && queue.length === 0) {
|
||
endStream();
|
||
} else {
|
||
flushQueue();
|
||
}
|
||
});
|
||
|
||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||
method: 'POST',
|
||
signal: abortCtrl.signal,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: true
|
||
})
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) {
|
||
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
|
||
}
|
||
var reader = res.body.getReader();
|
||
function pump() {
|
||
reader.read().then(function (ref) {
|
||
if (ref.done) {
|
||
fetchDone = true;
|
||
if (!srcBuf.updating && queue.length === 0) { endStream(); }
|
||
return;
|
||
}
|
||
queue.push(ref.value);
|
||
flushQueue();
|
||
pump();
|
||
}).catch(function (e) {
|
||
if (e.name !== 'AbortError') setError(e.message);
|
||
});
|
||
}
|
||
pump();
|
||
})
|
||
.catch(function (e) {
|
||
if (e.name !== 'AbortError') setError(e.message);
|
||
});
|
||
});
|
||
|
||
audio.load();
|
||
} else {
|
||
// ── Blob path (iOS / no MSE) ──────────────────────────────────────────────
|
||
// Fetch the full audio as a single blob then hand it to the <audio> element.
|
||
// Playback begins only once the download is complete, but this is the only
|
||
// approach that works reliably on iOS Safari.
|
||
status.textContent = 'Downloading\u2026';
|
||
fetch(KOKORO_URL + '/v1/audio/speech', {
|
||
method: 'POST',
|
||
signal: abortCtrl.signal,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: 'kokoro', input: text, voice: voiceSel.value,
|
||
response_format: 'mp3', speed: parseFloat(speedSlider.value), stream: false
|
||
})
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) {
|
||
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
|
||
}
|
||
return res.blob();
|
||
})
|
||
.then(function (blob) {
|
||
if (abortCtrl && abortCtrl.signal.aborted) return;
|
||
var url = URL.createObjectURL(blob);
|
||
audio.src = url;
|
||
audio.load();
|
||
// Trigger prefetch now that the main download is done.
|
||
if (autoplayChk.checked && NEXT_N && !prefetchAbort) {
|
||
startPrefetch();
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
if (e.name !== 'AbortError') setError(e.message);
|
||
});
|
||
}
|
||
}
|
||
|
||
// startFromPara stops any current stream and begins a new one from paraIdx.
|
||
function startFromPara(paraIdx) {
|
||
if (!KOKORO_URL) { setError('KOKORO_URL not configured on server'); return; }
|
||
stop();
|
||
var text = textFromPara(paraIdx);
|
||
if (!text) { setError('no text found'); return; }
|
||
setLoading(paraIdx);
|
||
startStream(text, paraIdx);
|
||
}
|
||
|
||
// ── auto-play next chapter ────────────────────────────────────────────────────
|
||
// When auto-play is on we try to hand off directly from the prefetched audio
|
||
// pipeline. The page navigation still happens (so the URL and chapter title
|
||
// update correctly) but audio starts immediately from the prefetch buffer
|
||
// instead of waiting for a cold Kokoro request.
|
||
|
||
function goNextChapter() {
|
||
if (!NEXT_N) return;
|
||
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
|
||
|
||
if (prefetchAudio && prefetchAbort) {
|
||
// We have a prefetch in progress (or complete). Swap the prefetch audio
|
||
// element into the main player so it plays without delay.
|
||
var pa = prefetchAudio;
|
||
// Detach from prefetch state so stopPrefetch() called by htmx:beforeSwap
|
||
// doesn't destroy it before we hand it off.
|
||
prefetchAudio = null;
|
||
prefetchAbort = null;
|
||
|
||
// Navigate the page (URL + content swap) without the ?autoplay=1 flag
|
||
// because we are starting audio ourselves right here.
|
||
htmx.ajax('GET', nextURL, {
|
||
target: '#main-content',
|
||
swap: 'innerHTML',
|
||
pushURL: nextURL
|
||
});
|
||
|
||
// Play prefetched audio immediately (or as soon as it can).
|
||
// The new page's script will take over its own audio element, so we
|
||
// drive this orphaned element directly until the user interacts.
|
||
// Use the main audio element to carry the blob URL across the swap.
|
||
if (pa.src && pa.src.startsWith('blob:')) {
|
||
audio.src = pa.src;
|
||
pa.src = ''; // transfer ownership so revokeObjectURL isn't called twice
|
||
if (prefetchReady || pa.readyState >= 3 /* HAVE_FUTURE_DATA */) {
|
||
audio.play().catch(function () {});
|
||
} else {
|
||
audio.addEventListener('canplay', function onCp() {
|
||
audio.removeEventListener('canplay', onCp);
|
||
audio.play().catch(function () {});
|
||
});
|
||
}
|
||
}
|
||
|
||
// Clean up the rest of the prefetch state (don't abort — the fetch
|
||
// may still be pumping chunks into the buffer we are now playing).
|
||
prefetchMSE = null; prefetchBuf = null;
|
||
prefetchQueue = []; prefetchAppending = false;
|
||
prefetchDone = false; prefetchReady = false;
|
||
} else {
|
||
// No prefetch available — fall back to cold navigation with ?autoplay=1.
|
||
htmx.ajax('GET', nextURL + '?autoplay=1', {
|
||
target: '#main-content',
|
||
swap: 'innerHTML',
|
||
pushURL: nextURL
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── audio events ──────────────────────────────────────────────────────────────
|
||
|
||
audio.addEventListener('canplay', function () {
|
||
if (audio.paused) {
|
||
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
|
||
}
|
||
});
|
||
audio.addEventListener('waiting', function () { status.textContent = 'Buffering\u2026'; });
|
||
audio.addEventListener('playing', setPlaying);
|
||
audio.addEventListener('ended', function () {
|
||
// Cancel the main stream state but keep prefetch alive — goNextChapter needs it.
|
||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
||
if (audio.src && audio.src.startsWith('blob:')) URL.revokeObjectURL(audio.src);
|
||
audio.src = ''; mse = null; srcBuf = null; queue = []; appending = false; fetchDone = false;
|
||
if (autoplayChk.checked && NEXT_N) {
|
||
goNextChapter();
|
||
} else {
|
||
stopPrefetch();
|
||
setStopped();
|
||
}
|
||
});
|
||
audio.addEventListener('error', function () { setError('audio decode error'); });
|
||
audio.addEventListener('pause', function () { if (!audio.ended) setPaused(); });
|
||
audio.addEventListener('play', setPlaying);
|
||
|
||
// ── toolbar toggle (plays from paragraph 0) ───────────────────────────────────
|
||
|
||
window.ttsToggle = function () {
|
||
if (audio.src && !audio.ended) {
|
||
if (audio.paused) {
|
||
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
|
||
} else {
|
||
audio.pause();
|
||
}
|
||
return;
|
||
}
|
||
startFromPara(0);
|
||
};
|
||
|
||
// Stop when HTMX navigates away.
|
||
document.body.addEventListener('htmx:beforeSwap', stop);
|
||
|
||
// ── auto-start TTS when arriving via auto-play navigation ────────────────────
|
||
// The previous chapter's goNextChapter() appends ?autoplay=1. Detect it here
|
||
// and kick off playback from paragraph 0 once the page is ready.
|
||
|
||
if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
|
||
autoplayChk.checked = true; // keep the toggle on for the chain to continue
|
||
// Small delay to let the DOM settle before starting the stream.
|
||
setTimeout(function () { startFromPara(0); }, 150);
|
||
}
|
||
}());
|
||
</script>`
|
||
|
||
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
n, err := strconv.Atoi(r.PathValue("n"))
|
||
if err != nil || n < 1 {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
raw, err := s.writer.ReadChapter(slug, n)
|
||
if err != nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
var htmlBuf bytes.Buffer
|
||
if err := md.Convert([]byte(raw), &htmlBuf); err != nil {
|
||
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
chapters, _ := s.writer.ListChapters(slug)
|
||
prevN, nextN := adjacentChapters(chapters, n)
|
||
|
||
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
|
||
|
||
t := template.Must(template.New("chapter").Parse(chapterTmpl))
|
||
var buf bytes.Buffer
|
||
_ = t.Execute(&buf, struct {
|
||
Slug string
|
||
HTML template.HTML
|
||
PrevN int
|
||
NextN int
|
||
ChapterN int
|
||
KokoroURL string
|
||
Voices []string
|
||
DefaultVoice string
|
||
}{
|
||
Slug: slug,
|
||
HTML: template.HTML(htmlBuf.String()),
|
||
PrevN: prevN,
|
||
NextN: nextN,
|
||
ChapterN: n,
|
||
KokoroURL: s.kokoroURL,
|
||
Voices: kokoroVoices,
|
||
DefaultVoice: s.kokoroVoice,
|
||
})
|
||
|
||
s.respond(w, r, title, buf.String())
|
||
}
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
// stripMarkdown removes Markdown syntax and returns clean plain text.
|
||
func stripMarkdown(src string) string {
|
||
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
|
||
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
|
||
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
|
||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||
return strings.TrimSpace(src)
|
||
}
|
||
|
||
// adjacentChapters returns the chapter numbers immediately before and after n
|
||
// in the sorted chapters list. 0 means "does not exist".
|
||
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
|
||
for i, ch := range chapters {
|
||
if ch.Number == n {
|
||
if i > 0 {
|
||
prev = chapters[i-1].Number
|
||
}
|
||
if i < len(chapters)-1 {
|
||
next = chapters[i+1].Number
|
||
}
|
||
return
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// firstHeading returns the text of the first non-empty line, stripping a
|
||
// leading "# " markdown heading marker. Falls back to fallback.
|
||
func firstHeading(md, fallback string) string {
|
||
for _, line := range strings.SplitN(md, "\n", 20) {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
return strings.TrimPrefix(line, "# ")
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
|
||
|
||
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||
bookURL := strings.TrimSpace(r.FormValue("url"))
|
||
if bookURL == "" {
|
||
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
|
||
return
|
||
}
|
||
|
||
s.mu.Lock()
|
||
already := s.running
|
||
if !already {
|
||
s.running = true
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
if already {
|
||
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
|
||
return
|
||
}
|
||
|
||
cfg := s.oCfg
|
||
cfg.SingleBookURL = bookURL
|
||
|
||
go func() {
|
||
defer func() {
|
||
s.mu.Lock()
|
||
s.running = false
|
||
s.mu.Unlock()
|
||
}()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
|
||
defer cancel()
|
||
|
||
o := orchestrator.New(cfg, s.novel, s.log)
|
||
if err := o.Run(ctx); err != nil {
|
||
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
|
||
}
|
||
}()
|
||
|
||
// Return a status badge that polls until the job finishes.
|
||
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
|
||
}
|
||
|
||
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
|
||
|
||
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
|
||
s.mu.Lock()
|
||
running := s.running
|
||
s.mu.Unlock()
|
||
|
||
if running {
|
||
// Keep polling every 3 s while the job is in progress.
|
||
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
|
||
return
|
||
}
|
||
// Job finished — show a done badge and stop polling.
|
||
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
|
||
}
|
||
|
||
// scrapeStatusHTML returns a self-contained status badge fragment.
|
||
// state is one of: "running" | "done" | "busy" | "error".
|
||
func scrapeStatusHTML(state, msg string) string {
|
||
var colour, dot, poll string
|
||
switch state {
|
||
case "running":
|
||
colour = "text-amber-300 bg-amber-950 border-amber-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
|
||
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
|
||
case "done":
|
||
colour = "text-green-300 bg-green-950 border-green-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
|
||
case "busy":
|
||
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
|
||
default: // error
|
||
colour = "text-red-300 bg-red-950 border-red-800"
|
||
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
|
||
}
|
||
return fmt.Sprintf(
|
||
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
|
||
colour, poll, dot, template.HTMLEscapeString(msg),
|
||
)
|
||
}
|