Compare commits

...

19 Commits

Author SHA1 Message Date
root
1c5c25e5dd feat(reader): add lines-per-page setting for paginated mode
Some checks failed
Release / Test backend (push) Successful in 58s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
Adds a PageLines preference (Few/Normal/Many) that adjusts the paginated
container height via a rem offset on the existing calc(). The setting row
appears in Reader Settings → Layout only when Pages mode is active, matching
the style of all other setting rows. Persisted in localStorage (reader_layout_v1).
2026-04-06 15:37:29 +05:00
root
5177320418 feat(player): scroll chapter list to current chapter on open
Some checks failed
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m35s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Use a Svelte action on each chapter button that calls scrollIntoView with
behavior:'instant' so the list opens centred on the active chapter with
no visible scroll animation.
2026-04-06 15:31:24 +05:00
root
836c9855af fix(player): use untrack() in toggleRequest effect to prevent play/pause loop
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 2m29s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Successful in 2m21s
Release / Gitea Release (push) Successful in 30s
Reading audioStore.isPlaying inside the toggleRequest $effect caused Svelte 5
to subscribe to it, so the effect re-ran on every isPlaying change. When
resuming from ListeningMode, play() would fire onplay → isPlaying=true →
effect re-ran → called pause() → onpause → isPlaying=false → effect re-ran
→ called play() → infinite loop. Wrapping the isPlaying read in untrack()
limits the effect's subscription to toggleRequest only.
2026-04-06 14:57:27 +05:00
Admin
5c2c9b1b67 feat(home): hero carousel with auto-advance, arrows, and dot indicators
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m37s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / runner (push) Successful in 2m28s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m14s
Release / Gitea Release (push) Successful in 32s
Cycles through all in-progress books every 6s; prev/next arrow buttons
overlay the card edges; active dot stretches to a pill; cover fades in
on slide change via {#key} + animate-fade-in; shelf excludes the current
hero to avoid duplication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 00:16:39 +05:00
Admin
79b3de3e8d fix(player): fill ListeningMode empty space + global audio context + chapter picker from mini-bar
Some checks failed
Release / Test backend (push) Successful in 49s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
- justify-between on scrollable body so cover art sits top, controls
  sit bottom — no more half-empty screen on tall phones
- Move ListeningMode outside {#if audioStore.active} so pausing never
  tears down the overlay and loses resume context
- Mini-bar time/track click now opens ListeningMode with chapter picker
  pre-shown (same view as the Chapters button inside ListeningMode)
- Remove the old chapterDrawerOpen mini-bar drawer (replaced by above)
- Add openChapters prop to ListeningMode for pre-opening chapter modal
2026-04-06 00:15:12 +05:00
Admin
5804cd629a feat(reader): convert settings bottom sheet to full-screen overlay
Replaces the bottom drawer + backdrop with a fixed full-screen overlay
matching the voice/chapter picker style in ListeningMode — chevron header,
tab bar with brand-color active state, scrollable content. Escape closes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 00:12:38 +05:00
Admin
b130ba4e1b feat(player): refactor chapter picker to full-screen overlay like voice picker
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / backend (push) Successful in 2m37s
Release / Docker / runner (push) Successful in 2m36s
Release / Upload source maps (push) Successful in 1m32s
Release / Docker / ui (push) Successful in 2m18s
Release / Gitea Release (push) Successful in 40s
Both ListeningMode and the standard AudioPlayer now open chapters via a
full-screen overlay (same UX as the voice selector) — header + search bar +
rows with circular chapter-number badge, title, and active indicator.
Removes the cramped inline card from the bottom of ListeningMode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:42:49 +05:00
Admin
cc1f6b87e4 fix(player): show '--:--' for unknown duration instead of '0:00'
Prevents the silly "2:01 / 0:00" display when audio src is being swapped
from preview to full audio and duration hasn't loaded yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:26:33 +05:00
Admin
8279bd5caa fix(reader): clean chapter title display and declutter audio panel
- Strip leading digit prefix (e.g. "6Chapter 6 → Chapter 6") and
  content after first newline (scraped date artifacts) from chapter titles
- Add "CHAPTER N" eyebrow label above the h1 for clear hierarchy
- Show date_label as small muted text in the meta row
- Remove double-border / mt-6 gap from standard AudioPlayer inside the
  chapter page's collapsible panel (was rendering two nested boxes)
- Remove redundant "Audio Narration" label (toggle already says "Listen")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:18:17 +05:00
Admin
59794e3694 fix(sessions): remove IP from device fingerprint to prevent duplicate sessions on network change
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m40s
Release / Docker / runner (push) Successful in 4m16s
Release / Upload source maps (push) Successful in 1m41s
Release / Docker / ui (push) Successful in 2m47s
Release / Gitea Release (push) Successful in 40s
- deviceFingerprint now hashes only User-Agent (not UA+IP) so switching
  networks (VPN, mobile data, wifi) no longer creates a new session row
- On re-login with same device, also refresh the stored IP field so the
  sessions page shows the current network address
- feat(library): bulk remove and bulk shelf-change actions on /books
  Long-press any card to enter selection mode; sticky action bar with
  Move to shelf dropdown and Remove button; POST /api/library/bulk-remove
  and POST /api/library/bulk-shelf endpoints
- fix(catalogue): make Scrape button visible with solid amber-500 fill
  and dark text instead of low-opacity ghost style that blended into card
2026-04-05 23:12:31 +05:00
Admin
150eb2a2af fix(book): move description to full-width section below header
The description was crammed into the narrow right column beside the cover,
creating a wall of text on mobile. Now it renders full-width below the
cover+title row with better line-height, 5-line collapse, gradient fade,
and a chevron-annotated show-more button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:11:49 +05:00
Admin
a0404cea57 feat(home): add streak widget, trending, genre recs, completed shelf, audio quick-play
- Reading streak + books-in-progress mini-widget (derived from progress timestamps)
- "N chapters left" badge on continue-reading shelf cards
- Audio listen button on hero card and hover-overlay on shelf cards (autoStartChapter + goto)
- Completed shelf section for books where chapter >= total_chapters
- Trending Now section (books sorted by ranking field, 15-min cache)
- "Because you read [Genre]" recommendations (genre-matched, excludes user's books, 10-min cache)
- Both new sections are hideable via the existing show/hide mechanism
- getTrendingBooks / getRecommendedBooks added to pocketbase.ts
- Cache invalidation for trending/recs added to invalidateBooksCache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:08:36 +05:00
Admin
45a0190d75 feat: async chapter-names AI generation with review & apply
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m38s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 3m15s
Release / Docker / runner (push) Successful in 4m34s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 3m39s
Release / Gitea Release (push) Successful in 40s
- Add POST /api/admin/text-gen/chapter-names/async backend endpoint: fire-and-forget,
  returns job_id immediately (HTTP 202), runs batch generation in background goroutine,
  persists proposed titles in ai_job payload when done
- Register new route in server.go alongside existing SSE endpoint (backward compat)
- Add SvelteKit proxy at /api/admin/text-gen/chapter-names/async
- Add SvelteKit proxy for GET /api/admin/ai-jobs/[id] (job detail with payload)
- Add Review button on ai-jobs page for done chapter-names jobs; inline panel shows
  editable title table (old to new) with Apply All button that POSTs to chapter-names/apply
2026-04-05 22:53:47 +05:00
Admin
1abb4cd714 feat(player): CF AI preview/swap + fix PB token expiry + local build time
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m44s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m56s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m14s
- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char
  chunk via CF AI so playback starts immediately; full chapter cached in MinIO
- Frontend: replace CF AI spinner with preview blob URL + background swap to
  full presigned URL when runner finishes, preserving currentTime
- AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap
- pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once
  on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h)
- Footer build time now rendered in user's local timezone via toLocaleString()
2026-04-05 22:12:22 +05:00
Admin
a308672317 fix(cache): reduce Valkey connectTimeout to 1.5s to avoid 10s hang
When Valkey is unreachable, ioredis was holding cache.get() calls in
the offline queue for the default 10s connectTimeout before failing.
This caused admin/image-gen and text-gen pages to stall on every load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:04:05 +05:00
Admin
5d7c3b42fa fix(player): move speed and auto-next controls out of mini-player bar
Speed and auto-next are already available in the full listening mode
overlay — no need to clutter the compact bottom bar with them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:58:09 +05:00
Admin
45f5c51da6 fix(ci): strip 'build' from .dockerignore before docker-ui build
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 3m23s
Release / Docker / runner (push) Successful in 2m53s
Release / Upload source maps (push) Successful in 1m27s
Release / Docker / ui (push) Successful in 2m32s
Release / Gitea Release (push) Successful in 42s
When PREBUILT=1 the pre-built artifact is downloaded into ui/build/ but
.dockerignore excludes 'build', so Docker never sees it and /app/build
doesn't exist in the builder stage — causing the runtime COPY to fail.

Fix: rewrite ui/.dockerignore on the CI runner (grep -v '^build$') so the
pre-built directory is included in the Docker context.

Also in this commit:
- book page: gate EPUB download on isPro (UI upsell + server 403 guard)
- book page: chapter names default pattern changed to '{scene}'
2026-04-05 21:26:05 +05:00
Admin
55df88c3e5 fix(player): remove duplicate inline player and declutter bottom bar
Some checks failed
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m47s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / backend (push) Successful in 3m11s
Release / Docker / runner (push) Successful in 2m42s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Failing after 1m35s
Release / Gitea Release (push) Has been skipped
When the mini-player is active on the current chapter, the collapsible
'Listen to this chapter' panel now shows a brief note instead of rendering
a full second AudioPlayer.

Bottom bar track info column: removed chapter title and book title lines
so the time display fits on one line without crowding the controls.
2026-04-05 20:31:01 +05:00
Admin
eb137fdbf5 fix: source maps — ship pre-built artifact with injected debug IDs to Docker
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m38s
Release / Docker / caddy (push) Successful in 49s
Release / Docker / backend (push) Successful in 2m54s
Release / Docker / runner (push) Successful in 2m52s
Release / Upload source maps (push) Successful in 1m39s
Release / Docker / ui (push) Failing after 1m34s
Release / Gitea Release (push) Has been skipped
The docker-ui job was rebuilding the UI from scratch inside Docker, producing
chunk hashes and JS files that didn't match the source maps uploaded to
GlitchTip, causing all stack traces to appear minified.

Fix:
- ui/Dockerfile: add PREBUILT=1 ARG; skip npm run build when set
- release.yaml upload-sourcemaps: re-upload artifact after sentry-cli inject
- release.yaml docker-ui: download injected artifact into ui/build/ and pass
  PREBUILT=1 so Docker reuses the exact same JS files whose debug IDs are in
  GlitchTip
2026-04-05 20:25:27 +05:00
25 changed files with 2126 additions and 556 deletions

View File

@@ -171,6 +171,13 @@ jobs:
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Upload injected build (for docker-ui)
uses: actions/upload-artifact@v3
with:
name: ui-build-injected
path: build
retention-days: 1
- name: Create GlitchTip release
run: sentry-cli releases new ${{ steps.ver.outputs.version }}
env:
@@ -226,6 +233,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download injected build (debug IDs already embedded)
uses: actions/download-artifact@v3
with:
name: ui-build-injected
path: ui/build
- name: Allow build/ into Docker context (override .dockerignore)
run: |
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -255,6 +273,7 @@ jobs:
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
PREBUILT=1
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline

View File

@@ -904,6 +904,115 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
// on its next poll as soon as the MinIO object is present.
}
// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}.
//
// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,
// roughly 1-2 minutes of audio) so the client can start playing immediately
// while the full audio is generated in the background by the runner.
//
// Fast path: if a preview object already exists in MinIO, redirects to its
// presigned URL (no regeneration).
//
// Slow path: generates the first chunk via CF AI, streams the MP3 bytes to the
// client, and simultaneously uploads to MinIO under a "_preview" key so future
// requests hit the fast path.
//
// Only CF AI voices are expected here. Calling this with a Kokoro/PocketTTS
// voice falls back to the normal audio-stream endpoint behaviour.
//
// Query params:
//
// voice (required — must be a cfai: voice)
func (s *Server) handleAudioPreview(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
jsonError(w, http.StatusBadRequest, "invalid chapter")
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
// Preview key: same as normal key with a "_preview" suffix before the extension.
// e.g. slug/1/cfai:luna_preview.mp3
previewKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice+"_preview", "mp3")
// ── Fast path: preview already in MinIO ──────────────────────────────────
if s.deps.AudioStore.AudioExists(r.Context(), previewKey) {
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), previewKey, 1*time.Hour)
if err != nil {
s.deps.Log.Error("handleAudioPreview: PresignAudio failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
http.Redirect(w, r, presignURL, http.StatusFound)
return
}
// ── Slow path: generate first chunk + stream + save ──────────────────────
// Read the chapter text.
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("handleAudioPreview: ReadChapter failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusNotFound, "chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
return
}
// Take only the first ~1 800 characters — one CF AI chunk, roughly 1-2 min.
const previewChars = 1800
firstChunk := text
if len([]rune(text)) > previewChars {
runes := []rune(text)
firstChunk = string(runes[:previewChars])
// Walk back to last sentence boundary (. ! ?) to avoid a mid-word cut.
for i := previewChars - 1; i > previewChars/2; i-- {
r := runes[i]
if r == '.' || r == '!' || r == '?' || r == '\n' {
firstChunk = string(runes[:i+1])
break
}
}
}
// Generate the preview chunk via CF AI.
mp3, err := s.deps.CFAI.GenerateAudio(r.Context(), firstChunk, voice)
if err != nil {
s.deps.Log.Error("handleAudioPreview: GenerateAudio failed", "slug", slug, "n", n, "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "tts generation failed")
return
}
// Upload to MinIO in the background so the next request hits the fast path.
go func() {
if uploadErr := s.deps.AudioStore.PutAudio(
context.Background(), previewKey, mp3,
); uploadErr != nil {
s.deps.Log.Error("handleAudioPreview: MinIO upload failed", "key", previewKey, "err", uploadErr)
}
}()
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Content-Length", strconv.Itoa(len(mp3)))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mp3)
}
// ── Translation ────────────────────────────────────────────────────────────────
// supportedTranslationLangs is the set of target locales the backend accepts.

View File

@@ -371,6 +371,215 @@ func parseChapterTitlesJSON(raw string) []rawChapterTitle {
return out
}
// handleAdminTextGenChapterNamesAsync handles POST /api/admin/text-gen/chapter-names/async.
//
// Fire-and-forget variant: validates inputs, creates an ai_job record, spawns a
// background goroutine, and returns HTTP 202 with {job_id} immediately. The
// goroutine runs all batches, stores the proposed titles in the job payload, and
// marks the job done/failed/cancelled when finished.
//
// The client can poll GET /api/admin/ai-jobs/{id} for progress, then call
// POST /api/admin/text-gen/chapter-names/apply once the job is "done".
func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req textGenChapterNamesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if strings.TrimSpace(req.Pattern) == "" {
jsonError(w, http.StatusBadRequest, "pattern is required")
return
}
// Load existing chapter list (use request context — just for validation).
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
if len(allChapters) == 0 {
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
return
}
// Apply chapter range filter.
chapters := allChapters
if req.FromChapter > 0 || req.ToChapter > 0 {
filtered := chapters[:0]
for _, ch := range allChapters {
if req.FromChapter > 0 && ch.Number < req.FromChapter {
continue
}
if req.ToChapter > 0 && ch.Number > req.ToChapter {
break
}
filtered = append(filtered, ch)
}
chapters = filtered
}
if len(chapters) == 0 {
jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "chapter-names",
Slug: req.Slug,
Status: domain.TaskStatusPending,
FromItem: req.FromChapter,
ToItem: req.ToChapter,
ItemsTotal: len(chapters),
Model: string(model),
Payload: jobPayload,
Started: time.Now(),
})
if createErr != nil {
jsonError(w, http.StatusInternalServerError, "create ai job: "+createErr.Error())
return
}
jobCtx, jobCancel := context.WithCancel(context.Background())
registerCancelJob(jobID, jobCancel)
s.deps.Log.Info("admin: text-gen chapter-names async started",
"job_id", jobID, "slug", req.Slug,
"chapters", len(chapters), "batches", totalBatches, "model", model)
// Mark running before returning so the UI sees it immediately.
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
})
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user provides a list of chapter numbers with their current titles, ` +
`and a naming pattern template. ` +
`Your job: produce one new title for every chapter, following the pattern exactly. ` +
`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (25 word) scene hint derived from the existing title. ` +
`RULES: ` +
`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
`5. Each element: {"number": <int>, "title": <string>}. ` +
`6. Output every chapter in the input list, in order. Do not skip any.`
// Capture all locals needed in the goroutine.
store := s.deps.AIJobStore
textGen := s.deps.TextGen
logger := s.deps.Log
capturedModel := model
capturedMaxTokens := maxTokens
capturedPattern := req.Pattern
capturedSlug := req.Slug
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
var allResults []proposedChapterTitle
chaptersDone := 0
for i, batch := range batches {
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
})
return
}
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", capturedPattern, chapterListSB.String())
raw, genErr := textGen.Generate(jobCtx, cfai.TextRequest{
Model: capturedModel,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: capturedMaxTokens,
})
if genErr != nil {
logger.Error("admin: text-gen chapter-names async batch failed",
"job_id", jobID, "batch", i+1, "err", genErr)
// Continue — skip errored batch rather than aborting.
continue
}
proposed := parseChapterTitlesJSON(raw)
for _, p := range proposed {
allResults = append(allResults, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"items_done": chaptersDone,
})
}
// Persist results into payload so the UI can load them for review.
resultsJSON, _ := json.Marshal(allResults)
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
capturedPattern, capturedSlug, string(resultsJSON))
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
"payload": finalPayload,
})
logger.Info("admin: text-gen chapter-names async done",
"job_id", jobID, "slug", capturedSlug,
"results", len(allResults), "status", string(status))
}()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}
// ── Apply chapter names ───────────────────────────────────────────────────────
// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.

