Compare commits

...

16 Commits

Author SHA1 Message Date
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
Admin
385c9cd8f2 feat(reader): voice modal with search, chapter search, comments collapse, audio stream fix
All checks were successful
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 4m15s
Release / Docker / runner (push) Successful in 4m28s
Release / Upload source maps (push) Successful in 53s
Release / Docker / ui (push) Successful in 7m30s
Release / Gitea Release (push) Successful in 49s
- ListeningMode: replace inline voice dropdown with full-screen modal + voice search
- ListeningMode: add chapter search box (shown when > 6 chapters) + click-to-play via autoStartChapter
- CommentsSection: collapse by default when empty, auto-expand once comments load
- AudioPlayer: always use streaming endpoint for Kokoro/PocketTTS (backend deduplicates via AudioExists)
2026-04-05 20:20:19 +05:00
Admin
e3bb19892c feat: add AI Jobs admin page, cache model lists, improve image-gen 502 UX
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m36s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 4m27s
Release / Docker / runner (push) Successful in 3m32s
Release / Upload source maps (push) Successful in 44s
Release / Docker / ui (push) Successful in 2m46s
Release / Gitea Release (push) Successful in 58s
- Add /admin/ai-jobs page with live-polling jobs table, status badges, progress bars, and cancel action
- Add listAIJobs() helper in pocketbase.ts; AIJob type
- Add POST /api/admin/ai-jobs/[id]/cancel SvelteKit proxy
- Add ai-jobs link to admin sidebar nav (all 5 locales)
- Cache image-gen and text-gen model lists in Valkey (10 min TTL) via scraper.ts helpers
- Cache changelog Gitea API response (5 min TTL)
- Improve 502/504 error message on image-gen page with CF AI timeout hint
- Show CF AI timeout warning in advanced options when a Cloudflare/FLUX model is selected
2026-04-05 20:07:54 +05:00
Admin
6ca704ec9a fix: use upload/download-artifact@v3 for Gitea GHES compatibility
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 5m3s
Release / Docker / runner (push) Successful in 3m41s
Release / Upload source maps (push) Successful in 1m44s
Release / Docker / ui (push) Successful in 4m8s
Release / Gitea Release (push) Successful in 2m42s
2026-04-05 18:15:43 +05:00
Admin
2bdb5e29af perf: reuse UI build artifact for source map upload in CI
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Failing after 51s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 4m28s
Release / Docker / runner (push) Successful in 4m56s
Release / Gitea Release (push) Has been skipped
Pass build output from check-ui to upload-sourcemaps via artifact
instead of rebuilding from scratch. Saves ~4-5 min (npm ci + npm run build)
from the upload-sourcemaps job.
2026-04-05 18:01:00 +05:00
Admin
222627a18c refactor: clean up chapter reader UI — nav, audio, lang switcher, bottom CTA
- Consolidate top nav into a single row: ← back | ← → chapter arrows | settings gear
  Removes the duplicate prev/next buttons that appeared at both top and bottom
- Language switcher moved inline into chapter meta line (after word count);
  lock icons made smaller and less distracting for free users; removes the
  separate "Upgrade to Pro" text link
- Audio player wrapped in a collapsible "Listen to this chapter" panel;
  auto-expands if audio is already playing for the chapter, collapsed otherwise
  so content is immediately visible on page load
- Bottom nav redesigned: next chapter is a full-width card CTA with hover
  accent, previous is a small secondary text link — clear visual hierarchy
