Compare commits

...

6 Commits

Author SHA1 Message Date
root
e78c44459e refactor(profile): visual voice picker, playback toggles, danger zone
All checks were successful
Release / Test backend (push) Successful in 1m2s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m9s
Release / Gitea Release (push) Successful in 29s
- Replace voice <select> with a two-column card grid grouped by engine
  (Kokoro GPU / Pocket TTS CPU / Cloudflare AI); each card has a per-voice
  sample play/pause button matching AudioPlayer behaviour
- Add Announce chapter and Audio mode (Stream/Generate) toggles to a
  unified Playback row in Preferences; Audio mode toggle disabled for
  CF AI voices
- Remove duplicate PUT /api/settings from the profile page; all writes
  go directly into audioStore / theme context and the layout's single
  debounced effect persists them
- Add Danger Zone section: collapsible, requires typing username to
  unlock Delete account button; calls DELETE /api/profile
- Add deleteUserAccount() to pocketbase.ts: purges user_settings,
  user_library, progress, comment_votes, book_ratings,
  user_subscriptions, notifications, user_sessions then the
  app_users record
- Add DELETE /api/profile server route (auth-guarded)
2026-04-10 22:30:39 +05:00
root
f8c66fcf63 feat: stream/generate audio mode toggle
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m6s
Release / Docker (push) Successful in 6m15s
Release / Gitea Release (push) Successful in 29s
Add a user-selectable playback mode stored in user_settings:
- 'stream' (default): /api/audio-stream starts playing within seconds,
  saves to MinIO concurrently — low latency
- 'generate': queue runner task, poll until full audio is ready in
  MinIO, then play via presigned URL — legacy behaviour

UI toggles in two places:
- AudioPlayer idle pill: compact '· Stream / · Generate' inline next
  to voice name and estimated duration
- ListeningMode controls row: pill alongside Auto, Announce, Sleep;
  disabled and grayed out for CF AI voices (batch-only, no streaming)

startPlayback() now branches on audioStore.audioMode for non-CF AI
voices; generate mode uses the same runner task + progress bar flow
as CF AI but without the preview clip.

PocketBase: audio_mode text field added to user_settings on
pb.libnovel.cc (live) and in pb-init-v3.sh (create block +
add_field migration line).
2026-04-10 20:06:56 +05:00
root
a1def0f0f8 feat: admin soft-delete and hard-delete for books
Some checks failed
Release / Test backend (push) Successful in 58s
Release / Docker (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc
- ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters
  archived=false so hidden books disappear from all public responses
- Meilisearch: add `archived` as a filterable attribute; Search and Catalogue
  always prepend `archived = false` to exclude archived books from results
- DeleteBook permanently removes the PocketBase record, all chapters_idx rows,
  MinIO chapter objects, cover image, and the Meilisearch document
- New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook
- Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive,
  DELETE /api/admin/books/{slug}
- PocketBase schema: archived field added to live pb.libnovel.cc and to
  pb-init-v3.sh (both create block and add_field migration)
2026-04-10 19:31:33 +05:00
root
e0dec05885 fix: chunk large chapter text for Kokoro TTS to prevent EOF on big inputs
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 34s
Split chapter text into ~1000-char sentence-boundary chunks before sending
to kokoro-fastapi. Each chunk is generated individually and the raw MP3 bytes
are concatenated. This prevents the EOF / timeout failures that occur when
the server receives a very large single request (e.g. a full PDF 'Full Text'
chapter). chunkText() breaks at sentence endings (. ! ? newlines) to preserve
natural speech flow.
2026-04-10 09:24:37 +05:00
root
8662aed565 feat: PDF single-chapter import, EPUB numbering fix, admin chapter split tool
All checks were successful
Release / Check ui (push) Successful in 2m10s
Release / Test backend (push) Successful in 53s
Release / Docker (push) Successful in 6m7s
Release / Gitea Release (push) Successful in 23s
- parsePDF: return all text as single 'Full Text' chapter (admin splits manually)
- parseEPUB: fix chapter numbering to use sequential counter not spine index
- Remove dead code: chaptersFromBookmarks, cleanChapterText, extractChaptersFromText, chapterHeadingRE; drop pdfcpu alias and regexp imports
- Backend: POST /api/admin/books/:slug/split-chapters endpoint — splits text on '---' dividers, optional '## Title' headers, writes chapters via WriteChapter
- UI: admin panel now shows for all admin users regardless of source_url; chapter split tool shown when book has single 'Full Text' chapter, pre-fills from MinIO content
2026-04-09 23:59:24 +05:00
root
cdfa1ac5b2 fix(pdf): fix page ordering, Win-1252 quotes, and chapter header cleanup
Some checks failed
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m49s
Release / Gitea Release (push) Has been cancelled
Release / Docker (push) Has been cancelled
Three fixes to PDF chapter extraction quality:

1. Page ordering: parse page number from pdfcpu filename (out_Content_page_N.txt)
   instead of using lexicographic sort index — fixes chapters bleeding into each
   other (e.g. Prologue text appearing inside Chapter 1).

2. Windows-1252 chars: map bytes 0x91-0x9F to proper Unicode (curly quotes U+2018/
   U+2019/U+201C/U+201D, em-dash U+2014, etc.) instead of raw Latin-1 control
   bytes that rendered as ◆ in the browser.

3. Chapter header cleanup: skip the first page of each bookmark range (decorative
   title art page) and strip any run-on title fragment at the start of the first
   body page (e.g. 'for New Journeys!I stood atop...' → 'I stood atop...'). The
   remaining sentence truncation is a fundamental limitation of this PDF's
   PUA-encoded body font (C2_1/Literata) — those glyphs cannot be decoded without
   the publisher's private ToUnicode mapping.
2026-04-09 23:43:09 +05:00
22 changed files with 1376 additions and 371 deletions

View File

@@ -198,6 +198,7 @@ func run() error {
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
BookAdminStore: store,
Log: log,
},
)

View File

@@ -0,0 +1,117 @@
package backend
import (
"errors"
"net/http"
"github.com/libnovel/backend/internal/storage"
)
// handleAdminArchiveBook handles PATCH /api/admin/books/{slug}/archive.
// Soft-deletes a book by setting archived=true in PocketBase and updating the
// Meilisearch document so it is excluded from all public search results.
// The book data is preserved and can be restored with the unarchive endpoint.
func (s *Server) handleAdminArchiveBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.ArchiveBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("archive book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Update the Meilisearch document so the archived flag takes effect
// immediately in search/catalogue results.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("archive book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book archived", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "archived"})
}
// handleAdminUnarchiveBook handles PATCH /api/admin/books/{slug}/unarchive.
// Restores a previously archived book by clearing the archived flag, making it
// publicly visible in search and catalogue results again.
func (s *Server) handleAdminUnarchiveBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.UnarchiveBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("unarchive book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Sync the updated archived=false state back to Meilisearch.
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
s.deps.Log.Warn("unarchive book: meili upsert failed", "slug", slug, "err", upsertErr)
}
}
s.deps.Log.Info("book unarchived", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "active"})
}
// handleAdminDeleteBook handles DELETE /api/admin/books/{slug}.
// Permanently removes all data for a book:
// - PocketBase books record and all chapters_idx records
// - All MinIO chapter markdown objects and the cover image
// - Meilisearch document
//
// This operation is irreversible. Use the archive endpoint for soft-deletion.
func (s *Server) handleAdminDeleteBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if s.deps.BookAdminStore == nil {
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
return
}
if err := s.deps.BookAdminStore.DeleteBook(r.Context(), slug); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "book not found")
return
}
s.deps.Log.Error("delete book failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
// Remove from Meilisearch — best-effort (log on failure, don't fail request).
if err := s.deps.SearchIndex.DeleteBook(r.Context(), slug); err != nil {
s.deps.Log.Warn("delete book: meili delete failed", "slug", slug, "err", err)
}
s.deps.Log.Info("book deleted", "slug", slug)
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "deleted"})
}

