Compare commits

...

9 Commits

Author SHA1 Message Date
root
3a9f3b773e fix: reduce log noise during catalogue/book scrapes
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 5m57s
Release / Gitea Release (push) Successful in 32s
Demote per-book and per-chapter-list-page Info logs to Debug — these
fire hundreds of times per catalogue run and drown out meaningful signals:
- orchestrator: RunBook starting (per book)
- metadata saved (per book)
- chapter list fetched (per book)
- scraping chapter list page N (per pagination page per book)

The 'book scrape finished' summary log (with scraped/skipped/errors
counters) remains at Info — it is the useful signal per book.
2026-04-11 12:39:41 +05:00
root
6776d9106f fix: catalogue job always shows 0 counters after cancel/finish
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 32s
Two bugs fixed in runScrapeTask / runCatalogueTask:

1. FinishScrapeTask was called with the task's own context, which is
   already cancelled when the task is stopped. The PATCH to PocketBase
   failed silently, leaving all counters at their initial zero values.
   Fix: use a fresh context.WithTimeout(Background, 15s) for the write.

2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
   success, but the accumulation loop added an extra +1 unconditionally,
   reporting 2 books per successful scrape.
   Fix: result.BooksFound += bookResult.BooksFound  (drop the + 1).
2026-04-11 12:33:30 +05:00
root
ada7de466a perf: remove voice picker from profile, parallelize server load
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m52s
Release / Docker (push) Successful in 5m58s
Release / Gitea Release (push) Successful in 33s
Remove the TTS voice section from the profile page — it fetched
/api/voices on every mount, blocking paint for the full round-trip.
Voice selection lives on the chapter page where voices are already loaded.

Rewrite the server load to run avatar, sessions+stats, and reading history
all concurrently via Promise.allSettled instead of sequentially, cutting
SSR latency by ~2-3x on the profile route.
2026-04-11 10:41:35 +05:00
root
c91dd20c8c refactor: clean up profile page UI — remove decorative icons
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m51s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 36s
Remove all decorative SVG icons (checkmarks, chevrons, stars, fire,
external-link arrows, empty-state illustrations). Replace icon-only
interactive elements with text (avatar hover shows 'Edit', voice sample
buttons show 'Play'/'Stop', danger zone toggle shows 'Open'/'Close').
Replace SVG avatar placeholder with the user's initial. Strip emoji
from stats cards and genre chips. Tighten playback toggle descriptions.
2026-04-11 10:21:14 +05:00
root
3b24f4560f feat: add OG/Twitter meta tags on book and chapter pages
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 37s
Add og:title, og:description, og:image (book cover), og:url, og:type,
og:site_name, twitter:card, twitter:image, and rel=canonical to the
book detail and chapter reader pages so link previews in Telegram,
WhatsApp, Twitter/X, Discord etc. show the cover image instead of
the site logo.
2026-04-11 09:35:21 +05:00
root
973e639274 refactor: extract shared ChapterPickerOverlay component
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 6m19s
Release / Gitea Release (push) Successful in 32s
Unify the duplicated chapter picker overlays from AudioPlayer and
ListeningMode into a single ChapterPickerOverlay component.
Both callers keep their own onselect handlers; the overlay owns
search state internally and includes safe-area insets + scrollIfActive.
2026-04-11 09:01:24 +05:00
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
24 changed files with 1068 additions and 487 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

@@ -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,11 @@ 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)

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

@@ -241,7 +241,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo in
}
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
s.log.Debug("scraping chapter list", "page", page, "url", pageURL)
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
if err != nil {

View File

@@ -68,7 +68,7 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
// if the run failed at the metadata or chapter-list level.
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
o.log.Info("orchestrator: RunBook starting",
o.log.Debug("orchestrator: RunBook starting",
"task_id", task.ID,
"kind", task.Kind,
"url", task.TargetURL,
@@ -103,7 +103,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
}
}
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
o.log.Debug("metadata saved", "slug", meta.Slug, "title", meta.Title)
// ── Step 2: Chapter list ──────────────────────────────────────────────────
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
@@ -114,7 +114,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
return result
}
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
o.log.Debug("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
// Persist chapter refs (without text) so the index exists early.
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {

View File

@@ -505,7 +505,11 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log.Warn("runner: unknown task kind")
}
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
// Use a fresh context for the final write so a cancelled task context doesn't
// prevent the result counters from being persisted to PocketBase.
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer finishCancel()
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
log.Error("runner: FinishScrapeTask failed", "err", err)
}
@@ -551,7 +555,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound + 1
result.BooksFound += bookResult.BooksFound
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors

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