- Remove floating gear button (settings now triggered from top nav settings icon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 17:57:44 +05:00
Admin
0ae71c62f9 fix: switch CI source map upload from glitchtip-cli to sentry-cli
All checks were successful
Release / Test backend (push) Successful in 1m9s
Release / Check ui (push) Successful in 51s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 4m30s
Release / Docker / runner (push) Successful in 3m54s
Release / Upload source maps (push) Successful in 53s
Release / Docker / ui (push) Successful in 2m57s
Release / Gitea Release (push) Successful in 57s
glitchtip-cli v0.1.0 uploads chunks but never calls the assemble endpoint,
leaving releases with 0 files. sentry-cli correctly calls assemble after
chunk upload — confirmed working in manual test (2.5.84-test3 = 211 files).
2026-04-05 17:52:42 +05:00
Admin
d0c95889ca fix: persist GlitchTip GzipChunk patch and prune old releases in CI
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 4m36s
Release / Docker / ui (push) Successful in 4m38s
Release / Gitea Release (push) Successful in 4m46s
- Add homelab/glitchtip/files_api.py with GzipChunk fallback for sentry-cli 3.x
  raw zip uploads; bind-mount into both glitchtip-web and glitchtip-worker
- Add release.yaml prune step to delete all but the 10 newest GlitchTip releases
- Reader page: remove dead code and simplify layout
2026-04-05 17:30:39 +05:00
Admin
a3ad54db70 feat: add Listening Mode overlay with voice picker, speed, sleep timer, and chapter list
Some checks failed
Release / Test backend (push) Has been cancelled
Release / Check ui (push) Has been cancelled
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
Adds a full-screen listening mode accessible via the headphones button in the
mini-player bar. Moves voice selector, speed, auto-next, and sleep timer out of
the reader settings panel into the new overlay. Voices are stored in AudioStore
so ListeningMode can read them without prop drilling.
2026-04-05 17:06:56 +05:00
Admin
48bc206c4e Fix GlitchTip source map assembly: shared uploads volume + MEDIA_ROOT
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m47s
Release / Docker / runner (push) Successful in 2m38s
Release / Upload source maps (push) Successful in 3m20s
Release / Docker / ui (push) Successful in 2m59s
Release / Gitea Release (push) Successful in 1m45s
- Mount glitchtip_uploads named volume to /code/uploads on both
  glitchtip-web and glitchtip-worker so chunk blobs written by the
  web container are readable by the worker during assembly
- Set MEDIA_ROOT=/code/uploads explicitly on both services so the
  Django storage path matches the volume mount and DB blob records
2026-04-05 16:58:56 +05:00
37 changed files with 2601 additions and 755 deletions

View File

@@ -55,6 +55,13 @@ jobs:
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: ui-build
path: ui/build
retention-days: 1
# ── docker: backend ───────────────────────────────────────────────────────────
docker-backend:
name: Docker / backend
@@ -140,46 +147,39 @@ jobs:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Compute release version (strip leading v)
id: ver
run: |
V="${{ gitea.ref_name }}"
echo "version=${V#v}" >> "$GITHUB_OUTPUT"
- name: Build with source maps
run: npm run build
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: ui-build
path: build
- name: Download glitchtip-cli
run: |
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Install sentry-cli
run: npm install -g @sentry/cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
run: sentry-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
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: glitchtip-cli releases new ${{ steps.ver.outputs.version }}
run: sentry-cli releases new ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
@@ -187,7 +187,7 @@ jobs:
SENTRY_PROJECT: ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
@@ -195,13 +195,36 @@ jobs:
SENTRY_PROJECT: ui
- name: Finalize GlitchTip release
run: glitchtip-cli releases finalize ${{ steps.ver.outputs.version }}
run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Prune old GlitchTip releases (keep latest 10)
run: |
set -euo pipefail
KEEP=10
OLD=$(curl -sf \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
| python3 -c "
import sys, json
releases = json.load(sys.stdin)
for r in releases[$KEEP:]:
print(r['version'])
" KEEP=$KEEP)
for ver in $OLD; do
echo "Deleting old release: $ver"
sentry-cli releases delete "$ver" || true
done
env:
SENTRY_URL: https://errors.libnovel.cc
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
@@ -210,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
@@ -239,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

@@ -169,6 +169,11 @@ services:
VALKEY_URL: "redis://valkey:6379/1"
PORT: "8000"
ENABLE_USER_REGISTRATION: "false"
MEDIA_ROOT: "/code/uploads"
volumes:
- glitchtip_uploads:/code/uploads
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
interval: 15s
@@ -190,6 +195,11 @@ services:
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
VALKEY_URL: "redis://valkey:6379/1"
SERVER_ROLE: "worker"
MEDIA_ROOT: "/code/uploads"
volumes:
- glitchtip_uploads:/code/uploads
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
# ── Umami ───────────────────────────────────────────────────────────────────
umami:
@@ -540,3 +550,4 @@ volumes:
grafana_data:
pocket_tts_cache:
hf_cache:
glitchtip_uploads:

View File

@@ -0,0 +1,127 @@
"""Port of sentry.api.endpoints.chunk.ChunkUploadEndpoint"""
import logging
from gzip import GzipFile
from io import BytesIO
from django.conf import settings
from django.shortcuts import aget_object_or_404
from django.urls import reverse
from ninja import File, Router
from ninja.errors import HttpError
from ninja.files import UploadedFile
from apps.organizations_ext.models import Organization
from glitchtip.api.authentication import AuthHttpRequest
from glitchtip.api.decorators import optional_slash
from glitchtip.api.permissions import has_permission
from .models import FileBlob
# Force just one blob
CHUNK_UPLOAD_BLOB_SIZE = 32 * 1024 * 1024 # 32MB
MAX_CHUNKS_PER_REQUEST = 1
MAX_REQUEST_SIZE = CHUNK_UPLOAD_BLOB_SIZE
MAX_CONCURRENCY = 1
HASH_ALGORITHM = "sha1"
CHUNK_UPLOAD_ACCEPT = (
"debug_files", # DIF assemble
"release_files", # Release files assemble
"pdbs", # PDB upload and debug id override
"sources", # Source artifact bundle upload
"artifact_bundles", # Artifact bundles contain debug ids to link source to sourcemaps
"proguard",
)
class GzipChunk(BytesIO):
def __init__(self, file):
raw = file.read()
try:
data = GzipFile(fileobj=BytesIO(raw), mode="rb").read()
except Exception:
# sentry-cli 3.x sends raw (uncompressed) zip data despite gzip being
# advertised by the server — fall back to using the raw bytes as-is.
data = raw
self.size = len(data)
self.name = file.name
super().__init__(data)
router = Router()
@optional_slash(router, "get", "organizations/{slug:organization_slug}/chunk-upload/")
async def get_chunk_upload_info(request: AuthHttpRequest, organization_slug: str):
"""Get server settings for chunk file upload"""
path = reverse("api:get_chunk_upload_info", args=[organization_slug])
url = (
path
if settings.GLITCHTIP_CHUNK_UPLOAD_USE_RELATIVE_URL
else settings.GLITCHTIP_URL.geturl() + path
)
return {
"url": url,
"chunkSize": CHUNK_UPLOAD_BLOB_SIZE,
"chunksPerRequest": MAX_CHUNKS_PER_REQUEST,
"maxFileSize": 2147483648,
"maxRequestSize": MAX_REQUEST_SIZE,
"concurrency": MAX_CONCURRENCY,
"hashAlgorithm": HASH_ALGORITHM,
"compression": ["gzip"],
"accept": CHUNK_UPLOAD_ACCEPT,
}
@optional_slash(router, "post", "organizations/{slug:organization_slug}/chunk-upload/")
@has_permission(["project:write", "project:admin", "project:releases"])
async def chunk_upload(
request: AuthHttpRequest,
organization_slug: str,
file_gzip: list[UploadedFile] = File(...),
):
"""Upload one more more gzipped files to save"""
logger = logging.getLogger("glitchtip.files")
logger.info("chunkupload.start")
organization = await aget_object_or_404(
Organization, slug=organization_slug.lower(), users=request.auth.user_id
)
files = [GzipChunk(chunk) for chunk in file_gzip]
if len(files) == 0:
# No files uploaded is ok
logger.info("chunkupload.end", extra={"status": 200})
return
logger.info("chunkupload.post.files", extra={"len": len(files)})
# Validate file size
checksums = []
size = 0
for chunk in files:
size += chunk.size
if chunk.size > CHUNK_UPLOAD_BLOB_SIZE:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Chunk size too large")
checksums.append(chunk.name)
if size > MAX_REQUEST_SIZE:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Request too large")
if len(files) > MAX_CHUNKS_PER_REQUEST:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Too many chunks")
try:
await FileBlob.from_files(
zip(files, checksums), organization=organization, logger=logger
)
except IOError as err:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, str(err)) from err
logger.info("chunkupload.end", extra={"status": 200})

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

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "AI Jobs",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tâches IA",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tugas AI",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Tarefas de IA",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",

View File

@@ -403,6 +403,7 @@
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_ai_jobs": "Задачи ИИ",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",

View File

@@ -32,6 +32,8 @@
* It only runs once per chapter (guarded by nextStatus !== 'none').
*/
import type { Voice } from '$lib/types';
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
@@ -50,6 +52,9 @@ class AudioStore {
/** Full chapter list for the currently loaded book (number + title). */
chapters = $state<{ number: number; title: string }[]>([]);
/** Available voices (populated by the chapter AudioPlayer on mount). */
voices = $state<Voice[]>([]);
// ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle');
audioUrl = $state('');
@@ -57,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

@@ -226,6 +226,11 @@
audioStore.nextChapter = nextChapter ?? null;
});
// Keep voices in store up to date whenever prop changes.
$effect(() => {
if (voices.length > 0) audioStore.voices = voices;
});
// Auto-start: if the layout navigated here via auto-next, kick off playback.
// We match against the chapter prop so the outgoing chapter's AudioPlayer
// (still mounted during the brief navigation window) never reacts to this.
@@ -530,6 +535,7 @@
audioStore.bookTitle = bookTitle;
audioStore.cover = cover;
audioStore.chapters = chapters;
if (voices.length > 0) audioStore.voices = voices;
// Update OS media session (lock screen / notification center).
setMediaSession();
@@ -569,11 +575,14 @@
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
// use the streaming endpoint — audio starts playing within seconds while
// generation runs and MinIO is populated concurrently.
// Skip when enqueued=true to avoid double-generation with the async runner.
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
// starts playing within seconds. The stream handler checks MinIO first
// (fast redirect if already cached) and otherwise generates + uploads
// concurrently. Even if the async runner is already working on this
// chapter, the stream will redirect to MinIO the moment the runner
// finishes — no harmful double-generation occurs because the backend
// deduplications via AudioExists on the next request.
if (!voice.startsWith('cfai:')) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
@@ -594,13 +603,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',
@@ -609,7 +622,6 @@
});
if (res.status === 402) {
// Free daily limit reached — surface upgrade CTA
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
@@ -619,37 +631,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;
@@ -1102,72 +1173,7 @@
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
<!-- Auto-next toggle (keep here as useful context) -->
{#if nextChapter !== null && nextChapter !== undefined}
<Button
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
aria-pressed={audioStore.autoNext}
>
<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>
{m.reader_auto_next()}
</Button>
{/if}
<!-- Sleep timer -->
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<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>
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep
{/if}
</Button>
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-(--color-muted) flex items-center gap-1">
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{m.reader_ch_ready({ n: String(nextChapter) })}
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
{/if}
</div>
{/if}
{/if}
{:else if audioStore.active}

View File

@@ -241,311 +241,321 @@
const totalCount = $derived(
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
);
// ── Collapsed state ───────────────────────────────────────────────────────
// Hidden by default when there are no comments; expand on user tap.
let expanded = $state(false);
const hasComments = $derived(!loading && comments.length > 0);
// Auto-expand once comments load in
$effect(() => {
if (hasComments) expanded = true;
});
</script>
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-(--color-text)">
{#if !expanded && !hasComments && !loading}
<!-- Collapsed: just a subtle link — no wasted real-estate for empty chapters -->
<button
type="button"
onclick={() => (expanded = true)}
class="flex items-center gap-1.5 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"/>
</svg>
{m.comments_heading()}
{#if !loading && totalCount > 0}
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')}
>{m.comments_top()}</Button>
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')}
>{m.comments_new()}</Button>
</div>
{/if}
</div>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<Textarea
bind:value={newBody}
placeholder={m.comments_placeholder()}
rows={3}
/>
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="default"
size="sm"
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? m.comments_posting() : m.comments_submit()}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
{m.comments_login_suffix()}
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-(--color-danger)">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-(--color-muted)">{m.comments_empty()}</p>
</button>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<!-- Expanded: full comments section -->
<div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-(--color-text)">
{m.comments_heading()}
{#if !loading && totalCount > 0}
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title={m.comments_vote_up()}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<!-- Downvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title={m.comments_vote_down()}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
<!-- Reply button -->
{#if isLoggedIn}
</h2>
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
{m.comments_reply()}
</Button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')}
>{m.comments_top()}</Button>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
{m.comments_delete()}
</Button>
{/if}
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')}
>{m.comments_new()}</Button>
</div>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<Textarea
bind:value={replyBody}
bind:value={newBody}
placeholder={m.comments_placeholder()}
rows={2}
rows={3}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="ghost"
variant="default"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>{m.common_cancel()}</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? m.comments_posting() : m.comments_reply()}
</Button>
</div>
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? m.comments_posting() : m.comments_submit()}
</Button>
</div>
</div>
{/if}
</div>
{:else}
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
{m.comments_login_suffix()}
</p>
{/if}
</div>
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-(--color-danger)">{loadError}</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
<div class={cn('rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2', deleting && 'opacity-50')}>
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Reply body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title={m.comments_vote_up()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title={m.comments_vote_down()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
{#if isLoggedIn}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null; replyBody = ''; replyError = '';
} else {
replyingTo = comment.id; replyBody = ''; replyError = '';
}
}}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
{m.comments_delete()}
</Button>
{/if}
</div>
</div>
{/each}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
{m.comments_reply()}
</Button>
{/if}
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
{m.comments_delete()}
</Button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<Textarea
bind:value={replyBody}
placeholder={m.comments_placeholder()}
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>{m.common_cancel()}</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? m.comments_posting() : m.comments_reply()}
</Button>
</div>
</div>
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class={cn('rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5', replyDeleting && 'opacity-50')}>
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
</div>
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title={m.comments_vote_up()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title={m.comments_vote_down()}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
{m.comments_delete()}
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,559 @@
<script lang="ts">
import { audioStore } from '$lib/audio.svelte';
import { cn } from '$lib/utils';
import { goto } from '$app/navigation';
import type { Voice } from '$lib/types';
interface Props {
/** Called when the user closes the overlay. */
onclose: () => void;
}
let { onclose }: Props = $props();
// Voices come from the store (populated by AudioPlayer on mount/play)
const voices = $derived(audioStore.voices);
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
let showVoiceModal = $state(false);
let voiceSearch = $state('');
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
// ── Voice search filtering ────────────────────────────────────────────────
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
const filteredPocket = $derived(pocketVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
// ── Chapter search ────────────────────────────────────────────────────────
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 voiceLabel(v: Voice | string): string {
if (typeof v === 'string') {
const found = voices.find((x) => x.id === v);
if (found) return voiceLabel(found);
const id = v as string;
return id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
const base = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return v.lang !== 'en-us' ? `${base} (${v.lang})` : base;
}
function stopSample() {
if (sampleAudio) {
sampleAudio.pause();
sampleAudio.src = '';
sampleAudio = null;
}
samplePlayingVoice = null;
}
async function playSample(voiceId: string) {
if (samplePlayingVoice === voiceId) { stopSample(); return; }
stopSample();
samplePlayingVoice = voiceId;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
if (!res.ok) { samplePlayingVoice = null; return; }
const { url } = (await res.json()) as { url: string };
sampleAudio = new Audio(url);
sampleAudio.onended = () => stopSample();
sampleAudio.onerror = () => stopSample();
sampleAudio.play().catch(() => stopSample());
} catch {
samplePlayingVoice = null;
}
}
function selectVoice(voiceId: string) {
stopSample();
audioStore.voice = voiceId;
showVoiceModal = false;
voiceSearch = '';
}
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
// ── Speed ────────────────────────────────────────────────────────────────
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
// ── Sleep timer ──────────────────────────────────────────────────────────
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
let sleepRemainingSec = $derived.by(() => {
void audioStore.currentTime; // re-run every second while playing
if (!audioStore.sleepUntil) return 0;
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
});
function cycleSleepTimer() {
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
} else if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
} else {
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
}
function formatSleepRemaining(secs: number): string {
if (secs <= 0) return 'Off';
const m = Math.floor(secs / 60);
const s = secs % 60;
return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ''}` : `${s}s`;
}
const sleepLabel = $derived(
audioStore.sleepAfterChapter
? 'End Ch.'
: audioStore.sleepUntil > Date.now()
? formatSleepRemaining(sleepRemainingSec)
: 'Sleep'
);
// ── Format time ──────────────────────────────────────────────────────────
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── Playback controls ────────────────────────────────────────────────────
function seek(e: Event) {
audioStore.seekRequest = Number((e.currentTarget as HTMLInputElement).value);
}
function skipBack() {
audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15);
}
function skipForward() {
audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30);
}
function togglePlay() {
audioStore.toggleRequest++;
}
// Close on Escape
$effect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
else { onclose(); }
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="background: var(--color-surface);"
>
<!-- Blurred cover background -->
{#if audioStore.cover}
<div
class="absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
style="background-image: url('{audioStore.cover}');"
aria-hidden="true"
></div>
{/if}
<!-- Header bar -->
<div class="relative flex items-center justify-between px-4 py-3 shrink-0">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close listening mode"
>
<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">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>
<!-- Voice modal (full-screen overlay) -->
{#if showVoiceModal && voices.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={() => { stopSample(); showVoiceModal = false; voiceSearch = ''; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close voice 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">Select Voice</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 voices…"
bind:value={voiceSearch}
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>
<!-- Voice list -->
<div class="flex-1 overflow-y-auto">
{#each ([['Kokoro', filteredKokoro], ['Pocket TTS', filteredPocket], ['CF AI', filteredCfai]] as [string, Voice[]][]) as [label, group]}
{#if group.length > 0}
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2 sticky top-0 bg-(--color-surface) border-b border-(--color-border)/50">{label}</p>
{#each group as v (v.id)}
<div
class={cn(
'flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors',
audioStore.voice === v.id
? 'bg-(--color-brand)/8'
: 'hover:bg-(--color-surface-2)'
)}
>
<!-- Select voice -->
<button
type="button"
onclick={() => selectVoice(v.id)}
class="flex-1 flex items-center gap-3 text-left"
>
<!-- Selected indicator -->
<span class={cn(
'w-4 h-4 shrink-0 rounded-full border-2 flex items-center justify-center transition-colors',
audioStore.voice === v.id
? 'border-(--color-brand) bg-(--color-brand)'
: 'border-(--color-border)'
)}>
{#if audioStore.voice === v.id}
<svg class="w-2 h-2 text-(--color-surface)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</span>
<span class={cn(
'text-sm',
audioStore.voice === v.id ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{voiceLabel(v)}</span>
</button>
<!-- Sample play button -->
<button
type="button"
onclick={() => playSample(v.id)}
class={cn(
'shrink-0 p-2 rounded-full transition-colors',
samplePlayingVoice === v.id
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
>
{#if samplePlayingVoice === v.id}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
</div>
{/each}
{/if}
{/each}
{#if filteredKokoro.length === 0 && filteredPocket.length === 0 && filteredCfai.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No voices match "{voiceSearch}"</p>
{/if}
</div>
</div>
{/if}
<!-- Scrollable body -->
<div class="relative flex-1 overflow-y-auto flex flex-col">
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-40 h-56 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)">
<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>
</div>
{/if}
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
</p>
<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>
<!-- 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}
</div>
<div class="overflow-y-auto max-h-64">
{#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-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)'
)}
>
<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>
{/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>
</div>
{/if}
</div>
</div>

View File

@@ -374,6 +374,7 @@ export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_ai_jobs.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Ai_JobsInputs */
const en_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`AI Jobs`)
};
const ru_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Задачи ИИ`)
};
const id_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tugas AI`)
};
const pt_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tarefas de IA`)
};
const fr_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tâches IA`)
};
/**
* | output |
* | --- |
* | "AI Jobs" |
*
* @param {Admin_Nav_Ai_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_ai_jobs = /** @type {((inputs?: Admin_Nav_Ai_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Ai_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_ai_jobs(inputs)
if (locale === "ru") return ru_admin_nav_ai_jobs(inputs)
if (locale === "id") return id_admin_nav_ai_jobs(inputs)
if (locale === "pt") return pt_admin_nav_ai_jobs(inputs)
return fr_admin_nav_ai_jobs(inputs)
});

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

@@ -14,6 +14,23 @@ const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AIJob {
id: string;
kind: string;
slug: string;
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
from_item: number;
to_item: number;
items_done: number;
items_total: number;
model: string;
payload: string;
error_message?: string;
started?: string;
finished?: string;
heartbeat_at?: string;
}
export interface Book {
id: string;
slug: string;
@@ -83,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 });
@@ -102,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;
}
@@ -115,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}`);
@@ -2162,3 +2193,13 @@ export async function getUserStats(
streak
};
}
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
/**
* List all AI jobs from PocketBase, sorted by started descending.
* No caching — admin views always want fresh data.
*/
export async function listAIJobs(): Promise<AIJob[]> {
return listAll<AIJob>('ai_jobs', '', '-started');
}