View File

@@ -0,0 +1,141 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
)
// handleAdminSplitChapters handles POST /api/admin/books/{slug}/split-chapters.
//
// Request body (JSON):
//
// { "text": "<full text with --- dividers and optional ## Title lines>" }
//
// The text is split on lines containing only "---". Each segment may start with
// a "## Title" line which becomes the chapter title; remaining lines are the
// chapter content. Sequential chapter numbers 1..N are assigned.
//
// All existing chapters for the book are replaced: WriteChapter is called for
// each new chapter (upsert by number), so chapters beyond N are not deleted —
// use the dedup endpoint afterwards if needed.
func (s *Server) handleAdminSplitChapters(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Text) == "" {
jsonError(w, http.StatusBadRequest, "text is required")
return
}
chapters := splitChapterText(req.Text)
if len(chapters) == 0 {
jsonError(w, http.StatusUnprocessableEntity, "no chapters produced from text")
return
}
for _, ch := range chapters {
var mdContent string
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
} else {
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
}
domainCh := domain.Chapter{
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
Text: mdContent,
}
if err := s.deps.BookWriter.WriteChapter(r.Context(), slug, domainCh); err != nil {
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("write chapter %d: %s", ch.Number, err.Error()))
return
}
}
writeJSON(w, 0, map[string]any{
"chapters": len(chapters),
"slug": slug,
})
}
// splitChapterText splits text on "---" divider lines into bookstore.Chapter
// slices. Each segment may optionally start with a "## Title" header line.
func splitChapterText(text string) []bookstore.Chapter {
lines := strings.Split(text, "\n")
// Collect raw segments split on "---" dividers.
var segments [][]string
cur := []string{}
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
segments = append(segments, cur)
cur = []string{}
} else {
cur = append(cur, line)
}
}
segments = append(segments, cur) // last segment
var chapters []bookstore.Chapter
chNum := 0
for _, seg := range segments {
// Trim leading/trailing blank lines from the segment.
start, end := 0, len(seg)
for start < end && strings.TrimSpace(seg[start]) == "" {
start++
}
for end > start && strings.TrimSpace(seg[end-1]) == "" {
end--
}
seg = seg[start:end]
if len(seg) == 0 {
continue
}
// Check for a "## Title" header on the first line.
title := ""
contentStart := 0
if strings.HasPrefix(strings.TrimSpace(seg[0]), "## ") {
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(seg[0]), "## "))
contentStart = 1
// Skip blank lines after the title.
for contentStart < len(seg) && strings.TrimSpace(seg[contentStart]) == "" {
contentStart++
}
}
content := strings.TrimSpace(strings.Join(seg[contentStart:], "\n"))
if content == "" {
continue
}
chNum++
if title == "" {
title = fmt.Sprintf("Chapter %d", chNum)
}
chapters = append(chapters, bookstore.Chapter{
Number: chNum,
Title: title,
Content: content,
})
}
return chapters
}

View File

@@ -91,6 +91,9 @@ type Dependencies struct {
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
// If nil, the admin book management endpoints return 503.
BookAdminStore bookstore.BookAdminStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -247,6 +250,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Admin book management (soft-delete / hard-delete)
mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
// Admin chapter split (imported books)
mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
// Import (PDF/EPUB)
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)

View File

@@ -216,6 +216,27 @@ type BookImporter interface {
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
}
// BookAdminStore covers admin-only operations for managing books in the catalogue.
// All methods require admin authorisation at the HTTP handler level.
type BookAdminStore interface {
// ArchiveBook sets archived=true on a book record, hiding it from all
// public search and catalogue responses. Returns ErrNotFound when the
// slug does not exist.
ArchiveBook(ctx context.Context, slug string) error
// UnarchiveBook clears archived on a book record, making it publicly
// visible again. Returns ErrNotFound when the slug does not exist.
UnarchiveBook(ctx context.Context, slug string) error
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
// - MinIO cover image (covers/{slug}.jpg)
// The caller is responsible for also deleting the Meilisearch document.
DeleteBook(ctx context.Context, slug string) error
}
// ImportFileStore uploads raw import files to object storage.
// Kept separate from BookImporter so the HTTP handler can upload the file
// without a concrete type assertion, regardless of which Producer is wired.

View File