@@ -54,6 +54,7 @@
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
interface Props {
slug: string;
@@ -107,22 +108,10 @@
// ── Chapter picker state ─────────────────────────────────────────────────
let showChapterPanel = $state(false);
let chapterSearch = $state('');
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
showChapterPanel = false;
chapterSearch = '';
goto(`/books/${slug}/chapters/${chapterNumber}`);
}
@@ -293,7 +282,7 @@
// Close panels on Escape.
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
if (showChapterPanel) { showChapterPanel = false; }
else { stopSample(); showVoicePanel = false; }
}
}
@@ -613,41 +602,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();
@@ -1001,7 +1044,7 @@
{#if voices.length > 0}
<button
type="button"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
title={m.reader_change_voice()}
>
@@ -1019,6 +1062,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>
@@ -1119,7 +1189,7 @@
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title={m.reader_change_voice()}
>
@@ -1308,75 +1378,13 @@
the fixed inset-0 positioning is never clipped by overflow-hidden or
border-radius on any ancestor wrapping the AudioPlayer component. -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={chapter}
zIndex="z-[60]"
onselect={playChapter}
onclose={() => { showChapterPanel = false; }}
/>
{/if}
<!-- ── Float player overlay ──────────────────────────────────────────────────

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface ChapterMeta {
number: number;
title: string;
}
interface Props {
/** Full chapter list to render and filter. */
chapters: ChapterMeta[];
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
activeChapter: number;
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
zIndex?: string;
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
onselect: (chapterNumber: number) => void;
/** Called when the close / chevron-down button is tapped. */
onclose: () => void;
}
let {
chapters,
activeChapter,
zIndex = 'z-[60]',
onselect,
onclose
}: Props = $props();
let search = $state('');
const filtered = $derived(
search.trim() === ''
? chapters
: chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(search.toLowerCase()) ||
String(ch.number).includes(search)
)
);
/** Scroll the active chapter into view instantly (no animation) when the
* list is first rendered so the user never has to hunt for their position. */
function scrollIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
}
function handleClose() {
search = '';
onclose();
}
function handleSelect(n: number) {
search = '';
onselect(n);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 flex flex-col {zIndex}"
style="background: var(--color-surface);"
>
<!-- Header -->
<div
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
>
<button
type="button"
onclick={handleClose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<!-- chevron-down -->
<svg class="w-5 h-5" 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>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={search}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div
class="flex-1 overflow-y-auto overscroll-contain"
style="padding-bottom: env(safe-area-inset-bottom);"
>
{#each filtered as ch (ch.number)}
<button
type="button"
onclick={() => handleSelect(ch.number)}
use:scrollIfActive={ch.number === activeChapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === activeChapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<span class={cn(
'flex-1 text-sm truncate',
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === activeChapter}
<!-- play icon -->
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filtered.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
{/if}
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { cn } from '$lib/utils';
import { goto } from '$app/navigation';
import type { Voice } from '$lib/types';
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
interface Props {
/** Called when the user closes the overlay. */
@@ -92,25 +93,14 @@
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
// ── Chapter search ────────────────────────────────────────────────────────
let chapterSearch = $state('');
// (search state is managed internally by ChapterPickerOverlay)
// Scroll the current chapter into view instantly (no animation) when the
// chapter modal opens. Applied to every chapter button; only scrolls when
// the chapter number matches the currently playing one. Runs once on mount
// before the browser paints so no scroll animation is ever visible.
function scrollIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function voiceLabel(v: Voice | string): string {
if (typeof v === 'string') {
@@ -156,13 +146,6 @@
voiceSearch = '';
}
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
// ── Speed ────────────────────────────────────────────────────────────────
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
@@ -453,63 +436,13 @@
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[80] flex flex-col"
style="background: var(--color-surface);"
>
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
<button
type="button"
onclick={() => { showChapterModal = false; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" 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>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[80]"
onselect={playChapter}
onclose={() => { showChapterModal = false; }}
/>
{/if}
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
@@ -664,24 +597,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

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import StarRating from '$lib/components/StarRating.svelte';
@@ -660,6 +661,28 @@
<svelte:head>
<title>{data.scraping ? m.book_detail_scraping() : (data.book?.title ?? 'Book')} — libnovel</title>
{#if data.book && !data.scraping}
{@const ogTitle = `${data.book.title} libnovel`}
{@const ogDesc = data.book.summary ? (data.book.summary.length > 200 ? data.book.summary.slice(0, 200) + '…' : data.book.summary) : `${data.book.total_chapters} chapters · ${data.book.author}`}
{@const ogUrl = `${page.url.origin}/books/${data.book.slug}`}
<link rel="canonical" href={ogUrl} />
<meta property="og:type" content="book" />
<meta property="og:site_name" content="libnovel" />
<meta property="og:url" content={ogUrl} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={ogDesc} />
{#if data.book.cover}
<meta property="og:image" content={data.book.cover} />
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="450" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={data.book.cover} />
{:else}
<meta name="twitter:card" content="summary" />
{/if}
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={ogDesc} />
{/if}
</svelte:head>
{#if data.scraping}

View File

@@ -332,6 +332,23 @@
<svelte:head>
<title>{cleanTitle}{data.book.title} — libnovel</title>
<link rel="canonical" href="{page.url.origin}/books/{data.book.slug}/chapters/{data.chapter.number}" />
<meta property="og:type" content="book" />
<meta property="og:site_name" content="libnovel" />
<meta property="og:url" content="{page.url.origin}/books/{data.book.slug}/chapters/{data.chapter.number}" />
<meta property="og:title" content="{cleanTitle}{data.book.title} — libnovel" />
<meta property="og:description" content="Chapter {data.chapter.number} of {data.book.title}" />
{#if data.book.cover}
<meta property="og:image" content={data.book.cover} />
<meta property="og:image:width" content="300" />
<meta property="og:image:height" content="450" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={data.book.cover} />
{:else}
<meta name="twitter:card" content="summary" />
{/if}
<meta name="twitter:title" content="{cleanTitle}{data.book.title} — libnovel" />
<meta name="twitter:description" content="Chapter {data.chapter.number} of {data.book.title}" />
</svelte:head>
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->

View File

@@ -15,49 +15,58 @@ export const load: PageServerLoad = async ({ locals }) => {
redirect(302, '/login');
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
let email: string | null = null;
let polarCustomerId: string | null = null;
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
email = record?.email ?? null;
polarCustomerId = record?.polar_customer_id ?? null;
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
try {
[sessions, stats] = await Promise.all([
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id)
]);
} catch (e) {
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
// Reading history — last 50 progress entries with book metadata
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
try {
const progress = await allProgress(locals.sessionId, locals.user.id);
// Helper: fetch reading history (progress → books, sequential by necessity)
async function fetchHistory() {
const progress = await allProgress(locals.sessionId, locals.user!.id);
const recent = progress.slice(0, 50);
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
const bookMap = new Map(books.map((b) => [b.slug, b]));
history = recent.map((p) => ({
return recent.map((p) => ({
slug: p.slug,
chapter: p.chapter,
updated: p.updated,
title: bookMap.get(p.slug)?.title ?? p.slug,
cover: bookMap.get(p.slug)?.cover ?? null
}));
} catch (e) {
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
}
// Helper: fetch avatar/email/polarCustomerId (getUserByUsername → resolveAvatarUrl)
async function fetchUserRecord() {
const record = await getUserByUsername(locals.user!.username);
const avatarUrl = await resolveAvatarUrl(locals.user!.id, record?.avatar_url);
return {
avatarUrl,
email: record?.email ?? null,
polarCustomerId: record?.polar_customer_id ?? null
};
}
// Run all three independent groups concurrently
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
fetchUserRecord(),
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id),
fetchHistory()
]);
if (userRecord.status === 'rejected')
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(userRecord.reason) });
if (sessionsResult.status === 'rejected')
log.warn('profile', 'sessions fetch failed (non-fatal)', { err: String(sessionsResult.reason) });
if (statsResult.status === 'rejected')
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
if (historyResult.status === 'rejected')
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
const { avatarUrl = null, email = null, polarCustomerId = null } =
userRecord.status === 'fulfilled' ? userRecord.value : {};
const sessions =
sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
const stats =
statsResult.status === 'fulfilled' ? statsResult.value : null;
const history =
historyResult.status === 'fulfilled' ? historyResult.value : [];
return {
user: locals.user,
avatarUrl,

View File

@@ -3,15 +3,15 @@
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 +41,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,63 +81,17 @@
}
}
function handleCropCancel() {
cropFile = null;
}
function handleCropCancel() { cropFile = null; }
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
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'));
function voiceLabel(v: Voice): string {
if (v.engine === 'cfai') {
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
}
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()})` : '');
}
// 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',
};
const prefix = v.id.slice(0, 2);
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
const gender = v.gender ? v.gender.toUpperCase() : '?';
return `${name} (${lang} ${gender})`;
}
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
// ── 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 +118,48 @@
{ 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);
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;
markSaved();
});
$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 +173,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 +196,36 @@
}
}
// ── 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;
}
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 {
@@ -284,16 +263,13 @@
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
{#if justSubscribed}
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<div>
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4">
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
{/if}
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<!-- ── Profile header ───────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
@@ -306,9 +282,9 @@
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
<span class="text-3xl font-bold text-(--color-muted) select-none">
{data.user.username.slice(0, 1).toUpperCase()}
</span>
</div>
{/if}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
@@ -318,10 +294,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
{:else}
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="text-xs font-semibold text-white tracking-wide">Edit</span>
{/if}
</div>
</button>
@@ -333,8 +306,7 @@
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
{m.profile_plan_pro()}
</span>
{/if}
@@ -353,10 +325,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 +338,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">
@@ -390,8 +365,6 @@
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 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>
{:else}
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{/if}
{m.profile_upgrade_monthly()}
</button>
@@ -417,27 +390,27 @@
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
class="shrink-0 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
</a>
</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 +419,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 +448,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 +470,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,71 +484,100 @@
</div>
</div>
<!-- TTS voice -->
<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>
{#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></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>
{: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>
{/if}
</div>
<!-- Playback speed -->
<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 -->
<div class="px-6 py-5 space-y-5">
<p class="text-sm font-medium text-(--color-text)">Playback</p>
<!-- Auto-advance -->
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Load the next chapter automatically when audio ends</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 gap-4">
<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 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 gap-4">
<div>
<p class="text-sm text-(--color-text)">Audio mode</p>
<p class="text-xs text-(--color-muted) mt-0.5">
{audioStore.audioMode === 'stream' ? 'Stream — starts within seconds' : 'Generate — waits for full audio'}
{#if audioStore.voice.startsWith('cfai:')} <span class="text-(--color-border)">(not available for CF AI)</span>{/if}
</p>
</div>
<button
type="button"
aria-label="Toggle audio mode"
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)'
)}
>
<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 +593,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 +619,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 +633,66 @@
</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>
<span class="text-xs text-(--color-muted)">{deleteConfirmOpen ? 'Close' : 'Open'}</span>
</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}
Delete my account
{/if}
</button>
</div>
{/if}
</section>
{/if} <!-- end profile tab -->
{#if activeTab === 'stats'}
<div class="space-y-4">
@@ -628,10 +702,10 @@
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
<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: 'Chapters Read', value: data.stats.totalChaptersRead },
{ label: 'Completed', value: data.stats.booksCompleted },
{ label: 'Reading', value: data.stats.booksReading },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead },
] 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>
@@ -645,21 +719,15 @@
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted)">day streak</p>
</div>
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted) mt-1">day streak</p>
</div>
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0"></div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted)">avg rating given</p>
</div>
<div class="bg-(--color-surface-3) rounded-lg p-4">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted) mt-1">avg rating given</p>
</div>
</div>
</section>
@@ -670,9 +738,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)'}">
{#if i === 0}<span class="text-xs">🏆</span>{/if}
<span class={cn(
'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)'
)}>
{genre}
</span>
{/each}
@@ -680,7 +751,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' : ''}
@@ -694,10 +764,7 @@
{#if activeTab === 'history'}
<div class="space-y-2">
{#if data.history.length === 0}
<div class="py-12 text-center text-(--color-muted)">
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="py-16 text-center text-(--color-muted)">
<p class="text-sm">No reading history yet.</p>
</div>
{:else}
@@ -706,26 +773,17 @@
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" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div class="w-full h-full bg-(--color-surface-3)"></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}
{(() => {