Compare commits

...

18 Commits

Author SHA1 Message Date
root
12963342bb fix: update votedBooks state immediately on swipe so history drawer isn't empty
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m35s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / runner (push) Successful in 2m42s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 29s
doAction() was fire-and-forgetting the POST but never updating the client-side
votedBooks array. History was only populated from SSR data.votedBooks (loaded
at page init), so any votes cast during the current session were invisible in
the drawer until a full page reload. Now we prepend/replace an entry in
votedBooks optimistically the moment a swipe action fires.
2026-04-06 21:45:29 +05:00
root
bdbec3ae16 feat: redesign discover page with bigger cards and prominent action buttons
All checks were successful
Release / Test backend (push) Successful in 1m12s
Release / Check ui (push) Successful in 2m35s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m38s
Release / Docker / runner (push) Successful in 2m35s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 2m10s
Release / Gitea Release (push) Successful in 30s
- Remove tab switcher, move history behind a modal drawer (clock icon in header with badge count)
- Increase card aspect ratio from 3/4.2 to 3/4.6 for more cover real estate
- Replace 5 small icon-only buttons with 3 large labeled buttons (Skip / Read Now / Like)
- Read Now is solid blue as the center primary CTA; Skip and Like use tinted bg with colored border
- Swipe indicators are larger (text-2xl, border-[3px], bg tint) for better visibility
- Remove swipe hint text to reclaim vertical space
- Larger title text on card (text-2xl)
2026-04-06 21:36:28 +05:00
root
c98d43a503 chore: add announce_chapter field to user_settings pb-init script
- Added announce_chapter (bool) to the user_settings create block
- Added add_field migration line for existing installs
- Also backfilled missing user_settings fields in the create block
  (theme, locale, font_family, font_size were already migrated but
  absent from the create definition)
- Migrated live prod PocketBase (pb.libnovel.cc) — field confirmed present
2026-04-06 21:18:59 +05:00
root
1f83a7c05f feat: add chapter announcing setting to audio player
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 1m51s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m24s
Release / Docker / runner (push) Successful in 2m49s
Release / Upload source maps (push) Successful in 1m25s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 30s
When enabled, the Web Speech API speaks the upcoming chapter number and
title (e.g. 'Chapter 12 — The Final Battle') between auto-next chapters,
giving an audible cue before the next narration begins.

- AudioStore.announceChapter ( boolean, default false)
- PBUserSettings.announce_chapter persisted to PocketBase
- GET/PUT /api/settings includes announceChapter field
- +layout.server.ts loads + defaults the field
- +layout.svelte applies on load, saves in debounced PUT, and fires
  SpeechSynthesisUtterance in onended before navigating (falls back to
  immediate navigation if speechSynthesis is unavailable)
- ListeningMode: 'Announce' pill added to the Speed · Auto · Sleep row
2026-04-06 21:12:10 +05:00
root
93e9d88066 fix: add missing fmtBytes helper in image-gen page
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 3m37s
Release / Upload source maps (push) Successful in 1m35s
Release / Docker / ui (push) Successful in 2m39s
Release / Gitea Release (push) Successful in 35s
Fixes CI type-check failure: fmtBytes was used on line 592 to format
the reference image file size but was never defined.
2026-04-06 20:37:02 +05:00
root
5b8987a191 fix: use theme tokens on catalogue scrape buttons
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
Replace hardcoded bg-amber-500/text-zinc-900 with bg-(--color-brand)/text-(--color-surface)
to match the rest of the UI's button palette (both grid and list views).
2026-04-06 20:36:25 +05:00
root
b6904bcb6e perf: cache admin job lists + targeted polling for audio/translation pages
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Failing after 34s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 40s
Release / Docker / runner (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
- Add 30s Valkey cache to listScrapingTasks, listAudioJobs, listTranslationJobs
  (use listN(500) instead of unbounded listAll to cap at one request)
- Delete listAudioCache() — derive AudioCacheEntry[] from jobs in server load
- Add listBookSlugs() with 10min cache — replaces full listBooks() in translation load
- Add GET /api/admin/audio-jobs, /api/admin/translation-jobs, /api/admin/scrape-tasks
  (lightweight polling endpoints backed by the Valkey cache)
- Replace invalidateAll() interval polling in audio+translation pages with
  targeted fetch to the new endpoints (avoids re-running full server load)
2026-04-06 20:33:46 +05:00
root
75e6a870d3 feat: async image-gen and description jobs with review panels
Some checks failed
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Failing after 38s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m44s
Release / Docker / runner (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Add POST /api/admin/image-gen/async: fire-and-forget image generation
  that stores the result (base64) in an ai_job payload and returns 202
  immediately — no more 60-120s blocking on FLUX models
- Add POST /api/admin/text-gen/description/async: same pattern for book
  description generation
- Register both new routes in server.go
- Rewrite image-gen admin page to use the async path (submit → redirect
  to AI Jobs for monitoring)
- Extend ai-jobs page with Review panels for image-gen jobs (show image,
  Save as cover / Download / Discard) and description jobs (diff old vs
  new, editable textarea, Apply / Discard)
2026-04-06 19:46:59 +05:00
root
5098acea20 feat: universal search modal
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m42s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 3m37s
Release / Upload source maps (push) Successful in 1m29s
Release / Docker / ui (push) Successful in 2m33s
Release / Gitea Release (push) Successful in 37s
- New SearchModal.svelte: full-screen modal with blurred backdrop
  - Live results as you type (300ms debounce, min 2 chars)
  - Local vs Novelfire badge on each result card (cover + title + author +
    genres + chapter count)
  - Local/remote counts shown in result header
  - 'See all in catalogue' shortcut button + footer repeat link
  - Recent searches (localStorage, max 8, per-item remove + clear all)
  - Genre suggestion chips shown when query is empty or no results found
  - Keyboard navigation: ArrowUp/Down to select, Enter to open, Escape to close
  - Body scroll lock while open
- +layout.svelte:
  - Imports SearchModal, adds searchOpen state
  - Search icon button in nav header (hidden on chapter reader pages)
  - Global keyboard shortcut: '/' or Cmd/Ctrl+K opens modal
  - Shortcut ignored when focused in input/textarea or on chapter pages
  - Modal not shown while ListeningMode is open
  - Auto-closes on route change
2026-04-06 18:41:32 +05:00
root
3e4d7b54d7 feat: merge page nav into focus mode floating pill
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m38s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 1m33s
Release / Docker / ui (push) Successful in 2m18s
Release / Gitea Release (push) Successful in 35s
In paginated + focus mode there were two separate UI elements: an inline
Prev/counter/Next bar and a floating chapter-nav pill. Merged into one:

- ‹ Ch.N | ‹ (page) N/M (page) › | × Exit focus | Ch.N ›
- Inline page bar + hint text are now hidden when focusMode is active
- Floating pill grows to include page controls only in paginated mode;
  scroll mode pill is unchanged (just chapter nav + exit)
- Added max-w-[calc(100vw-2rem)] so pill never overflows on small screens
2026-04-06 18:23:25 +05:00
Admin
495f386b4f fix(player): standard skip icons, next/prev start playback, fix auto-next stale presign
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m40s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m34s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 1m44s
Release / Docker / ui (push) Successful in 2m19s
Release / Gitea Release (push) Successful in 34s
- Replace double-triangle icons with proper skip-prev (|◄) and skip-next (►|) icons
- Convert prev/next chapter <a> links to buttons calling playChapter() so navigation auto-starts audio
- Fix auto-next silent failure: fast path A now re-presigns instead of reusing the cached URL, preventing stale/expired MinIO presigned URL from silently failing on the audio element
2026-04-06 17:22:42 +05:00
root
bb61a4654a feat: portrait cover card + blurred bg in ListeningMode
Some checks failed
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
Release / Test backend (push) Has been cancelled
Replace the full-bleed landscape hero with the Apple Music / Spotify
layout pattern:
- Full-screen blurred+darkened cover as atmospheric background layer
- Centered portrait 2/3 cover card (38svh tall, rounded-2xl, shadow-2xl)
- Track info (chapter label, title, book name) moved below cover card
- Radial vignette overlay for depth
- No dead empty space between art and controls
- Header bar and controls area lifted to z-index 2 above the bg layers
2026-04-06 17:22:31 +05:00
root
1cdc7275f8 fix: homepage overlay blocker and carousel auto-advance
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m43s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m31s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 49s
- Add pointer-events:none to ListeningMode fly-transition wrapper div in
  +layout.svelte so the exiting animation div never blocks page interaction
- Add pointer-events:auto to ListeningMode root div so it still captures
  all touch/click events correctly despite the parent being pointer-events:none
- Rewrite carousel auto-advance using $effect + autoAdvanceSeed pattern:
  replaces the stale-closure setInterval in resetAutoAdvance() with a
  reactive $effect that owns the interval and re-starts cleanly on manual
  navigation by bumping a seed counter
2026-04-06 16:58:23 +05:00
root
9d925382b3 fix(player): register touchmove with passive:false via $effect
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m34s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m36s
Release / Docker / runner (push) Successful in 2m34s
Release / Upload source maps (push) Successful in 1m35s
Release / Docker / ui (push) Successful in 3m18s
Release / Gitea Release (push) Successful in 1m27s
Svelte 5 has no |nonpassive modifier. Register the touchmove listener
manually so e.preventDefault() can suppress page scroll during the
pull-down gesture.
2026-04-06 16:32:25 +05:00
root
718929e9cd feat(player): pull-down-to-dismiss gesture on ListeningMode
Some checks failed
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Failing after 31s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m33s
Release / Docker / runner (push) Successful in 2m30s
Release / Gitea Release (push) Has been skipped
- Slide-up transition on open (fly from bottom, 320ms)
- Drag-down on the overlay follows the finger in real time with no
  transition; on release springs back (0.32s cubic-bezier) if drag
  < 130px and velocity < 0.4px/ms, otherwise slides off-screen and
  calls onclose after 220ms
- Opacity fades as the overlay is pulled down (fully transparent at 500px)
- Touch guard: gesture does not activate if touch starts inside an
  .overflow-y-auto element (chapter/voice lists) or while a modal is open
2026-04-06 16:16:09 +05:00
root
e8870a11da feat(player): redesign ListeningMode UI
All checks were successful
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 1m39s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m57s
Release / Docker / runner (push) Successful in 2m44s
Release / Upload source maps (push) Successful in 1m35s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 40s
- Full-bleed cover fills top ~52% of screen with top+bottom gradient
  overlays for header and track info legibility; eliminates the large
  dead space between cover and seek bar
- Chapter number shown as brand-coloured label above the chapter title
- Remaining time (−m:ss) displayed in the centre of the seek bar row
- Transport row uses justify-between; chapter-skip buttons are smaller
  (w-5, muted/60 opacity) vs time-skip (w-7, full muted) to clearly
  distinguish secondary from primary seek controls
- Speed / Auto-next / Sleep now sit on a single tidy row — no more
  wrapping or mixed visual styles between the segmented speed control
  and the two pill buttons
- Header buttons use frosted-glass style (bg-black/25 backdrop-blur)
  so they remain legible over the cover image
2026-04-06 15:51:06 +05:00
root
b70fed5cd7 fix(reader): clean up focus mode footer
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 1m35s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m32s
Release / Upload source maps (push) Successful in 1m31s
Release / Docker / ui (push) Successful in 2m48s
Release / Gitea Release (push) Successful in 45s
- Hide the global site footer on chapter pages (not useful mid-reading)
- Merge the three separate floating nav pills into a single unified pill
  with dividers, removing the visual clutter of multiple bordered bubbles
- Float the pill lower (bottom-6) when the mini-player is not active
2026-04-06 15:42:45 +05:00
root
5dd9dd2ebb feat(nav): make book title in chapter header a link back to the book page
Some checks failed
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
Release / Test backend (push) Has been cancelled
2026-04-06 15:38:18 +05:00
25 changed files with 2210 additions and 903 deletions

View File

@@ -1,14 +1,17 @@
package backend
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
@@ -288,3 +291,231 @@ func sniffImageContentType(data []byte) string {
}
return "image/png"
}
// handleAdminImageGenAsync handles POST /api/admin/image-gen/async.
//
// Fire-and-forget variant: validates the request, creates an ai_job record of
// kind "image-gen", spawns a background goroutine, and returns HTTP 202 with
// {job_id} immediately. The goroutine calls Cloudflare AI, stores the result
// as base64 in the job payload, and marks the job done/failed when finished.
//
// The admin can then review the result via the ai-jobs page and approve
// (save as cover) or reject (discard) the image.
func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
var req imageGenRequest
var refImageData []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
if jsonPart := r.FormValue("json"); jsonPart != "" {
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
return
}
}
if f, _, err := r.FormFile("reference"); err == nil {
defer f.Close()
refImageData, _ = io.ReadAll(f)
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.ReferenceImageB64 != "" {
var decErr error
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
return
}
}
}
}
if strings.TrimSpace(req.Prompt) == "" {
jsonError(w, http.StatusBadRequest, "prompt is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type == "chapter" && req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
return
}
// Resolve model.
model := cfai.ImageModel(req.Model)
if model == "" {
if req.Type == "cover" {
model = cfai.DefaultImageModel
} else {
model = cfai.ImageModelFlux2Klein4B
}
}
// Encode request params as job payload so the UI can reconstruct context.
type jobParams struct {
Prompt string `json:"prompt"`
Type string `json:"type"`
Chapter int `json:"chapter,omitempty"`
NumSteps int `json:"num_steps,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Guidance float64 `json:"guidance,omitempty"`
Strength float64 `json:"strength,omitempty"`
HasRef bool `json:"has_ref,omitempty"`
}
paramsJSON, _ := json.Marshal(jobParams{
Prompt: req.Prompt,
Type: req.Type,
Chapter: req.Chapter,
NumSteps: req.NumSteps,
Width: req.Width,
Height: req.Height,
Guidance: req.Guidance,
Strength: req.Strength,
HasRef: len(refImageData) > 0,
})
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "image-gen",
Slug: req.Slug,
Status: domain.TaskStatusPending,
Model: string(model),
Payload: string(paramsJSON),
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)
// Mark running before returning.
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
})
s.deps.Log.Info("admin: image-gen async started",
"job_id", jobID, "slug", req.Slug, "type", req.Type, "model", model)
// Capture locals for the goroutine.
store := s.deps.AIJobStore
imageGen := s.deps.ImageGen
coverStore := s.deps.CoverStore
logger := s.deps.Log
capturedReq := req
capturedModel := model
capturedRefImage := refImageData
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
})
return
}
imgReq := cfai.ImageRequest{
Prompt: capturedReq.Prompt,
Model: capturedModel,
NumSteps: capturedReq.NumSteps,
Width: capturedReq.Width,
Height: capturedReq.Height,
Guidance: capturedReq.Guidance,
Strength: capturedReq.Strength,
}
var imgData []byte
var genErr error
if len(capturedRefImage) > 0 {
imgData, genErr = imageGen.GenerateImageFromReference(jobCtx, imgReq, capturedRefImage)
} else {
imgData, genErr = imageGen.GenerateImage(jobCtx, imgReq)
}
if genErr != nil {
logger.Error("admin: image-gen async failed", "job_id", jobID, "err", genErr)
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusFailed),
"error_message": genErr.Error(),
"finished": time.Now().Format(time.RFC3339),
})
return
}
contentType := sniffImageContentType(imgData)
b64 := base64.StdEncoding.EncodeToString(imgData)
// Build result payload: include the original params + the generated image.
type resultPayload struct {
Prompt string `json:"prompt"`
Type string `json:"type"`
Chapter int `json:"chapter,omitempty"`
ContentType string `json:"content_type"`
ImageB64 string `json:"image_b64"`
Bytes int `json:"bytes"`
NumSteps int `json:"num_steps,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Guidance float64 `json:"guidance,omitempty"`
}
resultJSON, _ := json.Marshal(resultPayload{
Prompt: capturedReq.Prompt,
Type: capturedReq.Type,
Chapter: capturedReq.Chapter,
ContentType: contentType,
ImageB64: b64,
Bytes: len(imgData),
NumSteps: capturedReq.NumSteps,
Width: capturedReq.Width,
Height: capturedReq.Height,
Guidance: capturedReq.Guidance,
})
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
"items_total": 1,
"payload": string(resultJSON),
"finished": time.Now().Format(time.RFC3339),
})
logger.Info("admin: image-gen async done",
"job_id", jobID, "slug", capturedReq.Slug,
"bytes", len(imgData), "content_type", contentType)
// Suppress unused variable warning for coverStore when SaveToCover is false.
_ = coverStore
}()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