@@ -24,6 +24,9 @@ type BookMeta struct {
// updated in PocketBase. Populated on read; not sent on write (PocketBase
// manages its own updated field).
MetaUpdated int64 `json:"meta_updated,omitempty"`
// Archived is true when the book has been soft-deleted by an admin.
// Archived books are excluded from all public search and catalogue responses.
Archived bool `json:"archived,omitempty"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.

View File

@@ -32,11 +32,15 @@ type Client interface {
// BookExists reports whether a book with the given slug is already in the
// index. Used by the catalogue refresh to skip re-indexing known books.
BookExists(ctx context.Context, slug string) bool
// DeleteBook removes a book document from the search index by slug.
DeleteBook(ctx context.Context, slug string) error
// Search returns up to limit books matching query.
// Archived books are always excluded.
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
// Catalogue queries books with optional filters, sort, and pagination.
// Returns books, the total hit count for pagination, and a FacetResult
// with available genre and status values from the index.
// Archived books are always excluded.
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error)
}
@@ -99,7 +103,7 @@ func Configure(host, apiKey string) error {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres"}
filterable := []interface{}{"status", "genres", "archived"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
@@ -128,6 +132,9 @@ type bookDoc struct {
// MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update.
// Used for sort=update ("recently updated" ordering).
MetaUpdated int64 `json:"meta_updated"`
// Archived is true when the book has been soft-deleted by an admin.
// Used as a filter to exclude archived books from all search results.
Archived bool `json:"archived"`
}
func toDoc(b domain.BookMeta) bookDoc {
@@ -144,6 +151,7 @@ func toDoc(b domain.BookMeta) bookDoc {
Rank: b.Ranking,
Rating: b.Rating,
MetaUpdated: b.MetaUpdated,
Archived: b.Archived,
}
}
@@ -161,6 +169,7 @@ func fromDoc(d bookDoc) domain.BookMeta {
Ranking: d.Rank,
Rating: d.Rating,
MetaUpdated: d.MetaUpdated,
Archived: d.Archived,
}
}
@@ -184,13 +193,24 @@ func (c *MeiliClient) BookExists(_ context.Context, slug string) bool {
return err == nil && doc.Slug != ""
}
// DeleteBook removes a book document from the index by slug.
// The operation is fire-and-forget (Meilisearch processes tasks asynchronously).
func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error {
if _, err := c.idx.DeleteDocument(slug, nil); err != nil {
return fmt.Errorf("meili: delete book %q: %w", slug, err)
}
return nil
}
// Search returns books matching query, up to limit results.
// Archived books are always excluded.
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
if limit <= 0 {
limit = 20
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
Limit: int64(limit),
Filter: "archived = false",
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
@@ -231,17 +251,15 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
Facets: []string{"genres", "status"},
}
// Build filter
var filters []string
// Build filter — always exclude archived books
filters := []string{"archived = false"}
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}
if q.Status != "" && q.Status != "all" {
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
}
if len(filters) > 0 {
req.Filter = strings.Join(filters, " AND ")
}
req.Filter = strings.Join(filters, " AND ")
// Map UI sort tokens to Meilisearch sort expressions.
switch q.Sort {
@@ -318,7 +336,8 @@ func sortStrings(s []string) {
type NoopClient struct{}
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
return nil, nil
}

View File

@@ -19,3 +19,53 @@ func stripMarkdown(src string) string {
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// chunkText splits text into chunks of at most maxChars characters, breaking
// at sentence boundaries (". ", "! ", "? ", "\n") so that the TTS service
// receives natural prose fragments rather than mid-sentence cuts.
//
// If a single sentence exceeds maxChars it is included as its own chunk —
// never silently truncated.
func chunkText(text string, maxChars int) []string {
if len(text) <= maxChars {
return []string{text}
}
// Sentence-boundary delimiters — we split AFTER these sequences.
// Order matters: longer sequences first.
delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"}
var chunks []string
remaining := text
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Find the last sentence boundary within the maxChars window.
window := remaining[:maxChars]
cutAt := -1
for _, delim := range delimiters {
idx := strings.LastIndex(window, delim)
if idx > 0 && idx+len(delim) > cutAt {
cutAt = idx + len(delim)
}
}
if cutAt <= 0 {
// No boundary found — hard-break at maxChars to avoid infinite loop.
cutAt = maxChars
}
chunk := strings.TrimSpace(remaining[:cutAt])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = strings.TrimSpace(remaining[cutAt:])
}
return chunks
}

View File

@@ -656,7 +656,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
return
}
var genErr error
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
audioData, genErr = kokoroGenerateChunked(ctx, r.deps.Kokoro, text, task.Voice, log)
if genErr != nil {
fail(fmt.Sprintf("kokoro generate: %v", genErr))
return
@@ -685,6 +685,31 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
log.Info("runner: audio task finished", "key", key)
}
// kokoroGenerateChunked splits text into ~1 000-character sentence-boundary
// chunks, calls Kokoro.GenerateAudio for each, and concatenates the raw MP3
// bytes. This avoids EOF / timeout failures that occur when the Kokoro
// FastAPI server receives very large inputs (e.g. a full imported PDF chapter).
//
// Concatenating raw MP3 frames is valid — MP3 is a frame-based format and
// standard players handle multi-segment files correctly.
func kokoroGenerateChunked(ctx context.Context, k kokoro.Client, text, voice string, log *slog.Logger) ([]byte, error) {
const chunkSize = 1000
chunks := chunkText(text, chunkSize)
log.Info("runner: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text))
var combined []byte
for i, chunk := range chunks {
data, err := k.GenerateAudio(ctx, chunk, voice)
if err != nil {
return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
}
combined = append(combined, data...)
log.Info("runner: kokoro chunk done", "chunk", i+1, "of", len(chunks), "bytes", len(data))
}
return combined, nil
}
// runImportTask executes one PDF/EPUB import task.
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
// JSON from MinIO (written by the backend at upload time) and ingests them.

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"os"
"regexp"
"sort"
"strconv"
"strings"
@@ -16,16 +15,10 @@ import (
"github.com/libnovel/backend/internal/domain"
minio "github.com/minio/minio-go/v7"
"github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpu "github.com/pdfcpu/pdfcpu/pkg/pdfcpu"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"golang.org/x/net/html"
)
// chapterHeadingRE matches common chapter heading patterns:
// "Chapter 1", "Chapter 1:", "Chapter 1 -", "CHAPTER ONE", "1.", "Part 1", etc.
var chapterHeadingRE = regexp.MustCompile(
`(?i)^(?:chapter|ch\.?|part|episode|book)\s+(\d+|[ivxlcdm]+)\b|^\d{1,4}[\.\)]\s+\S`)
type importer struct {
mc *minioClient
}
@@ -148,17 +141,16 @@ var pdfSkipBookmarks = map[string]bool{
"appendix": true, "color insert": true, "color illustrations": true,
}
// parsePDF extracts chapters from PDF bytes.
// parsePDF extracts text from PDF bytes and returns it as a single chapter.
//
// The full readable text is returned as one chapter so the admin can manually
// split it into chapters via the UI using --- markers.
//
// Strategy:
// 1. Decrypt owner-protected PDFs (empty user password).
// 2. Read the PDF outline (bookmarks) — these give chapter titles and page ranges.
// 3. Extract raw content streams for every page using pdfcpu ExtractContent.
// 4. For each story bookmark, concatenate the extracted text of its pages.
//
// Falls back to paragraph-splitting when no bookmarks are found.
// This is fast (~100ms for a 250-page PDF) because it avoids font-glyph
// resolution which causes older PDF libraries to hang on publisher PDFs.
// 2. Extract raw content streams for every page using pdfcpu ExtractContent.
// 3. Concatenate text from all pages in order, skipping front matter
// (cover, title page, copyright — typically the first 10 pages).
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
// Decrypt owner-protected PDFs (empty user password).
decrypted, err := decryptPDF(data)
@@ -186,103 +178,147 @@ func parsePDF(data []byte) ([]bookstore.Chapter, error) {
return nil, fmt.Errorf("PDF has no content pages")
}
// Sort entries by filename so index == page number - 1.
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
// Build page-index → extracted text map.
// Parse page number from filename and build ordered text map.
pageTexts := make(map[int]string, len(entries))
for idx, e := range entries {
maxPage := 0
for _, e := range entries {
pageNum := pageNumFromFilename(e.Name())
if pageNum <= 0 {
continue
}
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
if readErr != nil {
continue
}
pageTexts[idx+1] = extractTextFromContentStream(raw)
pageTexts[pageNum] = fixWin1252(extractTextFromContentStream(raw))
if pageNum > maxPage {
maxPage = pageNum
}
}
// Try to use bookmarks (outline) for chapter structure.
// Determine front-matter cutoff using bookmarks if available,
// otherwise skip the first 10 pages (cover/title/copyright).
bodyStart := 1
bookmarks, bmErr := api.Bookmarks(bytes.NewReader(data), conf)
if bmErr == nil && len(bookmarks) > 0 {
chapters := chaptersFromBookmarks(bookmarks, pageTexts)
if len(chapters) > 0 {
return chapters, nil
}
}
// Fallback: concatenate all page texts and split by heading patterns.
var sb strings.Builder
for p := 1; p <= len(entries); p++ {
sb.WriteString(pageTexts[p])
sb.WriteByte('\n')
}
chapters := extractChaptersFromText(sb.String())
if len(chapters) == 0 {
return nil, fmt.Errorf("could not extract any chapters from PDF")
}
return chapters, nil
}
// chaptersFromBookmarks builds a chapter list from PDF bookmarks + per-page text.
// It flattens the bookmark tree, skips front/back matter entries, and assigns
// page ranges so each chapter spans from its own start page to the next
// bookmark's start page minus one.
func chaptersFromBookmarks(bookmarks []pdfcpu.Bookmark, pageTexts map[int]string) []bookstore.Chapter {
// Flatten bookmark tree.
var flat []pdfcpu.Bookmark
var flatten func([]pdfcpu.Bookmark)
flatten = func(bms []pdfcpu.Bookmark) {
for _, bm := range bms {
flat = append(flat, bm)
flatten(bm.Kids)
}
}
flatten(bookmarks)
// Sort by page number.
sort.Slice(flat, func(i, j int) bool { return flat[i].PageFrom < flat[j].PageFrom })
// Assign PageThru for entries where it's 0 (last bookmark or missing).
maxPage := 0
for p := range pageTexts {
if p > maxPage {
maxPage = p
}
}
for i := range flat {
if flat[i].PageThru == 0 {
if i+1 < len(flat) {
flat[i].PageThru = flat[i+1].PageFrom - 1
} else {
flat[i].PageThru = maxPage
if bmErr == nil {
for _, bm := range bookmarks {
title := strings.ToLower(strings.TrimSpace(bm.Title))
if !pdfSkipBookmarks[title] && bm.PageFrom > 0 {
// First non-front-matter bookmark — body starts here.
bodyStart = bm.PageFrom
break
}
}
} else if maxPage > 10 {
bodyStart = 11
}
var chapters []bookstore.Chapter
chNum := 0
for _, bm := range flat {
if pdfSkipBookmarks[strings.ToLower(strings.TrimSpace(bm.Title))] {
// Concatenate all body pages.
var sb strings.Builder
for p := bodyStart; p <= maxPage; p++ {
t := strings.TrimSpace(pageTexts[p])
if t == "" {
continue
}
// Gather text for all pages in this bookmark's range.
var sb strings.Builder
for p := bm.PageFrom; p <= bm.PageThru; p++ {
if t, ok := pageTexts[p]; ok {
sb.WriteString(t)
sb.WriteByte('\n')
sb.WriteString(t)
sb.WriteString("\n\n")
}
text := strings.TrimSpace(sb.String())
if text == "" {
return nil, fmt.Errorf("could not extract any text from PDF")
}
return []bookstore.Chapter{{
Number: 1,
Title: "Full Text",
Content: text,
}}, nil
}
// pageNumFromFilename extracts the page number from a pdfcpu content-stream
// filename like "out_Content_page_42.txt". Returns 0 if not parseable.
func pageNumFromFilename(name string) int {
// Strip directory prefix and extension.
base := name
if idx := strings.LastIndex(base, "/"); idx >= 0 {
base = base[idx+1:]
}
if idx := strings.LastIndex(base, "."); idx >= 0 {
base = base[:idx]
}
// Find last "_" and parse the number after it.
if idx := strings.LastIndex(base, "_"); idx >= 0 {
n, err := strconv.Atoi(base[idx+1:])
if err == nil && n > 0 {
return n
}
}
return 0
}
// win1252ToUnicode maps the Windows-1252 control range 0x800x9F to the
// Unicode characters they actually represent in that encoding.
// Standard Latin-1 maps these bytes to control characters; Win-1252 maps
// them to typographic symbols that appear in publisher PDFs.
var win1252ToUnicode = map[byte]rune{
0x80: '\u20AC', // €
0x82: '\u201A', //
0x83: '\u0192', // ƒ
0x84: '\u201E', // „
0x85: '\u2026', // …
0x86: '\u2020', // †
0x87: '\u2021', // ‡
0x88: '\u02C6', // ˆ
0x89: '\u2030', // ‰
0x8A: '\u0160', // Š
0x8B: '\u2039', //
0x8C: '\u0152', // Œ
0x8E: '\u017D', // Ž
0x91: '\u2018', // ' (left single quotation mark)
0x92: '\u2019', // ' (right single quotation mark / apostrophe)
0x93: '\u201C', // " (left double quotation mark)
0x94: '\u201D', // " (right double quotation mark)
0x95: '\u2022', // • (bullet)
0x96: '\u2013', // (en dash)
0x97: '\u2014', // — (em dash)
0x98: '\u02DC', // ˜
0x99: '\u2122', // ™
0x9A: '\u0161', // š
0x9B: '\u203A', //
0x9C: '\u0153', // œ
0x9E: '\u017E', // ž
0x9F: '\u0178', // Ÿ
}
// fixWin1252 replaces Windows-1252 specific bytes (0x800x9F) in a string
// that was decoded as raw Latin-1 bytes with their proper Unicode equivalents.
func fixWin1252(s string) string {
// Fast path: if no bytes in 0x800x9F range, return unchanged.
needsFix := false
for i := 0; i < len(s); i++ {
b := s[i]
if b >= 0x80 && b <= 0x9F {
needsFix = true
break
}
}
if !needsFix {
return s
}
var sb strings.Builder
sb.Grow(len(s))
for i := 0; i < len(s); i++ {
b := s[i]
if b >= 0x80 && b <= 0x9F {
if r, ok := win1252ToUnicode[b]; ok {
sb.WriteRune(r)
continue
}
}
text := strings.TrimSpace(sb.String())
if len(text) < 50 {
continue // skip nearly-empty sections
}
chNum++
chapters = append(chapters, bookstore.Chapter{
Number: chNum,
Title: bm.Title,
Content: text,
})
sb.WriteByte(b)
}
return chapters
return sb.String()
}
// extractTextFromContentStream parses a raw PDF content stream and extracts
@@ -476,6 +512,7 @@ func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
}
var chapters []bookstore.Chapter
chNum := 0
for i, href := range spineFiles {
fullPath := opfDir + href
content, err := epubFileContent(zr, fullPath)
@@ -486,12 +523,14 @@ func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
if strings.TrimSpace(text) == "" {
continue
}
chNum++
title := titleMap[href]
if title == "" {
title = fmt.Sprintf("Chapter %d", i+1)
title = fmt.Sprintf("Chapter %d", chNum)
}
_ = i // spine index unused for numbering
chapters = append(chapters, bookstore.Chapter{
Number: i + 1,
Number: chNum,
Title: title,
Content: text,
})
@@ -788,80 +827,6 @@ func htmlToText(data []byte) string {
return strings.TrimSpace(strings.Join(out, "\n"))
}
// ── Chapter segmentation (shared by PDF and plain-text paths) ─────────────────
// extractChaptersFromText splits a block of plain text into chapters by
// detecting heading lines that match chapterHeadingRE.
// Falls back to paragraph-splitting when no headings are found.
func extractChaptersFromText(text string) []bookstore.Chapter {
lines := strings.Split(text, "\n")
type segment struct {
title string
number int
lines []string
}
var segments []segment
var cur *segment
chNum := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if chapterHeadingRE.MatchString(line) {
if cur != nil {
segments = append(segments, *cur)
}
chNum++
// Try to parse the explicit chapter number from the heading.
if m := regexp.MustCompile(`\d+`).FindString(line); m != "" {
if n, err := strconv.Atoi(m); err == nil && n > 0 && n < 100000 {
chNum = n
}
}
cur = &segment{title: line, number: chNum}
} else if cur != nil && line != "" {
cur.lines = append(cur.lines, line)
}
}
if cur != nil {
segments = append(segments, *cur)
}
// Require segments to have meaningful content (>= 100 chars).
var chapters []bookstore.Chapter
for _, seg := range segments {
content := strings.Join(seg.lines, "\n")
if len(strings.TrimSpace(content)) < 50 {
continue
}
chapters = append(chapters, bookstore.Chapter{
Number: seg.number,
Title: seg.title,
Content: content,
})
}
// Fallback: no headings found — split by double newlines (paragraph blocks).
if len(chapters) == 0 {
paragraphs := strings.Split(text, "\n\n")
n := 0
for _, para := range paragraphs {
para = strings.TrimSpace(para)
if len(para) > 100 {
n++
chapters = append(chapters, bookstore.Chapter{
Number: n,
Title: fmt.Sprintf("Chapter %d", n),
Content: para,
})
}
}
}
return chapters
}
// ── Chapter ingestion ─────────────────────────────────────────────────────────
// IngestChapters stores extracted chapters for a book.

View File

@@ -55,6 +55,7 @@ var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ bookstore.ChapterImageStore = (*Store)(nil)
var _ bookstore.BookAdminStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -226,6 +227,7 @@ type pbBook struct {
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
Archived bool `json:"archived"`
}
func (b pbBook) toDomain() domain.BookMeta {
@@ -246,6 +248,7 @@ func (b pbBook) toDomain() domain.BookMeta {
Ranking: b.Ranking,
Rating: b.Rating,
MetaUpdated: metaUpdated,
Archived: b.Archived,
}
}
@@ -275,7 +278,7 @@ func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta,
}
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
items, err := s.pb.listAll(ctx, "books", "", "title")
items, err := s.pb.listAll(ctx, "books", "archived=false", "title")
if err != nil {
return nil, err
}
@@ -376,6 +379,84 @@ func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
return count, nil
}
// ── BookAdminStore ────────────────────────────────────────────────────────────
// ArchiveBook sets archived=true on the book record for slug.
func (s *Store) ArchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("ArchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": true})
}
// UnarchiveBook clears archived on the book record for slug.
func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("UnarchiveBook: %w", err)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
map[string]any{"archived": false})
}
// DeleteBook permanently removes all data for a book:
// - PocketBase books record
// - All PocketBase chapters_idx records for the slug
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
// - MinIO cover image (covers/{slug}.jpg)
func (s *Store) DeleteBook(ctx context.Context, slug string) error {
// 1. Fetch the book record to get its PocketBase ID.
book, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("DeleteBook: fetch: %w", err)
}
// 2. Delete all chapters_idx records.
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil && err != ErrNotFound {
return fmt.Errorf("DeleteBook: list chapters_idx: %w", err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeleteBook: delete chapters_idx record failed", "slug", slug, "id", rec.ID, "err", delErr)
}
}
}
// 3. Delete MinIO chapter objects.
if err := s.mc.deleteObjects(ctx, s.mc.bucketChapters, slug+"/"); err != nil {
s.log.Warn("DeleteBook: delete chapter objects failed", "slug", slug, "err", err)
}
// 4. Delete MinIO cover image.
if err := s.mc.deleteObjects(ctx, s.mc.bucketBrowse, CoverObjectKey(slug)); err != nil {
s.log.Warn("DeleteBook: delete cover failed", "slug", slug, "err", err)
}
// 5. Delete the PocketBase books record.
if err := s.pb.delete(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID)); err != nil {
return fmt.Errorf("DeleteBook: delete books record: %w", err)
}
return nil
}
// ── RankingStore ──────────────────────────────────────────────────────────────
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {

View File

@@ -144,7 +144,8 @@ create "books" '{
{"name":"total_chapters","type":"number"},
{"name":"source_url", "type":"text"},
{"name":"ranking", "type":"number"},
{"name":"meta_updated", "type":"text"}
{"name":"meta_updated", "type":"text"},
{"name":"archived", "type":"bool"}
]}'
create "chapters_idx" '{
@@ -255,6 +256,7 @@ create "user_settings" '{
{"name":"font_family", "type":"text"},
{"name":"font_size", "type":"number"},
{"name":"announce_chapter","type":"bool"},
{"name":"audio_mode", "type":"text"},
{"name":"updated", "type":"text"}
]}'
@@ -389,6 +391,8 @@ add_field "user_settings" "locale" "text"
add_field "user_settings" "font_family" "text"
add_field "user_settings" "font_size" "number"
add_field "user_settings" "announce_chapter" "bool"
add_field "user_settings" "audio_mode" "text"
add_field "books" "archived" "bool"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \

View File

@@ -36,6 +36,14 @@ import type { Voice } from '$lib/types';
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
/**
* 'stream' Use /api/audio-stream: audio starts playing within seconds,
* stream is saved to MinIO concurrently. No runner task needed.
* 'generate' Legacy mode: queue a runner task, poll until done, then play
* from the presigned MinIO URL. Needed for CF AI voices which
* do not support native streaming.
*/
export type AudioMode = 'stream' | 'generate';
class AudioStore {
// ── What is loaded ──────────────────────────────────────────────────────
@@ -46,6 +54,13 @@ class AudioStore {
voice = $state('af_bella');
speed = $state(1.0);
/**
* Playback mode:
* 'stream' pipe from /api/audio-stream (low latency, saves concurrently)
* 'generate' queue runner task, poll, then play presigned URL (CF AI / legacy)
*/
audioMode = $state<AudioMode>('stream');
/** Cover image URL for the currently loaded book. */
cover = $state('');

View File

@@ -613,41 +613,95 @@
return;
}
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
// starts playing within seconds. The stream handler checks MinIO first
// (fast redirect if already cached) and otherwise generates + uploads
// concurrently. Even if the async runner is already working on this
// chapter, the stream will redirect to MinIO the moment the runner
// finishes — no harmful double-generation occurs because the backend
// deduplications via AudioExists on the next request.
if (!voice.startsWith('cfai:')) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
// audio starts playing within seconds. The stream handler checks MinIO
// first (fast redirect if already cached) and otherwise generates +
// uploads concurrently.
//
// In 'generate' mode (user preference): queue a runner task and poll,
// same as CF AI — audio plays only after the full file is ready in MinIO.
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
audioStore.status = 'idle';
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
// wait for full audio in MinIO before playing (same as CF AI but no preview).
if (!voice.startsWith('cfai:')) {
audioStore.status = 'generating';
audioStore.isPreview = false;
startProgress();
if (!presignResult.enqueued) {
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (res.status === 402) {
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status === 200) {
await res.body?.cancel();
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
restoreSavedAudioTime();
maybeStartPrefetch();
return;
}
// 202 — runner task enqueued, fall through to poll.
}
// CF AI voices: use preview/swap strategy.
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
// so playback starts immediately — no more waiting behind a spinner.
// 2. Meanwhile keep polling the full audio job; when it finishes,
// swap the <audio> src to the full URL preserving currentTime.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
restoreSavedAudioTime();
maybeStartPrefetch();
return;
}
// CF AI voices: use preview/swap strategy.
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
// so playback starts immediately — no more waiting behind a spinner.
// 2. Meanwhile keep polling the full audio job; when it finishes,
// swap the <audio> src to the full URL preserving currentTime.
audioStore.status = 'generating';
audioStore.isPreview = false;
startProgress();
@@ -1019,6 +1073,33 @@
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
{/if}
<!-- Stream / Generate mode toggle -->
{#if !audioStore.voice.startsWith('cfai:')}
<span class="text-(--color-border) text-xs leading-none">·</span>
<button
type="button"
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
class={cn(
'flex items-center gap-0.5 text-xs leading-none transition-colors',
audioStore.audioMode === 'stream'
? 'text-(--color-brand)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
>
{#if audioStore.audioMode === 'stream'}
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Stream
{:else}
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Generate
{/if}
</button>
{/if}
</div>
</div>

View File

@@ -664,24 +664,57 @@
{/if}
</button>
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
Announce
</button>
<!-- Stream / Generate mode toggle -->
<!-- CF AI voices are batch-only and always use generate mode regardless of this setting -->
<button
type="button"
onclick={() => {
if (!audioStore.voice.startsWith('cfai:')) {
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
}
}}
disabled={audioStore.voice.startsWith('cfai:')}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.voice.startsWith('cfai:')
? 'border-(--color-border) bg-(--color-surface-2) text-(--color-border) cursor-not-allowed opacity-50'
: audioStore.audioMode === 'stream'
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
)}
aria-pressed={audioStore.audioMode === 'stream'}
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : audioStore.audioMode === 'stream' ? 'Stream mode — audio starts instantly' : 'Generate mode — wait for full audio before playing'}
>
{#if audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:')}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
<path d="M8 5v14l11-7z"/>
</svg>
Announce
</button>
{:else}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{/if}
{audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'Stream' : 'Generate'}
</button>
<!-- Sleep timer pill -->
<button

View File

@@ -76,6 +76,7 @@ export interface PBUserSettings {
font_family?: string;
font_size?: number;
announce_chapter?: boolean;
audio_mode?: string;
updated?: string;
}
@@ -1013,7 +1014,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -1033,6 +1034,7 @@ export async function saveSettings(
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
if (userId) payload.user_id = userId;
if (existing) {
@@ -1413,6 +1415,56 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
);
}
/**
* Delete all data associated with a user account:
* - user_settings, user_library, progress, comment_votes, book_ratings,
* user_subscriptions, user_sessions, notifications rows owned by the user
* - the app_users record itself
*
* Does NOT delete audio files from MinIO (shared cache) or book comments
* (anonymised to preserve discussion threads).
*/
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
const collections = [
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
{ name: 'comment_votes', filter: `user_id="${userId}"` },
{ name: 'book_ratings', filter: `user_id="${userId}"` },
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
{ name: 'notifications', filter: `user_id="${userId}"` },
{ name: 'user_sessions', filter: `user_id="${userId}"` },
];
const token = await getToken();
for (const { name, filter } of collections) {
try {
const rows = await listAll<{ id: string }>(name, filter);
await Promise.all(
rows.map((r) =>
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
)
);
} catch {
// Best-effort: log and continue so one failure doesn't abort the rest
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
}
}
// Delete the user record last
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
throw new Error(`Failed to delete user record (${res.status})`);
}
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
}
/**
* Update the avatar_url field for a user record.
*/

View File

@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false, audioMode: 'stream' };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
@@ -29,7 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size || 1.0,
announceChapter: row.announce_chapter ?? false
announceChapter: row.announce_chapter ?? false,
audioMode: row.audio_mode ?? 'stream'
};
}
} catch (e) {

View File

@@ -157,6 +157,7 @@
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
audioStore.announceChapter = data.settings.announceChapter ?? false;
audioStore.audioMode = (data.settings.audioMode === 'generate' ? 'generate' : 'stream');
}
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
@@ -179,6 +180,7 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
const announceChapter = audioStore.announceChapter;
const audioMode = audioStore.audioMode;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
@@ -189,7 +191,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
}).catch(() => {});
}, 800) as unknown as number;
});

View File

@@ -0,0 +1,26 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteUserAccount } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* DELETE /api/profile
*
* Permanently deletes the authenticated user's account and all associated data:
* settings, library, progress, votes, ratings, sessions, notifications.
*
* The app_users record is removed last. The caller should immediately log the
* user out (submit the logout form) to clear the session cookie.
*/
export const DELETE: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated');
try {
await deleteUserAccount(locals.user.id, locals.sessionId);
} catch (e) {
log.error('profile', 'DELETE /api/profile failed', { userId: locals.user.id, err: String(e) });
error(500, { message: 'Failed to delete account. Please try again or contact support.' });
}
return json({ ok: true });
};

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter, audioMode).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -19,7 +19,8 @@ export const GET: RequestHandler = async ({ locals }) => {
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size || 1.0,
announceChapter: settings?.announce_chapter ?? false
announceChapter: settings?.announce_chapter ?? false,
audioMode: settings?.audio_mode ?? 'stream'
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -29,7 +30,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean, audioMode?: string }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -73,6 +74,12 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid announceChapter — must be boolean');
}
// audioMode is optional — if provided it must be a known value
const validAudioModes = ['stream', 'generate'];
if (body.audioMode !== undefined && !validAudioModes.includes(body.audioMode)) {
error(400, `Invalid audioMode — must be one of: ${validAudioModes.join(', ')}`);
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {

View File

@@ -100,6 +100,58 @@
const genres = $derived(parseGenres(data.book?.genres ?? []));
const chapterList = $derived(data.chapters ?? []);
// ── Admin: split chapters (imported PDF/EPUB books) ──────────────────────
const isFullTextBook = $derived(
chapterList.length === 1 && chapterList[0].title === 'Full Text'
);
let splitText = $state('');
let splitSaving = $state(false);
let splitResult = $state<'saved' | 'error' | ''>('');
let splitError = $state('');
let splitOpen = $state(false);
$effect(() => {
// Pre-fill the textarea with chapter 1 content when the panel is opened.
if (splitOpen && !splitText && data.book?.slug && isFullTextBook) {
fetch(`/api/chapter-markdown/${encodeURIComponent(data.book.slug)}/1`)
.then((r) => r.ok ? r.text() : '')
.then((t) => {
// Strip leading "# Full Text\n\n" header if present.
splitText = t.replace(/^# Full Text\n\n/, '').trim();
})
.catch(() => {});
}
});
async function splitChapters() {
const slug = data.book?.slug;
if (splitSaving || !slug) return;
splitSaving = true;
splitResult = '';
splitError = '';
try {
const res = await fetch(`/api/admin/books/${encodeURIComponent(slug)}/split-chapters`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: splitText })
});
if (res.ok) {
splitResult = 'saved';
splitOpen = false;
await invalidateAll();
} else {
const d = await res.json().catch(() => ({}));
splitError = (d as any).error ?? 'Unknown error';
splitResult = 'error';
}
} catch (e: any) {
splitError = e?.message ?? '';
splitResult = 'error';
} finally {
splitSaving = false;
}
}
// ── Admin: rescrape ───────────────────────────────────────────────────────
let scraping = $state(false);
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
@@ -979,7 +1031,7 @@
</a>
<!-- Admin panel (collapsed by default, admin only) -->
{#if data.isAdmin && book.source_url}
{#if data.isAdmin}
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
@@ -997,6 +1049,62 @@
{#if adminOpen}
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
<!-- Chapter split tool (only for imported books with single "Full Text" chapter) -->
{#if isFullTextBook}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Split Chapters</p>
<button
onclick={() => { splitOpen = !splitOpen; splitResult = ''; splitError = ''; }}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{splitOpen ? 'Hide' : 'Edit'}
</button>
</div>
{#if !splitOpen}
<p class="text-xs text-(--color-muted)">
This book has a single "Full Text" chapter. Use this tool to split it into chapters.
</p>
{/if}
{#if splitOpen}
<p class="text-xs text-(--color-muted)">
Insert <code class="bg-(--color-surface-3) px-1 rounded">---</code> on its own line to divide chapters.
Optionally start a segment with <code class="bg-(--color-surface-3) px-1 rounded">## Chapter Title</code>.
</p>
<textarea
bind:value={splitText}
rows="16"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs font-mono focus:outline-none focus:border-(--color-brand) resize-y"
placeholder="Paste or edit the full text here. Use --- to split chapters."
></textarea>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={splitChapters}
disabled={splitSaving || !splitText.trim()}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{splitSaving || !splitText.trim() ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{#if splitSaving}
<svg class="w-3 h-3 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-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Saving…
{:else}
Save chapters
{/if}
</button>
{#if splitResult === 'saved'}
<span class="text-xs text-green-400">Saved.</span>
{:else if splitResult === 'error'}
<span class="text-xs text-(--color-danger)">{splitError || 'Error.'}</span>
{/if}
</div>
{/if}
</div>
<hr class="border-(--color-border)" />
{/if}
<!-- Rescrape / range-scrape (only for scraped books with a source URL) -->
{#if book.source_url}
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
@@ -1065,6 +1173,7 @@
</span>
{/if}
</div>
{/if}
<hr class="border-(--color-border)" />

View File

@@ -3,15 +3,16 @@
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import type { AudioMode } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import type { Voice } from '$lib/types';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Polar checkout ───────────────────────────────────────────────────────────
// Customer portal: always link to the org portal
const manageUrl = `https://polar.sh/libnovel/portal`;
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
@@ -41,14 +42,12 @@
}
// ── Avatar ───────────────────────────────────────────────────────────────────
// Show a welcome banner when Polar redirects back with ?subscribed=1
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
@@ -83,9 +82,7 @@
}
}
function handleCropCancel() {
cropFile = null;
}
function handleCropCancel() { cropFile = null; }
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
@@ -93,7 +90,7 @@
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
function voiceLabel(v: Voice): string {
if (v.engine === 'cfai') {
@@ -102,14 +99,14 @@
}
if (v.engine === 'pocket-tts') {
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return name + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
}
// Kokoro: "af_bella" → "Bella (US F)"
const langMap: Record<string, string> = {
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
ef: 'ES', em: 'ES', ff: 'FR',
hf: 'IN', hm: 'IN', 'if': 'IT', im: 'IT',
jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
af:'US', am:'US', bf:'UK', bm:'UK',
ef:'ES', em:'ES', ff:'FR',
hf:'IN', hm:'IN', 'if':'IT', im:'IT',
jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH',
};
const prefix = v.id.slice(0, 2);
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
@@ -125,21 +122,41 @@
.catch(() => { voicesLoaded = true; });
});
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Voice sample playback
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio = $state<HTMLAudioElement | null>(null);
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
function stopSample() {
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
samplePlayingVoice = null;
}
async function toggleSample(voiceId: string) {
if (samplePlayingVoice === voiceId) { stopSample(); return; }
stopSample();
samplePlayingVoice = voiceId;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
if (res.status === 404) { samplePlayingVoice = null; return; }
if (!res.ok) throw new Error();
const { url } = await res.json() as { url: string };
const audio = new Audio(url);
sampleAudio = audio;
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
await audio.play();
} catch { samplePlayingVoice = null; }
}
// ── Settings state ────────────────────────────────────────────────────────────
// All changes are written directly into audioStore / theme context.
// The layout's debounced $effect owns the single PUT /api/settings call.
// We only maintain a local saveStatus indicator here.
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
@@ -166,51 +183,50 @@
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
// ── Auto-save ────────────────────────────────────────────────────────────────
// Local save-status indicator — layout's effect does the actual debounced save.
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
function markSaved() {
saveStatus = 'saving';
clearTimeout(savedTimer);
// Give a tick for layout's effect to fire, then show ✓ Saved
savedTimer = setTimeout(() => {
saveStatus = 'saved';
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
}, 900) as unknown as number;
}
// Propagate all settings changes into audioStore / context immediately.
// Layout effect watches these and persists to the server (debounced 800ms).
$effect(() => {
// Read all settings deps to subscribe
const t = selectedTheme;
const t = selectedTheme;
const ff = selectedFontFamily;
const fs = selectedFontSize;
const v = voice;
const sp = speed;
const an = autoNext;
const v = audioStore.voice;
const sp = audioStore.speed;
const an = audioStore.autoNext;
const ac = audioStore.announceChapter;
const am = audioStore.audioMode;
// Apply context immediately (font/theme previews live without waiting for save)
if (settingsCtx) {
settingsCtx.current = t;
settingsCtx.current = t;
settingsCtx.fontFamily = ff;
settingsCtx.fontSize = fs;
settingsCtx.fontSize = fs;
}
audioStore.voice = v;
audioStore.autoNext = an;
if (!initialized) { initialized = true; return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
saveStatus = 'saving';
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
});
saveStatus = 'saved';
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
} catch {
saveStatus = 'idle';
}
}, 800) as unknown as number;
void v; void sp; void an; void ac; void am; // keep subscriptions live
markSaved();
});
// Keep theme/font writes flowing into layout context when changed from selectors
$effect(() => { if (settingsCtx) settingsCtx.current = selectedTheme; });
$effect(() => { if (settingsCtx) settingsCtx.fontFamily = selectedFontFamily; });
$effect(() => { if (settingsCtx) settingsCtx.fontSize = selectedFontSize; });
// ── Tab ──────────────────────────────────────────────────────────────────────
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
@@ -224,12 +240,12 @@
is_current: boolean;
};
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
let revokingId = $state<string | null>(null);
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
let revokingId = $state<string | null>(null);
let revokeError = $state('');
async function revokeSession(session: Session) {
revokingId = session.id;
revokingId = session.id;
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
@@ -247,6 +263,37 @@
}
}
// ── Danger zone ──────────────────────────────────────────────────────────────
let deleteConfirmOpen = $state(false);
let deleteConfirmText = $state('');
let deleting = $state(false);
let deleteError = $state('');
const DELETE_KEYWORD = untrack(() => data.user.username);
const deleteReady = $derived(deleteConfirmText.trim() === DELETE_KEYWORD);
async function deleteAccount() {
if (!deleteReady) return;
deleting = true;
deleteError = '';
try {
const res = await fetch('/api/profile', { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
deleteError = body.message ?? `Delete failed (${res.status}). Please try again.`;
return;
}
// Server deleted account — submit logout form to clear session cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) logoutForm.submit();
} catch {
deleteError = 'Network error. Please try again.';
} finally {
deleting = false;
}
}
// ── Utilities ────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
if (!iso) return '—';
try {
@@ -293,7 +340,7 @@
</div>
{/if}
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<!-- ── Profile header ───────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
@@ -353,10 +400,12 @@
<button
type="button"
onclick={() => (activeTab = tab)}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === tab
class={cn(
'flex-1 py-2 rounded-lg text-sm font-medium transition-colors',
activeTab === tab
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
</button>
@@ -364,7 +413,8 @@
</div>
{#if activeTab === 'profile'}
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
<!-- ── Subscription ──────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
@@ -424,20 +474,21 @@
</section>
{/if}
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<!-- ── Preferences ───────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<!-- Section header with auto-save indicator -->
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
{#if saveStatus === 'saving'}
{m.profile_saving()}
{:else if saveStatus === 'saved'}
{m.profile_saved()}
{:else}
{m.profile_saved()}
{/if}
<span class={cn(
'text-xs transition-all duration-300',
saveStatus === 'saving' ? 'text-(--color-muted)' :
saveStatus === 'saved' ? 'text-green-400' :
'opacity-0 pointer-events-none'
)}>
{#if saveStatus === 'saving'}{m.profile_saving()}
{:else if saveStatus === 'saved'}{m.profile_saved()}
{:else}{m.profile_saved()}{/if}
</span>
</div>
@@ -446,16 +497,18 @@
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-2 flex-wrap items-center">
{#each THEMES as t, i}
{#if i === 3}
{#if i === 6}
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
{/if}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
class="flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedTheme === t.id
class={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedTheme === 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)'}"
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedTheme === t.id}
>
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
@@ -473,10 +526,12 @@
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontFamily === f.id
class={cn(
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedFontFamily === 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)'}"
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedFontFamily === f.id}
>
{f.label()}
@@ -493,10 +548,12 @@
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontSize === s.value
class={cn(
'px-3 py-2 rounded-lg border text-sm font-medium transition-colors',
selectedFontSize === 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)'}"
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'
)}
aria-pressed={selectedFontSize === s.value}
>
{s.label()}
@@ -505,34 +562,81 @@
</div>
</div>
<!-- TTS voice -->
<!-- TTS voice — visual card picker grouped by engine -->
<div class="px-6 py-5 space-y-3">
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
{#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
<div class="space-y-2">
{#each [1,2,3] as _}
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{/each}
</div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
<option>{m.common_loading()}</option>
</select>
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
{:else}
<select id="voice-select" bind:value={voice}
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)">
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
{#if cfaiVoices.length > 0}
<optgroup label="Cloudflare AI">
{#each cfaiVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
</select>
<!-- Engine groups -->
{#each [
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
{ label: 'Cloudflare AI', voices: cfaiVoices },
].filter(g => g.voices.length > 0) as group}
<div>
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{#each group.voices as v (v.id)}
{@const isSelected = audioStore.voice === v.id}
{@const isPlaying = samplePlayingVoice === v.id}
<!-- Use role=option div to avoid nested <button> inside <button> -->
<div
role="option"
aria-selected={isSelected}
tabindex="0"
onclick={() => { audioStore.voice = v.id; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
class={cn(
'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-sm transition-colors cursor-pointer select-none',
isSelected
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
)}
>
<div class="flex items-center gap-2 min-w-0">
{#if isSelected}
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{:else}
<span class="w-3.5 h-3.5 shrink-0"></span>
{/if}
<span class="truncate font-medium">{voiceLabel(v)}</span>
</div>
<!-- Sample play button -->
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
class={cn(
'shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors',
isPlaying
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'
)}
title={isPlaying ? 'Stop sample' : 'Play sample'}
>
{#if isPlaying}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
{:else}
<svg class="w-3 h-3 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>
@@ -540,36 +644,101 @@
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
<span class="text-sm font-mono text-(--color-brand)">{audioStore.speed.toFixed(1)}x</span>
</div>
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1"
bind:value={audioStore.speed}
style="accent-color: var(--color-brand);" class="w-full" />
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span><span>3.0x</span>
</div>
</div>
<!-- Auto-advance -->
<div class="px-6 py-5 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
<!-- Playback toggles row -->
<div class="px-6 py-5 space-y-4">
<p class="text-sm font-medium text-(--color-text)">Playback</p>
<!-- Auto-advance -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
</div>
<button
type="button"
role="switch"
aria-checked={audioStore.autoNext}
aria-label="Auto-advance to next chapter"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.autoNext ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Announce chapter -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-(--color-text)">Announce chapter</p>
<p class="text-xs text-(--color-muted) mt-0.5">Read the chapter title aloud before auto-advancing</p>
</div>
<button
type="button"
role="switch"
aria-checked={audioStore.announceChapter}
aria-label="Announce chapter title before auto-advance"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.announceChapter ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'
)}
>
<span class={cn('inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', audioStore.announceChapter ? 'translate-x-6' : 'translate-x-1')}></span>
</button>
</div>
<!-- Audio mode -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-(--color-text)">Audio mode</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{#if audioStore.audioMode === 'stream'}
<strong class="text-(--color-text)">Stream</strong> — audio starts within seconds, saved in background
{:else}
<strong class="text-(--color-text)">Generate</strong> — wait for full audio before playing
{/if}
</p>
</div>
<button
type="button"
onclick={() => {
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
}}
disabled={audioStore.voice.startsWith('cfai:')}
class={cn(
'shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface)',
audioStore.voice.startsWith('cfai:')
? 'opacity-40 cursor-not-allowed bg-(--color-surface-3) border border-(--color-border)'
: audioStore.audioMode === 'stream'
? 'bg-(--color-brand)'
: 'bg-(--color-surface-3) border border-(--color-border)'
)}
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : undefined}
>
<span class={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'translate-x-6' : 'translate-x-1'
)}></span>
</button>
</div>
<button
type="button"
role="switch"
aria-checked={autoNext}
aria-label="Auto-advance to next chapter"
onclick={() => (autoNext = !autoNext)}
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button>
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
<!-- ── Active sessions ───────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div>
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
@@ -585,7 +754,12 @@
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-(--color-brand)/10 border border-(--color-brand)/30' : 'bg-(--color-surface-3)/50 border border-(--color-border)/50'}">
<li class={cn(
'flex items-start justify-between gap-3 rounded-lg px-4 py-3',
session.is_current
? 'bg-(--color-brand)/10 border border-(--color-brand)/30'
: 'bg-(--color-surface-3)/50 border border-(--color-border)/50'
)}>
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
@@ -606,10 +780,12 @@
<button
onclick={() => revokeSession(session)}
disabled={revokingId === session.id}
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
class={cn(
'shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50',
session.is_current
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'
)}
>
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button>
@@ -618,7 +794,74 @@
</ul>
{/if}
</section>
{/if}
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->
<section class="rounded-xl border border-red-500/30 bg-red-500/5 overflow-hidden">
<button
type="button"
onclick={() => { deleteConfirmOpen = !deleteConfirmOpen; deleteConfirmText = ''; deleteError = ''; }}
class="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-red-500/5 transition-colors"
>
<div>
<p class="text-sm font-semibold text-red-400">Danger zone</p>
<p class="text-xs text-(--color-muted) mt-0.5">Irreversible actions — proceed with care</p>
</div>
<svg
class={cn('w-4 h-4 text-(--color-muted) transition-transform', deleteConfirmOpen && 'rotate-180')}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if deleteConfirmOpen}
<div class="px-6 pb-6 space-y-4 border-t border-red-500/20">
<div class="pt-4">
<p class="text-sm font-medium text-(--color-text)">Delete account</p>
<p class="text-xs text-(--color-muted) mt-1">
This permanently deletes your account, reading history, settings, and all associated data. This action cannot be undone.
</p>
</div>
<div class="space-y-2">
<label for="delete-confirm" class="text-xs text-(--color-muted)">
Type <strong class="text-(--color-text) font-mono">{DELETE_KEYWORD}</strong> to confirm
</label>
<input
id="delete-confirm"
type="text"
bind:value={deleteConfirmText}
placeholder={DELETE_KEYWORD}
autocomplete="off"
class="w-full bg-(--color-surface-3) border border-red-500/40 rounded-lg px-3 py-2 text-sm text-(--color-text) placeholder:text-(--color-border) focus:outline-none focus:ring-2 focus:ring-red-500/50"
/>
</div>
{#if deleteError}
<p class="text-sm text-red-400">{deleteError}</p>
{/if}
<button
type="button"
onclick={deleteAccount}
disabled={!deleteReady || deleting}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/40 text-sm font-semibold transition-colors hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
{#if deleting}
<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"/></svg>
Deleting…
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete my account
{/if}
</button>
</div>
{/if}
</section>
{/if} <!-- end profile tab -->
{#if activeTab === 'stats'}
<div class="space-y-4">
@@ -629,9 +872,9 @@
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each [
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
] as stat}
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
@@ -670,8 +913,12 @@
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
<div class="flex flex-wrap gap-2">
{#each data.stats.topGenres as genre, i}
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
<span class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium',
i === 0
? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'
)}>
{#if i === 0}<span class="text-xs">🏆</span>{/if}
{genre}
</span>
@@ -680,7 +927,6 @@
</section>
{/if}
<!-- Dropped books (only if any) -->
{#if data.stats.booksDropped > 0}
<p class="text-xs text-(--color-muted) text-center">
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''}
@@ -706,7 +952,6 @@
href="/books/{item.slug}/chapters/{item.chapter}"
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
>
<!-- Cover thumbnail -->
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
@@ -718,14 +963,10 @@
</div>
{/if}
</div>
<!-- Title + chapter -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
</div>
<!-- Relative time -->
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
{#if item.updated}
{(() => {