Compare commits

...

12 Commits

Author SHA1 Message Date
Admin
ad2d1a2603 feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
2026-04-05 00:32:18 +05:00
Admin
b0d8c02787 fix: add created field to chapters_idx to fix recentlyUpdatedBooks 400
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.

- Add created date field to chapters_idx schema in pb-init-v3.sh
  (also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
  chapters are immediately sortable; existing records retain empty created
  and will sort to the back (acceptable — only affects home page recency)
2026-04-04 23:47:23 +05:00
Admin
5b4c1db931 fix: add watchtower label to runner service so auto-updates work
Without com.centurylinklabs.watchtower.enable=true the homelab watchtower
(running with --label-enable) silently skipped the runner container,
leaving it stuck on v2.5.60 while fixes accumulated on newer tags.
2026-04-04 23:39:19 +05:00
Admin
0c54c59586 fix: guard against font_size=0 collapsing chapter text
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m1s
Release / Gitea Release (push) Successful in 41s
- Replace ?? with || when reading font_size so 0 falls back to 1.0
  (affects GET /api/settings, layout.server.ts, +layout.svelte)
- Remove the explicit 'body.fontSize !== 0' exception in PUT /api/settings
  validation so 0 is now correctly rejected as an invalid font size
- Add add_index helper + idx_chapters_idx_slug_number declaration to
  scripts/pb-init-v3.sh (idempotent UNIQUE INDEX on chapters_idx)
2026-04-04 23:26:54 +05:00
Admin
0e5eb84097 feat: add SvelteKit proxy route for admin dedup-chapters endpoint
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 6m12s
Release / Docker / runner (push) Successful in 3m8s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 47s
2026-04-04 22:34:26 +05:00
Admin
6ef82a1d12 fix: add DeduplicateChapters stub to test mocks to satisfy BookWriter interface
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
2026-04-04 21:17:55 +05:00
Admin
7a418ee62b fix: await marked() to prevent Promise being passed as chapter HTML
Some checks failed
Release / Test backend (push) Failing after 15s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / ui (push) Successful in 2m41s
Release / Gitea Release (push) Has been skipped
marked() returns string | Promise<string>; the previous cast 'as string'
silently passed a Promise object, which Svelte rendered as nothing.
Free users saw blank content even though SSR HTML was correct.
2026-04-04 21:15:06 +05:00
Admin
d4f35a4899 fix: prevent duplicate chapters_idx records + add dedup endpoint
Some checks failed
Release / Test backend (push) Failing after 18s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata)
  so concurrent goroutines don't double-POST the same chapter number
- Add DeduplicateChapters to BookWriter interface and Store implementation;
  keeps the latest record per (slug, number) and deletes extras
- Wire POST /api/admin/dedup-chapters/{slug} handler in server.go
2026-04-04 21:00:10 +05:00
Admin
6559a8c015 fix: split long text into chunks before sending to Cloudflare AI TTS
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.

GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
2026-04-04 20:45:22 +05:00
Admin
05bfd110b8 refactor: replace floating settings panel with bottom sheet + Reading/Listening tabs
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
The old vertical dropdown was too tall on mobile — 14 stacked groups
required heavy scrolling and had no visual hierarchy.

New design:
- Full-width bottom sheet slides from screen edge (natural mobile gesture)
- Drag handle + dimmed backdrop for clarity
- Two tabs split the settings: Reading (typography + layout) and Listening
  (player style, speed, auto-next, sleep timer)
- Each row uses label-on-left + pill-group-on-right layout — saves one line
  per setting and makes the list scannable at a glance
- Settings are grouped into titled cards (Typography, Layout, Player)
  with dividers between rows instead of floating individual blocks
- Gear button moved to bottom-[4.5rem] to clear the mini-player bar
2026-04-04 20:35:15 +05:00
Admin
bfd0ad8fb7 fix: chapter content vanishes — replace stale untrack snapshots with $derived
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / ui (push) Successful in 4m58s
Release / Gitea Release (push) Has been cancelled
html and fetchingContent were captured with untrack() at mount time.
When SvelteKit re-ran the page load (triggered by the layout's settings PUT),
data.html updated but html stayed stale. The {#key} block in the layout then
destroyed and recreated the component, and on remount data.html was momentarily
empty so html became '' and the live-scrape fallback ran unnecessarily.

Fix:
- html is now $derived(scrapedHtml || data.html || '') — always tracks load
- scrapedHtml is a separate $state only set by the live-scrape fallback
- fetchingContent starts false; the fallback sets it true only when actually fetching
- translationStatus/translatingLang: dropped untrack() so they also react to re-runs
- Removed unused untrack import
2026-04-04 20:26:43 +05:00
Admin
4b7fcf432b fix: pass POLAR_API_TOKEN and POLAR_WEBHOOK_SECRET to ui container
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 43s
Both vars were present in Doppler but never injected into the ui service
environment, causing all checkout requests to fail with 500 and webhooks
to be silently rejected.
2026-04-04 20:18:19 +05:00
24 changed files with 1234 additions and 425 deletions

View File

@@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
}
// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
// Removes duplicate chapters_idx records for a book, keeping the latest record
// per chapter number. Returns the number of duplicate records deleted.
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
if err != nil {
s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
"deleted": deleted,
})
return
}
s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
}
// ── Audio ──────────────────────────────────────────────────────────────────────
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.