View File

@@ -180,6 +180,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Streaming audio: serves from MinIO if cached, else streams live TTS
// while simultaneously uploading to MinIO for future requests.
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
// CF AI preview: generates only the first ~1 800-char chunk so the client
// can start playing immediately while the full audio is generated by the runner.
mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
@@ -203,6 +206,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin text generation endpoints (chapter names + book description)
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)

View File

@@ -21,7 +21,11 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
# already been compiled (and debug IDs injected) by a prior job. The caller
# must copy the pre-built build/ into the Docker context before building.
ARG PREBUILT=0
RUN [ "$PREBUILT" = "1" ] || npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles most server-side code, but packages with dynamic

View File

@@ -189,6 +189,15 @@ html {
display: none; /* Chrome / Safari / WebKit */
}
/* ── Hero carousel fade ─────────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }

View File

@@ -62,6 +62,13 @@ class AudioStore {
/** Pseudo-progress bar value 0100 during generation */
progress = $state(0);
/**
* True while playing a short CF AI preview clip (~1-2 min) and the full
* audio is still being generated in the background. Set to false once the
* full audio URL has been swapped in.
*/
isPreview = $state(false);
// ── Playback state (kept in sync with the <audio> element) ─────────────
currentTime = $state(0);
duration = $state(0);

