Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6904bcb6e | ||
|
|
75e6a870d3 | ||
|
|
5098acea20 | ||
|
|
3e4d7b54d7 | ||
|
|
495f386b4f | ||
|
|
bb61a4654a | ||
|
|
1cdc7275f8 | ||
|
|
9d925382b3 | ||
|
|
718929e9cd | ||
|
|
e8870a11da | ||
|
|
b70fed5cd7 | ||
|
|
5dd9dd2ebb | ||
|
|
1c5c25e5dd | ||
|
|
5177320418 | ||
|
|
836c9855af | ||
|
|
5c2c9b1b67 | ||
|
|
79b3de3e8d | ||
|
|
5804cd629a | ||
|
|
b130ba4e1b | ||
|
|
cc1f6b87e4 | ||
|
|
8279bd5caa |
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 2–4 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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -189,6 +189,15 @@ html {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Hero carousel fade ─────────────────────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
@@ -98,6 +99,27 @@
|
||||
/** Currently active sample <audio> element — one at a time. */
|
||||
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// ── Chapter picker state ─────────────────────────────────────────────────
|
||||
let showChapterPanel = $state(false);
|
||||
let chapterSearch = $state('');
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
showChapterPanel = false;
|
||||
chapterSearch = '';
|
||||
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for a voice.
|
||||
* Kokoro: "af_bella" → "Bella (US F)"
|
||||
@@ -219,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;
|
||||
});
|
||||
@@ -256,11 +278,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Close voice panel when user clicks outside (escape key).
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
stopSample();
|
||||
showVoicePanel = false;
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,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).
|
||||
@@ -787,6 +815,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -990,7 +1023,7 @@
|
||||
</button>
|
||||
<!-- Time display -->
|
||||
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
<!-- Speed cycle -->
|
||||
<button
|
||||
@@ -1024,21 +1057,29 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
class={cn('gap-1.5 text-xs', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title="Browse chapters"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
Chapters
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- Voice selector button -->
|
||||
{#if voices.length > 0}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1119,6 +1160,79 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge (mirrors voice radio indicator) -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
<!-- ── This chapter is the active one ── -->
|
||||
|
||||
@@ -1170,7 +1284,7 @@
|
||||
<span>{m.reader_paused()}</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
onclose: () => void;
|
||||
/** When true, open the chapter picker immediately on mount. */
|
||||
openChapters?: boolean;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
let { onclose, openChapters = false }: Props = $props();
|
||||
|
||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||
const voices = $derived(audioStore.voices);
|
||||
@@ -18,10 +20,71 @@
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
let showVoiceModal = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showChapterModal = $state(openChapters && audioStore.chapters.length > 0);
|
||||
let voiceSearch = $state('');
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio: HTMLAudioElement | null = null;
|
||||
|
||||
// ── 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)));
|
||||
@@ -30,6 +93,14 @@
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
@@ -163,7 +234,8 @@
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
if (showChapterModal) { showChapterModal = false; }
|
||||
else if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
else { onclose(); }
|
||||
}
|
||||
}
|
||||
@@ -175,47 +247,126 @@
|
||||
<!-- 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>
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showVoiceModal = !showVoiceModal)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoiceModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showChapterModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/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"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
Chapters
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoiceModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 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;"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
{#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) -->
|
||||
@@ -239,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">
|
||||
@@ -254,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]}
|
||||
@@ -264,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'}
|
||||
>
|
||||
@@ -323,33 +451,69 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Cover art + track info -->
|
||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
<!-- 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);">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
|
||||
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
|
||||
</p>
|
||||
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── 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;">
|
||||
|
||||
<!-- Seek bar -->
|
||||
<div class="px-6 shrink-0">
|
||||
<div class="shrink-0 mb-1">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
@@ -357,34 +521,39 @@
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
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>
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
<div class="flex items-center justify-between pt-3 pb-4 shrink-0">
|
||||
<!-- Prev chapter — smaller, clearly secondary -->
|
||||
{#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"
|
||||
<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 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
<path d="M6 6h2v12H6zm2 6 8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<!-- Skip back 15s — medium -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
@@ -392,31 +561,32 @@
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<!-- Play / Pause — largest, centred -->
|
||||
<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"
|
||||
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-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-8 h-8" 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">
|
||||
<svg class="w-8 h-8 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<!-- Skip forward 30s — medium -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
@@ -424,33 +594,34 @@
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<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 -->
|
||||
<!-- Next chapter — smaller, clearly secondary -->
|
||||
{#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"
|
||||
<button
|
||||
type="button"
|
||||
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-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
|
||||
<div class="flex items-center justify-center gap-2 shrink-0">
|
||||
<!-- 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"
|
||||
@@ -466,7 +637,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<!-- Auto-next pill -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
@@ -490,7 +661,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<!-- Sleep timer pill -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
@@ -509,51 +680,5 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
|
||||
<!-- Header + search -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-(--color-border)">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider shrink-0">Chapters</p>
|
||||
{#if audioStore.chapters.length > 6}
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-6 pr-2 py-0.5 text-xs bg-(--color-surface-3) border border-(--color-border) rounded-md text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-y-auto max-h-64">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3) text-left',
|
||||
ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'
|
||||
)}
|
||||
>
|
||||
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
|
||||
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-4 text-xs text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
437
ui/src/lib/components/SearchModal.svelte
Normal file
437
ui/src/lib/components/SearchModal.svelte
Normal 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>
|
||||
@@ -248,6 +248,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 +280,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
|
||||
@@ -1052,16 +1085,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 +1104,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 +1130,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 +1161,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(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '../app.css';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { setContext } from 'svelte';
|
||||
import { setContext, untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -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);
|
||||
@@ -33,28 +44,13 @@
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
let listeningModeChapters = $state(false);
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chapterDrawerOpen && activeChapterEl) {
|
||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||
@@ -171,15 +167,23 @@
|
||||
});
|
||||
|
||||
// Handle toggle requests from AudioPlayer controller.
|
||||
// IMPORTANT: isPlaying must be read inside untrack() so the effect only
|
||||
// re-runs when toggleRequest increments, not every time isPlaying changes.
|
||||
// Without untrack the effect subscribes to both toggleRequest AND isPlaying,
|
||||
// causing an infinite play/pause loop: play() fires onplay → isPlaying=true
|
||||
// → effect re-runs → sees isPlaying=true → calls pause() → onpause fires
|
||||
// → isPlaying=false → effect re-runs → calls play() → …
|
||||
$effect(() => {
|
||||
// Read toggleRequest to subscribe; ignore value 0 (initial).
|
||||
const _req = audioStore.toggleRequest;
|
||||
if (!audioEl || _req === 0) return;
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl.pause();
|
||||
} else {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
untrack(() => {
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl!.pause();
|
||||
} else {
|
||||
audioEl!.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle seek requests from AudioPlayer controller.
|
||||
@@ -268,6 +272,11 @@
|
||||
}, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -391,9 +400,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}
|
||||
@@ -432,6 +444,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
|
||||
@@ -720,7 +746,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">
|
||||
@@ -787,52 +815,6 @@
|
||||
{#if audioStore.active}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Scrollable list -->
|
||||
<div class="overflow-y-auto px-4">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<div class="h-0.5 bg-(--color-surface-2)">
|
||||
@@ -859,10 +841,10 @@
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
||||
|
||||
<!-- Track info (click to open chapter list drawer) -->
|
||||
<!-- Track info (click to open chapter list in listening mode) -->
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
onclick={() => { if (audioStore.chapters.length > 0) { listeningModeChapters = true; listeningModeOpen = true; } }}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
@@ -872,7 +854,7 @@
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
{#if audioStore.isPreview}
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
|
||||
{/if}
|
||||
@@ -965,7 +947,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (listeningModeOpen = true)}
|
||||
onclick={() => { listeningModeChapters = false; listeningModeOpen = true; }}
|
||||
title="Listening mode"
|
||||
aria-label="Open listening mode"
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
@@ -991,9 +973,34 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening mode full-screen overlay -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<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;
|
||||
}
|
||||
}} />
|
||||
|
||||
@@ -72,9 +72,50 @@
|
||||
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||
];
|
||||
|
||||
const heroBook = $derived(data.continueInProgress[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueInProgress.slice(1));
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
// ── Hero carousel ────────────────────────────────────────────────────────
|
||||
const heroBooks = $derived(data.continueInProgress);
|
||||
let heroIndex = $state(0);
|
||||
const heroBook = $derived(heroBooks[heroIndex] ?? null);
|
||||
// Shelf shows remaining books not in the hero
|
||||
const shelfBooks = $derived(
|
||||
heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : []
|
||||
);
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
|
||||
function heroPrev() {
|
||||
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroNext() {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroDot(i: number) {
|
||||
heroIndex = i;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
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;
|
||||
@@ -86,63 +127,108 @@
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
<section class="mb-6">
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="relative">
|
||||
<!-- Card -->
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 animate-fade-in" loading="eager" />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
|
||||
<!-- Prev / Next arrow buttons (only when multiple books) -->
|
||||
{#if heroBooks.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
onclick={heroPrev}
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Previous book"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroNext}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Next book"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-1.5 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
class="rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
@@ -307,147 +336,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 60–120 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 +718,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 +729,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 →
|
||||
<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 60–120 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>
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
16
ui/src/routes/api/admin/audio-jobs/+server.ts
Normal file
16
ui/src/routes/api/admin/audio-jobs/+server.ts
Normal 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 });
|
||||
};
|
||||
16
ui/src/routes/api/admin/scrape-tasks/+server.ts
Normal file
16
ui/src/routes/api/admin/scrape-tasks/+server.ts
Normal 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 });
|
||||
};
|
||||
16
ui/src/routes/api/admin/translation-jobs/+server.ts
Normal file
16
ui/src/routes/api/admin/translation-jobs/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -53,6 +53,8 @@
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'compact';
|
||||
/** Controls how many lines fit on a page by adjusting the container height offset. */
|
||||
type PageLines = 'less' | 'normal' | 'more';
|
||||
|
||||
interface LayoutPrefs {
|
||||
readMode: ReadMode;
|
||||
@@ -61,12 +63,19 @@
|
||||
paraStyle: ParaStyle;
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
pageLines: PageLines;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v1';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
/**
|
||||
* Extra rem subtracted from (or added to) the paginated container height.
|
||||
* Normal (0rem) keeps the existing calc(); Less (-4rem) makes the container
|
||||
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
|
||||
*/
|
||||
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -114,8 +123,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html changes or container is bound
|
||||
// Re-run when html, container refs, or mini-player visibility changes
|
||||
// (mini-player adds pb-24 which reduces the available viewport height)
|
||||
void html; void paginatedContainerEl; void paginatedContentEl;
|
||||
void audioStore.active; void audioExpanded;
|
||||
requestAnimationFrame(() => {
|
||||
if (!paginatedContainerEl || !paginatedContentEl) return;
|
||||
const h = paginatedContainerEl.clientHeight;
|
||||
@@ -139,10 +150,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard nav for paginated mode
|
||||
// Keyboard nav for paginated mode + Escape to close settings overlay
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && settingsPanelOpen) {
|
||||
settingsPanelOpen = false;
|
||||
return;
|
||||
}
|
||||
if (layout.readMode !== 'paginated') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -287,6 +302,15 @@
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
|
||||
// Strip scraper artifacts from chapter titles:
|
||||
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
||||
// - Everything after the first newline (often includes a scraped date)
|
||||
const cleanTitle = $derived.by(() => {
|
||||
let t = (data.chapter.title || '').split('\n')[0].trim();
|
||||
t = t.replace(/^\d+(?=Chapter)/i, '').trim();
|
||||
return t || `Chapter ${data.chapter.number}`;
|
||||
});
|
||||
|
||||
// Audio panel: auto-open if this chapter is already loaded/playing in the store
|
||||
// svelte-ignore state_referenced_locally
|
||||
let audioExpanded = $state(
|
||||
@@ -301,7 +325,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
<title>{cleanTitle} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
||||
@@ -364,8 +388,11 @@
|
||||
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
|
||||
{m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</p>
|
||||
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
{cleanTitle}
|
||||
</h1>
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
|
||||
{#if wordCount > 0}
|
||||
@@ -375,6 +402,9 @@
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.chapter.date_label}
|
||||
<span class="text-(--color-muted) text-xs opacity-60">{data.chapter.date_label}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Language switcher (inline, compact) -->
|
||||
{#if !data.isPreview}
|
||||
@@ -483,7 +513,7 @@
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
@@ -523,7 +553,9 @@
|
||||
role="none"
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
style="height: {layout.focusMode
|
||||
? 'calc(100svh - 8rem)'
|
||||
: `calc(100svh - 26rem - ${PAGE_LINES_OFFSET[layout.pageLines]})`};"
|
||||
onclick={handlePaginatedClick}
|
||||
>
|
||||
<div
|
||||
@@ -535,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"
|
||||
@@ -564,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' : ''}">
|
||||
@@ -617,65 +651,112 @@
|
||||
|
||||
<!-- ── 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}
|
||||
|
||||
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
|
||||
<!-- ── Reader settings overlay ───────────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
{#if settingsPanelOpen}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsPanelOpen = false)}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Reader Settings</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
|
||||
<div class="flex gap-1 mx-4 mt-3 mb-1 p-1 rounded-xl bg-(--color-surface-2) shrink-0 border border-(--color-border)">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsTab = 'reading')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'reading'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Reading</button>
|
||||
<button
|
||||
@@ -683,13 +764,13 @@
|
||||
onclick={() => (settingsTab = 'listening')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'listening'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Listening</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-6 flex flex-col gap-0 mt-2">
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
@@ -755,6 +836,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if layout.readMode === 'paginated'}
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Lines</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['less', 'Few'], ['normal', 'Normal'], ['more', 'Many']] as const) as [v, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('pageLines', v)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.pageLines === v
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.pageLines === v}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
|
||||
Reference in New Issue
Block a user