View File

@@ -801,3 +801,161 @@ func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *ht
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
writeJSON(w, 0, map[string]any{"updated": true})
}
// handleAdminTextGenDescriptionAsync handles POST /api/admin/text-gen/description/async.
//
// Fire-and-forget variant: validates inputs, creates an ai_job record of kind
// "description", spawns a background goroutine that calls the LLM, stores the
// old/new description in the job payload, and marks the job done/failed.
// Returns HTTP 202 with {job_id} immediately.
func (s *Server) handleAdminTextGenDescriptionAsync(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
}
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
var req textGenDescriptionRequest
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
}
// Load current metadata eagerly so we can fail fast if the book is missing.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
instructions := strings.TrimSpace(req.Instructions)
if instructions == "" {
instructions = "Write a compelling 24 sentence description. Keep it spoiler-free and engaging."
}
// Encode the initial params (without result) as the starting payload.
type initPayload struct {
Instructions string `json:"instructions"`
OldDescription string `json:"old_description"`
}
initJSON, _ := json.Marshal(initPayload{
Instructions: instructions,
OldDescription: meta.Summary,
})
jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "description",
Slug: req.Slug,
Status: domain.TaskStatusPending,
Model: string(model),
Payload: string(initJSON),
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.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
})
s.deps.Log.Info("admin: text-gen description async started",
"job_id", jobID, "slug", req.Slug, "model", model)
// Capture locals.
store := s.deps.AIJobStore
textGen := s.deps.TextGen
logger := s.deps.Log
capturedMeta := meta
capturedModel := model
capturedInstructions := instructions
capturedMaxTokens := req.MaxTokens
go func() {
defer deregisterCancelJob(jobID)
defer jobCancel()
if jobCtx.Err() != nil {
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
})
return
}
systemPrompt := `You are a book description writer for a web novel platform. ` +
`Given a book's title, author, genres, and current description, write an improved ` +
`description that accurately captures the story. ` +
`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`
userPrompt := fmt.Sprintf(
"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
capturedMeta.Title,
capturedMeta.Author,
strings.Join(capturedMeta.Genres, ", "),
capturedMeta.Status,
capturedMeta.Summary,
capturedInstructions,
)
newDesc, 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 description async failed", "job_id", jobID, "err", genErr)
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusFailed),
"error_message": genErr.Error(),
"finished": time.Now().Format(time.RFC3339),
})
return
}
type resultPayload struct {
Instructions string `json:"instructions"`
OldDescription string `json:"old_description"`
NewDescription string `json:"new_description"`
}
resultJSON, _ := json.Marshal(resultPayload{
Instructions: capturedInstructions,
OldDescription: capturedMeta.Summary,
NewDescription: strings.TrimSpace(newDesc),
})
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
"status": string(domain.TaskStatusDone),
"items_done": 1,
"items_total": 1,
"payload": string(resultJSON),
"finished": time.Now().Format(time.RFC3339),
})
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
}()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": jobID})
}

View File

@@ -201,6 +201,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Admin image generation endpoints
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
// Admin text generation endpoints (chapter names + book description)
@@ -209,6 +210,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
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/async", s.handleAdminTextGenDescriptionAsync)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin catalogue enrichment endpoints