View File

@@ -10,6 +10,10 @@ import (
"github.com/libnovel/backend/internal/domain"
)
// chapterNamesBatchSize is the number of chapters sent per LLM request.
// Keeps output well within the 4096-token response limit (~30 tokens/title).
const chapterNamesBatchSize = 100
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
// Returns the list of supported Cloudflare AI text generation models.
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
@@ -36,16 +40,6 @@ type textGenChapterNamesRequest struct {
MaxTokens int `json:"max_tokens"`
}
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
type textGenChapterNamesResponse struct {
// Chapters is the list of proposed chapter titles, indexed by chapter number.
Chapters []proposedChapterTitle `json:"chapters"`
// Model is the model that was used.
Model string `json:"model"`
// RawResponse is the raw model output for debugging / manual editing.
RawResponse string `json:"raw_response"`
}
// proposedChapterTitle is a single chapter with its AI-proposed title.
type proposedChapterTitle struct {
Number int `json:"number"`
@@ -55,12 +49,35 @@ type proposedChapterTitle struct {
NewTitle string `json:"new_title"`
}
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
TotalBatches int `json:"total_batches"`
// ChaptersDone is the cumulative count of chapters processed so far.
ChaptersDone int `json:"chapters_done"`
// TotalChapters is the total chapter count for this book.
TotalChapters int `json:"total_chapters"`
// Model is the CF AI model used.
Model string `json:"model"`
// Chapters contains the proposed titles for this batch.
Chapters []proposedChapterTitle `json:"chapters"`
// Error is non-empty if this batch failed.
Error string `json:"error,omitempty"`
// Done is true on the final sentinel event (no Chapters).
Done bool `json:"done,omitempty"`
}
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Reads all chapter titles for the given slug, sends them to the LLM with the
// requested naming pattern, and returns proposed replacements. Does NOT persist
// anything — the frontend shows a diff and the user must confirm via
// POST /api/admin/text-gen/chapter-names/apply.
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
// to the LLM sequentially, and streams results back as Server-Sent Events so
// the frontend can show live progress. Each SSE data line is a JSON-encoded
// chapterNamesBatchEvent. The final event has Done=true.
//
// Does NOT persist anything — the frontend shows a diff and the user must
// confirm via POST /api/admin/text-gen/chapter-names/apply.
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
@@ -92,11 +109,29 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
return
}
// Build the prompt.
var chapterListSB strings.Builder
for _, ch := range chapters {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
// Partition chapters into batches.
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters),
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user provides a list of chapter numbers with their current titles, ` +
@@ -111,64 +146,91 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
`5. Each element: {"number": <int>, "title": <string>}. ` +
`6. Output every chapter in the input list, in order. Do not skip any.`
userPrompt := fmt.Sprintf(
"Naming pattern: %s\n\nChapters:\n%s",
req.Pattern,
chapterListSB.String(),
)
// Switch to SSE before writing anything.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
flusher, canFlush := w.(http.Flusher)
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
sseWrite := func(evt chapterNamesBatchEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
// Default to 4096 tokens so large chapter lists are not truncated.
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
chaptersDone := 0
for i, batch := range batches {
if r.Context().Err() != nil {
return // client disconnected
}
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
"batch", i+1, "err", genErr)
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
})
continue
}
// Parse the JSON array from the model response.
proposed := parseChapterTitlesJSON(raw)
proposed := parseChapterTitlesJSON(raw)
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
// Build the response: merge proposed titles with old titles.
// Index existing chapters by number for O(1) lookup.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
})
}
writeJSON(w, 0, textGenChapterNamesResponse{
Chapters: result,
Model: string(model),
RawResponse: raw,
})
// Final sentinel event.
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
}
// chunkChapters splits a chapter slice into batches of at most size n.
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
var batches [][]domain.ChapterInfo
for len(chapters) > 0 {
end := n
if end > len(chapters) {
end = len(chapters)
}
batches = append(batches, chapters[:end])
chapters = chapters[end:]
}
return batches
}
// parseChapterTitlesJSON extracts the JSON array from a model response.

View File

@@ -204,6 +204,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -35,6 +35,11 @@ type BookWriter interface {
// ChapterExists returns true if the markdown object for ref already exists.
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
// DeduplicateChapters removes duplicate chapters_idx records for slug,
// keeping only one record per chapter number (the one with the latest
// updated timestamp). Returns the number of duplicate records deleted.
DeduplicateChapters(ctx context.Context, slug string) (int, error)
}
// BookReader is the read side used by the backend to serve content.

View File

@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
// RankingStore
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}

View File

@@ -17,6 +17,10 @@
// response. There is no 100-second Cloudflare proxy timeout because we are
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
// homelab tunnel.
//
// The aura-2-en model enforces a hard 2 000-character limit per request.
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
// and concatenates the resulting MP3 frames.
package cfai
import (
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
}
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("cfai: empty text")
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
speaker = "luna"
}
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
var combined []byte
for _, chunk := range chunks {
part, err := c.generateChunk(ctx, chunk, speaker)
if err != nil {
return nil, err
}
combined = append(combined, part...)
}
return combined, nil
}
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
body, err := json.Marshal(map[string]any{
"text": text,
"speaker": speaker,
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
return mp3, nil
}
// splitText splits src into chunks of at most maxChars characters each.
// It tries to break at paragraph boundaries first, then at sentence-ending
// punctuation (. ! ?), and falls back to the nearest space.
func splitText(src string, maxChars int) []string {
if len(src) <= maxChars {
return []string{src}
}
var chunks []string
remaining := src
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Search window: the first maxChars bytes of remaining.
// Use byte length here because the API limit is in bytes/chars for ASCII;
// for safety we operate on rune-aware slices.
window := remaining
if len(window) > maxChars {
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
window = runeSlice(remaining, maxChars)
}
cut := -1
// 1. Prefer paragraph break (\n\n or \n).
if i := strings.LastIndex(window, "\n\n"); i > 0 {
cut = i + 2
} else if i := strings.LastIndex(window, "\n"); i > 0 {
cut = i + 1
}
// 2. Fall back to sentence-ending punctuation followed by a space.
if cut < 0 {
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
if i := strings.LastIndex(window, punct); i > 0 {
candidate := i + len(punct)
if cut < 0 || candidate > cut {
cut = candidate
}
}
}
}
// 3. Last resort: nearest space.
if cut < 0 {
if i := strings.LastIndex(window, " "); i > 0 {
cut = i + 1
}
}
// 4. Hard cut at maxChars runes if no boundary found.
if cut < 0 {
cut = len(window)
}
chunk := strings.TrimSpace(remaining[:cut])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = remaining[cut:]
}
return chunks
}
// runeSlice returns the first n runes of s as a string.
func runeSlice(s string, n int) string {
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
mp3, err := c.GenerateAudio(ctx, text, voice)

View File

@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
return nil
}
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
return false
}
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
return 0, nil
}
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
type stubBookReader struct {
text string

View File

@@ -130,7 +130,23 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
insertPayload := map[string]any{
"slug": slug,
"number": ref.Number,
"title": ref.Title,
"created": time.Now().UTC().Format(time.RFC3339),
}
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
if postErr == nil {
return nil
}
// POST failed — a concurrent writer may have inserted the same slug+number.
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil || len(items) == 0 {
return postErr // original POST error is more informative
}
}
var rec struct {
ID string `json:"id"`
@@ -139,6 +155,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
}
// DeduplicateChapters removes duplicate chapters_idx records for slug.
// For each chapter number that has more than one record, it keeps the record
// with the latest "updated" timestamp and deletes the rest.
// Returns the number of records deleted.
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
if err != nil {
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
}
type record struct {
ID string `json:"id"`
Number int `json:"number"`
Updated string `json:"updated"`
}
// Group records by chapter number.
byNumber := make(map[int][]record)
for _, raw := range items {
var rec record
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
continue
}
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
}
deleted := 0
for _, recs := range byNumber {
if len(recs) <= 1 {
continue
}
// Keep the record with the latest Updated timestamp; delete the rest.
keep := 0
for i := 1; i < len(recs); i++ {
if recs[i].Updated > recs[keep].Updated {
keep = i
}
}
for i, rec := range recs {
if i == keep {
continue
}
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
continue
}
deleted++
}
}
return deleted, nil
}
// ── BookReader ────────────────────────────────────────────────────────────────
type pbBook struct {

View File

@@ -310,6 +310,9 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
# Polar (subscriptions)
POLAR_API_TOKEN: "${POLAR_API_TOKEN}"
POLAR_WEBHOOK_SECRET: "${POLAR_WEBHOOK_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s

View File

@@ -38,6 +38,8 @@ services:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s

View File

@@ -62,6 +62,39 @@ create() {
esac
}
# add_index COLLECTION INDEX_NAME SQL_EXPR
# Fetches current schema, adds index if absent by name, PATCHes collection.
add_index() {
COLL="$1"; INAME="$2"; ISQL="$3"
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
PARSED=$(echo "$SCHEMA" | python3 -c "
import sys, json
d = json.load(sys.stdin)
indexes = d.get('indexes', [])
exists = any('$INAME' in idx for idx in indexes)
print('exists=' + str(exists))
print('id=' + d.get('id', ''))
if not exists:
indexes.append('$ISQL')
print('indexes=' + json.dumps(indexes))
" 2>/dev/null)
if echo "$PARSED" | grep -q "^exists=True"; then
log "index exists (skip): $COLL.$INAME"; return
fi
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PATCH "$PB/api/collections/$COLL_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "{\"indexes\":${NEW_INDEXES}}")
case "$STATUS" in
200|201) log "added index: $COLL.$INAME" ;;
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
esac
}
# add_field COLLECTION FIELD_NAME FIELD_TYPE
# Fetches current schema, appends field if absent, PATCHes collection.
# Requires python3 for safe JSON manipulation.
@@ -116,9 +149,10 @@ create "books" '{
create "chapters_idx" '{
"name":"chapters_idx","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"number","type":"number", "required":true},
{"name":"title", "type":"text"}
{"name":"slug", "type":"text", "required":true},
{"name":"number", "type":"number", "required":true},
{"name":"title", "type":"text"},
{"name":"created", "type":"date"}
]}'
create "ranking" '{
@@ -293,5 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
add_field "chapters_idx" "created" "date"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
add_index "chapters_idx" "idx_chapters_idx_created" \
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
log "done"

View File

@@ -1,4 +1,2 @@
/* eslint-disable */
export * from './messages/_index.js'
// enabling auto-import by exposing all messages as m
export * as m from './messages/_index.js'
export * from './messages/_index.js'

View File

@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size ?? 1.0
fontSize: row.font_size || 1.0
};
}
} catch (e) {

View File

@@ -100,7 +100,7 @@
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
currentFontSize = data.settings.fontSize || 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);

View File

@@ -1,6 +1,7 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface ImageModelInfo {
id: string;
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
description: string;
}
export interface BookSummary {
slug: string;
title: string;
summary: string;
cover: string;
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) {
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
return { models: [] as ImageModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as ImageModelInfo[] };
} catch (e) {
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
return { models: [] as ImageModelInfo[] };
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as ImageModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
};
};

View File

@@ -1,26 +1,120 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { ImageModelInfo } from './+page.server';
import type { ImageModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
const CONFIG_KEY = 'admin_image_gen_config_v1';
interface SavedConfig {
selectedModel: string;
numSteps: number;
guidance: number;
strength: number;
width: number;
height: number;
showAdvanced: boolean;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state('');
let selectedModel = $state(saved.selectedModel ?? '');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
let useCoverAsRef = $state(false);
// Advanced
let showAdvanced = $state(false);
let numSteps = $state(20);
let guidance = $state(7.5);
let strength = $state(0.75);
let width = $state(1024);
let height = $state(1024);
let showAdvanced = $state(saved.showAdvanced ?? false);
let numSteps = $state(saved.numSteps ?? 20);
let guidance = $state(saved.guidance ?? 7.5);
let strength = $state(saved.strength ?? 0.75);
let width = $state(saved.width ?? 1024);
let height = $state(saved.height ?? 1024);
// Persist config on change
$effect(() => {
void selectedModel; void numSteps; void guidance; void strength;
void width; void height; void showAdvanced;
saveConfig();
});
// ── Book autocomplete ────────────────────────────────────────────────────────
const books = data.books as BookSummary[];
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);
let bookSuggestions = $derived(
slugInput.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(slugInput.toLowerCase()) ||
b.title.toLowerCase().includes(slugInput.toLowerCase())
)
.slice(0, 8)
);
function selectBook(b: BookSummary) {
selectedBook = b;
slug = b.slug;
slugInput = b.slug;
slugFocused = false;
// Reset cover-as-ref if no cover
if (!b.cover) useCoverAsRef = false;
}
function onSlugInput() {
slug = slugInput;
// If user edits away from selected book slug, deselect
if (selectedBook && slugInput !== selectedBook.slug) {
selectedBook = null;
useCoverAsRef = false;
}
}
// When useCoverAsRef toggled on, load the book cover as reference
$effect(() => {
if (!browser) return;
if (!useCoverAsRef || !selectedBook?.cover) {
if (useCoverAsRef) useCoverAsRef = false;
return;
}
// Fetch the cover image and set as referenceFile
(async () => {
try {
const res = await fetch(selectedBook!.cover);
const blob = await res.blob();
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
handleReferenceFile(file);
} catch {
useCoverAsRef = false;
}
})();
});
// ── Generation state ─────────────────────────────────────────────────────────
let generating = $state(false);
@@ -52,16 +146,10 @@
// ── Model helpers ────────────────────────────────────────────────────────────
const models = data.models as ImageModelInfo[];
let filteredModels = $derived(
referenceFile
? models // show all; warn on ones without ref support
: models
);
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
let otherModels = $derived(
filteredModels.filter(
models.filter(
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
)
);
@@ -74,12 +162,10 @@
}
});
// Reset model selection when type changes if current selection no longer fits
$effect(() => {
void imageType; // track
void imageType;
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (preferred.length > 0) {
// only auto-switch if current model isn't in preferred list for this type
const current = models.find((m) => m.id === selectedModel);
if (!current || !current.recommended_for.includes(imageType)) {
selectedModel = preferred[0].id;
@@ -90,14 +176,21 @@
// ── Prompt templates ────────────────────────────────────────────────────────
let promptTemplate = $derived(
imageType === 'cover'
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
);
function applyTemplate() {
prompt = promptTemplate;
}
function injectDescription() {
const desc = selectedBook?.summary?.trim();
if (!desc) return;
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
}
// ── Reference image handling ─────────────────────────────────────────────────
let dragOver = $state(false);
@@ -110,17 +203,22 @@
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
handleReferenceFile(input.files?.[0] ?? null);
useCoverAsRef = false;
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
if (file && file.type.startsWith('image/')) {
handleReferenceFile(file);
useCoverAsRef = false;
}
}
function clearReference() {
handleReferenceFile(null);
useCoverAsRef = false;
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
if (input) input.value = '';
}
@@ -219,7 +317,6 @@
saveSuccess = false;
try {
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
const b64 = result.imageSrc.split(',')[1];
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
@@ -308,13 +405,63 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
Book slug
</label>
<input
id="slug-input"
type="text"
bind:value={slug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<!-- Autocomplete wrapper -->
<div class="relative">
<input
id="slug-input"
type="text"
bind:value={slugInput}
oninput={onSlugInput}
onfocus={() => (slugFocused = true)}
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if slugFocused && bookSuggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each bookSuggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={selectedBook?.slug === b.slug}
onmousedown={() => selectBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
{#if b.cover}
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
{:else}
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
{/if}
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Book info pill when a book is selected -->
{#if selectedBook}
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-(--color-success) flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{selectedBook.title}
</span>
{#if selectedBook.summary}
<button
onclick={injectDescription}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
title="Append book description to prompt"
>
+ inject description
</button>
{/if}
</div>
{/if}
</div>
{#if imageType === 'chapter'}
@@ -385,12 +532,22 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
Prompt
</label>
<button
onclick={applyTemplate}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
Use template
</button>
<div class="flex items-center gap-3">
{#if selectedBook?.summary}
<button
onclick={injectDescription}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
Inject description
</button>
{/if}
<button
onclick={applyTemplate}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Use template
</button>
</div>
</div>
<textarea
id="prompt-input"
@@ -403,9 +560,26 @@
<!-- Reference image drop zone -->
<div class="space-y-1">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<div
role="checkbox"
aria-checked={useCoverAsRef}
tabindex="0"
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') useCoverAsRef = !useCoverAsRef; }}
onclick={() => (useCoverAsRef = !useCoverAsRef)}
class="w-8 h-4 rounded-full transition-colors relative {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
>
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
</div>
<span class="text-xs text-(--color-muted)">Use book cover</span>
</label>
{/if}
</div>
{#if referenceFile && referencePreviewUrl}
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
<img
@@ -416,6 +590,9 @@
<div class="min-w-0 flex-1 space-y-0.5">
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
{#if useCoverAsRef}
<p class="text-xs text-(--color-brand)">Current book cover</p>
{/if}
</div>
<button
onclick={clearReference}
@@ -530,7 +707,6 @@
flex items-center justify-center gap-2"
>
{#if generating}
<!-- Spinner -->
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />

View File

@@ -1,6 +1,12 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface BookSummary {
slug: string;
title: string;
}
export interface TextModelInfo {
id: string;
@@ -12,16 +18,25 @@ export interface TextModelInfo {
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) {
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
return { models: [] as TextModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as TextModelInfo[] };
} catch (e) {
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
return { models: [] as TextModelInfo[] };
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as TextModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

View File

@@ -1,26 +1,82 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { TextModelInfo } from './+page.server';
import type { TextModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
const models = data.models as TextModelInfo[];
const books = data.books as BookSummary[];
// ── Config persistence ───────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_text_gen_config_v1';
interface SavedConfig {
selectedModel: string;
activeTab: 'chapters' | 'description';
chPattern: string;
dInstructions: string;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, activeTab, chPattern, dInstructions };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
// ── Shared ────────────────────────────────────────────────────────────────────
type ActiveTab = 'chapters' | 'description';
let activeTab = $state<ActiveTab>('chapters');
let selectedModel = $state(models[0]?.id ?? '');
let activeTab = $state<ActiveTab>(saved.activeTab ?? 'chapters');
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
function fmtCtx(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
return `${n} ctx`;
}
// ── Book autocomplete (shared component logic) ───────────────────────────────
function makeBookAC() {
let inputVal = $state('');
let focused = $state(false);
const suggestions = $derived(
inputVal.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(inputVal.toLowerCase()) ||
b.title.toLowerCase().includes(inputVal.toLowerCase())
)
.slice(0, 8)
);
return {
get inputVal() { return inputVal; },
set inputVal(v: string) { inputVal = v; },
get focused() { return focused; },
set focused(v: boolean) { focused = v; },
get suggestions() { return suggestions; }
};
}
// ── Chapter names state ───────────────────────────────────────────────────────
let chAC = makeBookAC();
let chSlug = $state('');
let chPattern = $state('Chapter {n}: {scene}');
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
let chGenerating = $state(false);
let chError = $state('');
@@ -28,13 +84,13 @@
number: number;
old_title: string;
new_title: string;
// editable copy
edited: string;
}
let chProposals = $state<ProposedChapter[]>([]);
let chRawResponse = $state('');
let chUsedModel = $state('');
let chShowRaw = $state(false);
let chBatchProgress = $state('');
let chBatchWarnings = $state<string[]>([]);
let chApplying = $state(false);
let chApplyError = $state('');
@@ -43,11 +99,24 @@
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
function selectChBook(b: BookSummary) {
chSlug = b.slug;
chAC.inputVal = b.slug;
chAC.focused = false;
}
function onChSlugInput() {
chSlug = chAC.inputVal;
}
async function generateChapterNames() {
if (!chCanGenerate) return;
chGenerating = true;
chError = '';
chProposals = [];
chUsedModel = '';
chBatchProgress = '';
chBatchWarnings = [];
chApplySuccess = false;
chApplyError = '';
@@ -61,20 +130,77 @@
model: selectedModel
})
});
const body = await res.json().catch(() => ({}));
// Non-SSE error response (e.g. 400/404/502 before streaming started).
if (!res.ok) {
const body = await res.json().catch(() => ({}));
chError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chRawResponse = body.raw_response ?? '';
chUsedModel = body.model ?? '';
// If backend returned chapters:[] but we have a raw response, the model
// output was unparseable (likely truncated). Treat it as an error.
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
// Stream SSE events line by line.
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
outer: while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process all complete SSE messages in the buffer.
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep incomplete last line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: {
batch?: number;
total_batches?: number;
chapters_done?: number;
total_chapters?: number;
model?: string;
chapters?: { number: number; old_title: string; new_title: string }[];
error?: string;
done?: boolean;
};
try {
evt = JSON.parse(payload);
} catch {
continue;
}
if (evt.done) {
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
chGenerating = false;
break outer;
}
if (evt.model) chUsedModel = evt.model;
if (evt.error) {
chBatchWarnings = [
...chBatchWarnings,
`Batch ${evt.batch}/${evt.total_batches} failed: ${evt.error}`
];
} else if (evt.chapters) {
const incoming = (evt.chapters as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chProposals = [...chProposals, ...incoming];
}
if (evt.batch != null && evt.total_batches != null) {
chBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chProposals.length}/${evt.total_chapters ?? '?'} chapters`;
}
}
}
if (chProposals.length === 0 && chBatchWarnings.length === 0) {
chError = 'No proposals returned. The model may have failed to parse the chapters.';
}
} catch {
chError = 'Network error.';
@@ -112,8 +238,9 @@
}
// ── Description state ─────────────────────────────────────────────────────────
let dAC = makeBookAC();
let dSlug = $state('');
let dInstructions = $state('');
let dInstructions = $state(saved.dInstructions ?? '');
let dGenerating = $state(false);
let dError = $state('');
@@ -128,6 +255,16 @@
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
function selectDBook(b: BookSummary) {
dSlug = b.slug;
dAC.inputVal = b.slug;
dAC.focused = false;
}
function onDSlugInput() {
dSlug = dAC.inputVal;
}
async function generateDescription() {
if (!dCanGenerate) return;
dGenerating = true;
@@ -247,13 +384,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
Book slug
</label>
<input
id="ch-slug"
type="text"
bind:value={chSlug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="relative">
<input
id="ch-slug"
type="text"
bind:value={chAC.inputVal}
oninput={onChSlugInput}
onfocus={() => (chAC.focused = true)}
onblur={() => setTimeout(() => { chAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if chAC.focused && chAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each chAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={chSlug === b.slug}
onmousedown={() => selectChBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">
@@ -292,9 +453,6 @@
{#if chError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
{#if chRawResponse}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
{/if}
{/if}
</div>
@@ -309,16 +467,25 @@
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
{/if}
</p>
<button
onclick={() => (chShowRaw = !chShowRaw)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{chShowRaw ? 'Hide raw' : 'Show raw'}
</button>
{#if chBatchProgress}
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
{#if chGenerating}
<svg class="w-3 h-3 animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
{/if}
{chBatchProgress}
</span>
{/if}
</div>
{#if chShowRaw}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
{#if chBatchWarnings.length > 0}
<div class="space-y-1">
{#each chBatchWarnings as w}
<p class="text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">{w}</p>
{/each}
</div>
{/if}
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
@@ -349,7 +516,7 @@
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
</button>
<button
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
onclick={() => { chProposals = []; chBatchProgress = ''; chBatchWarnings = []; chApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors border border-(--color-border)"
>
@@ -373,7 +540,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating chapter titles…</p>
<p class="text-sm text-(--color-muted)">
{chBatchProgress || 'Generating chapter titles…'}
</p>
</div>
</div>
{:else}
@@ -394,13 +563,37 @@
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
Book slug
</label>
<input
id="d-slug"
type="text"
bind:value={dSlug}
placeholder="e.g. shadow-slave"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<div class="relative">
<input
id="d-slug"
type="text"
bind:value={dAC.inputVal}
oninput={onDSlugInput}
onfocus={() => (dAC.focused = true)}
onblur={() => setTimeout(() => { dAC.focused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if dAC.focused && dAC.suggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each dAC.suggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={dSlug === b.slug}
onmousedown={() => selectDBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<div class="space-y-1">

View File

@@ -0,0 +1,33 @@
/**
* POST /api/admin/dedup-chapters/[slug]
*
* Admin-only proxy to the Go backend's dedup endpoint.
* Removes duplicate chapters_idx records for a book, keeping the latest
* record per chapter number. Returns { slug, deleted }.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
method: 'POST'
});
} catch (e) {
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -2,10 +2,11 @@
* POST /api/admin/text-gen/chapter-names
*
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
* Returns AI-proposed chapter titles; does NOT persist anything.
* The backend streams SSE events; this handler pipes the stream through
* directly so the browser can consume it without buffering.
*/
import { json, error } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
// Non-2xx: the backend returned a JSON error before switching to SSE.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
// Pipe the SSE stream straight through — do not buffer.
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ locals }) => {
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size ?? 1.0
fontSize: settings?.font_size || 1.0
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -61,9 +61,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
// fontSize is optional — if provided it must be one of the valid steps (0 is not valid)
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}

View File

@@ -154,7 +154,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
}
const markdown = await res.text();
html = marked(markdown) as string;
html = await marked(markdown);
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
// Don't hard-fail — show empty content with error message

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, untrack, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
@@ -11,14 +11,16 @@
let { data }: { data: PageData } = $props();
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let scrapedHtml = $state(''); // only set by the live-preview fallback
let html = $derived(scrapedHtml || data.html || '');
let fetchingContent = $state(false);
let fetchError = $state('');
let audioProRequired = $state(false);
// ── Reader settings panel ────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let settingsPanelOpen = $state(false);
let settingsTab = $state<'reading' | 'listening'>('reading');
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
@@ -202,8 +204,8 @@
{ code: 'pt', label: 'PT' },
{ code: 'fr', label: 'FR' }
];
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
let translatingLang = $state(untrack(() => data.lang ?? ''));
let translationStatus = $state(data.translationStatus ?? 'idle');
let translatingLang = $state(data.lang ?? '');
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
function currentLang() {
@@ -293,6 +295,7 @@
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
fetchingContent = true;
(async () => {
try {
const res = await fetch(
@@ -302,7 +305,7 @@
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
scrapedHtml = await marked(d.text, { async: true });
} else {
fetchError = m.reader_audio_error();
}
@@ -589,22 +592,14 @@
</div>
{/if}
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
{#if settingsCtx}
<!-- Backdrop -->
{#if settingsPanelOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40"
onclick={() => (settingsPanelOpen = false)}
></div>
{/if}
<!-- Gear button -->
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
<button
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="fixed bottom-20 right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
>
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
@@ -612,231 +607,277 @@
</svg>
</button>
<!-- Settings drawer -->
<!-- Bottom sheet -->
{#if settingsPanelOpen}
<div
class="fixed bottom-36 right-4 z-50 w-72 bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-2xl p-4 flex flex-col gap-4"
>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Reader Settings</p>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
<!-- Theme -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Theme</p>
<div class="flex flex-wrap gap-1.5">
{#each READER_THEMES as t}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1 shrink-0">
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
</div>
<!-- Tab bar -->
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
<button
type="button"
onclick={() => (settingsTab = 'reading')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'reading'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Reading</button>
<button
type="button"
onclick={() => (settingsTab = 'listening')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'listening'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Listening</button>
</div>
<!-- Scrollable content -->
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
{#if settingsTab === 'reading'}
<!-- ── Typography group ──────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Theme -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
<div class="flex flex-wrap gap-1.5 flex-1">
{#each READER_THEMES as t}
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
>
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Font -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
<div class="flex gap-1.5 flex-1">
{#each READER_FONTS as f}
<button
onclick={() => applyFont(f.id)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelFont === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelFont === f.id}
>{f.label}</button>
{/each}
</div>
</div>
<!-- Size -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
<div class="flex gap-1.5 flex-1">
{#each READER_SIZES as s}
<button
onclick={() => applySize(s.value)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelSize === s.value}
>{s.label}</button>
{/each}
</div>
</div>
</div>
</div>
<!-- ── Layout group ──────────────────────────────────────────── -->
<div class="mt-4 mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Read mode -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
<div class="flex gap-1.5 flex-1">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, lbl]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Width -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
<div class="flex gap-1.5 flex-1">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, lbl]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Paragraphs -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
<div class="flex gap-1.5 flex-1">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{layout.focusMode ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={layout.focusMode}
>
<span class="w-2.5 h-2.5 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
<span>Focus mode</span>
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
{/each}
</div>
</div>
<!-- Font family -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Font</p>
<div class="flex gap-1.5">
{#each READER_FONTS as f}
</div>
</div>
{:else}
<!-- ── Listening tab ──────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Player style -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.playerStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.playerStyle === s}
>{lbl}</button>
{/each}
</div>
</div>
{#if page.data.user}
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
<div class="flex gap-1 flex-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
onclick={() => applyFont(f.id)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelFont === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelFont === f.id}
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={audioStore.autoNext}
>
{f.label}
<span>Auto-next chapter</span>
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
{/each}
</div>
</div>
<!-- Text size -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Text size</p>
<div class="flex gap-1.5">
{#each READER_SIZES as s}
<!-- Sleep timer -->
<button
onclick={() => applySize(s.value)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelSize === s.value}
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
>
{s.label}
<span>Sleep timer</span>
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
<p class="text-[11px] text-(--color-muted)/50 text-center mt-3">Changes save automatically</p>
</div>
<!-- Divider -->
<div class="border-t border-(--color-border)"></div>
<!-- Read mode -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Read mode</p>
<div class="flex gap-1.5">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{label}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Line spacing</p>
<div class="flex gap-1.5">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Reading width -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Width</p>
<div class="flex gap-1.5">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{label}</button>
{/each}
</div>
</div>
<!-- Paragraph style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Paragraphs</p>
<div class="flex gap-1.5">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{layout.focusMode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.focusMode}
>
<span>Focus mode</span>
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
<!-- ── Listening section ─────────────────────────────────────────── -->
<div class="border-t border-(--color-border)"></div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
<!-- Player style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Player style</p>
<div class="flex gap-1.5">
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.playerStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.playerStyle === s}
>{label}</button>
{/each}
</div>
</div>
{#if page.data.user}
<!-- Playback speed -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Speed</p>
<div class="flex gap-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.autoNext}
>
<span>Auto-next chapter</span>
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
>
<span>Sleep timer</span>
<span class="opacity-60">{sleepSettingsLabel}</span>
</button>
{/if}
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
</div>
{/if}
{/if}