View File

@@ -49,6 +49,7 @@
*/
import { audioStore } from '$lib/audio.svelte';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
@@ -98,6 +99,27 @@
/** Currently active sample <audio> element — one at a time. */
let sampleAudio = $state<HTMLAudioElement | null>(null);
// ── 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}`);
}
/**
* Human-readable label for a voice.
* Kokoro: "af_bella" → "Bella (US F)"
@@ -256,11 +278,11 @@
}
});
// Close voice panel when user clicks outside (escape key).
// Close panels on Escape.
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
stopSample();
showVoicePanel = false;
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
else { stopSample(); showVoicePanel = false; }
}
}
@@ -603,13 +625,17 @@
return;
}
// CF AI (batch-only) or already enqueued by presign: keep the traditional
// POST → poll → presign flow. For enqueued, we skip the POST and 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.
audioStore.status = 'generating';
audioStore.isPreview = false;
startProgress();
// presignResult.enqueued=true means /api/presign/audio already POSTed on our
// behalf — skip the duplicate POST and go straight to polling.
// Kick off the full audio generation task in the background
// (presignResult.enqueued=true means the presign endpoint already did it).
if (!presignResult.enqueued) {
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
@@ -618,7 +644,6 @@
});
if (res.status === 402) {
// Free daily limit reached — surface upgrade CTA
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
@@ -628,37 +653,96 @@
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status === 200) {
// Already cached — body is { status: 'done' }, no url needed.
// Already cached — fast path: presign and play directly.
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.isPreview = false;
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// 202: fall through to polling below.
// 202 accepted — fall through: start preview while runner generates
}
// Poll until the runner finishes generating.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
// Fetch the preview clip (first ~1-2 min chunk).
// Use an AbortController so we can cancel the background polling if the
// user navigates away or stops playback before the full audio is ready.
const previewAbort = new AbortController();
const qs = new URLSearchParams({ voice });
const previewUrl = `/api/audio-preview/${slug}/${chapter}?${qs}`;
try {
const previewRes = await fetch(previewUrl, { signal: previewAbort.signal });
if (previewRes.status === 402) {
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
return;
}
if (!previewRes.ok) throw new Error(`Preview failed: HTTP ${previewRes.status}`);
// The backend responded with the MP3 bytes (or a redirect to MinIO).
// Build a blob URL so we can swap it out later without reloading the page.
const previewBlob = await previewRes.blob();
const previewBlobUrl = URL.createObjectURL(previewBlob);
audioStore.isPreview = true;
audioStore.audioUrl = previewBlobUrl;
audioStore.status = 'ready';
// Don't restore saved time here — preview is always from 0.
// Kick off prefetch of next chapter in the background.
maybeStartPrefetch();
} catch (previewErr: unknown) {
if (previewErr instanceof DOMException && previewErr.name === 'AbortError') return;
// Preview failed — fall through to the spinner (old behaviour).
// We'll wait for the full audio to finish instead.
audioStore.isPreview = false;
}
await finishProgress();
// Background: poll for full audio; when done, swap src preserving position.
try {
const final = await pollAudioStatus(slug, chapter, voice, 2000, previewAbort.signal);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
// Audio is ready in MinIO — always use a presigned URL for direct playback.
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';
// Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background.
maybeStartPrefetch();
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
// Swap: save currentTime → update URL → seek to saved position.
const savedTime = audioStore.currentTime;
const blobUrlToRevoke = audioStore.audioUrl; // capture before overwrite
audioStore.isPreview = false;
audioStore.audioUrl = doneUrl.url;
// If we never started a preview (preview fetch failed), switch to ready now.
if (audioStore.status !== 'ready') audioStore.status = 'ready';
// The layout $effect will load the new src and auto-play from 0.
// We seek back to savedTime after a short delay to let the element
// attach the new source before accepting a seek.
if (savedTime > 0) {
setTimeout(() => {
audioStore.seekRequest = savedTime;
}, 300);
}
// Revoke the preview blob URL to free memory.
// (We need to wait until the new src is playing; 2 s is safe.)
setTimeout(() => {
if (blobUrlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(blobUrlToRevoke);
}
}, 2000);
maybeStartPrefetch();
} catch (pollErr: unknown) {
if (pollErr instanceof DOMException && pollErr.name === 'AbortError') return;
throw pollErr;
}
} catch (e) {
stopProgress();
audioStore.progress = 0;
@@ -725,6 +809,11 @@
}
}
function formatDuration(s: number): string {
if (!isFinite(s) || s <= 0) return '--:--';
return formatTime(s);
}
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
@@ -928,7 +1017,7 @@
</button>
<!-- Time display -->
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
</span>
<!-- Speed cycle -->
<button
@@ -962,21 +1051,29 @@
</div>
{:else}
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
</div>
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
{#if audioStore.chapters.length > 0}
<Button
variant="ghost"
size="sm"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
class={cn('gap-1.5 text-xs', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title="Browse chapters"
>
<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 6h16M4 10h16M4 14h10"/>
</svg>
Chapters
</Button>
{/if}
<!-- Voice selector button -->
{#if voices.length > 0}
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
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()}
>
@@ -1057,6 +1154,79 @@
</div>
{/if}
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
{#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">
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
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>
<!-- 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 (mirrors voice radio indicator) -->
<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>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->
@@ -1108,7 +1278,7 @@
<span>{m.reader_paused()}</span>
{/if}
<span class="tabular-nums text-(--color-muted) opacity-60">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
</span>
</div>
</div>

View File

@@ -7,9 +7,11 @@
interface Props {
/** Called when the user closes the overlay. */
onclose: () => void;
/** When true, open the chapter picker immediately on mount. */
openChapters?: boolean;
}
let { onclose }: Props = $props();
let { onclose, openChapters = false }: Props = $props();
// Voices come from the store (populated by AudioPlayer on mount/play)
const voices = $derived(audioStore.voices);
@@ -18,6 +20,8 @@
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
let showVoiceModal = $state(false);
// svelte-ignore state_referenced_locally
let showChapterModal = $state(openChapters && audioStore.chapters.length > 0);
let voiceSearch = $state('');
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
@@ -30,6 +34,14 @@
// ── Chapter search ────────────────────────────────────────────────────────
let chapterSearch = $state('');
// 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' });
}
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
@@ -163,7 +175,8 @@
$effect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
if (showChapterModal) { showChapterModal = false; }
else if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
else { onclose(); }
}
}
@@ -200,22 +213,43 @@
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Now Playing</span>
<!-- Voice selector button -->
<button
type="button"
onclick={() => (showVoiceModal = !showVoiceModal)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
</button>
<div class="flex items-center gap-2">
<!-- Chapters button -->
{#if audioStore.chapters.length > 0}
<button
type="button"
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-label="Browse chapters"
>
<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 6h16M4 10h16M4 14h10"/>
</svg>
Chapters
</button>
{/if}
<!-- Voice selector button -->
<button
type="button"
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoiceModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
</button>
</div>
</div>
<!-- Voice modal (full-screen overlay) -->
@@ -323,19 +357,93 @@
</div>
{/if}
<!-- Scrollable body -->
<div class="relative flex-1 overflow-y-auto flex flex-col">
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-70 flex flex-col"
style="background: var(--color-surface);"
>
<!-- Modal header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<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>
<!-- Search input -->
<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)}
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)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<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>
<!-- Title -->
<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>
<!-- Now-playing indicator -->
{#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>
{/if}
<!-- Scrollable body — fills remaining height, content spread vertically -->
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
<div class="flex flex-col items-center px-8 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
/>
{:else}
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
@@ -348,212 +456,171 @@
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
</div>
<!-- Seek bar -->
<div class="px-6 shrink-0">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Bottom controls cluster: seek + transport + secondary -->
<div class="flex flex-col gap-0 px-6 shrink-0">
<!-- Transport controls -->
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
<!-- Prev chapter -->
{#if audioStore.chapter > 1 && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Skip back 15s -->
<button
type="button"
onclick={skipBack}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip back 15 seconds"
title="Back 15s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Play / Pause -->
<button
type="button"
onclick={togglePlay}
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={skipForward}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip forward 30 seconds"
title="Forward 30s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
<!-- Next chapter -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
</div>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
<!-- Speed -->
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => (audioStore.speed = s)}
class={cn(
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
audioStore.speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.autoNext
? '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.autoNext}
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={cycleSleepTimer}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title="Sleep timer"
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{sleepLabel}
</button>
</div>
<!-- Chapter list -->
{#if audioStore.chapters.length > 0}
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
<!-- Header + search -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-(--color-border)">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider shrink-0">Chapters</p>
{#if audioStore.chapters.length > 6}
<div class="relative flex-1">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 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…"
bind:value={chapterSearch}
class="w-full pl-6 pr-2 py-0.5 text-xs bg-(--color-surface-3) border border-(--color-border) rounded-md text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
{/if}
<!-- Seek bar -->
<div class="shrink-0">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<span>{formatTime(audioStore.duration)}</span>
</div>
<div class="overflow-y-auto max-h-64">
{#each filteredChapters as ch (ch.number)}
</div>
<!-- Transport controls -->
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
<!-- Prev chapter -->
{#if audioStore.chapter > 1 && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Skip back 15s -->
<button
type="button"
onclick={skipBack}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip back 15 seconds"
title="Back 15s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Play / Pause -->
<button
type="button"
onclick={togglePlay}
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={skipForward}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip forward 30 seconds"
title="Forward 30s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
<!-- Next chapter -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
</div>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
<!-- Speed -->
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => playChapter(ch.number)}
onclick={() => (audioStore.speed = s)}
class={cn(
'w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3) text-left',
ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
audioStore.speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
>
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-4 text-xs text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.autoNext
? '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.autoNext}
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={cycleSleepTimer}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title="Sleep timer"
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{sleepLabel}
</button>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -20,7 +20,8 @@ function client(): Redis {
_client = new Redis(url, {
lazyConnect: false,
enableOfflineQueue: true,
maxRetriesPerRequest: 2
maxRetriesPerRequest: 1,
connectTimeout: 1500
});
_client.on('error', (err: Error) => {
console.error('[cache] Valkey error:', err.message);

View File

@@ -100,8 +100,8 @@ export interface User {
let _token = '';
let _tokenExp = 0;
async function getToken(): Promise<string> {
if (_token && Date.now() < _tokenExp) return _token;
async function getToken(forceRefresh = false): Promise<string> {
if (!forceRefresh && _token && Date.now() < _tokenExp) return _token;
log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });
@@ -119,7 +119,9 @@ async function getToken(): Promise<string> {
const data = await res.json();
_token = data.token as string;
_tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours
// PocketBase superuser tokens expire in ~1 hour by default.
// Cache for 50 minutes to stay safely within that window.
_tokenExp = Date.now() + 50 * 60 * 1000;
log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
return _token;
}
@@ -132,6 +134,18 @@ async function pbGet<T>(path: string): Promise<T> {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
// On 401/403, the token may have expired on the PocketBase side even if
// our local TTL hasn't fired yet. Force a refresh and retry once.
if (res.status === 401 || res.status === 403) {
const freshToken = await getToken(true);
const retry = await fetch(`${PB_URL}${path}`, {
headers: { Authorization: `Bearer ${freshToken}` }
});
if (retry.ok) return retry.json() as Promise<T>;
const retryBody = await retry.text().catch(() => '');
log.error('pocketbase', 'GET failed', { path, status: retry.status, body: retryBody });
throw new Error(`PocketBase GET ${path} failed: ${retry.status}${retryBody}`);
}
const body = await res.text().catch(() => '');
log.error('pocketbase', 'GET failed', { path, status: res.status, body });
throw new Error(`PocketBase GET ${path} failed: ${res.status}${body}`);
@@ -317,10 +331,46 @@ export async function invalidateBooksCache(): Promise<void> {
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*'),
cache.invalidatePattern('books:recently-updated:*')
cache.invalidatePattern('books:recently-updated:*'),
cache.invalidatePattern('books:trending:*'),
cache.invalidatePattern('books:recs:*')
]);
}
/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */
export async function getTrendingBooks(limit = 8): Promise<Book[]> {
const key = `books:trending:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
await cache.set(key, books, 15 * 60);
return books;
}
/**
* Books matching the given genres that the user hasn't read yet.
* The raw genre-query result is cached (shared across users); per-user slug
* exclusion is applied in memory afterwards.
*/
export async function getRecommendedBooks(
topGenres: string[],
excludeSlugs: Set<string>,
limit = 8
): Promise<Book[]> {
if (topGenres.length === 0) return [];
const sortedGenres = [...topGenres].sort();
const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
let books = await cache.get<Book[]>(key);
if (!books) {
const genreFilter = sortedGenres
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
.join('||');
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
await cache.set(key, books, 10 * 60);
}
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
}
export async function getBook(slug: string): Promise<Book | null> {
return listOne<Book>('books', `slug="${slug}"`);
}
@@ -1108,12 +1158,14 @@ export interface UserSession {
}
/**
* Generate a short device fingerprint from user-agent + IP.
* SHA-256 of the concatenation, first 16 hex chars.
* Generate a short device fingerprint from the user-agent alone.
* IP is intentionally excluded so that network changes (VPN, mobile data,
* home vs. office wifi) don't create duplicate sessions for the same device.
* SHA-256 of the user-agent string, first 16 hex chars.
*/
function deviceFingerprint(userAgent: string, ip: string): string {
function deviceFingerprint(userAgent: string, _ip?: string): string {
return createHash('sha256')
.update(`${userAgent}::${ip}`)
.update(userAgent)
.digest('hex')
.slice(0, 16);
}
@@ -1140,12 +1192,13 @@ export async function upsertUserSession(
);
if (existing) {
// Touch last_seen and return the existing authSessionId
// Touch last_seen and update IP (may have changed due to network switch).
// Return the existing authSessionId so no new session row is created.
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen: new Date().toISOString() })
body: JSON.stringify({ last_seen: new Date().toISOString(), ip })
}).catch(() => {});
return { authSessionId: existing.session_id, recordId: existing.id };
}

View File

@@ -2,7 +2,7 @@
import '../app.css';
import { page, navigating } from '$app/state';
import { goto } from '$app/navigation';
import { setContext } from 'svelte';
import { setContext, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -33,21 +33,16 @@
];
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
let activeChapterEl = $state<HTMLElement | null>(null);
let listeningModeOpen = $state(false);
let listeningModeChapters = $state(false);
function setIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) activeChapterEl = node;
return {
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
};
}
// Build time formatted in the user's local timezone (populated on mount so
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
let buildTimeLocal = $state('');
$effect(() => {
if (chapterDrawerOpen && activeChapterEl) {
activeChapterEl.scrollIntoView({ block: 'center' });
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
}
});
@@ -161,15 +156,23 @@
});
// Handle toggle requests from AudioPlayer controller.
// IMPORTANT: isPlaying must be read inside untrack() so the effect only
// re-runs when toggleRequest increments, not every time isPlaying changes.
// Without untrack the effect subscribes to both toggleRequest AND isPlaying,
// causing an infinite play/pause loop: play() fires onplay → isPlaying=true
// → effect re-runs → sees isPlaying=true → calls pause() → onpause fires
// → isPlaying=false → effect re-runs → calls play() → …
$effect(() => {
// Read toggleRequest to subscribe; ignore value 0 (initial).
const _req = audioStore.toggleRequest;
if (!audioEl || _req === 0) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
untrack(() => {
if (audioStore.isPlaying) {
audioEl!.pause();
} else {
audioEl!.play().catch(() => {});
}
});
});
// Handle seek requests from AudioPlayer controller.
@@ -258,6 +261,11 @@
}, 2000) as unknown as number;
}
function formatDuration(s: number): string {
if (!isFinite(s) || s <= 0) return '--:--';
return formatTime(s);
}
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
@@ -750,10 +758,9 @@
</div>
<!-- Build version / commit SHA / build time -->
{#snippet buildTime()}
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
{#if buildTimeLocal}
<span class="text-(--color-muted)" title="Build time">
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
· {buildTimeLocal}
</span>
{/if}
{/snippet}
@@ -778,52 +785,6 @@
{#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
<div class="w-full md:w-80 flex flex-col max-h-72">
<!-- Sticky header -->
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
<Button
variant="ghost"
size="icon"
onclick={() => (chapterDrawerOpen = false)}
aria-label={m.player_close_chapter_list()}
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
>
<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="M19 9l-7 7-7-7"/>
</svg>
</Button>
</div>
<!-- Scrollable list -->
<div class="overflow-y-auto px-4">
{#each audioStore.chapters as ch (ch.number)}
<a
use:setIfActive={ch.number === audioStore.chapter}
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
? 'text-(--color-brand) font-semibold'
: 'text-(--color-muted)'}"
>
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
</div>
</div>
</div>
{/if}
<!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-(--color-surface-2)">
@@ -850,26 +811,23 @@
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
<!-- Track info (click to open chapter list drawer) -->
<!-- Track info (click to open chapter list in listening mode) -->
<button
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
onclick={() => { if (audioStore.chapters.length > 0) { listeningModeChapters = true; listeningModeOpen = true; } }}
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
>
{#if audioStore.chapterTitle}
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
{#if audioStore.bookTitle}
<p class="text-xs text-(--color-muted) truncate leading-tight">{audioStore.bookTitle}</p>
{/if}
{#if audioStore.status === 'generating'}
<p class="text-xs text-(--color-brand) leading-tight">
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
</p>
{:else if audioStore.status === 'ready'}
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
{#if audioStore.isPreview}
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
{/if}
</p>
{:else if audioStore.status === 'loading'}
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
@@ -922,46 +880,6 @@
</svg>
</Button>
<!-- Speed control — fixed-width pill, kept as raw button -->
<button
onclick={cycleSpeed}
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
title={m.player_change_speed()}
aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
>
{audioStore.speed}×
</button>
<!-- Auto-next toggle — has absolute-positioned status dots, kept as raw button -->
<button
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'relative p-1.5 rounded flex-shrink-0 transition-colors',
audioStore.autoNext
? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
title={audioStore.autoNext
? audioStore.nextStatus === 'prefetched'
? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
: audioStore.nextStatus === 'prefetching'
? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
: m.player_auto_next_on()
: m.player_auto_next_off()}
aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
aria-pressed={audioStore.autoNext}
>
<!-- "skip to end" / auto-advance icon -->
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
<!-- Prefetch status dot -->
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
{:else if audioStore.status === 'generating'}
<!-- Spinner during generation -->
<svg class="w-6 h-6 text-(--color-brand) animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
@@ -999,7 +917,7 @@
<Button
variant="ghost"
size="icon"
onclick={() => (listeningModeOpen = true)}
onclick={() => { listeningModeChapters = false; listeningModeOpen = true; }}
title="Listening mode"
aria-label="Open listening mode"
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
@@ -1025,9 +943,13 @@
</Button>
</div>
</div>
<!-- Listening mode full-screen overlay -->
{#if listeningModeOpen}
<ListeningMode onclose={() => (listeningModeOpen = false)} />
{/if}
{/if}
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
{/if}

View File

@@ -4,15 +4,35 @@ import {
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
getSubscriptionFeed,
getTrendingBooks,
getRecommendedBooks
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
function parseGenresLocal(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
function computeStreak(progressList: Progress[]): number {
const days = new Set(
progressList.filter((p) => p.updated).map((p) => p.updated.slice(0, 10))
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
if (days.has(d.toISOString().slice(0, 10))) streak++;
else if (i > 0) break;
}
return streak;
}
export const load: PageServerLoad = async ({ locals }) => {
// Step 1: fetch progress + recent books + stats in parallel.
// We intentionally do NOT call listBooks() here — we only need books that
// appear in the user's progress list, which is a tiny subset of 15k books.
let recentBooks: Book[] = [];
let progressList: Progress[] = [];
let stats = { totalBooks: 0, totalChapters: 0 };
@@ -27,8 +47,9 @@ export const load: PageServerLoad = async ({ locals }) => {
log.error('home', 'failed to load home data', { err: String(e) });
}
// Step 2: fetch only the books we actually need for continue-reading.
// This is O(progress entries) instead of O(15k books).
const streak = computeStreak(progressList);
// Fetch only the books we need for continue-reading (avoid loading all books)
const progressSlugs = progressList.map((p) => p.slug);
const progressBooks = progressSlugs.length > 0
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
@@ -36,31 +57,65 @@ export const load: PageServerLoad = async ({ locals }) => {
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
// Continue reading: progress entries joined with book data, most recent first
// All continue-reading entries joined with book data, most recent first
const continueReading = progressList
.filter((p) => bookMap.has(p.slug))
.slice(0, 6)
.slice(0, 8)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
// Recently updated: deduplicate against continueReading slugs
// Split into in-progress vs completed
const continueInProgress = continueReading.filter(
({ book, chapter }) => book.total_chapters === 0 || chapter < book.total_chapters
);
const continueCompleted = continueReading.filter(
({ book, chapter }) => book.total_chapters > 0 && chapter >= book.total_chapters
);
// Top genres from books the user has been reading
const genreFreq = new Map<string, number>();
for (const { book } of continueReading) {
for (const g of parseGenresLocal(book.genres)) {
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
}
}
const topGenres = [...genreFreq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
// Deduplicate recently-updated against in-progress slugs
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Subscription feed — only when logged in
const subscriptionFeed = locals.user
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: [];
// Fetch trending, recommendations, and subscription feed in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
getTrendingBooks(8).catch(() => [] as Book[]),
topGenres.length > 0
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
: Promise.resolve([] as Book[]),
locals.user
? getSubscriptionFeed(locals.user.id, 12).catch((e) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: Promise.resolve([])
]);
// Strip books the user is already reading from trending (redundant)
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
return {
continueReading,
continueInProgress,
continueCompleted,
recentlyUpdated,
subscriptionFeed,
trendingBooks: trendingFiltered,
recommendedBooks,
topGenre: topGenres[0] ?? null,
stats: {
...stats,
booksInProgress: continueReading.length
booksInProgress: continueInProgress.length,
streak
}
};
};

View File

@@ -1,20 +1,16 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { audioStore } from '$lib/audio.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
// ── Section visibility ────────────────────────────────────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
const SECTIONS_KEY = 'home_sections_v1';
const SECTION_LABELS: Record<SectionId, string> = {
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
};
function loadHidden(): Set<SectionId> {
if (!browser) return new Set();
try {
@@ -38,6 +34,14 @@
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
}
const SECTION_LABELS = $derived<Record<SectionId, string>>({
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
'trending': 'Trending Now',
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
});
const hiddenList = $derived(
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
);
@@ -51,8 +55,6 @@
} catch { return []; }
}
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
// counting how many times the same book appears (= new chapters added).
const dedupedRecent = $derived.by(() => {
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
for (const book of data.recentlyUpdated) {
@@ -70,63 +72,180 @@
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
];
// Hero = first continue-reading item; shelf = the rest
const heroBook = $derived(data.continueReading[0] ?? null);
const shelfBooks = $derived(data.continueReading.slice(1));
// ── Hero carousel ────────────────────────────────────────────────────────
const heroBooks = $derived(data.continueInProgress);
let heroIndex = $state(0);
const heroBook = $derived(heroBooks[heroIndex] ?? null);
// Shelf shows remaining books not in the hero
const shelfBooks = $derived(
heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : []
);
const streak = $derived(data.stats.streak ?? 0);
function heroPrev() {
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
resetAutoAdvance();
}
function heroNext() {
heroIndex = (heroIndex + 1) % heroBooks.length;
resetAutoAdvance();
}
function heroDot(i: number) {
heroIndex = i;
resetAutoAdvance();
}
let autoAdvanceTimer = $state<ReturnType<typeof setInterval> | null>(null);
function resetAutoAdvance() {
if (autoAdvanceTimer) clearInterval(autoAdvanceTimer);
if (heroBooks.length > 1) {
autoAdvanceTimer = setInterval(() => {
heroIndex = (heroIndex + 1) % heroBooks.length;
}, 6000);
}
}
$effect(() => {
resetAutoAdvance();
return () => { if (autoAdvanceTimer) clearInterval(autoAdvanceTimer); };
});
function playChapter(slug: string, chapter: number) {
audioStore.autoStartChapter = chapter;
goto(`/books/${slug}/chapters/${chapter}`);
}
</script>
<svelte:head>
<title>{m.home_title()}</title>
</svelte:head>
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
{#if heroBook}
<section class="mb-10">
<a
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
>
<!-- Cover -->
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
{#if heroBook.book.cover}
<img src={heroBook.book.cover} alt={heroBook.book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
{: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="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>
<section class="mb-6">
<div class="relative">
<!-- Card -->
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
<!-- Cover -->
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
{#if heroBook.book.cover}
{#key heroIndex}
<img src={heroBook.book.cover} alt={heroBook.book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 animate-fade-in" loading="eager" />
{/key}
{: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="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>
{/if}
</a>
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.summary}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</a>
<button
type="button"
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
title="Listen to narration"
>
<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="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Listen
</button>
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
{/if}
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
</div>
</div>
<!-- Prev / Next arrow buttons (only when multiple books) -->
{#if heroBooks.length > 1}
<button
type="button"
onclick={heroPrev}
class="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
aria-label="Previous book"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<button
type="button"
onclick={heroNext}
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
aria-label="Next book"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
{/if}
</div>
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.summary}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</span>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
<!-- Dot indicators -->
{#if heroBooks.length > 1}
<div class="flex items-center justify-center gap-1.5 mt-2.5">
{#each heroBooks as _, i}
<button
type="button"
onclick={() => heroDot(i)}
aria-label="Go to book {i + 1}"
class="rounded-full transition-all duration-300 {i === heroIndex
? 'w-4 h-1.5 bg-(--color-brand)'
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"
></button>
{/each}
</div>
</div>
</a>
{/if}
</div>
</section>
{/if}
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
{#if streak > 0}
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<svg class="w-4 h-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
</svg>
<span class="font-semibold text-(--color-text)">{streak}</span>
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
</span>
{#if data.stats.booksInProgress > 0}
<span class="text-(--color-muted)">
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
</span>
{/if}
</div>
{/if}
<!-- ── Continue Reading shelf ──────────────────────────────────────────────────── -->
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
@@ -135,7 +254,56 @@
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each shelfBooks as { book, chapter }}
<a href="/books/{book.slug}/chapters/{chapter}"
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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>
{/if}
<!-- Chapter badge -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
<!-- Chapters ahead badge -->
{#if book.total_chapters > 0 && chapter < book.total_chapters}
<span class="absolute top-1.5 left-1.5 text-xs bg-black/60 text-white font-medium px-1.5 py-0.5 rounded">
{book.total_chapters - chapter} left
</span>
{/if}
</div>
</a>
<!-- Listen button (hover overlay) -->
<button
type="button"
onclick={() => playChapter(book.slug, chapter)}
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
title="Listen"
aria-label="Listen to chapter {chapter}"
>
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</a>
</div>
{/each}
</div>
</section>
{/if}
<!-- ── Completed shelf ────────────────────────────────────────────────────────── -->
{#if data.continueCompleted.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.continueCompleted as { book, chapter }}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
@@ -145,12 +313,13 @@
<svg class="w-8 h-8 text-(--color-muted)" 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>
{/if}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
{m.home_chapter_badge({ n: String(chapter) })}
</span>
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">✓ Done</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.total_chapters > 0}
<p class="text-xs text-(--color-muted) mt-0.5">{chapter} chapters</p>
{/if}
</div>
</a>
{/each}
@@ -184,6 +353,102 @@
</section>
{/if}
<!-- ── Trending Now ───────────────────────────────────────────────────────────── -->
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('trending')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.trendingBooks as book}
{@const genres = parseGenres(book.genres)}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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>
{/if}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Because you read [Genre] ──────────────────────────────────────────────── -->
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
</h2>
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.recommendedBooks as book}
{@const genres = parseGenres(book.genres)}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" 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>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
@@ -272,8 +537,8 @@
</section>
{/if}
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
<!-- ── Empty state ───────────────────────────────────────────────────────────── -->
{#if data.continueInProgress.length === 0 && data.continueCompleted.length === 0 && dedupedRecent.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">{m.home_empty_body()}</p>
@@ -301,8 +566,12 @@
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted) flex-wrap">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
{#if streak > 0}
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak 🔥</span>
{/if}
</div>

View File

@@ -77,6 +77,92 @@
}
}
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
interface ProposedTitle {
number: number;
old_title: string;
new_title: string;
}
interface ReviewState {
jobId: string;
slug: string;
pattern: string;
titles: ProposedTitle[];
loading: boolean;
error: string;
applying: boolean;
applyError: string;
applyDone: boolean;
}
let review = $state<ReviewState | null>(null);
async function openReview(job: AIJob) {
review = {
jobId: job.id,
slug: job.slug,
pattern: '',
titles: [],
loading: true,
error: '',
applying: false,
applyError: '',
applyDone: false
};
try {
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
try {
payload = JSON.parse(data.payload ?? '{}');
} catch {
// ignore
}
review.pattern = payload.pattern ?? '';
review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
review.loading = false;
} catch (e) {
review.loading = false;
review.error = String(e);
}
}
function closeReview() {
review = null;
}
async function applyReview() {
if (!review || review.applying) return;
review.applying = true;
review.applyError = '';
review.applyDone = false;
const chapters = review.titles.map((t) => ({ number: t.number, title: t.new_title }));
try {
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: review.slug, chapters })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
review.applyError = body.error ?? `Error ${res.status}`;
} else {
review.applyDone = true;
}
} catch {
review.applyError = 'Network error.';
} finally {
review.applying = false;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
@@ -304,6 +390,14 @@
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if job.kind === 'chapter-names' && job.status === 'done'}
<button
onclick={() => openReview(job)}
class="px-2 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
>
Review
</button>
{/if}
{#if job.error_message}
<span
class="text-xs text-(--color-danger) max-w-[12rem] truncate"
@@ -324,3 +418,112 @@
</p>
{/if}
</div>
<!-- ── Review & Apply panel ──────────────────────────────────────────────────── -->
{#if review}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onclick={closeReview}
role="presentation"
></div>
<!-- Panel -->
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] overflow-hidden flex flex-col rounded-t-2xl bg-(--color-surface) border-t border-(--color-border) shadow-2xl sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:w-[min(900px,95vw)] sm:max-h-[85vh] sm:rounded-xl sm:border sm:shadow-2xl">
<!-- Panel header -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-(--color-border) shrink-0">
<div>
<h2 class="text-base font-semibold text-(--color-text)">Review Chapter Names</h2>
<p class="text-xs text-(--color-muted) mt-0.5">
<span class="font-mono">{review.slug}</span>
{#if review.pattern}
· pattern: <span class="font-mono">{review.pattern}</span>
{/if}
</p>
</div>
<button
onclick={closeReview}
class="p-1.5 rounded-md text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto">
{#if review.loading}
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">
Loading results…
</div>
{:else if review.error}
<div class="px-5 py-8 text-center">
<p class="text-(--color-danger) text-sm">{review.error}</p>
</div>
{:else if review.titles.length === 0}
<div class="px-5 py-8 text-center">
<p class="text-(--color-muted) text-sm">No results found in this job's payload.</p>
</div>
{:else}
<table class="w-full text-sm">
<thead class="sticky top-0 bg-(--color-surface) border-b border-(--color-border)">
<tr>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-16">#</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider w-1/2">Old Title</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">New Title (editable)</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)">
{#each review.titles as title (title.number)}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
<td class="px-4 py-2 text-xs text-(--color-muted) tabular-nums">{title.number}</td>
<td class="px-4 py-2 text-xs text-(--color-muted) max-w-0">
<span class="block truncate" title={title.old_title}>{title.old_title || '—'}</span>
</td>
<td class="px-4 py-2">
<input
type="text"
bind:value={title.new_title}
class="w-full px-2 py-1 rounded bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<!-- Footer -->
{#if !review.loading && !review.error && review.titles.length > 0}
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
<div class="text-xs text-(--color-muted)">
{review.titles.length} chapters
</div>
<div class="flex items-center gap-3">
{#if review.applyError}
<p class="text-xs text-(--color-danger)">{review.applyError}</p>
{/if}
{#if review.applyDone}
<p class="text-xs text-green-400">Applied successfully.</p>
{/if}
<button
onclick={closeReview}
class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
Close
</button>
<button
onclick={applyReview}
disabled={review.applying || review.applyDone}
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
>
{review.applying ? 'Applying…' : review.applyDone ? 'Applied' : 'Apply All'}
</button>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,31 @@
/**
* GET /api/admin/ai-jobs/[id]
*
* Admin-only proxy to the Go backend's AI job detail endpoint.
* Returns the full job record including the payload field (which contains
* results for completed chapter-names jobs).
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { id } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/ai-jobs/${id}`, { method: 'GET' });
} catch (e) {
log.error('admin/ai-jobs/get', 'backend proxy error', { id, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,35 @@
/**
* POST /api/admin/text-gen/chapter-names/async
*
* Fire-and-forget variant: forwards to the Go backend's async endpoint and
* returns {job_id} immediately (HTTP 202). The backend runs generation in the
* background; the client polls GET /api/admin/ai-jobs/{id} for progress and
* then reviews/applies via POST /api/admin/text-gen/chapter-names/apply.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/chapter-names/async', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/chapter-names/async', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -2,7 +2,11 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, locals }) => {
if (!locals.isPro) {
error(403, 'EPUB download requires a Pro subscription');
}
const { slug } = params;
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');

View File

@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { unsaveBook } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/library/bulk-remove
* Body: { slugs: string[] }
* Removes multiple books from the user's library at once.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
const slugs: unknown = body?.slugs;
if (!Array.isArray(slugs) || slugs.length === 0) {
error(400, 'slugs must be a non-empty array');
}
const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string');
if (validSlugs.length === 0) error(400, 'no valid slugs provided');
const results = await Promise.allSettled(
validSlugs.map((slug) => unsaveBook(locals.sessionId, slug, locals.user?.id))
);
const failed = results
.map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null))
.filter(Boolean);
if (failed.length > 0) {
log.error('library', 'bulk-remove partial failure', { failed });
}
return json({ ok: true, removed: validSlugs.length - failed.length, failed });
};

View File

@@ -0,0 +1,44 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { updateBookShelf } from '$lib/server/pocketbase';
import type { ShelfName } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
const VALID_SHELVES: ShelfName[] = ['', 'plan_to_read', 'completed', 'dropped'];
/**
* POST /api/library/bulk-shelf
* Body: { slugs: string[], shelf: ShelfName }
* Moves multiple books to the given shelf at once.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
const slugs: unknown = body?.slugs;
const shelf: unknown = body?.shelf;
if (!Array.isArray(slugs) || slugs.length === 0) {
error(400, 'slugs must be a non-empty array');
}
if (typeof shelf !== 'string' || !VALID_SHELVES.includes(shelf as ShelfName)) {
error(400, 'invalid shelf value');
}
const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string');
if (validSlugs.length === 0) error(400, 'no valid slugs provided');
const results = await Promise.allSettled(
validSlugs.map((slug) =>
updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id)
)
);
const failed = results
.map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null))
.filter(Boolean);
if (failed.length > 0) {
log.error('library', 'bulk-shelf partial failure', { failed, shelf });
}
return json({ ok: true, updated: validSlugs.length - failed.length, failed });
};

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -38,19 +39,143 @@
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length
});
// ── Selection / bulk-action state ─────────────────────────────────────────
let selectMode = $state(false);
let selected = $state<Set<string>>(new Set());
let busy = $state(false);
let shelfPickerOpen = $state(false);
const selectedCount = $derived(selected.size);
const allVisibleSelected = $derived(
filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug))
);
function enterSelectMode(slug: string) {
selectMode = true;
selected = new Set([slug]);
}
function exitSelectMode() {
selectMode = false;
selected = new Set();
shelfPickerOpen = false;
}
function toggleSelect(slug: string) {
const next = new Set(selected);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
selected = next;
if (next.size === 0) exitSelectMode();
}
function toggleSelectAll() {
if (allVisibleSelected) {
selected = new Set();
exitSelectMode();
} else {
selected = new Set(filteredBooks.map((b) => b.slug));
}
}
// Long-press support (pointer events, works on desktop + mobile)
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressFired = false;
function onPointerDown(slug: string) {
if (selectMode) return;
longPressFired = false;
longPressTimer = setTimeout(() => {
longPressFired = true;
enterSelectMode(slug);
}, 500);
}
function onPointerUp() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function onPointerCancel() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Prevent navigation click if long-press just fired
function onCardClick(e: MouseEvent, slug: string) {
if (selectMode) {
e.preventDefault();
toggleSelect(slug);
return;
}
if (longPressFired) {
e.preventDefault();
longPressFired = false;
}
}
// ── Bulk actions ──────────────────────────────────────────────────────────
async function bulkRemove() {
if (busy || selected.size === 0) return;
busy = true;
try {
await fetch('/api/library/bulk-remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...selected] })
});
await invalidateAll();
} finally {
busy = false;
exitSelectMode();
}
}
async function bulkMoveShelf(shelf: Shelf) {
if (busy || selected.size === 0) return;
busy = true;
shelfPickerOpen = false;
try {
await fetch('/api/library/bulk-shelf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...selected], shelf })
});
await invalidateAll();
} finally {
busy = false;
exitSelectMode();
}
}
</script>
<svelte:head>
<title>{m.books_page_title()}</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
</p>
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
</p>
</div>
{#if selectMode}
<button
type="button"
onclick={exitSelectMode}
class="text-sm text-(--color-muted) hover:text-(--color-text) transition-colors pt-1"
>
Cancel
</button>
{/if}
</div>
{#if !data.books?.length}
@@ -63,22 +188,34 @@
</p>
</div>
{:else}
<!-- Shelf tabs -->
<div class="flex gap-1 flex-wrap mb-4">
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
<!-- Shelf tabs + select-all row -->
<div class="flex items-center gap-2 mb-4 flex-wrap">
{#if selectMode}
<button
type="button"
onclick={toggleSelectAll}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)"
>
{allVisibleSelected ? 'Deselect all' : 'Select all'}
</button>
<span class="text-sm text-(--color-muted)">{selectedCount} selected</span>
{:else}
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@@ -86,18 +223,41 @@
{@const lastChapter = data.progressMap[book.slug]}
{@const genres = parseGenres(book.genres)}
{@const bookShelf = shelfMap[book.slug] ?? ''}
{@const isSelected = selected.has(book.slug)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
onclick={(e) => onCardClick(e, book.slug)}
onpointerdown={() => onPointerDown(book.slug)}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
draggable="false"
class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none
{isSelected
? 'border-(--color-brand) ring-2 ring-(--color-brand)/40'
: 'border-(--color-border) hover:bg-(--color-surface-3) hover:border-zinc-500'}"
>
<!-- Selection overlay -->
{#if selectMode}
<div class="absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors
{isSelected ? 'bg-(--color-brand) border-(--color-brand)' : 'bg-black/40 border-white/60'}">
{#if isSelected}
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</div>
{/if}
<!-- Cover image -->
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 {selectMode ? 'pointer-events-none' : ''}"
loading="lazy"
draggable="false"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
@@ -148,3 +308,73 @@
{/each}
</div>
{/if}
<!-- Bulk action bar (sticky bottom, shown in selection mode) -->
{#if selectMode}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) px-4 py-3 flex items-center gap-3 shadow-lg">
<span class="text-sm text-(--color-muted) mr-auto">
{selectedCount} selected
</span>
<!-- Move to shelf picker -->
<div class="relative">
<button
type="button"
disabled={busy || selectedCount === 0}
onclick={() => (shelfPickerOpen = !shelfPickerOpen)}
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
bg-(--color-surface-3) text-(--color-text) border border-(--color-border)
hover:border-zinc-500 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Move to shelf
<svg class="w-3.5 h-3.5 transition-transform {shelfPickerOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if shelfPickerOpen}
<div class="absolute bottom-full mb-2 right-0 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden min-w-[160px]">
{#each ([['', 'Reading'], ['plan_to_read', 'Plan to Read'], ['completed', 'Completed'], ['dropped', 'Dropped']] as const) as [val, label]}
<button
type="button"
onclick={() => bulkMoveShelf(val as Shelf)}
class="w-full text-left px-4 py-2.5 text-sm text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
{label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Remove button -->
<button
type="button"
disabled={busy || selectedCount === 0}
onclick={bulkRemove}
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
bg-red-500/10 text-red-400 border border-red-500/30
hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{#if busy}
<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-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{/if}
Remove
</button>
</div>
<!-- Spacer so last row of cards isn't hidden behind the action bar -->
<div class="h-20"></div>
{/if}

View File

@@ -64,8 +64,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
lastChapter: null,
userRating: 0,
ratingAvg: { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
isAdmin: locals.user?.role === 'admin',
isPro: locals.isPro,
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
scraping: true,
taskId: body.task_id

View File

@@ -326,7 +326,7 @@
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
let chapNamesApplying = $state(false);
let chapNamesResult = $state<'applied' | 'error' | ''>('');
let chapNamesPattern = $state('Chapter {n}: {scene}');
let chapNamesPattern = $state('{scene}');
let chapNamesBatchProgress = $state('');
let chapNamesBatchWarnings = $state<string[]>([]);
@@ -342,7 +342,7 @@
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || '{scene}' })
});
if (!res.ok) {
chapNamesResult = 'error';
@@ -633,22 +633,6 @@
{/if}
</div>
<!-- Summary with expand toggle -->
{#if book.summary}
<div class="mt-1">
<p class="text-(--color-muted) text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
{book.summary}
</p>
{#if book.summary.length > 220}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
>
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
</button>
{/if}
</div>
{/if}
<!-- CTA buttons — desktop only -->
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
@@ -825,6 +809,34 @@
</div>
</div>
<!-- ── Book description ──────────────────────────────────────────────────────── -->
{#if book.summary}
<div class="mb-6">
<div class="relative">
<p
class="text-(--color-muted) text-sm leading-7 break-words whitespace-pre-line {summaryExpanded ? '' : 'line-clamp-5'}"
>
{book.summary}
</p>
{#if !summaryExpanded && book.summary.length > 300}
<!-- gradient fade over the last line when collapsed -->
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-(--color-surface) to-transparent pointer-events-none"></div>
{/if}
</div>
{#if book.summary.length > 300}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="mt-2 text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors inline-flex items-center gap-1"
>
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
<svg class="w-3 h-3 transition-transform {summaryExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{/if}
</div>
{/if}
<!-- ══════════════════════════════════════════════════ Download row ══ -->
{#if data.inLib && chapterList.length > 0}
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
@@ -835,13 +847,22 @@
<p class="text-sm font-medium text-(--color-text)">Download</p>
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
</div>
<a
href="/api/export/{book.slug}"
download="{book.slug}.epub"
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
>
.epub
</a>
{#if data.isPro}
<a
href="/api/export/{book.slug}"
download="{book.slug}.epub"
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
>
.epub
</a>
{:else}
<a
href="/profile"
class="px-3 py-1.5 rounded-lg bg-(--color-brand)/15 border border-(--color-brand)/30 text-sm font-medium text-(--color-brand) hover:bg-(--color-brand)/25 transition-colors flex-shrink-0"
>
Pro
</a>
{/if}
</div>
{/if}
@@ -1135,7 +1156,7 @@
<input
type="text"
bind:value={chapNamesPattern}
placeholder="Chapter {'{n}'}: {'{scene}'}"
placeholder="{'{scene}'}"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
<div class="flex items-center gap-3 flex-wrap">

View File

@@ -53,6 +53,8 @@
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'compact';
/** Controls how many lines fit on a page by adjusting the container height offset. */
type PageLines = 'less' | 'normal' | 'more';
interface LayoutPrefs {
readMode: ReadMode;
@@ -61,12 +63,19 @@
paraStyle: ParaStyle;
focusMode: boolean;
playerStyle: PlayerStyle;
pageLines: PageLines;
}
const LAYOUT_KEY = 'reader_layout_v1';
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
/**
* Extra rem subtracted from (or added to) the paginated container height.
* Normal (0rem) keeps the existing calc(); Less (-4rem) makes the container
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
*/
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
function loadLayout(): LayoutPrefs {
if (!browser) return DEFAULT_LAYOUT;
@@ -114,8 +123,10 @@
$effect(() => {
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
// Re-run when html changes or container is bound
// Re-run when html, container refs, or mini-player visibility changes
// (mini-player adds pb-24 which reduces the available viewport height)
void html; void paginatedContainerEl; void paginatedContentEl;
void audioStore.active; void audioExpanded;
requestAnimationFrame(() => {
if (!paginatedContainerEl || !paginatedContentEl) return;
const h = paginatedContainerEl.clientHeight;
@@ -139,10 +150,14 @@
}
}
// Keyboard nav for paginated mode
// Keyboard nav for paginated mode + Escape to close settings overlay
$effect(() => {
if (!browser) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && settingsPanelOpen) {
settingsPanelOpen = false;
return;
}
if (layout.readMode !== 'paginated') return;
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault();
@@ -287,6 +302,15 @@
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
);
// Strip scraper artifacts from chapter titles:
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
// - Everything after the first newline (often includes a scraped date)
const cleanTitle = $derived.by(() => {
let t = (data.chapter.title || '').split('\n')[0].trim();
t = t.replace(/^\d+(?=Chapter)/i, '').trim();
return t || `Chapter ${data.chapter.number}`;
});
// Audio panel: auto-open if this chapter is already loaded/playing in the store
// svelte-ignore state_referenced_locally
let audioExpanded = $state(
@@ -301,7 +325,7 @@
</script>
<svelte:head>
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}{data.book.title} — libnovel</title>
<title>{cleanTitle}{data.book.title} — libnovel</title>
</svelte:head>
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
@@ -364,8 +388,11 @@
<!-- Chapter heading + meta + language switcher -->
<div class="mb-6">
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
{m.reader_chapter_n({ n: String(data.chapter.number) })}
</p>
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
{cleanTitle}
</h1>
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
{#if wordCount > 0}
@@ -375,6 +402,9 @@
~{Math.max(1, Math.round(wordCount / 200))} min
</p>
{/if}
{#if data.chapter.date_label}
<span class="text-(--color-muted) text-xs opacity-60">{data.chapter.date_label}</span>
{/if}
<!-- Language switcher (inline, compact) -->
{#if !data.isPreview}
@@ -471,18 +501,28 @@
</button>
{#if audioExpanded}
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={cleanTitle}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
</div>
{/if}
</div>
@@ -513,7 +553,9 @@
role="none"
bind:this={paginatedContainerEl}
class="paginated-container mt-8"
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
style="height: {layout.focusMode
? 'calc(100svh - 8rem)'
: `calc(100svh - 26rem - ${PAGE_LINES_OFFSET[layout.pageLines]})`};"
onclick={handlePaginatedClick}
>
<div
@@ -644,28 +686,37 @@
</div>
{/if}
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
<!-- ── Reader settings overlay ───────────────────────────────────────────── -->
{#if settingsCtx}
{#if settingsPanelOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1 shrink-0">
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 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">
<button
type="button"
onclick={() => (settingsPanelOpen = false)}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close settings"
>
<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">Reader Settings</span>
</div>
<!-- Tab bar -->
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
<div class="flex gap-1 mx-4 mt-3 mb-1 p-1 rounded-xl bg-(--color-surface-2) shrink-0 border border-(--color-border)">
<button
type="button"
onclick={() => (settingsTab = 'reading')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'reading'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Reading</button>
<button
@@ -673,13 +724,13 @@
onclick={() => (settingsTab = 'listening')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'listening'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Listening</button>
</div>
<!-- Scrollable content -->
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
<div class="flex-1 overflow-y-auto px-4 pb-6 flex flex-col gap-0 mt-2">
{#if settingsTab === 'reading'}
@@ -745,6 +796,25 @@
</div>
</div>
{#if layout.readMode === 'paginated'}
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Lines</span>
<div class="flex gap-1.5 flex-1">
{#each ([['less', 'Few'], ['normal', 'Normal'], ['more', 'Many']] as const) as [v, lbl]}
<button
type="button"
onclick={() => setLayout('pageLines', v)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.pageLines === v
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.pageLines === v}
>{lbl}</button>
{/each}
</div>
</div>
{/if}
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">

View File

@@ -591,7 +591,7 @@
<button
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
disabled={scraping[novel.slug]}
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
class="w-full text-xs px-2 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
</button>
@@ -694,7 +694,7 @@
<button
onclick={() => scrapeNovel(novel)}
disabled={scraping[novel.slug]}
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
class="text-xs px-2.5 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
</button>