View File

@@ -245,12 +245,17 @@ create "user_library" '{
create "user_settings" '{
"name":"user_settings","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"auto_next","type":"bool"},
{"name":"voice", "type":"text"},
{"name":"speed", "type":"number"},
{"name":"updated", "type":"text"}
{"name":"session_id", "type":"text", "required":true},
{"name":"user_id", "type":"text"},
{"name":"auto_next", "type":"bool"},
{"name":"voice", "type":"text"},
{"name":"speed", "type":"number"},
{"name":"theme", "type":"text"},
{"name":"locale", "type":"text"},
{"name":"font_family", "type":"text"},
{"name":"font_size", "type":"number"},
{"name":"announce_chapter","type":"bool"},
{"name":"updated", "type":"text"}
]}'
create "user_subscriptions" '{
@@ -345,6 +350,11 @@ add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
add_field "chapters_idx" "created" "date"
add_field "user_settings" "theme" "text"
add_field "user_settings" "locale" "text"
add_field "user_settings" "font_family" "text"
add_field "user_settings" "font_size" "number"
add_field "user_settings" "announce_chapter" "bool"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \

View File

@@ -101,6 +101,13 @@ class AudioStore {
*/
autoNext = $state(false);
/**
* When true, announces the upcoming chapter number and title via the
* Web Speech API before auto-next navigation fires.
* e.g. "Chapter 12 — The Final Battle"
*/
announceChapter = $state(false);
/**
* The next chapter number for the currently playing chapter, or null if
* there is no next chapter. Written by the chapter page's AudioPlayer.

View File

@@ -241,9 +241,9 @@
}
// Keep nextChapter in the store so the layout's onended can navigate.
// NOTE: we do NOT clear on unmount here — the store retains the value so
// onended (which may fire after {#key} unmounts this component) can still
// read it. The value is superseded when the new chapter mounts.
// We write null on mount (before deriving the real value) so there is no
// stale window where the previous chapter's nextChapter is still set while
// this chapter's AudioPlayer hasn't written its own value yet.
$effect(() => {
audioStore.nextChapter = nextChapter ?? null;
});
@@ -566,21 +566,27 @@
audioStore.errorMsg = '';
try {
// Fast path A: pre-fetch already landed for THIS chapter.
// Fast path A: pre-fetch already confirmed audio is in MinIO for THIS chapter.
// Re-presign instead of using the cached URL — it may have expired if the
// user paused for a while between the prefetch and actually reaching this chapter.
if (
audioStore.nextStatus === 'prefetched' &&
audioStore.nextChapterPrefetched === chapter &&
audioStore.nextAudioUrl
audioStore.nextChapterPrefetched === chapter
) {
const url = audioStore.nextAudioUrl;
// Consume the pre-fetch — reset so it doesn't carry over
// Consume the pre-fetch state first so it doesn't carry over on error.
audioStore.resetNextPrefetch();
audioStore.audioUrl = url;
audioStore.status = 'ready';
// Don't restore saved time for auto-next; position is 0
// Immediately start pre-generating the chapter after this one.
maybeStartPrefetch();
return;
// Fresh presign — audio is confirmed in MinIO so this is a fast, cheap call.
const presigned = await tryPresign(slug, chapter, voice);
if (presigned.ready) {
audioStore.audioUrl = presigned.url;
audioStore.status = 'ready';
// Don't restore saved time for auto-next; position is 0.
// Immediately start pre-generating the chapter after this one.
maybeStartPrefetch();
return;
}
// Presign returned not-ready (race: MinIO object vanished?).
// Fall through to the normal slow path below.
}
// Fast path B: audio already in MinIO (presign check).

View File

@@ -26,6 +26,65 @@
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
// ── Pull-down-to-dismiss gesture ─────────────────────────────────────────
let dragY = $state(0);
let isDragging = $state(false);
let dragStartY = 0;
let dragStartTime = 0;
let overlayEl = $state<HTMLDivElement | null>(null);
// Register ontouchmove with passive:false so e.preventDefault() works.
// Svelte 5 does not support the |nonpassive modifier, so we use $effect.
$effect(() => {
if (!overlayEl) return;
overlayEl.addEventListener('touchmove', onTouchMove, { passive: false });
return () => overlayEl!.removeEventListener('touchmove', onTouchMove);
});
function onTouchStart(e: TouchEvent) {
// Don't hijack touches that start inside a scrollable element
const target = e.target as Element;
if (target.closest('.overflow-y-auto')) return;
// Don't activate if a modal is open (they handle their own scroll)
if (showVoiceModal || showChapterModal) return;
isDragging = true;
dragStartY = e.touches[0].clientY;
dragStartTime = Date.now();
dragY = 0;
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return;
const delta = e.touches[0].clientY - dragStartY;
// Only track downward movement
if (delta > 0) {
dragY = delta;
// Prevent page scroll while dragging the overlay down
e.preventDefault();
} else {
dragY = 0;
}
}
function onTouchEnd() {
if (!isDragging) return;
isDragging = false;
const elapsed = Date.now() - dragStartTime;
const velocity = dragY / Math.max(elapsed, 1); // px/ms
// Dismiss if dragged far enough (>130px) or flicked fast enough (>0.4px/ms)
if (dragY > 130 || velocity > 0.4) {
// Animate out: snap to bottom then close
dragY = window.innerHeight;
setTimeout(onclose, 220);
} else {
// Spring back to 0
dragY = 0;
}
}
// ── Voice search filtering ────────────────────────────────────────────────
const voiceSearchLower = $derived(voiceSearch.toLowerCase());
const filteredKokoro = $derived(kokoroVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
@@ -188,31 +247,53 @@
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={overlayEl}
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="background: var(--color-surface);"
style="
background: var(--color-surface);
transform: translateY({dragY}px);
opacity: {Math.max(0, 1 - dragY / 500)};
transition: {isDragging ? 'none' : 'transform 0.32s cubic-bezier(0.32,0.72,0,1), opacity 0.32s ease'};
will-change: transform;
touch-action: pan-x;
pointer-events: auto;
"
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<!-- 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">
<!-- ── Blurred background (full-screen atmospheric layer) ───────────── -->
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
aria-hidden="true"
class="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
style="filter: blur(40px) brightness(0.25) saturate(1.4); transform: scale(1.15); z-index: 0;"
/>
{:else}
<div class="absolute inset-0 pointer-events-none" style="background: var(--color-surface-2); z-index: 0;"></div>
{/if}
<!-- Subtle vignette overlay for depth -->
<div
class="absolute inset-0 pointer-events-none"
style="background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.55) 100%); z-index: 1;"
aria-hidden="true"
></div>
<!-- ── Header bar ─────────────────────────────────────────────────────── -->
<div class="relative flex items-center justify-between px-4 pt-3 pb-2 shrink-0" style="z-index: 2;">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
class="p-2 rounded-full text-(--color-text)/70 hover:text-(--color-text) hover:bg-white/10 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>
<span class="text-xs font-semibold text-(--color-text)/60 uppercase tracking-wider">Now Playing</span>
<div class="flex items-center gap-2">
<!-- Chapters button -->
{#if audioStore.chapters.length > 0}
@@ -222,8 +303,8 @@
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showChapterModal
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
aria-label="Browse chapters"
>
@@ -240,8 +321,8 @@
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)'
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-white/20 bg-black/25 text-(--color-text)/70 hover:text-(--color-text) backdrop-blur-sm'
)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -252,6 +333,42 @@
</div>
</div>
<!-- ── Portrait cover card + track info ───────────────────────────────── -->
<div class="relative flex flex-col items-center gap-4 px-8 pt-2 pb-4 shrink-0" style="z-index: 2;">
<!-- Cover card -->
<div
class="rounded-2xl overflow-hidden shadow-2xl"
style="height: 38svh; min-height: 180px; max-height: 320px; aspect-ratio: 2/3;"
>
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-(--color-surface-2) flex items-center justify-center">
<svg class="w-16 h-16 text-(--color-muted)/30" 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}
</div>
<!-- Track info -->
<div class="text-center w-full">
{#if audioStore.chapter > 0}
<p class="text-[10px] font-bold uppercase tracking-widest text-(--color-brand) mb-0.5">
Chapter {audioStore.chapter}
</p>
{/if}
<p class="text-lg font-bold text-(--color-text) leading-snug line-clamp-2">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
</p>
<p class="text-sm text-(--color-text)/50 mt-0.5 truncate">{audioStore.bookTitle}</p>
</div>
</div>
<!-- Voice modal (full-screen overlay) -->
{#if showVoiceModal && voices.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -273,7 +390,6 @@
</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">
@@ -288,7 +404,6 @@
/>
</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]}
@@ -298,45 +413,24 @@
<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)'
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 -->
<button type="button" onclick={() => selectVoice(v.id)} class="flex-1 flex items-center gap-3 text-left">
<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)'
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>
<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>
<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)'
)}
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'}
>
@@ -360,11 +454,7 @@
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-70 flex flex-col"
style="background: var(--color-surface);"
>
<!-- Modal header -->
<div class="absolute inset-0 z-70 flex flex-col" style="background: var(--color-surface);">
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
@@ -378,7 +468,6 @@
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search input -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -392,35 +481,24 @@
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
use:scrollIfActive={ch.number === audioStore.chapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === audioStore.chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
{/each}
@@ -431,195 +509,194 @@
</div>
{/if}
<!-- Scrollable body — fills remaining height, content spread vertically -->
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
/>
{:else}
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</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>
<!-- Seek bar -->
<div class="shrink-0 mb-1">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 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>
<!-- Remaining time in centre -->
{#if audioStore.duration > 0}
<span class="text-(--color-muted)/60">{formatTime(Math.max(0, audioStore.duration - audioStore.currentTime))}</span>
{/if}
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Bottom controls cluster: seek + transport + secondary -->
<div class="flex flex-col gap-0 px-6 shrink-0">
<!-- Transport controls -->
<div class="flex items-center justify-between pt-3 pb-4 shrink-0">
<!-- Prev chapter — smaller, clearly secondary -->
{#if audioStore.chapter > 1 && audioStore.slug}
<button
type="button"
onclick={() => playChapter(audioStore.chapter - 1)}
class="p-2 rounded-full text-(--color-muted)/60 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 6h2v12H6zm2 6 8.5 6V6z"/>
</svg>
</button>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Seek bar -->
<div class="shrink-0">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Skip back 15s — medium -->
<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-7 h-7" 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>
<!-- Transport controls -->
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
<!-- Prev chapter -->
{#if audioStore.chapter > 1 && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</a>
<!-- Play / Pause — largest, centred -->
<button
type="button"
onclick={togglePlay}
class="w-18 h-18 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-xl"
style="width: 4.5rem; height: 4.5rem;"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<div class="w-9 h-9"></div>
<svg class="w-8 h-8 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip back 15s -->
<!-- Skip forward 30s — medium -->
<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-7 h-7" 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 — smaller, clearly secondary -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<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"
onclick={() => playChapter(audioStore.nextChapter!)}
class="p-2 rounded-full text-(--color-muted)/60 hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<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 class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
{:else}
<div class="w-9 h-9"></div>
{/if}
</div>
<!-- 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}
<!-- Secondary controls: unified single row — Speed · Auto · Announce · Sleep -->
<div class="flex items-center justify-center gap-2 shrink-0 flex-wrap">
<!-- Speed — segmented pill -->
<div class="flex items-center gap-0.5 bg-(--color-surface-2) rounded-full px-1.5 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>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
<!-- Speed -->
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => (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 pill -->
<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>
<!-- 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>
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
<button
type="button"
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.announceChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.announceChapter}
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
Announce
</button>
<!-- Sleep timer pill -->
<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>
</div>

View File

@@ -0,0 +1,437 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { cn } from '$lib/utils';
interface Props {
onclose: () => void;
}
let { onclose }: Props = $props();
// ── Types ─────────────────────────────────────────────────────────────────
interface SearchResult {
slug: string;
title: string;
cover?: string;
author?: string;
genres?: string[];
status?: string;
chapters?: string; // e.g. "42 chapters"
url?: string; // novelfire source url — present for remote results
}
interface SearchResponse {
results: SearchResult[];
local_count: number;
remote_count: number;
}
// ── State ─────────────────────────────────────────────────────────────────
const RECENTS_KEY = 'search_recents_v1';
const MAX_RECENTS = 8;
function loadRecents(): string[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(RECENTS_KEY);
if (raw) return JSON.parse(raw) as string[];
} catch { /* ignore */ }
return [];
}
function saveRecents(list: string[]) {
if (!browser) return;
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(list)); } catch { /* ignore */ }
}
let recents = $state<string[]>(loadRecents());
let query = $state('');
let results = $state<SearchResult[]>([]);
let localCount = $state(0);
let remoteCount = $state(0);
let loading = $state(false);
let error = $state('');
// For keyboard navigation through results
let selectedIdx = $state(-1);
// Input element ref for autofocus
let inputEl = $state<HTMLInputElement | null>(null);
// ── Autofocus + body scroll lock ──────────────────────────────────────────
$effect(() => {
if (inputEl) inputEl.focus();
if (browser) {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}
});
// ── Keyboard shortcuts (global): Escape closes ────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { onclose(); return; }
const total = visibleResults.length;
if (total === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = (selectedIdx + 1) % total;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = (selectedIdx - 1 + total) % total;
} else if (e.key === 'Enter' && selectedIdx >= 0) {
e.preventDefault();
navigateTo(visibleResults[selectedIdx]);
}
}
// ── Debounced search ──────────────────────────────────────────────────────
let debounceTimer = 0;
$effect(() => {
const q = query.trim();
selectedIdx = -1;
if (q.length < 2) {
results = [];
localCount = 0;
remoteCount = 0;
loading = false;
error = '';
clearTimeout(debounceTimer);
return;
}
loading = true;
error = '';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: SearchResponse = await res.json();
results = data.results ?? [];
localCount = data.local_count ?? 0;
remoteCount = data.remote_count ?? 0;
} catch (e) {
error = 'Search failed. Please try again.';
results = [];
} finally {
loading = false;
}
}, 300) as unknown as number;
});
// Results visible in the list — same as results (no client-side filter needed)
const visibleResults = $derived(results);
// ── Genre suggestions shown when query is empty ───────────────────────────
const GENRE_SUGGESTIONS = [
'Fantasy', 'Action', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
];
// ── Navigation helpers ────────────────────────────────────────────────────
function navigateTo(r: SearchResult) {
pushRecent(query.trim());
goto(`/books/${r.slug}`);
onclose();
}
function searchGenre(genre: string) {
goto(`/catalogue?genre=${encodeURIComponent(genre)}`);
onclose();
}
function submitQuery() {
const q = query.trim();
if (!q) return;
pushRecent(q);
goto(`/catalogue?q=${encodeURIComponent(q)}`);
onclose();
}
// ── Recent searches ───────────────────────────────────────────────────────
function pushRecent(q: string) {
if (!q || q.length < 2) return;
const next = [q, ...recents.filter(r => r.toLowerCase() !== q.toLowerCase())].slice(0, MAX_RECENTS);
recents = next;
saveRecents(next);
}
function removeRecent(q: string) {
const next = recents.filter(r => r !== q);
recents = next;
saveRecents(next);
}
function clearAllRecents() {
recents = [];
saveRecents([]);
}
function applyRecent(q: string) {
query = q;
if (inputEl) inputEl.focus();
}
// ── Helpers ───────────────────────────────────────────────────────────────
function parseGenres(genres: string[] | string | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
}
const isRemote = (r: SearchResult) => r.url != null && r.url.includes('novelfire');
</script>
<!-- svelte:window for global keyboard handling -->
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex flex-col"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<!-- Modal panel — slides down from top -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
style="max-height: 100svh; sm:max-height: calc(100svh - 8rem);"
onpointerdown={(e) => e.stopPropagation()}
>
<!-- ── Search input row ──────────────────────────────────────────────── -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<!-- Search icon -->
{#if loading}
<svg class="w-5 h-5 text-(--color-brand) animate-spin shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{:else}
<svg class="w-5 h-5 text-(--color-muted) shrink-0" 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>
{/if}
<input
bind:this={inputEl}
bind:value={query}
type="search"
placeholder="Search books, authors, genres…"
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitQuery(); } }}
autocomplete="off"
autocorrect="off"
spellcheck={false}
/>
{#if query}
<button
type="button"
onclick={() => { query = ''; inputEl?.focus(); }}
class="shrink-0 p-1 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Clear search"
>
<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>
{/if}
<button
type="button"
onclick={onclose}
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close search"
>
Cancel
</button>
</div>
<!-- ── Scrollable body ───────────────────────────────────────────────── -->
<div class="flex-1 overflow-y-auto overscroll-contain">
<!-- ── Error state ─────────────────────────────────────────────── -->
{#if error}
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
<!-- ── Results ─────────────────────────────────────────────────── -->
{:else if visibleResults.length > 0}
<!-- Result count + "see all" hint -->
<div class="flex items-center justify-between px-4 pt-3 pb-1.5">
<p class="text-xs text-(--color-muted)">
{#if localCount > 0 && remoteCount > 0}
<span class="text-(--color-text) font-medium">{localCount}</span> in library
· <span class="text-(--color-text) font-medium">{remoteCount}</span> from Novelfire
{:else if localCount > 0}
<span class="text-(--color-text) font-medium">{localCount}</span> in library
{:else}
<span class="text-(--color-text) font-medium">{remoteCount}</span> from Novelfire
{/if}
</p>
<!-- "All results in catalogue" shortcut -->
<button
type="button"
onclick={submitQuery}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
See all in catalogue →
</button>
</div>
{#each visibleResults as r, i}
{@const genres = parseGenres(r.genres)}
{@const remote = isRemote(r)}
<button
type="button"
onclick={() => navigateTo(r)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
selectedIdx === i ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Cover thumbnail -->
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border)">
{#if r.cover}
<img src={r.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center">
<svg class="w-5 h-5 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-2z"/>
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2">
<p class="text-sm font-semibold text-(--color-text) leading-snug line-clamp-1 flex-1">
{r.title}
</p>
{#if remote}
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) leading-none mt-0.5">
Novelfire
</span>
{/if}
</div>
{#if r.author}
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{r.author}</p>
{/if}
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
{#if r.chapters}
<span class="text-xs text-(--color-muted)/60">{r.chapters}</span>
{/if}
{#if r.status}
<span class="text-xs text-(--color-muted)/60 capitalize">{r.status}</span>
{/if}
{#each genres.slice(0, 2) as g}
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
{/each}
</div>
</div>
<!-- Chevron -->
<svg class="w-4 h-4 text-(--color-muted)/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
{/each}
<!-- "See all results" footer button -->
<button
type="button"
onclick={submitQuery}
class="w-full flex items-center justify-center gap-2 px-4 py-4 text-sm text-(--color-brand) hover:bg-(--color-surface-2) transition-colors"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
See all results for "{query.trim()}"
</button>
<!-- ── No results ───────────────────────────────────────────────── -->
{:else if query.trim().length >= 2 && !loading}
<div class="px-5 py-10 text-center">
<p class="text-sm font-semibold text-(--color-text) mb-1">No results for "{query.trim()}"</p>
<p class="text-xs text-(--color-muted) mb-5">Try a different title, author, or browse by genre below.</p>
<div class="flex flex-wrap gap-2 justify-center">
{#each GENRE_SUGGESTIONS as genre}
<button
type="button"
onclick={() => searchGenre(genre)}
class="px-3 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/50 transition-colors"
>
{genre}
</button>
{/each}
</div>
</div>
<!-- ── Empty state (query too short or empty) ───────────────────── -->
{:else if query.trim().length === 0}
<!-- Recent searches -->
{#if recents.length > 0}
<div class="px-4 pt-4 pb-2">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Recent</p>
<button
type="button"
onclick={clearAllRecents}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Clear all
</button>
</div>
{#each recents as r}
<div class="flex items-center gap-2 group">
<button
type="button"
onclick={() => applyRecent(r)}
class="flex-1 flex items-center gap-2.5 px-1 py-2 rounded-lg text-sm text-(--color-text) hover:bg-(--color-surface-2) transition-colors text-left"
>
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{r}
</button>
<button
type="button"
onclick={() => removeRecent(r)}
class="shrink-0 p-1 rounded text-(--color-muted)/40 hover:text-(--color-muted) opacity-0 group-hover:opacity-100 transition-all"
aria-label="Remove"
>
<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>
</button>
</div>
{/each}
</div>
<div class="mx-4 my-2 border-t border-(--color-border)/60"></div>
{/if}
<!-- Genre suggestions -->
<div class="px-4 pt-3 pb-5">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Browse by genre</p>
<div class="flex flex-wrap gap-2">
{#each GENRE_SUGGESTIONS as genre}
<button
type="button"
onclick={() => searchGenre(genre)}
class="px-3 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/50 transition-colors"
>
{genre}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -75,6 +75,7 @@ export interface PBUserSettings {
locale?: string;
font_family?: string;
font_size?: number;
announce_chapter?: boolean;
updated?: string;
}
@@ -248,6 +249,14 @@ const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
const SCRAPING_TASKS_CACHE_KEY = 'admin:scraping_tasks';
const AUDIO_JOBS_CACHE_KEY = 'admin:audio_jobs';
const TRANSLATION_JOBS_CACHE_KEY = 'admin:translation_jobs';
const ADMIN_JOBS_CACHE_TTL = 30; // 30 seconds — admin views poll frequently
const BOOK_SLUGS_CACHE_KEY = 'books:slugs';
const BOOK_SLUGS_CACHE_TTL = 10 * 60; // 10 minutes — slugs change rarely
async function getAllRatings(): Promise<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
@@ -272,6 +281,31 @@ export async function listBooks(): Promise<Book[]> {
return books;
}
export interface BookSlug {
slug: string;
title: string;
}
/**
* Returns only the slug and title of every book. Cheaper than listBooks() —
* used for datalist autocomplete in admin forms. Cached for 10 minutes.
*/
export async function listBookSlugs(): Promise<BookSlug[]> {
const cached = await cache.get<BookSlug[]>(BOOK_SLUGS_CACHE_KEY);
if (cached) return cached;
// Re-use full books cache if already warm — avoids a second PocketBase call.
const fullCached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (fullCached) {
const slugs = fullCached.map((b) => ({ slug: b.slug, title: b.title }));
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
return slugs;
}
const items = await listAll<BookSlug>('books', '', '+title').catch(() => [] as BookSlug[]);
const slugs = items.map((b) => ({ slug: b.slug, title: b.title }));
await cache.set(BOOK_SLUGS_CACHE_KEY, slugs, BOOK_SLUGS_CACHE_TTL);
return slugs;
}
/**
* Fetch only the books whose slugs are in the given set.
* Uses PocketBase filter `slug IN (...)` — a single request regardless of how
@@ -965,7 +999,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -984,6 +1018,7 @@ export async function saveSettings(
if (settings.locale !== undefined) payload.locale = settings.locale;
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
if (userId) payload.user_id = userId;
if (existing) {
@@ -1052,16 +1087,6 @@ export interface AudioCacheEntry {
updated: string;
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
return jobs.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────
export interface ScrapingTask {
@@ -1081,7 +1106,11 @@ export interface ScrapingTask {
}
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
const cached = await cache.get<ScrapingTask[]>(SCRAPING_TASKS_CACHE_KEY);
if (cached) return cached;
const tasks = await listN<ScrapingTask>('scraping_tasks', 500, '', '-started');
await cache.set(SCRAPING_TASKS_CACHE_KEY, tasks, ADMIN_JOBS_CACHE_TTL);
return tasks;
}
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
@@ -1103,7 +1132,11 @@ export interface AudioJob {
}
export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
const cached = await cache.get<AudioJob[]>(AUDIO_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listN<AudioJob>('audio_jobs', 500, '', '-started');
await cache.set(AUDIO_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
return jobs;
}
/**
@@ -1130,7 +1163,11 @@ export interface TranslationJob {
}
export async function listTranslationJobs(): Promise<TranslationJob[]> {
return listAll<TranslationJob>('translation_jobs', '', '-started');
const cached = await cache.get<TranslationJob[]>(TRANSLATION_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listN<TranslationJob>('translation_jobs', 500, '', '-started');
await cache.set(TRANSLATION_JOBS_CACHE_KEY, jobs, ADMIN_JOBS_CACHE_TTL);
return jobs;
}
export async function getAudioTime(

View File

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

View File

@@ -12,12 +12,23 @@
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
import SearchModal from '$lib/components/SearchModal.svelte';
import { fly } from 'svelte/transition';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
// Mobile nav drawer state
let menuOpen = $state(false);
// Universal search
let searchOpen = $state(false);
// Close search on navigation
$effect(() => {
void page.url.pathname;
searchOpen = false;
});
// Desktop dropdown menus
let userMenuOpen = $state(false);
let langMenuOpen = $state(false);
@@ -96,6 +107,7 @@
audioStore.autoNext = data.settings.autoNext;
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
audioStore.announceChapter = data.settings.announceChapter ?? false;
}
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
@@ -117,6 +129,7 @@
const theme = currentTheme;
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
const announceChapter = audioStore.announceChapter;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
@@ -127,7 +140,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -366,9 +379,26 @@
// Store the target chapter number so only the newly-mounted AudioPlayer
// for that chapter reacts — not the outgoing chapter's component.
audioStore.autoStartChapter = targetChapter;
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
// Announce the upcoming chapter via Web Speech API if enabled.
const doNavigate = () => {
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
};
if (audioStore.announceChapter && typeof window !== 'undefined' && 'speechSynthesis' in window) {
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
const titlePart = nextInfo?.title ? ` ${nextInfo.title}` : '';
const text = `Chapter ${targetChapter}${titlePart}`;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.onend = doNavigate;
utterance.onerror = doNavigate;
window.speechSynthesis.speak(utterance);
} else {
doNavigate();
}
}
}}
preload="metadata"
@@ -389,9 +419,12 @@
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
<a
href="/books/{page.data.book.slug}"
class="text-(--color-muted) hover:text-(--color-text) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs transition-colors"
>
{page.data.book.title}
</span>
</a>
{/if}
{#if data.user}
@@ -430,6 +463,20 @@
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Universal search button (hidden on chapter/reader pages) -->
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
{/if}
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
<button
@@ -718,7 +765,9 @@
{/key}
</main>
<footer class="border-t border-(--color-border) mt-auto">
<footer class="border-t border-(--color-border) mt-auto"
class:hidden={/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
>
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
@@ -948,8 +997,29 @@
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
</div>
{/if}
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
{#if searchOpen && !listeningModeOpen}
<SearchModal onclose={() => { searchOpen = false; }} />
{/if}
<svelte:window onkeydown={(e) => {
// Don't intercept when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
// Don't open on chapter reader pages
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
if (searchOpen) return;
// `/` key or Cmd/Ctrl+K
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
e.preventDefault();
searchOpen = true;
}
}} />

View File

@@ -95,22 +95,28 @@
resetAutoAdvance();
}
let autoAdvanceTimer = $state<ReturnType<typeof setInterval> | null>(null);
function resetAutoAdvance() {
if (autoAdvanceTimer) clearInterval(autoAdvanceTimer);
if (heroBooks.length > 1) {
autoAdvanceTimer = setInterval(() => {
heroIndex = (heroIndex + 1) % heroBooks.length;
}, 6000);
}
}
// Auto-advance carousel every 6 s when there are multiple books.
// We use a $state counter as a "restart token" so the $effect can be
// re-triggered by manual navigation without reading heroIndex (which would
// cause an infinite loop when the interval itself mutates heroIndex).
let autoAdvanceSeed = $state(0);
$effect(() => {
resetAutoAdvance();
return () => { if (autoAdvanceTimer) clearInterval(autoAdvanceTimer); };
if (heroBooks.length <= 1) return;
// Subscribe to heroBooks.length and autoAdvanceSeed only — not heroIndex.
const len = heroBooks.length;
void autoAdvanceSeed; // track the seed
const id = setInterval(() => {
heroIndex = (heroIndex + 1) % len;
}, 6000);
return () => clearInterval(id);
});
function resetAutoAdvance() {
// Bump the seed to restart the interval after manual navigation.
autoAdvanceSeed++;
}
function playChapter(slug: string, chapter: number) {
audioStore.autoStartChapter = chapter;
goto(`/books/${slug}/chapters/${chapter}`);

View File

@@ -85,7 +85,8 @@
new_title: string;
}
interface ReviewState {
interface ChapterNamesReview {
kind: 'chapter-names';
jobId: string;
slug: string;
pattern: string;
@@ -97,39 +98,160 @@
applyDone: boolean;
}
// ── Review (image-gen jobs) ───────────────────────────────────────────────────
interface ImageGenReview {
kind: 'image-gen';
jobId: string;
slug: string;
imageType: string;
prompt: string;
imageSrc: string;
contentType: string;
bytes: number;
loading: boolean;
error: string;
saving: boolean;
saveError: string;
savedUrl: string;
}
// ── Review (description jobs) ─────────────────────────────────────────────────
interface DescriptionReview {
kind: 'description';
jobId: string;
slug: string;
instructions: string;
oldDescription: string;
newDescription: string;
loading: boolean;
error: string;
applying: boolean;
applyError: string;
applyDone: boolean;
}
type ReviewState = ChapterNamesReview | ImageGenReview | DescriptionReview;
let review = $state<ReviewState | null>(null);
// ── Open review ───────────────────────────────────────────────────────────────
async function openReview(job: AIJob) {
review = {
jobId: job.id,
slug: job.slug,
pattern: '',
titles: [],
loading: true,
error: '',
applying: false,
applyError: '',
applyDone: false
};
if (job.kind === 'chapter-names') {
const r: ChapterNamesReview = {
kind: 'chapter-names',
jobId: job.id,
slug: job.slug,
pattern: '',
titles: [],
loading: true,
error: '',
applying: false,
applyError: '',
applyDone: false
};
review = r;
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
}
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
review.pattern = payload.pattern ?? '';
review.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
review.loading = false;
} catch (e) {
review.loading = false;
review.error = String(e);
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
r.pattern = payload.pattern ?? '';
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
}
} else if (job.kind === 'image-gen') {
const r: ImageGenReview = {
kind: 'image-gen',
jobId: job.id,
slug: job.slug,
imageType: '',
prompt: '',
imageSrc: '',
contentType: 'image/png',
bytes: 0,
loading: true,
error: '',
saving: false,
saveError: '',
savedUrl: ''
};
review = r;
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: {
prompt?: string;
type?: string;
content_type?: string;
image_b64?: string;
bytes?: number;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
if (!payload.image_b64) {
r.error = 'No image in job payload.';
r.loading = false;
return;
}
r.imageType = payload.type ?? 'cover';
r.prompt = payload.prompt ?? '';
r.contentType = payload.content_type ?? 'image/png';
r.bytes = payload.bytes ?? 0;
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
}
} else if (job.kind === 'description') {
const r: DescriptionReview = {
kind: 'description',
jobId: job.id,
slug: job.slug,
instructions: '',
oldDescription: '',
newDescription: '',
loading: true,
error: '',
applying: false,
applyError: '',
applyDone: false
};
review = r;
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: {
instructions?: string;
old_description?: string;
new_description?: string;
} = {};
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
r.instructions = payload.instructions ?? '';
r.oldDescription = payload.old_description ?? '';
r.newDescription = payload.new_description ?? '';
r.loading = false;
} catch (e) {
r.loading = false;
r.error = String(e);
}
}
}
@@ -137,8 +259,10 @@
review = null;
}
async function applyReview() {
if (!review || review.applying) return;
// ── Apply chapter names ───────────────────────────────────────────────────────
async function applyChapterNames() {
if (review?.kind !== 'chapter-names' || review.applying) return;
review.applying = true;
review.applyError = '';
review.applyDone = false;
@@ -163,16 +287,70 @@
}
}
// ── 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)';
// ── Save image as cover ───────────────────────────────────────────────────────
async function saveImageAsCover() {
if (review?.kind !== 'image-gen' || review.saving) return;
review.saving = true;
review.saveError = '';
const b64 = review.imageSrc.split(',')[1];
try {
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: review.slug, image_b64: b64 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
review.saveError = body.error ?? `Error ${res.status}`;
} else {
review.savedUrl = body.cover_url ?? `/api/cover/novelfire.net/${review.slug}`;
}
} catch {
review.saveError = 'Network error.';
} finally {
review.saving = false;
}
}
function downloadImage() {
if (review?.kind !== 'image-gen') return;
const a = document.createElement('a');
a.href = review.imageSrc;
const ext = review.contentType === 'image/jpeg' ? 'jpg' : 'png';
a.download = `${review.slug}-${review.imageType}.${ext}`;
a.click();
}
// ── Apply description ─────────────────────────────────────────────────────────
async function applyDescription() {
if (review?.kind !== 'description' || review.applying) return;
review.applying = true;
review.applyError = '';
review.applyDone = false;
try {
const res = await fetch('/api/admin/text-gen/description/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ slug: review.slug, description: review.newDescription })
});
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 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)';
@@ -185,6 +363,8 @@
function kindLabel(kind: string) {
const labels: Record<string, string> = {
'chapter-names': 'Chapter Names',
'image-gen': 'Image Gen',
'description': 'Description',
'batch-covers': 'Batch Covers',
'chapter-covers': 'Chapter Covers',
'refresh-metadata': 'Refresh Metadata'
@@ -216,6 +396,14 @@
if (!job.items_total) return null;
return Math.round((job.items_done / job.items_total) * 100);
}
function fmtBytes(b: number) {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(2)} MB`;
}
const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
</script>
<div class="max-w-6xl mx-auto space-y-6">
@@ -390,7 +578,7 @@
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if job.kind === 'chapter-names' && job.status === 'done'}
{#if REVIEWABLE_KINDS.has(job.kind) && 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"
@@ -419,7 +607,7 @@
{/if}
</div>
<!-- ── Review & Apply panel ──────────────────────────────────────────────────── -->
<!-- ── Review panel (shared backdrop + modal shell) ─────────────────────────── -->
{#if review}
<!-- Backdrop -->
<div
@@ -428,102 +616,241 @@
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>
<!-- Modal -->
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[90vh] 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(960px,95vw)] sm:max-h-[88vh] sm:rounded-xl sm:border sm:shadow-2xl">
<!-- 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…
<!-- ── Chapter names review ─────────────────────────────────────────────── -->
{#if review.kind === 'chapter-names'}
<!-- 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>
{: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>
<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>
{/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>
</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={applyChapterNames}
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}
<!-- ── Image-gen review ─────────────────────────────────────────────────── -->
{:else if review.kind === 'image-gen'}
<!-- 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 Generated Image</h2>
<p class="text-xs text-(--color-muted) mt-0.5">
<span class="font-mono">{review.slug}</span>
{#if review.imageType} · {review.imageType}{/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 p-5">
{#if review.loading}
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading image…</div>
{:else if review.error}
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
{:else if review.imageSrc}
<div class="flex flex-col sm:flex-row gap-5 items-start">
<!-- Image -->
<div class="flex-shrink-0 w-full sm:w-64">
<img
src={review.imageSrc}
alt="Generated"
class="w-full rounded-lg border border-(--color-border) object-contain max-h-80 bg-zinc-950"
/>
</div>
<!-- Meta -->
<div class="flex-1 space-y-3 text-sm">
{#if review.prompt}
<div>
<p class="text-xs text-(--color-muted) mb-1">Prompt</p>
<p class="text-(--color-text) leading-relaxed">{review.prompt}</p>
</div>
{/if}
{#if review.bytes > 0}
<div>
<p class="text-xs text-(--color-muted)">Size</p>
<p class="text-(--color-text)">{fmtBytes(review.bytes)}</p>
</div>
{/if}
{#if review.savedUrl}
<p class="text-xs text-green-400">
Saved as cover →
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
</p>
{/if}
{#if review.saveError}
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer -->
{#if !review.loading && !review.error && review.imageSrc}
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
<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">Discard</button>
<div class="flex items-center gap-3">
<button
onclick={downloadImage}
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
>
Download
</button>
{#if review.imageType === 'cover' && !review.savedUrl}
<button
onclick={saveImageAsCover}
disabled={review.saving}
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.saving ? 'Saving…' : 'Save as cover'}
</button>
{:else if review.savedUrl}
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
{/if}
</div>
</div>
{/if}
<!-- ── Description review ──────────────────────────────────────────────── -->
{:else if review.kind === 'description'}
<!-- 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 Description</h2>
<p class="text-xs text-(--color-muted) mt-0.5">
<span class="font-mono">{review.slug}</span>
</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 p-5 space-y-5">
{#if review.loading}
<div class="flex items-center justify-center py-16 text-(--color-muted) text-sm">Loading…</div>
{:else if review.error}
<div class="text-center py-8"><p class="text-(--color-danger) text-sm">{review.error}</p></div>
{:else}
<!-- Old description -->
<div class="space-y-1">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Current description</p>
<p class="text-sm text-(--color-muted) leading-relaxed bg-(--color-surface-2) rounded-lg px-4 py-3 border border-(--color-border)">
{review.oldDescription || '—'}
</p>
</div>
<!-- New description (editable) -->
<div class="space-y-1">
<p class="text-xs font-semibold text-(--color-text) uppercase tracking-wide">Proposed description <span class="normal-case font-normal text-(--color-muted)">(editable)</span></p>
<textarea
bind:value={review.newDescription}
rows="6"
class="w-full px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/50 text-sm text-(--color-text) leading-relaxed resize-y focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
></textarea>
</div>
{#if review.instructions}
<p class="text-xs text-(--color-muted)">
<span class="font-medium text-(--color-text)">Instructions:</span> {review.instructions}
</p>
{/if}
{/if}
</div>
<!-- Footer -->
{#if !review.loading && !review.error}
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4">
<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">Discard</button>
<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={applyDescription}
disabled={review.applying || review.applyDone || !review.newDescription.trim()}
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'}
</button>
</div>
</div>
{/if}
{/if}
</div>
{/if}

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listAudioCache, listAudioJobs, type AudioCacheEntry, type AudioJob } from '$lib/server/pocketbase';
import { listAudioJobs, type AudioCacheEntry, type AudioJob } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
@@ -8,16 +8,20 @@ export const load: PageServerLoad = async ({ locals }) => {
redirect(302, '/');
}
const [entries, jobs] = await Promise.all([
listAudioCache().catch((e): AudioCacheEntry[] => {
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
return [];
}),
listAudioJobs().catch((e): AudioJob[] => {
log.warn('admin/audio', 'failed to load audio jobs', { err: String(e) });
return [];
})
]);
const jobs = await listAudioJobs().catch((e): AudioJob[] => {
log.warn('admin/audio', 'failed to load audio jobs', { err: String(e) });
return [];
});
// Derive cache entries from done jobs — no second query needed.
const entries: AudioCacheEntry[] = jobs
.filter((j) => j.status === 'done')
.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
return { entries, jobs };
};

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
@@ -21,8 +20,17 @@
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
const id = setInterval(async () => {
const res = await fetch('/api/admin/audio-jobs').catch(() => null);
if (res?.ok) {
const body = await res.json().catch(() => null);
if (body?.jobs) {
jobs = body.jobs;
entries = (body.jobs as AudioJob[])
.filter((j) => j.status === 'done')
.map((j) => ({ id: j.id, cache_key: j.cache_key, filename: `${j.cache_key}.mp3`, updated: j.finished }));
}
}
}, 3000);
return () => clearInterval(id);
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import type { ImageModelInfo, BookSummary } from './+page.server';
@@ -120,30 +121,58 @@
// ── Generation state ─────────────────────────────────────────────────────────
let generating = $state(false);
let genError = $state('');
let elapsedMs = $state(0);
let elapsedInterval: ReturnType<typeof setInterval> | null = null;
// ── Result state ─────────────────────────────────────────────────────────────
interface GenResult {
imageSrc: string;
model: string;
bytes: number;
contentType: string;
saved: boolean;
coverUrl: string;
elapsedMs: number;
slug: string;
imageType: ImageType;
chapter: number;
// ── Generate (async: fire-and-forget → redirect to ai-jobs) ─────────────────
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
async function generate() {
if (!canGenerate) return;
generating = true;
genError = '';
try {
const payload = {
prompt: prompt.trim(),
model: selectedModel,
type: imageType,
slug: slug.trim(),
chapter: imageType === 'chapter' ? chapter : 0,
num_steps: numSteps,
guidance,
strength,
width,
height
};
let res: Response;
if (referenceFile && selectedModelInfo?.supports_ref) {
const fd = new FormData();
fd.append('json', JSON.stringify(payload));
fd.append('reference', referenceFile);
res = await fetch('/api/admin/image-gen/async', { method: 'POST', body: fd });
} else {
res = await fetch('/api/admin/image-gen/async', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
genError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
// Navigate to ai-jobs so the admin can monitor progress and review.
await goto('/admin/ai-jobs');
} catch {
genError = 'Network error.';
} finally {
generating = false;
}
}
let result = $state<GenResult | null>(null);
let history = $state<GenResult[]>([]);
let saving = $state(false);
let saveError = $state('');
let saveSuccess = $state(false);
// ── Model helpers ────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const models: ImageModelInfo[] = data.models ?? [];
@@ -300,6 +329,12 @@
if (input) input.value = '';
}
function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ── Selected model info ──────────────────────────────────────────────────────
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
let refWarning = $derived(
@@ -307,147 +342,6 @@
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
: ''
);
// ── Generate ────────────────────────────────────────────────────────────────
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
async function generate() {
if (!canGenerate) return;
generating = true;
genError = '';
result = null;
elapsedMs = 0;
saveSuccess = false;
saveError = '';
const startTs = Date.now();
elapsedInterval = setInterval(() => {
elapsedMs = Date.now() - startTs;
}, 200);
try {
const payload = {
prompt: prompt.trim(),
model: selectedModel,
type: imageType,
slug: slug.trim(),
chapter: imageType === 'chapter' ? chapter : 0,
num_steps: numSteps,
guidance,
strength,
width,
height
};
let res: Response;
if (referenceFile && selectedModelInfo?.supports_ref) {
const fd = new FormData();
fd.append('json', JSON.stringify(payload));
fd.append('reference', referenceFile);
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
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;
}
const totalMs = Date.now() - startTs;
const newResult: GenResult = {
imageSrc: `data:${body.content_type};base64,${body.image_b64}`,
model: body.model,
bytes: body.bytes,
contentType: body.content_type,
saved: body.saved ?? false,
coverUrl: body.cover_url ?? '',
elapsedMs: totalMs,
slug: slug.trim(),
imageType,
chapter
};
result = newResult;
history = [newResult, ...history].slice(0, 5);
} catch {
genError = 'Network error.';
} finally {
generating = false;
if (elapsedInterval) {
clearInterval(elapsedInterval);
elapsedInterval = null;
}
}
}
// ── Save as cover ────────────────────────────────────────────────────────────
async function saveAsCover() {
if (!result || saving) return;
saving = true;
saveError = '';
saveSuccess = false;
try {
const b64 = result.imageSrc.split(',')[1];
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: result.slug, image_b64: b64 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
saveError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
if (body.saved) {
saveSuccess = true;
result = { ...result, saved: true, coverUrl: body.cover_url ?? result.coverUrl };
} else {
saveError = 'Backend did not save the cover.';
}
} catch {
saveError = 'Network error.';
} finally {
saving = false;
}
}
// ── Download ─────────────────────────────────────────────────────────────────
function download() {
if (!result) return;
const a = document.createElement('a');
a.href = result.imageSrc;
const ext = result.contentType === 'image/jpeg' ? 'jpg' : 'png';
a.download =
result.imageType === 'cover'
? `${result.slug}-cover.${ext}`
: `${result.slug}-ch${result.chapter}.${ext}`;
a.click();
}
// ── Formatting helpers ───────────────────────────────────────────────────────
function fmtElapsed(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtBytes(b: number) {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(2)} MB`;
}
</script>
<svelte:head>
@@ -830,9 +724,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Generating… {fmtElapsed(elapsedMs)}
Queuing…
{:else}
Generate
Generate (async)
{/if}
</button>
@@ -841,116 +735,37 @@
{/if}
</div>
<!-- ── Right: Result panel ────────────────────────────────────────────────── -->
<!-- ── Right: Info panel ──────────────────────────────────────────────────── -->
<div class="space-y-4">
{#if result}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Image -->
<img
src={result.imageSrc}
alt=""
aria-label="Generated cover"
class="w-full object-contain max-h-[36rem] bg-zinc-950"
/>
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-3">
<h2 class="text-sm font-semibold text-(--color-text)">How it works</h2>
<ol class="space-y-2 text-sm text-(--color-muted) list-decimal list-inside">
<li>Fill in the form and click <strong class="text-(--color-text)">Generate (async)</strong>.</li>
<li>The job is queued in the background — no waiting on this page.</li>
<li>You'll be taken to <strong class="text-(--color-text)">AI Jobs</strong> to monitor progress.</li>
<li>When done, click <strong class="text-(--color-text)">Review</strong> to see the image and approve or discard it.</li>
</ol>
<a
href="/admin/ai-jobs"
class="mt-3 flex items-center gap-1.5 text-sm text-(--color-brand) hover:underline"
>
<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>
Go to AI Jobs
</a>
</div>
<!-- Meta bar -->
<div class="px-4 py-3 border-t border-(--color-border) space-y-3">
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<p class="text-(--color-muted)">Model</p>
<p class="text-(--color-text) font-mono truncate" title={result.model}>
{models.find((m) => m.id === result!.model)?.label ?? result.model}
</p>
</div>
<div>
<p class="text-(--color-muted)">Size</p>
<p class="text-(--color-text)">{fmtBytes(result.bytes)}</p>
</div>
<div>
<p class="text-(--color-muted)">Time</p>
<p class="text-(--color-text)">{fmtElapsed(result.elapsedMs)}</p>
</div>
</div>
{#if result.saved}
<p class="text-xs text-green-400">
Cover saved &rarr;
<a href={result.coverUrl} target="_blank" rel="noopener noreferrer"
class="underline hover:text-green-300">{result.coverUrl}</a>
</p>
{/if}
{#if saveSuccess && !result.saved}
<p class="text-xs text-green-400">Cover saved successfully.</p>
{/if}
{#if saveError}
<p class="text-xs text-(--color-danger)">{saveError}</p>
{/if}
<!-- Actions -->
<div class="flex gap-2 flex-wrap">
<button
onclick={download}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) text-xs font-medium hover:bg-zinc-600 transition-colors"
>
Download
</button>
{#if result.imageType === 'cover'}
<button
onclick={saveAsCover}
disabled={saving || result.saved}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) text-xs font-semibold
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : result.saved ? 'Saved ✓' : 'Save as cover'}
</button>
{/if}
</div>
</div>
</div>
{:else if generating}
<!-- Placeholder while generating -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-80">
<div class="text-center space-y-3">
<svg class="w-8 h-8 animate-spin mx-auto text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating… {fmtElapsed(elapsedMs)}</p>
</div>
</div>
{:else}
<!-- Empty state -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-80">
<p class="text-sm text-(--color-muted)">Generated image will appear here</p>
</div>
{/if}
<!-- History thumbnails -->
{#if history.length > 0}
<div class="space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Session history</p>
<div class="flex gap-2 flex-wrap">
{#each history as h, i}
<button
onclick={() => result = h}
class="relative rounded-md overflow-hidden border transition-colors shrink-0
{result === h ? 'border-(--color-brand)' : 'border-(--color-border) hover:border-(--color-brand)/50'}"
>
<img
src={h.imageSrc}
alt="History {i + 1}"
class="w-16 h-16 object-cover"
/>
{#if h.saved}
<span class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border border-(--color-surface)"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl p-5 space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wide">Tips</p>
<ul class="space-y-1.5 text-xs text-(--color-muted)">
<li>• Use <strong class="text-(--color-text)">Auto-prompt</strong> to generate a prompt from the book's description.</li>
<li>• FLUX models produce high-quality covers but take 60120 s — the async path prevents timeouts.</li>
<li>• Keep steps ≤ 20 on Cloudflare Workers AI to stay within the ~100 s limit.</li>
<li>• Reference images (img2img) only work with models that show ★ref.</li>
</ul>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { listBooks, listTranslationJobs, type TranslationJob } from '$lib/server/pocketbase';
import { listBookSlugs, listTranslationJobs, type TranslationJob } from '$lib/server/pocketbase';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
@@ -10,8 +10,8 @@ export const load: PageServerLoad = async ({ locals }) => {
}
const [books, jobs] = await Promise.all([
listBooks().catch((e): Awaited<ReturnType<typeof listBooks>> => {
log.warn('admin/translation', 'failed to load books', { err: String(e) });
listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
return [];
}),
listTranslationJobs().catch((e): TranslationJob[] => {

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import type { TranslationJob } from '$lib/server/pocketbase';
@@ -19,8 +18,12 @@
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
const id = setInterval(async () => {
const res = await fetch('/api/admin/translation-jobs').catch(() => null);
if (res?.ok) {
const body = await res.json().catch(() => null);
if (body?.jobs) jobs = body.jobs;
}
}, 3000);
return () => clearInterval(id);
});

View File

@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listAudioJobs } from '$lib/server/pocketbase';
/**
* GET /api/admin/audio-jobs
* Returns the current audio jobs list (served from 30 s Valkey cache).
* Used by the admin audio page for lightweight polling instead of invalidateAll().
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const jobs = await listAudioJobs().catch(() => []);
return json({ jobs });
};

View File

@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listScrapingTasks } from '$lib/server/pocketbase';
/**
* GET /api/admin/scrape-tasks
* Returns the current scraping task list (served from 30 s Valkey cache).
* Used by the admin scrape page for lightweight polling instead of invalidateAll().
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const tasks = await listScrapingTasks().catch(() => []);
return json({ tasks });
};

View File

@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listTranslationJobs } from '$lib/server/pocketbase';
/**
* GET /api/admin/translation-jobs
* Returns the current translation jobs list (served from 30 s Valkey cache).
* Used by the admin translation page for lightweight polling instead of invalidateAll().
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const jobs = await listTranslationJobs().catch(() => []);
return json({ jobs });
};

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
@@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ locals }) => {
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size || 1.0
fontSize: settings?.font_size || 1.0,
announceChapter: settings?.announce_chapter ?? false
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
@@ -28,7 +29,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -67,6 +68,11 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}
// announceChapter is optional boolean
if (body.announceChapter !== undefined && typeof body.announceChapter !== 'boolean') {
error(400, 'Invalid announceChapter — must be boolean');
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {

View File

@@ -567,7 +567,8 @@
</div>
</div>
<!-- Page indicator + nav -->
<!-- Page indicator + nav (hidden in focus mode — shown in floating pill instead) -->
{#if !layout.focusMode}
<div class="flex items-center justify-between mt-4 select-none">
<button
type="button"
@@ -596,6 +597,7 @@
</button>
</div>
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
{/if}
{:else}
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
@@ -649,40 +651,78 @@
<!-- ── 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="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"
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
<!-- Prev chapter -->
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors shrink-0"
aria-label="Previous 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>
{/if}
<!-- Page prev / counter / next (paginated mode only) -->
{#if layout.readMode === 'paginated'}
<button
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Previous page"
>
<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>
</button>
<span class="px-2.5 py-2 tabular-nums text-(--color-muted) shrink-0 select-none">
{pageIndex + 1}<span class="opacity-40">/</span>{totalPages}
</span>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
class="flex items-center justify-center px-2.5 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-30 transition-colors shrink-0"
aria-label="Next page"
>
<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>
</button>
{/if}
<!-- Exit focus -->
<button
type="button"
onclick={() => setLayout('focusMode', false)}
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-brand) hover:bg-(--color-surface-3) transition-colors shrink-0"
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="M15 19l-7-7 7-7"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
{m.reader_chapter_n({ n: String(data.prev) })}
</a>
{/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="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_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}
Exit focus
</button>
<!-- Next chapter -->
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="flex items-center gap-1 px-3 py-2 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors shrink-0"
aria-label="Next chapter"
>
{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>
{/if}

View File

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

View File

@@ -86,7 +86,7 @@
let transitioning = $state(false);
let showPreview = $state(false);
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
let activeTab = $state<'discover' | 'history'>('discover');
let showHistory = $state(false);
// svelte-ignore state_referenced_locally
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
@@ -239,6 +239,16 @@
body: JSON.stringify({ slug: book.slug, action })
});
// Optimistically add/update the history list so the drawer shows it immediately.
// If this slug was already voted (e.g. swiped twice via undo+re-swipe), replace it.
const existing = votedBooks.findIndex((v) => v.slug === book.slug);
const entry: VotedBook = { slug: book.slug, action, votedAt: new Date().toISOString(), book };
if (existing !== -1) {
votedBooks = [entry, ...votedBooks.filter((_, i) => i !== existing)];
} else {
votedBooks = [entry, ...votedBooks];
}
// Fly out
transitioning = true;
const target = flyTargets[action];
@@ -426,49 +436,125 @@
</div>
{/if}
<!-- ── History drawer ─────────────────────────────────────────────────────────── -->
{#if showHistory}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
role="presentation"
onclick={() => (showHistory = false)}
onkeydown={(e) => { if (e.key === 'Escape') showHistory = false; }}
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between p-5 border-b border-(--color-border) flex-shrink-0">
<h3 class="font-bold text-(--color-text)">History {#if votedBooks.length}<span class="text-(--color-muted) font-normal text-sm">({votedBooks.length})</span>{/if}</h3>
<button
type="button"
onclick={() => (showHistory = false)}
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
<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>
<div class="overflow-y-auto flex-1 p-4 space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{:else}
{#each votedBooks as v (v.slug)}
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-xl p-3">
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-2) flex-shrink-0"></div>
{/if}
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
<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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
Clear all history
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-4 pt-8 pb-6 select-none">
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-3 pt-6 pb-6 select-none">
<!-- Header -->
<div class="w-full max-w-sm flex items-center justify-between mb-6">
<div class="w-full max-w-sm flex items-center justify-between mb-4">
<div>
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
{#if !deckEmpty}
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
{/if}
</div>
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<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>
<div class="flex items-center gap-1">
<!-- History button -->
<button
type="button"
onclick={() => (showHistory = true)}
title="History"
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{#if votedBooks.length}
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
{votedBooks.length > 9 ? '9+' : votedBooks.length}
</span>
{/if}
</button>
<!-- Preferences button -->
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<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="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>
</div>
</div>
<!-- Tab switcher -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
<button
type="button"
onclick={() => (activeTab = 'discover')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
History {#if votedBooks.length}({votedBooks.length}){/if}
</button>
</div>
{#if activeTab === 'discover'}
{#if deckEmpty}
<!-- Empty state -->
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
@@ -501,8 +587,9 @@
</div>
{:else}
{@const book = currentBook!}
<!-- Card stack -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
<!-- Card stack — fills available width, taller ratio -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.6;">
<!-- Back card 2 -->
{#if nextNextBook}
@@ -561,9 +648,9 @@
{/if}
<!-- Bottom gradient + info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none"></div>
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
{#if book.author}
<p class="text-white/70 text-sm mb-2">{book.author}</p>
{/if}
@@ -582,164 +669,91 @@
<!-- LIKE indicator (right swipe) -->
<div
class="absolute top-8 right-6 px-3 py-1.5 rounded-lg border-2 border-green-400 rotate-[-15deg] pointer-events-none"
class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-green-400 font-black text-lg tracking-widest">LIKE</span>
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
</div>
<!-- SKIP indicator (left swipe) -->
<div
class="absolute top-8 left-6 px-3 py-1.5 rounded-lg border-2 border-red-400 rotate-[15deg] pointer-events-none"
class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-red-400 font-black text-lg tracking-widest">SKIP</span>
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
</div>
<!-- READ NOW indicator (swipe up) -->
<div
class="absolute top-8 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-blue-400 pointer-events-none"
class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-blue-400 font-black text-lg tracking-widest">READ NOW</span>
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
</div>
<!-- NOPE indicator (swipe down) -->
<div
class="absolute bottom-28 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-(--color-muted) pointer-events-none"
class="absolute bottom-32 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-zinc-400 pointer-events-none bg-black/20"
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
<span class="text-zinc-300 font-black text-2xl tracking-widest">NOPE</span>
</div>
</div>
{/if}
</div>
<!-- Action buttons -->
<div class="w-full max-w-sm flex items-center justify-center gap-4 mt-6">
<!-- Skip (left) -->
<!-- Action buttons — 3 prominent labeled buttons -->
<div class="w-full max-w-sm flex items-stretch gap-3 mt-5">
<!-- Skip -->
<button
type="button"
onclick={() => doAction('skip')}
disabled={animating}
title="Skip"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-red-400 hover:bg-red-400/10 hover:border-red-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-red-500/15 border border-red-500/30 text-red-400
hover:bg-red-500/25 hover:border-red-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="text-xs font-bold tracking-wide">Skip</span>
</button>
<!-- Read Now (up) -->
<!-- Read Now — center, most prominent -->
<button
type="button"
onclick={() => doAction('read_now')}
disabled={animating}
title="Read Now"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-blue-400 hover:bg-blue-400/10 hover:border-blue-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-blue-500 text-white
hover:bg-blue-400
active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span class="text-xs font-bold tracking-wide">Read Now</span>
</button>
<!-- Preview (center) -->
<button
type="button"
onclick={() => (showPreview = true)}
disabled={animating}
title="Details"
class="w-10 h-10 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<!-- Like (right) -->
<!-- Like -->
<button
type="button"
onclick={() => doAction('like')}
disabled={animating}
title="Add to Library"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-success) hover:bg-green-400/10 hover:border-green-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-green-500/15 border border-green-500/30 text-green-400
hover:bg-green-500/25 hover:border-green-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</button>
<!-- Nope (down) -->
<button
type="button"
onclick={() => doAction('nope')}
disabled={animating}
title="Never show again"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-muted)/60 hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
<span class="text-xs font-bold tracking-wide">Like</span>
</button>
</div>
<!-- Swipe hint (shown briefly) -->
<p class="mt-4 text-xs text-(--color-muted)/50 text-center">
Swipe or tap buttons · Tap card for details
</p>
{/if}
{/if}
{#if activeTab === 'history'}
<div class="w-full max-w-sm space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{:else}
{#each votedBooks as v (v.slug)}
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
<!-- Cover thumbnail -->
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
{/if}
<!-- Info -->
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<!-- Undo button -->
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
<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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
Clear all history
</button>
{/if}
</div>
{/if}
</div>