Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2d1a2603 | ||
|
|
b0d8c02787 | ||
|
|
5b4c1db931 | ||
|
|
0c54c59586 | ||
|
|
0e5eb84097 | ||
|
|
6ef82a1d12 | ||
|
|
7a418ee62b | ||
|
|
d4f35a4899 | ||
|
|
6559a8c015 | ||
|
|
05bfd110b8 | ||
|
|
bfd0ad8fb7 |
@@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
|
||||
}
|
||||
|
||||
// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
|
||||
// Removes duplicate chapters_idx records for a book, keeping the latest record
|
||||
// per chapter number. Returns the number of duplicate records deleted.
|
||||
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(),
|
||||
"deleted": deleted,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
|
||||
writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
|
||||
}
|
||||
|
||||
// ── Audio ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// chapterNamesBatchSize is the number of chapters sent per LLM request.
|
||||
// Keeps output well within the 4096-token response limit (~30 tokens/title).
|
||||
const chapterNamesBatchSize = 100
|
||||
|
||||
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
|
||||
// Returns the list of supported Cloudflare AI text generation models.
|
||||
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -36,16 +40,6 @@ type textGenChapterNamesRequest struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesResponse struct {
|
||||
// Chapters is the list of proposed chapter titles, indexed by chapter number.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
// RawResponse is the raw model output for debugging / manual editing.
|
||||
RawResponse string `json:"raw_response"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
type proposedChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
@@ -55,12 +49,35 @@ type proposedChapterTitle struct {
|
||||
NewTitle string `json:"new_title"`
|
||||
}
|
||||
|
||||
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
|
||||
type chapterNamesBatchEvent struct {
|
||||
// Batch is the 1-based batch index.
|
||||
Batch int `json:"batch"`
|
||||
// TotalBatches is the total number of batches.
|
||||
TotalBatches int `json:"total_batches"`
|
||||
// ChaptersDone is the cumulative count of chapters processed so far.
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
// TotalChapters is the total chapter count for this book.
|
||||
TotalChapters int `json:"total_chapters"`
|
||||
// Model is the CF AI model used.
|
||||
Model string `json:"model"`
|
||||
// Chapters contains the proposed titles for this batch.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Error is non-empty if this batch failed.
|
||||
Error string `json:"error,omitempty"`
|
||||
// Done is true on the final sentinel event (no Chapters).
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
|
||||
//
|
||||
// Reads all chapter titles for the given slug, sends them to the LLM with the
|
||||
// requested naming pattern, and returns proposed replacements. Does NOT persist
|
||||
// anything — the frontend shows a diff and the user must confirm via
|
||||
// POST /api/admin/text-gen/chapter-names/apply.
|
||||
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
|
||||
// to the LLM sequentially, and streams results back as Server-Sent Events so
|
||||
// the frontend can show live progress. Each SSE data line is a JSON-encoded
|
||||
// chapterNamesBatchEvent. The final event has Done=true.
|
||||
//
|
||||
// Does NOT persist anything — the frontend shows a diff and the user must
|
||||
// confirm via POST /api/admin/text-gen/chapter-names/apply.
|
||||
func (s *Server) handleAdminTextGenChapterNames(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)")
|
||||
@@ -92,11 +109,29 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt.
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range chapters {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
|
||||
// Index existing titles for old/new diff.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
// Partition chapters into batches.
|
||||
batches := chunkChapters(chapters, chapterNamesBatchSize)
|
||||
totalBatches := len(batches)
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters),
|
||||
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
|
||||
|
||||
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
|
||||
`The user provides a list of chapter numbers with their current titles, ` +
|
||||
@@ -111,64 +146,91 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Naming pattern: %s\n\nChapters:\n%s",
|
||||
req.Pattern,
|
||||
chapterListSB.String(),
|
||||
)
|
||||
// Switch to SSE before writing anything.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
sseWrite := func(evt chapterNamesBatchEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 4096 tokens so large chapter lists are not truncated.
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
chaptersDone := 0
|
||||
for i, batch := range batches {
|
||||
if r.Context().Err() != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range batch {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
}
|
||||
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
|
||||
"batch", i+1, "err", genErr)
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Error: genErr.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the JSON array from the model response.
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
chaptersDone += len(batch)
|
||||
|
||||
// Build the response: merge proposed titles with old titles.
|
||||
// Index existing chapters by number for O(1) lookup.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Chapters: result,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenChapterNamesResponse{
|
||||
Chapters: result,
|
||||
Model: string(model),
|
||||
RawResponse: raw,
|
||||
})
|
||||
// Final sentinel event.
|
||||
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
|
||||
}
|
||||
|
||||
// chunkChapters splits a chapter slice into batches of at most size n.
|
||||
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
|
||||
var batches [][]domain.ChapterInfo
|
||||
for len(chapters) > 0 {
|
||||
end := n
|
||||
if end > len(chapters) {
|
||||
end = len(chapters)
|
||||
}
|
||||
batches = append(batches, chapters[:end])
|
||||
chapters = chapters[end:]
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// parseChapterTitlesJSON extracts the JSON array from a model response.
|
||||
|
||||
@@ -204,6 +204,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ type BookWriter interface {
|
||||
|
||||
// ChapterExists returns true if the markdown object for ref already exists.
|
||||
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug,
|
||||
// keeping only one record per chapter number (the one with the latest
|
||||
// updated timestamp). Returns the number of duplicate records deleted.
|
||||
DeduplicateChapters(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// BookReader is the read side used by the backend to serve content.
|
||||
|
||||
@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
|
||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
// RankingStore
|
||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
// response. There is no 100-second Cloudflare proxy timeout because we are
|
||||
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
|
||||
// homelab tunnel.
|
||||
//
|
||||
// The aura-2-en model enforces a hard 2 000-character limit per request.
|
||||
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
|
||||
// and concatenates the resulting MP3 frames.
|
||||
package cfai
|
||||
|
||||
import (
|
||||
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
|
||||
}
|
||||
|
||||
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
|
||||
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
|
||||
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("cfai: empty text")
|
||||
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
speaker = "luna"
|
||||
}
|
||||
|
||||
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
|
||||
var combined []byte
|
||||
for _, chunk := range chunks {
|
||||
part, err := c.generateChunk(ctx, chunk, speaker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combined = append(combined, part...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
|
||||
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"speaker": speaker,
|
||||
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
return mp3, nil
|
||||
}
|
||||
|
||||
// splitText splits src into chunks of at most maxChars characters each.
|
||||
// It tries to break at paragraph boundaries first, then at sentence-ending
|
||||
// punctuation (. ! ?), and falls back to the nearest space.
|
||||
func splitText(src string, maxChars int) []string {
|
||||
if len(src) <= maxChars {
|
||||
return []string{src}
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
remaining := src
|
||||
|
||||
for len(remaining) > 0 {
|
||||
if len(remaining) <= maxChars {
|
||||
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||
break
|
||||
}
|
||||
|
||||
// Search window: the first maxChars bytes of remaining.
|
||||
// Use byte length here because the API limit is in bytes/chars for ASCII;
|
||||
// for safety we operate on rune-aware slices.
|
||||
window := remaining
|
||||
if len(window) > maxChars {
|
||||
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
|
||||
window = runeSlice(remaining, maxChars)
|
||||
}
|
||||
|
||||
cut := -1
|
||||
|
||||
// 1. Prefer paragraph break (\n\n or \n).
|
||||
if i := strings.LastIndex(window, "\n\n"); i > 0 {
|
||||
cut = i + 2
|
||||
} else if i := strings.LastIndex(window, "\n"); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
|
||||
// 2. Fall back to sentence-ending punctuation followed by a space.
|
||||
if cut < 0 {
|
||||
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
|
||||
if i := strings.LastIndex(window, punct); i > 0 {
|
||||
candidate := i + len(punct)
|
||||
if cut < 0 || candidate > cut {
|
||||
cut = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Last resort: nearest space.
|
||||
if cut < 0 {
|
||||
if i := strings.LastIndex(window, " "); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hard cut at maxChars runes if no boundary found.
|
||||
if cut < 0 {
|
||||
cut = len(window)
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(remaining[:cut])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
remaining = remaining[cut:]
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// runeSlice returns the first n runes of s as a string.
|
||||
func runeSlice(s string, n int) string {
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return s[:i]
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
mp3, err := c.GenerateAudio(ctx, text, voice)
|
||||
|
||||
@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||
type stubBookReader struct {
|
||||
text string
|
||||
|
||||
@@ -130,7 +130,23 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
|
||||
insertPayload := map[string]any{
|
||||
"slug": slug,
|
||||
"number": ref.Number,
|
||||
"title": ref.Title,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug+number.
|
||||
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
|
||||
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil || len(items) == 0 {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
@@ -139,6 +155,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug.
|
||||
// For each chapter number that has more than one record, it keeps the record
|
||||
// with the latest "updated" timestamp and deletes the rest.
|
||||
// Returns the number of records deleted.
|
||||
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
|
||||
}
|
||||
|
||||
type record struct {
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// Group records by chapter number.
|
||||
byNumber := make(map[int][]record)
|
||||
for _, raw := range items {
|
||||
var rec record
|
||||
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
|
||||
continue
|
||||
}
|
||||
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, recs := range byNumber {
|
||||
if len(recs) <= 1 {
|
||||
continue
|
||||
}
|
||||
// Keep the record with the latest Updated timestamp; delete the rest.
|
||||
keep := 0
|
||||
for i := 1; i < len(recs); i++ {
|
||||
if recs[i].Updated > recs[keep].Updated {
|
||||
keep = i
|
||||
}
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i == keep {
|
||||
continue
|
||||
}
|
||||
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
|
||||
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// ── BookReader ────────────────────────────────────────────────────────────────
|
||||
|
||||
type pbBook struct {
|
||||
|
||||
@@ -38,6 +38,8 @@ services:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
- libretranslate
|
||||
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
|
||||
|
||||
@@ -62,6 +62,39 @@ create() {
|
||||
esac
|
||||
}
|
||||
|
||||
# add_index COLLECTION INDEX_NAME SQL_EXPR
|
||||
# Fetches current schema, adds index if absent by name, PATCHes collection.
|
||||
add_index() {
|
||||
COLL="$1"; INAME="$2"; ISQL="$3"
|
||||
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
indexes = d.get('indexes', [])
|
||||
exists = any('$INAME' in idx for idx in indexes)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
indexes.append('$ISQL')
|
||||
print('indexes=' + json.dumps(indexes))
|
||||
" 2>/dev/null)
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "index exists (skip): $COLL.$INAME"; return
|
||||
fi
|
||||
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
||||
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB/api/collections/$COLL_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "{\"indexes\":${NEW_INDEXES}}")
|
||||
case "$STATUS" in
|
||||
200|201) log "added index: $COLL.$INAME" ;;
|
||||
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# add_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
# Fetches current schema, appends field if absent, PATCHes collection.
|
||||
# Requires python3 for safe JSON manipulation.
|
||||
@@ -116,9 +149,10 @@ create "books" '{
|
||||
|
||||
create "chapters_idx" '{
|
||||
"name":"chapters_idx","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number","type":"number", "required":true},
|
||||
{"name":"title", "type":"text"}
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number", "type":"number", "required":true},
|
||||
{"name":"title", "type":"text"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ranking" '{
|
||||
@@ -293,5 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
add_field "user_sessions" "device_fingerprint" "text"
|
||||
add_field "chapters_idx" "created" "date"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
|
||||
add_index "chapters_idx" "idx_chapters_idx_created" \
|
||||
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
|
||||
|
||||
log "done"
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
/* eslint-disable */
|
||||
export * from './messages/_index.js'
|
||||
// enabling auto-import by exposing all messages as m
|
||||
export * as m from './messages/_index.js'
|
||||
export * from './messages/_index.js'
|
||||
@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
theme: row.theme ?? 'amber',
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size ?? 1.0
|
||||
fontSize: row.font_size || 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||
currentFontSize = data.settings.fontSize ?? 1.0;
|
||||
currentFontSize = data.settings.fontSize || 1.0;
|
||||
// Mark dirty only after the synchronous apply is done so the save
|
||||
// effect doesn't fire for this initial load.
|
||||
setTimeout(() => { settingsDirty = true; }, 0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
id: string;
|
||||
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as ImageModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as ImageModelInfo[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { ImageModelInfo } from './+page.server';
|
||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────────────────────
|
||||
type ImageType = 'cover' | 'chapter';
|
||||
|
||||
const CONFIG_KEY = 'admin_image_gen_config_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
numSteps: number;
|
||||
guidance: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imageType = $state<ImageType>('cover');
|
||||
let slug = $state('');
|
||||
let chapter = $state<number>(1);
|
||||
let selectedModel = $state('');
|
||||
let selectedModel = $state(saved.selectedModel ?? '');
|
||||
let prompt = $state('');
|
||||
let referenceFile = $state<File | null>(null);
|
||||
let referencePreviewUrl = $state('');
|
||||
let useCoverAsRef = $state(false);
|
||||
|
||||
// Advanced
|
||||
let showAdvanced = $state(false);
|
||||
let numSteps = $state(20);
|
||||
let guidance = $state(7.5);
|
||||
let strength = $state(0.75);
|
||||
let width = $state(1024);
|
||||
let height = $state(1024);
|
||||
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let guidance = $state(saved.guidance ?? 7.5);
|
||||
let strength = $state(saved.strength ?? 0.75);
|
||||
let width = $state(saved.width ?? 1024);
|
||||
let height = $state(saved.height ?? 1024);
|
||||
|
||||
// Persist config on change
|
||||
$effect(() => {
|
||||
void selectedModel; void numSteps; void guidance; void strength;
|
||||
void width; void height; void showAdvanced;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
const books = data.books as BookSummary[];
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
let bookSuggestions = $derived(
|
||||
slugInput.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(slugInput.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(slugInput.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
function selectBook(b: BookSummary) {
|
||||
selectedBook = b;
|
||||
slug = b.slug;
|
||||
slugInput = b.slug;
|
||||
slugFocused = false;
|
||||
// Reset cover-as-ref if no cover
|
||||
if (!b.cover) useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onSlugInput() {
|
||||
slug = slugInput;
|
||||
// If user edits away from selected book slug, deselect
|
||||
if (selectedBook && slugInput !== selectedBook.slug) {
|
||||
selectedBook = null;
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
// When useCoverAsRef toggled on, load the book cover as reference
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!useCoverAsRef || !selectedBook?.cover) {
|
||||
if (useCoverAsRef) useCoverAsRef = false;
|
||||
return;
|
||||
}
|
||||
// Fetch the cover image and set as referenceFile
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(selectedBook!.cover);
|
||||
const blob = await res.blob();
|
||||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
|
||||
handleReferenceFile(file);
|
||||
} catch {
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Generation state ─────────────────────────────────────────────────────────
|
||||
let generating = $state(false);
|
||||
@@ -52,16 +146,10 @@
|
||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||
const models = data.models as ImageModelInfo[];
|
||||
|
||||
let filteredModels = $derived(
|
||||
referenceFile
|
||||
? models // show all; warn on ones without ref support
|
||||
: models
|
||||
);
|
||||
|
||||
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let otherModels = $derived(
|
||||
filteredModels.filter(
|
||||
models.filter(
|
||||
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
|
||||
)
|
||||
);
|
||||
@@ -74,12 +162,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reset model selection when type changes if current selection no longer fits
|
||||
$effect(() => {
|
||||
void imageType; // track
|
||||
void imageType;
|
||||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||||
if (preferred.length > 0) {
|
||||
// only auto-switch if current model isn't in preferred list for this type
|
||||
const current = models.find((m) => m.id === selectedModel);
|
||||
if (!current || !current.recommended_for.includes(imageType)) {
|
||||
selectedModel = preferred[0].id;
|
||||
@@ -90,14 +176,21 @@
|
||||
// ── Prompt templates ────────────────────────────────────────────────────────
|
||||
let promptTemplate = $derived(
|
||||
imageType === 'cover'
|
||||
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
);
|
||||
|
||||
function applyTemplate() {
|
||||
prompt = promptTemplate;
|
||||
}
|
||||
|
||||
function injectDescription() {
|
||||
const desc = selectedBook?.summary?.trim();
|
||||
if (!desc) return;
|
||||
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
|
||||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||||
}
|
||||
|
||||
// ── Reference image handling ─────────────────────────────────────────────────
|
||||
let dragOver = $state(false);
|
||||
|
||||
@@ -110,17 +203,22 @@
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
handleReferenceFile(input.files?.[0] ?? null);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleReferenceFile(file);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReference() {
|
||||
handleReferenceFile(null);
|
||||
useCoverAsRef = false;
|
||||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
@@ -219,7 +317,6 @@
|
||||
saveSuccess = false;
|
||||
|
||||
try {
|
||||
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
|
||||
const b64 = result.imageSrc.split(',')[1];
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
@@ -308,13 +405,63 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<!-- Autocomplete wrapper -->
|
||||
<div class="relative">
|
||||
<input
|
||||
id="slug-input"
|
||||
type="text"
|
||||
bind:value={slugInput}
|
||||
oninput={onSlugInput}
|
||||
onfocus={() => (slugFocused = true)}
|
||||
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if slugFocused && bookSuggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each bookSuggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={selectedBook?.slug === b.slug}
|
||||
onmousedown={() => selectBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{#if b.cover}
|
||||
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
|
||||
{:else}
|
||||
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Book info pill when a book is selected -->
|
||||
{#if selectedBook}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-(--color-success) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{selectedBook.title}
|
||||
</span>
|
||||
{#if selectedBook.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
title="Append book description to prompt"
|
||||
>
|
||||
+ inject description
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if imageType === 'chapter'}
|
||||
@@ -385,12 +532,22 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
|
||||
Prompt
|
||||
</label>
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Use template
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if selectedBook?.summary}
|
||||
<button
|
||||
onclick={injectDescription}
|
||||
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Inject description
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={applyTemplate}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Use template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
@@ -403,9 +560,26 @@
|
||||
|
||||
<!-- Reference image drop zone -->
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
|
||||
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
|
||||
</p>
|
||||
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={useCoverAsRef}
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') useCoverAsRef = !useCoverAsRef; }}
|
||||
onclick={() => (useCoverAsRef = !useCoverAsRef)}
|
||||
class="w-8 h-4 rounded-full transition-colors relative {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
||||
>
|
||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted)">Use book cover</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if referenceFile && referencePreviewUrl}
|
||||
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
|
||||
<img
|
||||
@@ -416,6 +590,9 @@
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
|
||||
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
|
||||
{#if useCoverAsRef}
|
||||
<p class="text-xs text-(--color-brand)">Current book cover</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={clearReference}
|
||||
@@ -530,7 +707,6 @@
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if generating}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TextModelInfo {
|
||||
id: string;
|
||||
@@ -12,16 +18,25 @@ export interface TextModelInfo {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as TextModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as TextModelInfo[] };
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as TextModelInfo[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { TextModelInfo } from './+page.server';
|
||||
import type { TextModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const models = data.models as TextModelInfo[];
|
||||
const books = data.books as BookSummary[];
|
||||
|
||||
// ── Config persistence ───────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
activeTab: 'chapters' | 'description';
|
||||
chPattern: string;
|
||||
dInstructions: string;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
const cfg: SavedConfig = { selectedModel, activeTab, chPattern, dInstructions };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────────
|
||||
type ActiveTab = 'chapters' | 'description';
|
||||
let activeTab = $state<ActiveTab>('chapters');
|
||||
let selectedModel = $state(models[0]?.id ?? '');
|
||||
let activeTab = $state<ActiveTab>(saved.activeTab ?? 'chapters');
|
||||
let selectedModel = $state(saved.selectedModel ?? (models[0]?.id ?? ''));
|
||||
|
||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||
|
||||
$effect(() => { void selectedModel; void activeTab; void chPattern; void dInstructions; saveConfig(); });
|
||||
|
||||
function fmtCtx(n: number) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
|
||||
return `${n} ctx`;
|
||||
}
|
||||
|
||||
// ── Book autocomplete (shared component logic) ───────────────────────────────
|
||||
function makeBookAC() {
|
||||
let inputVal = $state('');
|
||||
let focused = $state(false);
|
||||
|
||||
const suggestions = $derived(
|
||||
inputVal.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(inputVal.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(inputVal.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
return {
|
||||
get inputVal() { return inputVal; },
|
||||
set inputVal(v: string) { inputVal = v; },
|
||||
get focused() { return focused; },
|
||||
set focused(v: boolean) { focused = v; },
|
||||
get suggestions() { return suggestions; }
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chapter names state ───────────────────────────────────────────────────────
|
||||
let chAC = makeBookAC();
|
||||
let chSlug = $state('');
|
||||
let chPattern = $state('Chapter {n}: {scene}');
|
||||
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
|
||||
let chGenerating = $state(false);
|
||||
let chError = $state('');
|
||||
|
||||
@@ -28,13 +84,13 @@
|
||||
number: number;
|
||||
old_title: string;
|
||||
new_title: string;
|
||||
// editable copy
|
||||
edited: string;
|
||||
}
|
||||
let chProposals = $state<ProposedChapter[]>([]);
|
||||
let chRawResponse = $state('');
|
||||
let chUsedModel = $state('');
|
||||
let chShowRaw = $state(false);
|
||||
|
||||
let chBatchProgress = $state('');
|
||||
let chBatchWarnings = $state<string[]>([]);
|
||||
|
||||
let chApplying = $state(false);
|
||||
let chApplyError = $state('');
|
||||
@@ -43,11 +99,24 @@
|
||||
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
|
||||
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
|
||||
|
||||
function selectChBook(b: BookSummary) {
|
||||
chSlug = b.slug;
|
||||
chAC.inputVal = b.slug;
|
||||
chAC.focused = false;
|
||||
}
|
||||
|
||||
function onChSlugInput() {
|
||||
chSlug = chAC.inputVal;
|
||||
}
|
||||
|
||||
async function generateChapterNames() {
|
||||
if (!chCanGenerate) return;
|
||||
chGenerating = true;
|
||||
chError = '';
|
||||
chProposals = [];
|
||||
chUsedModel = '';
|
||||
chBatchProgress = '';
|
||||
chBatchWarnings = [];
|
||||
chApplySuccess = false;
|
||||
chApplyError = '';
|
||||
|
||||
@@ -61,20 +130,77 @@
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
// Non-SSE error response (e.g. 400/404/502 before streaming started).
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
chError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
|
||||
(p) => ({ ...p, edited: p.new_title })
|
||||
);
|
||||
chRawResponse = body.raw_response ?? '';
|
||||
chUsedModel = body.model ?? '';
|
||||
// If backend returned chapters:[] but we have a raw response, the model
|
||||
// output was unparseable (likely truncated). Treat it as an error.
|
||||
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
|
||||
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
|
||||
|
||||
// Stream SSE events line by line.
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
outer: while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process all complete SSE messages in the buffer.
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? ''; // keep incomplete last line
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let evt: {
|
||||
batch?: number;
|
||||
total_batches?: number;
|
||||
chapters_done?: number;
|
||||
total_chapters?: number;
|
||||
model?: string;
|
||||
chapters?: { number: number; old_title: string; new_title: string }[];
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
try {
|
||||
evt = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (evt.done) {
|
||||
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
|
||||
chGenerating = false;
|
||||
break outer;
|
||||
}
|
||||
|
||||
if (evt.model) chUsedModel = evt.model;
|
||||
|
||||
if (evt.error) {
|
||||
chBatchWarnings = [
|
||||
...chBatchWarnings,
|
||||
`Batch ${evt.batch}/${evt.total_batches} failed: ${evt.error}`
|
||||
];
|
||||
} else if (evt.chapters) {
|
||||
const incoming = (evt.chapters as { number: number; old_title: string; new_title: string }[]).map(
|
||||
(p) => ({ ...p, edited: p.new_title })
|
||||
);
|
||||
chProposals = [...chProposals, ...incoming];
|
||||
}
|
||||
|
||||
if (evt.batch != null && evt.total_batches != null) {
|
||||
chBatchProgress = `Batch ${evt.batch}/${evt.total_batches} · ${evt.chapters_done ?? chProposals.length}/${evt.total_chapters ?? '?'} chapters`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chProposals.length === 0 && chBatchWarnings.length === 0) {
|
||||
chError = 'No proposals returned. The model may have failed to parse the chapters.';
|
||||
}
|
||||
} catch {
|
||||
chError = 'Network error.';
|
||||
@@ -112,8 +238,9 @@
|
||||
}
|
||||
|
||||
// ── Description state ─────────────────────────────────────────────────────────
|
||||
let dAC = makeBookAC();
|
||||
let dSlug = $state('');
|
||||
let dInstructions = $state('');
|
||||
let dInstructions = $state(saved.dInstructions ?? '');
|
||||
let dGenerating = $state(false);
|
||||
let dError = $state('');
|
||||
|
||||
@@ -128,6 +255,16 @@
|
||||
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
|
||||
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
|
||||
|
||||
function selectDBook(b: BookSummary) {
|
||||
dSlug = b.slug;
|
||||
dAC.inputVal = b.slug;
|
||||
dAC.focused = false;
|
||||
}
|
||||
|
||||
function onDSlugInput() {
|
||||
dSlug = dAC.inputVal;
|
||||
}
|
||||
|
||||
async function generateDescription() {
|
||||
if (!dCanGenerate) return;
|
||||
dGenerating = true;
|
||||
@@ -247,13 +384,37 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="ch-slug"
|
||||
type="text"
|
||||
bind:value={chSlug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="ch-slug"
|
||||
type="text"
|
||||
bind:value={chAC.inputVal}
|
||||
oninput={onChSlugInput}
|
||||
onfocus={() => (chAC.focused = true)}
|
||||
onblur={() => setTimeout(() => { chAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if chAC.focused && chAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each chAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={chSlug === b.slug}
|
||||
onmousedown={() => selectChBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@@ -292,9 +453,6 @@
|
||||
|
||||
{#if chError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
|
||||
{#if chRawResponse}
|
||||
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -309,16 +467,25 @@
|
||||
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (chShowRaw = !chShowRaw)}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
{chShowRaw ? 'Hide raw' : 'Show raw'}
|
||||
</button>
|
||||
{#if chBatchProgress}
|
||||
<span class="text-xs text-(--color-muted) flex items-center gap-1.5">
|
||||
{#if chGenerating}
|
||||
<svg class="w-3 h-3 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-8v8H4z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{chBatchProgress}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chShowRaw}
|
||||
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
|
||||
{#if chBatchWarnings.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each chBatchWarnings as w}
|
||||
<p class="text-xs text-amber-400 bg-amber-400/10 rounded-lg px-3 py-2">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
|
||||
@@ -349,7 +516,7 @@
|
||||
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
|
||||
onclick={() => { chProposals = []; chBatchProgress = ''; chBatchWarnings = []; chApplySuccess = false; }}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
|
||||
hover:text-(--color-text) transition-colors border border-(--color-border)"
|
||||
>
|
||||
@@ -373,7 +540,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>
|
||||
<p class="text-sm text-(--color-muted)">Generating chapter titles…</p>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
{chBatchProgress || 'Generating chapter titles…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -394,13 +563,37 @@
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<input
|
||||
id="d-slug"
|
||||
type="text"
|
||||
bind:value={dSlug}
|
||||
placeholder="e.g. shadow-slave"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="d-slug"
|
||||
type="text"
|
||||
bind:value={dAC.inputVal}
|
||||
oninput={onDSlugInput}
|
||||
onfocus={() => (dAC.focused = true)}
|
||||
onblur={() => setTimeout(() => { dAC.focused = false; }, 150)}
|
||||
placeholder="e.g. shadow-slave"
|
||||
autocomplete="off"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
{#if dAC.focused && dAC.suggestions.length > 0}
|
||||
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
|
||||
{#each dAC.suggestions as b}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={dSlug === b.slug}
|
||||
onmousedown={() => selectDBook(b)}
|
||||
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono">{b.slug}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
|
||||
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/dedup-chapters/[slug]
|
||||
*
|
||||
* Admin-only proxy to the Go backend's dedup endpoint.
|
||||
* Removes duplicate chapters_idx records for a book, keeping the latest
|
||||
* record per chapter number. Returns { slug, deleted }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -2,10 +2,11 @@
|
||||
* POST /api/admin/text-gen/chapter-names
|
||||
*
|
||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
||||
* Returns AI-proposed chapter titles; does NOT persist anything.
|
||||
* The backend streams SSE events; this handler pipes the stream through
|
||||
* directly so the browser can consume it without buffering.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
@@ -28,6 +29,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
// Non-2xx: the backend returned a JSON error before switching to SSE.
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Pipe the SSE stream straight through — do not buffer.
|
||||
return new Response(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
theme: settings?.theme ?? 'amber',
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size ?? 1.0
|
||||
fontSize: settings?.font_size || 1.0
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -61,9 +61,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
|
||||
}
|
||||
|
||||
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
|
||||
// fontSize is optional — if provided it must be one of the valid steps (0 is not valid)
|
||||
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
|
||||
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
|
||||
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
|
||||
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
||||
}
|
||||
const markdown = await res.text();
|
||||
html = marked(markdown) as string;
|
||||
html = await marked(markdown);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
// Don't hard-fail — show empty content with error message
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack, getContext } from 'svelte';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
@@ -11,14 +11,16 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let html = $state(untrack(() => data.html));
|
||||
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
|
||||
let scrapedHtml = $state(''); // only set by the live-preview fallback
|
||||
let html = $derived(scrapedHtml || data.html || '');
|
||||
let fetchingContent = $state(false);
|
||||
let fetchError = $state('');
|
||||
let audioProRequired = $state(false);
|
||||
|
||||
// ── Reader settings panel ────────────────────────────────────────────────
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let settingsPanelOpen = $state(false);
|
||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||
|
||||
const READER_THEMES = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
@@ -202,8 +204,8 @@
|
||||
{ code: 'pt', label: 'PT' },
|
||||
{ code: 'fr', label: 'FR' }
|
||||
];
|
||||
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
|
||||
let translatingLang = $state(untrack(() => data.lang ?? ''));
|
||||
let translationStatus = $state(data.translationStatus ?? 'idle');
|
||||
let translatingLang = $state(data.lang ?? '');
|
||||
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function currentLang() {
|
||||
@@ -293,6 +295,7 @@
|
||||
|
||||
// If the normal path returned no content, fall back to live preview scrape
|
||||
if (!data.isPreview && !data.html) {
|
||||
fetchingContent = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
@@ -302,7 +305,7 @@
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
scrapedHtml = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = m.reader_audio_error();
|
||||
}
|
||||
@@ -589,22 +592,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
|
||||
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
<!-- Backdrop -->
|
||||
{#if settingsPanelOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => (settingsPanelOpen = false)}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gear button -->
|
||||
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
|
||||
<button
|
||||
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
|
||||
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
||||
aria-label="Reader settings"
|
||||
class="fixed bottom-20 right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
||||
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
@@ -612,231 +607,277 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Settings drawer -->
|
||||
<!-- Bottom sheet -->
|
||||
{#if settingsPanelOpen}
|
||||
<div
|
||||
class="fixed bottom-36 right-4 z-50 w-72 bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-2xl p-4 flex flex-col gap-4"
|
||||
>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Reader Settings</p>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Theme</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each READER_THEMES as t}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
|
||||
<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'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Reading</button>
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
: '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">
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
<!-- ── Typography group ──────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
|
||||
<div class="flex flex-wrap gap-1.5 flex-1">
|
||||
{#each READER_THEMES as t}
|
||||
<button
|
||||
onclick={() => applyTheme(t.id)}
|
||||
title={t.label}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
|
||||
{panelTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelTheme === t.id}
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each READER_FONTS as f}
|
||||
<button
|
||||
onclick={() => applyFont(f.id)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelFont === f.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelFont === f.id}
|
||||
>{f.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each READER_SIZES as s}
|
||||
<button
|
||||
onclick={() => applySize(s.value)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelSize === s.value
|
||||
? '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={panelSize === s.value}
|
||||
>{s.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Layout group ──────────────────────────────────────────── -->
|
||||
<div class="mt-4 mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readMode', mode)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readMode === mode
|
||||
? '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.readMode === mode}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('lineSpacing', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.lineSpacing === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.lineSpacing === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Width -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readWidth', w)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readWidth === w
|
||||
? '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.readWidth === w}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraphs -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('paraStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.paraStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.paraStyle === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
onclick={() => applyTheme(t.id)}
|
||||
title={t.label}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelTheme === t.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelTheme === t.id}
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{layout.focusMode ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={layout.focusMode}
|
||||
>
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
<span>Focus mode</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font family -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Font</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each READER_FONTS as f}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
|
||||
<!-- ── Listening tab ──────────────────────────────────────────── -->
|
||||
<div class="mb-1">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
|
||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||
|
||||
<!-- Player style -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.playerStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.playerStyle === s}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if page.data.user}
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
|
||||
<div class="flex gap-1 flex-1">
|
||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.speed = s; }}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.speed === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
onclick={() => applyFont(f.id)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelFont === f.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelFont === f.id}
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
{f.label}
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text size -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Text size</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each READER_SIZES as s}
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
onclick={() => applySize(s.value)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{panelSize === s.value
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={panelSize === s.value}
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
>
|
||||
{s.label}
|
||||
<span>Sleep timer</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<p class="text-[11px] text-(--color-muted)/50 text-center mt-3">Changes save automatically</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
|
||||
<!-- Read mode -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Read mode</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readMode', mode)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readMode === mode
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readMode === mode}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line spacing -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Line spacing</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('lineSpacing', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.lineSpacing === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.lineSpacing === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reading width -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Width</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('readWidth', w)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.readWidth === w
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.readWidth === w}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph style -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Paragraphs</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('paraStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.paraStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.paraStyle === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focus mode -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.focusMode
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.focusMode}
|
||||
>
|
||||
<span>Focus mode</span>
|
||||
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Listening section ─────────────────────────────────────────── -->
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
|
||||
|
||||
<!-- Player style -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Player style</p>
|
||||
<div class="flex gap-1.5">
|
||||
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('playerStyle', s)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.playerStyle === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={layout.playerStyle === s}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if page.data.user}
|
||||
<!-- Playback speed -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Speed</p>
|
||||
<div class="flex gap-1">
|
||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.speed = s; }}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.speed === s
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
>
|
||||
<span>Sleep timer</span>
|
||||
<span class="opacity-60">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user