View File

@@ -12,6 +12,7 @@
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import * as cache from '$lib/server/cache';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
@@ -33,6 +34,50 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
// ─── Cached admin model lists ─────────────────────────────────────────────────
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change
/**
* Fetch image-gen model list from the Go backend with a 10-minute cache.
* Returns an empty array on error (callers should surface a warning).
*/
export async function listImageModels<T>(): Promise<T[]> {
const key = 'backend:models:image-gen';
const cached = await cache.get<T[]>(key);
if (cached) return cached;
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) return [];
const data = await res.json();
const models = (data.models ?? []) as T[];
await cache.set(key, models, MODELS_CACHE_TTL);
return models;
} catch {
return [];
}
}
/**
* Fetch text-gen model list from the Go backend with a 10-minute cache.
* Returns an empty array on error.
*/
export async function listTextModels<T>(): Promise<T[]> {
const key = 'backend:models:text-gen';
const cached = await cache.get<T[]>(key);
if (cached) return cached;
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) return [];
const data = await res.json();
const models = (data.models ?? []) as T[];
await cache.set(key, models, MODELS_CACHE_TTL);
return models;
} catch {
return [];
}
}
// ─── Response types ───────────────────────────────────────────────────────────
/**

View File

@@ -11,6 +11,7 @@
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -34,6 +35,11 @@
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
let activeChapterEl = $state<HTMLElement | null>(null);
let listeningModeOpen = $state(false);
// 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('');
function setIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) activeChapterEl = node;
@@ -49,6 +55,12 @@
}
});
$effect(() => {
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
}
});
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null);
@@ -287,7 +299,7 @@
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
const speedSteps = [0.75, 1.0, 1.25, 1.5, 2.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
@@ -748,10 +760,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}
@@ -855,19 +866,16 @@
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">
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
{formatTime(audioStore.currentTime)} / {formatTime(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>
@@ -920,46 +928,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">
@@ -993,6 +961,21 @@
</a>
{/if}
<!-- Headphones: open listening mode -->
<Button
variant="ghost"
size="icon"
onclick={() => (listeningModeOpen = true)}
title="Listening mode"
aria-label="Open listening mode"
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
>
<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="M3 18v-6a9 9 0 0118 0v6"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/>
</svg>
</Button>
<!-- Dismiss -->
<Button
variant="ghost"
@@ -1008,4 +991,9 @@
</Button>
</div>
</div>
<!-- Listening mode full-screen overlay -->
{#if listeningModeOpen}
<ListeningMode onclose={() => (listeningModeOpen = false)} />
{/if}
{/if}

View File

@@ -9,7 +9,8 @@
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() },
{ href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() }
];
const externalLinks = [

View File

@@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { listAIJobs, type AIJob } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export type { AIJob };
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const jobs = await listAIJobs().catch((e): AIJob[] => {
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
return [];
});
return { jobs };
};

View File

@@ -0,0 +1,529 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
import { cn } from '$lib/utils';
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
// Keep in sync on server reloads
$effect(() => {
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
}, 3000);
return () => clearInterval(id);
});
// ── Filter / search ───────────────────────────────────────────────────────────
let query = $state('');
let statusFilter = $state<string>('all');
const STATUS_OPTIONS = ['all', 'pending', 'running', 'done', 'failed', 'cancelled'] as const;
let filteredJobs = $derived(
jobs.filter((j) => {
const q = query.trim().toLowerCase();
const matchesQ =
!q ||
j.slug.toLowerCase().includes(q) ||
j.kind.toLowerCase().includes(q) ||
j.model.toLowerCase().includes(q) ||
(j.error_message ?? '').toLowerCase().includes(q);
const matchesStatus = statusFilter === 'all' || j.status === statusFilter;
return matchesQ && matchesStatus;
})
);
// ── Stats ────────────────────────────────────────────────────────────────────
let stats = $derived({
total: jobs.length,
running: jobs.filter((j) => j.status === 'running').length,
pending: jobs.filter((j) => j.status === 'pending').length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length
});
// ── Cancel ────────────────────────────────────────────────────────────────────
let cancellingId = $state<string | null>(null);
let cancelError = $state('');
async function cancelJob(id: string) {
if (cancellingId) return;
cancellingId = id;
cancelError = '';
try {
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelError = body.error ?? `Error ${res.status}`;
} else {
await invalidateAll();
}
} catch {
cancelError = 'Network error.';
} finally {
cancellingId = null;
}
}
// ── 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';
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-(--color-text)';
}
function statusBg(status: string) {
if (status === 'done') return 'bg-green-400/10 text-green-400';
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
if (status === 'pending') return 'bg-sky-400/10 text-sky-400';
if (status === 'failed') return 'bg-(--color-danger)/10 text-(--color-danger)';
if (status === 'cancelled') return 'bg-zinc-700/50 text-(--color-muted)';
return 'bg-zinc-700/50 text-(--color-text)';
}
function kindLabel(kind: string) {
const labels: Record<string, string> = {
'chapter-names': 'Chapter Names',
'batch-covers': 'Batch Covers',
'chapter-covers': 'Chapter Covers',
'refresh-metadata': 'Refresh Metadata'
};
return labels[kind] ?? kind;
}
function fmtDate(s: string | undefined) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function duration(started: string | undefined, finished: string | undefined) {
if (!started || !finished) return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
function progress(job: AIJob) {
if (!job.items_total) return null;
return Math.round((job.items_done / job.items_total) * 100);
}
</script>
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
</div>
<button
onclick={() => invalidateAll()}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
<!-- Stats bar -->
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
{#each [
{ label: 'Total', value: stats.total, color: 'text-(--color-text)' },
{ label: 'Running', value: stats.running, color: 'text-(--color-brand)' },
{ label: 'Pending', value: stats.pending, color: 'text-sky-400' },
{ label: 'Done', value: stats.done, color: 'text-green-400' },
{ label: 'Failed', value: stats.failed, color: 'text-(--color-danger)' }
] as stat}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-4 py-3">
<p class="text-xs text-(--color-muted) mb-1">{stat.label}</p>
<p class={cn('text-xl font-bold tabular-nums', stat.color)}>{stat.value}</p>
</div>
{/each}
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 items-center">
<input
type="search"
placeholder="Search slug, kind, model…"
bind:value={query}
class="flex-1 min-w-48 px-3 py-1.5 rounded-md bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
<div class="flex gap-1 flex-wrap">
{#each STATUS_OPTIONS as s}
<button
onclick={() => (statusFilter = s)}
class={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize',
statusFilter === s
? 'bg-(--color-brand) text-black'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
{s}
</button>
{/each}
</div>
</div>
<!-- Cancel error -->
{#if cancelError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
{/if}
<!-- Jobs table -->
{#if filteredJobs.length === 0}
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
<p class="text-(--color-muted) text-sm">
{jobs.length === 0 ? 'No AI jobs found.' : 'No jobs match your filters.'}
</p>
</div>
{:else}
<div class="rounded-lg border border-(--color-border) overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Status</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Kind</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Slug</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden sm:table-cell">Model</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden md:table-cell">Progress</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Started</th>
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Duration</th>
<th class="px-4 py-2.5 text-right text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)">
{#each filteredJobs as job (job.id)}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
<!-- Status badge -->
<td class="px-4 py-3">
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
{job.status}
</span>
</td>
<!-- Kind -->
<td class="px-4 py-3 font-medium text-(--color-text)">
{kindLabel(job.kind)}
</td>
<!-- Slug -->
<td class="px-4 py-3 max-w-[12rem]">
{#if job.slug}
<a
href="/admin/image-gen?slug={job.slug}"
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
>
{job.slug}
</a>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
<!-- Model -->
<td class="px-4 py-3 hidden sm:table-cell">
<span class="font-mono text-xs text-(--color-muted) truncate block max-w-[10rem]" title={job.model}>
{job.model || '—'}
</span>
</td>
<!-- Progress -->
<td class="px-4 py-3 hidden md:table-cell">
{#if job.items_total > 0}
{@const pct = progress(job)}
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 rounded-full bg-(--color-border) overflow-hidden">
<div
class={cn(
'h-full rounded-full transition-all',
job.status === 'done' ? 'bg-green-400' :
job.status === 'failed' ? 'bg-(--color-danger)' :
'bg-(--color-brand)'
)}
style="width: {pct}%"
></div>
</div>
<span class="text-xs text-(--color-muted) tabular-nums whitespace-nowrap">
{job.items_done}/{job.items_total}
</span>
</div>
{:else}
<span class="text-(--color-muted) text-xs"></span>
{/if}
</td>
<!-- Started -->
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-xs text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</span>
</td>
<!-- Duration -->
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-xs text-(--color-muted) whitespace-nowrap tabular-nums">
{duration(job.started, job.finished)}
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
{#if job.status === 'pending' || job.status === 'running'}
<button
onclick={() => cancelJob(job.id)}
disabled={cancellingId === job.id}
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
>
{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"
title={job.error_message}
>
{job.error_message}
</span>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="text-xs text-(--color-muted)">
Showing {filteredJobs.length} of {jobs.length} jobs
</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

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { listImageModels } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
@@ -12,13 +12,10 @@ export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
let imgModels: ImageModelInfo[] = [];
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (res.ok) {
const data = await res.json();
imgModels = (data.models ?? []) as ImageModelInfo[];
}
imgModels = await listImageModels<ImageModelInfo>();
} catch (e) {
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
}
return { imgModels };
};

View File

@@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import * as cache from '$lib/server/cache';
export interface Release {
id: number;
@@ -13,7 +14,15 @@ export interface Release {
const GITEA_RELEASES_URL =
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached };
}
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
@@ -22,7 +31,9 @@ export const load: PageServerLoad = async ({ fetch }) => {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
const releases: Release[] = await res.json();
return { releases: releases.filter((r) => !r.draft) };
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}

View File

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

View File

@@ -355,7 +355,13 @@
const body = await res.json().catch(() => ({}));
if (!res.ok) {
genError = body.error ?? body.message ?? `Error ${res.status}`;
if (res.status === 502 || res.status === 504) {
genError =
body.error ??
`Generation timed out (${res.status}). FLUX models can take 60120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
} else {
genError = body.error ?? body.message ?? `Error ${res.status}`;
}
return;
}
@@ -754,6 +760,13 @@
{#if showAdvanced}
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
<!-- Cloudflare AI timeout warning -->
{#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')}
<p class="text-xs text-amber-400/80 bg-amber-400/10 rounded px-2.5 py-1.5">
Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits.
</p>
{/if}
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">

View File

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

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,29 @@
/**
* POST /api/admin/ai-jobs/[id]/cancel
*
* Admin-only proxy to the Go backend's AI job cancel endpoint.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { id } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
} catch (e) {
log.error('admin/ai-jobs/cancel', '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

@@ -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';
@@ -835,13 +835,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 +1144,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

@@ -22,14 +22,6 @@
let settingsPanelOpen = $state(false);
let settingsTab = $state<'reading' | 'listening'>('reading');
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
] as const;
const READER_FONTS = [
{ id: 'system', label: 'System' },
{ id: 'serif', label: 'Serif' },
@@ -43,14 +35,9 @@
] as const;
// Mirror context values into local reactive state so the panel shows current values
let panelTheme = $state(settingsCtx?.current ?? 'amber');
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
function applyTheme(id: string) {
panelTheme = id;
if (settingsCtx) settingsCtx.current = id;
}
function applyFont(id: string) {
panelFont = id;
if (settingsCtx) settingsCtx.fontFamily = id;
@@ -97,34 +84,6 @@
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
}
// ── Listening settings helpers ───────────────────────────────────────────────
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
const sleepSettingsLabel = $derived(
audioStore.sleepAfterChapter
? 'End Ch.'
: audioStore.sleepUntil > Date.now()
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
: 'Off'
);
function toggleSleepFromSettings() {
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
} else if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
} else {
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
}
// Apply reading CSS vars whenever layout changes
$effect(() => {
if (!browser) return;
@@ -327,22 +286,36 @@
const wordCount = $derived(
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
);
// Audio panel: auto-open if this chapter is already loaded/playing in the store
// svelte-ignore state_referenced_locally
let audioExpanded = $state(
audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number
);
$effect(() => {
// Expand automatically when the store starts playing this chapter
if (audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying) {
audioExpanded = true;
}
});
</script>
<svelte:head>
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}{data.book.title} — libnovel</title>
</svelte:head>
<!-- Reading progress bar (scroll mode) -->
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
{#if layout.readMode === 'scroll'}
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
{/if}
<!-- Top nav -->
<div class="flex items-center justify-between mb-6 gap-4">
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
{#if !layout.focusMode}
<div class="flex items-center justify-between mb-8 gap-2">
<!-- Left: back to chapters list -->
<a
href="/books/{data.book.slug}/chapters"
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-text) text-sm transition-colors shrink-0"
>
<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" />
@@ -350,147 +323,187 @@
{m.reader_back_to_chapters()}
</a>
<div class="flex gap-2">
<!-- Right: prev/next chapter arrows + settings -->
<div class="flex items-center gap-1">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
title="{m.reader_chapter_n({ n: String(data.prev) })}"
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_chapter_n({ n: String(data.prev) })}
<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>
</a>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
title="{m.reader_chapter_n({ n: String(data.next) })}"
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_chapter_n({ n: String(data.next) })} &rarr;
<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>
</a>
{/if}
{#if settingsCtx}
<button
type="button"
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="p-2 rounded-lg transition-colors hover:bg-(--color-surface-2) {settingsPanelOpen ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Chapter heading -->
<div class="mb-4">
<h1 class="text-xl font-bold text-(--color-text)">
<!-- Chapter heading + meta + language switcher -->
<div class="mb-6">
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
</h1>
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs mt-1">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-50 mx-1">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min read
</p>
{/if}
</div>
<!-- Language switcher (not shown for preview chapters or focus mode) -->
{#if !data.isPreview && !layout.focusMode}
<div class="flex items-center gap-2 mb-6 flex-wrap">
<span class="text-(--color-muted) text-xs">Read in:</span>
<!-- English (original) -->
<a
href={langUrl('')}
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
EN
</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if !data.isPro}
<!-- Locked for free users -->
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<!-- Spinning indicator while translating -->
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{label}
</span>
{:else if currentLang() === code}
<!-- Active translated lang -->
<a
href={langUrl(code)}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
>{label}</a>
{:else}
<!-- Inactive lang: click to request/navigate -->
<button
onclick={() => requestTranslation(code)}
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>{label}</button>
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-40 mx-0.5">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min
</p>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
{/if}
<!-- Language switcher (inline, compact) -->
{#if !data.isPreview}
<div class="flex items-center gap-1">
<a
href={langUrl('')}
class="px-2 py-0.5 rounded text-xs font-medium transition-colors
{currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>EN</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if !data.isPro}
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)/50 cursor-pointer"
>
<svg class="w-2.5 h-2.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<span class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-2.5 h-2.5 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>
{label}
</span>
{:else if currentLang() === code}
<a href={langUrl(code)} class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)">{label}</a>
{:else}
<button
type="button"
onclick={() => requestTranslation(code)}
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>{label}</button>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-0.5">Pro</a>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<!-- Audio player (hidden in focus mode) -->
<!-- ── Audio section (collapsible, hidden in focus/preview mode) ──────────── -->
{#if !data.isPreview && !layout.focusMode}
{#if !page.data.user}
<!-- Unauthenticated: sign-in prompt -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
{#if !page.data.user}
<!-- Unauthenticated -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
</div>
<a href="/login" class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
{m.nav_sign_in()}
</a>
</div>
<a
href="/login"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
{m.nav_sign_in()}
</a>
</div>
{:else if audioProRequired}
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
{:else if audioProRequired}
<div class="mb-6 px-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Upgrade to Pro for unlimited audio.</p>
</div>
<a href="/profile" class="shrink-0 px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
Upgrade
</a>
</div>
<a
href="/profile"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Upgrade
</a>
{:else}
<!-- Collapsible audio panel -->
<div class="mb-6">
<button
type="button"
onclick={() => { audioExpanded = !audioExpanded; }}
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) transition-colors hover:border-(--color-brand)/40
{audioExpanded ? 'rounded-t-lg' : 'rounded-lg'}"
>
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a8 8 0 010-12.728M8.464 15.536a5 5 0 010-7.072" />
</svg>
<span class="font-medium">Listen to this chapter</span>
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying}
<span class="text-xs text-(--color-brand)">● Playing</span>
{/if}
</span>
<svg class="w-4 h-4 text-(--color-muted) transition-transform duration-200 {audioExpanded ? '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 audioExpanded}
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
{#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={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}
</div>
{/if}
</div>
{/if}
{:else if data.isPreview}
<div class="mb-6 px-4 py-2 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
</div>
{:else}
<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}
{:else}
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
</div>
{/if}
<!-- Chapter content -->
<!-- ── Chapter content ───────────────────────────────────────────────────── -->
{#if fetchingContent}
<div class="flex flex-col items-center gap-3 py-16 text-(--color-muted) text-sm">
<svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
@@ -504,7 +517,7 @@
<p>{fetchError || m.reader_audio_error()}</p>
</div>
{:else if layout.readMode === 'paginated'}
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
<!-- ── Paginated reader ───────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
@@ -535,11 +548,9 @@
</svg>
Prev
</button>
<span class="text-sm text-(--color-muted) tabular-nums">
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</span>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
@@ -552,65 +563,99 @@
</svg>
</button>
</div>
<!-- Tap hint -->
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
{:else}
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
{@html html}
</div>
{/if}
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
{#if !(layout.focusMode && layout.readMode === 'paginated')}
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
{#if !layout.focusMode}
<div class="mt-14 pt-6 border-t border-(--color-border)">
<!-- Next chapter: prominent full-width CTA -->
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="group flex items-center justify-between px-5 py-4 rounded-xl bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-colors"
>
<div>
<p class="text-xs text-(--color-muted) mb-0.5">{m.reader_next_chapter()}</p>
<p class="text-(--color-text) font-semibold group-hover:text-(--color-brand) transition-colors">
{m.reader_chapter_n({ n: String(data.next) })}
</p>
</div>
<svg class="w-5 h-5 text-(--color-muted) group-hover:text-(--color-brand) group-hover:translate-x-0.5 transition-all" 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>
</a>
{/if}
<!-- Previous chapter: small secondary link -->
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="mt-3 inline-flex items-center gap-1 text-sm 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="M15 19l-7-7 7-7" />
</svg>
{m.reader_prev_chapter()}
</a>
{/if}
</div>
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>
{/if}
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
{#if layout.focusMode}
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
>
&larr; {m.reader_prev_chapter()}
<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="M15 19l-7-7 7-7"/>
</svg>
{m.reader_chapter_n({ n: String(data.prev) })}
</a>
{:else}
<span></span>
{/if}
<button
type="button"
onclick={() => setLayout('focusMode', false)}
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
aria-label="Exit focus mode"
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
Exit focus
</button>
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
>
{m.reader_next_chapter()} &rarr;
{m.reader_chapter_n({ n: String(data.next) })}
<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="M9 5l7 7-7 7"/>
</svg>
</a>
{/if}
</div>
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>
{/if}
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
{#if settingsCtx}
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
<button
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
>
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<!-- Bottom sheet -->
{#if settingsPanelOpen}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -648,33 +693,11 @@
{#if settingsTab === 'reading'}
<!-- ── Typography group ──────────────────────────────────────── -->
<!-- ── Typography ──────────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Theme -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
<div class="flex flex-wrap gap-1.5 flex-1">
{#each READER_THEMES as t}
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
>
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Font -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
<div class="flex gap-1.5 flex-1">
@@ -691,7 +714,6 @@
</div>
</div>
<!-- Size -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
<div class="flex gap-1.5 flex-1">
@@ -711,12 +733,11 @@
</div>
</div>
<!-- ── Layout group ──────────────────────────────────────────── -->
<!-- ── Layout ──────────────────────────────────────────────────── -->
<div class="mt-4 mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Read mode -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
<div class="flex gap-1.5 flex-1">
@@ -734,7 +755,6 @@
</div>
</div>
<!-- Line spacing -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">
@@ -752,7 +772,6 @@
</div>
</div>
<!-- Width -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
<div class="flex gap-1.5 flex-1">
@@ -770,7 +789,6 @@
</div>
</div>
<!-- Paragraphs -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
<div class="flex gap-1.5 flex-1">
@@ -788,7 +806,6 @@
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
@@ -805,12 +822,11 @@
{:else}
<!-- ── Listening tab ──────────────────────────────────────────── -->
<!-- ── Listening tab ──────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Player style -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">
@@ -828,51 +844,6 @@
</div>
</div>
{#if page.data.user}
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
<div class="flex gap-1 flex-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={audioStore.autoNext}
>
<span>Auto-next chapter</span>
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
>
<span>Sleep timer</span>
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
</button>
{/if}
</div>
</div>