Compare commits

...

14 Commits

Author SHA1 Message Date
Admin
ad2d1a2603 feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
2026-04-05 00:32:18 +05:00
Admin
b0d8c02787 fix: add created field to chapters_idx to fix recentlyUpdatedBooks 400
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.

- Add created date field to chapters_idx schema in pb-init-v3.sh
  (also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
  chapters are immediately sortable; existing records retain empty created
  and will sort to the back (acceptable — only affects home page recency)
2026-04-04 23:47:23 +05:00
Admin
5b4c1db931 fix: add watchtower label to runner service so auto-updates work
Without com.centurylinklabs.watchtower.enable=true the homelab watchtower
(running with --label-enable) silently skipped the runner container,
leaving it stuck on v2.5.60 while fixes accumulated on newer tags.
2026-04-04 23:39:19 +05:00
Admin
0c54c59586 fix: guard against font_size=0 collapsing chapter text
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m1s
Release / Gitea Release (push) Successful in 41s
- Replace ?? with || when reading font_size so 0 falls back to 1.0
  (affects GET /api/settings, layout.server.ts, +layout.svelte)
- Remove the explicit 'body.fontSize !== 0' exception in PUT /api/settings
  validation so 0 is now correctly rejected as an invalid font size
- Add add_index helper + idx_chapters_idx_slug_number declaration to
  scripts/pb-init-v3.sh (idempotent UNIQUE INDEX on chapters_idx)
2026-04-04 23:26:54 +05:00
Admin
0e5eb84097 feat: add SvelteKit proxy route for admin dedup-chapters endpoint
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 6m12s
Release / Docker / runner (push) Successful in 3m8s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 47s
2026-04-04 22:34:26 +05:00
Admin
6ef82a1d12 fix: add DeduplicateChapters stub to test mocks to satisfy BookWriter interface
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
2026-04-04 21:17:55 +05:00
Admin
7a418ee62b fix: await marked() to prevent Promise being passed as chapter HTML
Some checks failed
Release / Test backend (push) Failing after 15s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / ui (push) Successful in 2m41s
Release / Gitea Release (push) Has been skipped
marked() returns string | Promise<string>; the previous cast 'as string'
silently passed a Promise object, which Svelte rendered as nothing.
Free users saw blank content even though SSR HTML was correct.
2026-04-04 21:15:06 +05:00
Admin
d4f35a4899 fix: prevent duplicate chapters_idx records + add dedup endpoint
Some checks failed
Release / Test backend (push) Failing after 18s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata)
  so concurrent goroutines don't double-POST the same chapter number
- Add DeduplicateChapters to BookWriter interface and Store implementation;
  keeps the latest record per (slug, number) and deletes extras
- Wire POST /api/admin/dedup-chapters/{slug} handler in server.go
2026-04-04 21:00:10 +05:00
Admin
6559a8c015 fix: split long text into chunks before sending to Cloudflare AI TTS
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.

GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
2026-04-04 20:45:22 +05:00
Admin
05bfd110b8 refactor: replace floating settings panel with bottom sheet + Reading/Listening tabs
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
The old vertical dropdown was too tall on mobile — 14 stacked groups
required heavy scrolling and had no visual hierarchy.

New design:
- Full-width bottom sheet slides from screen edge (natural mobile gesture)
- Drag handle + dimmed backdrop for clarity
- Two tabs split the settings: Reading (typography + layout) and Listening
  (player style, speed, auto-next, sleep timer)
- Each row uses label-on-left + pill-group-on-right layout — saves one line
  per setting and makes the list scannable at a glance
- Settings are grouped into titled cards (Typography, Layout, Player)
  with dividers between rows instead of floating individual blocks
- Gear button moved to bottom-[4.5rem] to clear the mini-player bar
2026-04-04 20:35:15 +05:00
Admin
bfd0ad8fb7 fix: chapter content vanishes — replace stale untrack snapshots with $derived
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / ui (push) Successful in 4m58s
Release / Gitea Release (push) Has been cancelled
html and fetchingContent were captured with untrack() at mount time.
When SvelteKit re-ran the page load (triggered by the layout's settings PUT),
data.html updated but html stayed stale. The {#key} block in the layout then
destroyed and recreated the component, and on remount data.html was momentarily
empty so html became '' and the live-scrape fallback ran unnecessarily.

Fix:
- html is now $derived(scrapedHtml || data.html || '') — always tracks load
- scrapedHtml is a separate $state only set by the live-scrape fallback
- fetchingContent starts false; the fallback sets it true only when actually fetching
- translationStatus/translatingLang: dropped untrack() so they also react to re-runs
- Removed unused untrack import
2026-04-04 20:26:43 +05:00
Admin
4b7fcf432b fix: pass POLAR_API_TOKEN and POLAR_WEBHOOK_SECRET to ui container
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 43s
Both vars were present in Doppler but never injected into the ui service
environment, causing all checkout requests to fail with 500 and webhooks
to be silently rejected.
2026-04-04 20:18:19 +05:00
Admin
c4a0256f6e feat: /subscribe pricing page + Pro nav link + fix checkout token scope
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 41s
- Add /subscribe route with hero, benefits list, and pricing cards
  (annual featured with Save 33% badge, monthly secondary)
- Add 'Pro' link in nav for non-Pro users
- Add 'See plans' link in profile subscription section
- i18n keys across en/fr/id/pt/ru for all subscribe strings

Note: checkout still requires POLAR_API_TOKEN with checkouts:write scope.
Regenerate the token at polar.sh to fix the 502 error on subscribe buttons.
2026-04-04 20:12:24 +05:00
Admin
18f490f790 feat: admin controls on book detail page (cover/desc/chapter-names/audio TTS)
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m22s
Release / Docker / runner (push) Successful in 2m51s
Release / Docker / ui (push) Successful in 3m17s
Release / Gitea Release (push) Successful in 1m12s
Add 5 admin sections to /books/[slug] for admins:
- Book cover generation (CF AI image-gen with preview + save)
- Chapter cover generation (chapter number input + preview)
- Description regeneration (preview + apply/discard)
- Chapter names generation (preview table + apply/discard)
- Audio TTS bulk enqueue (voice selector, chapter range, cancel)

Also adds /api/admin/audio/bulk and /api/admin/audio/cancel-bulk proxy routes,
and all i18n keys across en/fr/id/pt/ru.
2026-04-04 19:57:16 +05:00
53 changed files with 2411 additions and 426 deletions

View File

@@ -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}.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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 {

View File

@@ -310,6 +310,9 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
# Polar (subscriptions)
POLAR_API_TOKEN: "${POLAR_API_TOKEN}"
POLAR_WEBHOOK_SECRET: "${POLAR_WEBHOOK_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s

View File

@@ -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

View File

@@ -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"

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
"subscribe_page_title": "Go Pro \u2014 libnovel",
"subscribe_heading": "Read more. Listen more.",
"subscribe_subheading": "Upgrade to Pro and unlock the full libnovel experience.",
"subscribe_monthly_label": "Monthly",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per month",
"subscribe_annual_label": "Annual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per year",
"subscribe_annual_save": "Save 33%",
"subscribe_cta_monthly": "Start monthly plan",
"subscribe_cta_annual": "Start annual plan",
"subscribe_already_pro": "You already have a Pro subscription.",
"subscribe_manage": "Manage subscription",
"subscribe_benefit_audio": "Unlimited audio chapters per day",
"subscribe_benefit_voices": "Voice selection across all TTS engines",
"subscribe_benefit_translation": "Read in French, Indonesian, Portuguese, and Russian",
"subscribe_benefit_downloads": "Download chapters for offline listening",
"subscribe_login_prompt": "Sign in to subscribe",
"subscribe_login_cta": "Sign in",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
"subscribe_monthly_price": "6 $",
"subscribe_monthly_period": "par mois",
"subscribe_annual_label": "Annuel",
"subscribe_annual_price": "48 $",
"subscribe_annual_period": "par an",
"subscribe_annual_save": "Économisez 33 %",
"subscribe_cta_monthly": "Commencer le plan mensuel",
"subscribe_cta_annual": "Commencer le plan annuel",
"subscribe_already_pro": "Vous avez déjà un abonnement Pro.",
"subscribe_manage": "Gérer l'abonnement",
"subscribe_benefit_audio": "Chapitres audio illimités par jour",
"subscribe_benefit_voices": "Sélection de voix pour tous les moteurs TTS",
"subscribe_benefit_translation": "Lire en français, indonésien, portugais et russe",
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per bulan",
"subscribe_annual_label": "Tahunan",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per tahun",
"subscribe_annual_save": "Hemat 33%",
"subscribe_cta_monthly": "Mulai paket bulanan",
"subscribe_cta_annual": "Mulai paket tahunan",
"subscribe_already_pro": "Anda sudah berlangganan Pro.",
"subscribe_manage": "Kelola langganan",
"subscribe_benefit_audio": "Bab audio tak terbatas per hari",
"subscribe_benefit_voices": "Pilihan suara untuk semua mesin TTS",
"subscribe_benefit_translation": "Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia",
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "por mês",
"subscribe_annual_label": "Anual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "por ano",
"subscribe_annual_save": "Economize 33%",
"subscribe_cta_monthly": "Começar plano mensal",
"subscribe_cta_annual": "Começar plano anual",
"subscribe_already_pro": "Você já tem uma assinatura Pro.",
"subscribe_manage": "Gerenciar assinatura",
"subscribe_benefit_audio": "Capítulos de áudio ilimitados por dia",
"subscribe_benefit_voices": "Seleção de voz para todos os mecanismos TTS",
"subscribe_benefit_translation": "Leia em francês, indonésio, português e russo",
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "в месяц",
"subscribe_annual_label": "Ежегодно",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "в год",
"subscribe_annual_save": "Сэкономьте 33%",
"subscribe_cta_monthly": "Начать месячный план",
"subscribe_cta_annual": "Начать годовой план",
"subscribe_already_pro": "У вас уже есть подписка Pro.",
"subscribe_manage": "Управление подпиской",
"subscribe_benefit_audio": "Неограниченные аудиоглавы в день",
"subscribe_benefit_voices": "Выбор голоса для всех TTS-движков",
"subscribe_benefit_translation": "Читайте на французском, индонезийском, португальском и русском",
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",

View File

@@ -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'

View File

@@ -339,6 +339,26 @@ export * from './profile_upgrade_desc.js'
export * from './profile_upgrade_monthly.js'
export * from './profile_upgrade_annual.js'
export * from './profile_free_limits.js'
export * from './subscribe_page_title.js'
export * from './subscribe_heading.js'
export * from './subscribe_subheading.js'
export * from './subscribe_monthly_label.js'
export * from './subscribe_monthly_price.js'
export * from './subscribe_monthly_period.js'
export * from './subscribe_annual_label.js'
export * from './subscribe_annual_price.js'
export * from './subscribe_annual_period.js'
export * from './subscribe_annual_save.js'
export * from './subscribe_cta_monthly.js'
export * from './subscribe_cta_annual.js'
export * from './subscribe_already_pro.js'
export * from './subscribe_manage.js'
export * from './subscribe_benefit_audio.js'
export * from './subscribe_benefit_voices.js'
export * from './subscribe_benefit_translation.js'
export * from './subscribe_benefit_downloads.js'
export * from './subscribe_login_prompt.js'
export * from './subscribe_login_cta.js'
export * from './user_currently_reading.js'
export * from './user_library_count.js'
export * from './user_joined.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Already_ProInputs */
const en_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`You already have a Pro subscription.`)
};
const ru_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`У вас уже есть подписка Pro.`)
};
const id_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anda sudah berlangganan Pro.`)
};
const pt_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Você já tem uma assinatura Pro.`)
};
const fr_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Vous avez déjà un abonnement Pro.`)
};
/**
* | output |
* | --- |
* | "You already have a Pro subscription." |
*
* @param {Subscribe_Already_ProInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_already_pro = /** @type {((inputs?: Subscribe_Already_ProInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Already_ProInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_already_pro(inputs)
if (locale === "ru") return ru_subscribe_already_pro(inputs)
if (locale === "id") return id_subscribe_already_pro(inputs)
if (locale === "pt") return pt_subscribe_already_pro(inputs)
return fr_subscribe_already_pro(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_LabelInputs */
const en_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annual`)
};
const ru_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежегодно`)
};
const id_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tahunan`)
};
const pt_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anual`)
};
const fr_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annuel`)
};
/**
* | output |
* | --- |
* | "Annual" |
*
* @param {Subscribe_Annual_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_label = /** @type {((inputs?: Subscribe_Annual_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_label(inputs)
if (locale === "ru") return ru_subscribe_annual_label(inputs)
if (locale === "id") return id_subscribe_annual_label(inputs)
if (locale === "pt") return pt_subscribe_annual_label(inputs)
return fr_subscribe_annual_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PeriodInputs */
const en_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per year`)
};
const ru_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в год`)
};
const id_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per tahun`)
};
const pt_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por ano`)
};
const fr_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par an`)
};
/**
* | output |
* | --- |
* | "per year" |
*
* @param {Subscribe_Annual_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_period = /** @type {((inputs?: Subscribe_Annual_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_period(inputs)
if (locale === "ru") return ru_subscribe_annual_period(inputs)
if (locale === "id") return id_subscribe_annual_period(inputs)
if (locale === "pt") return pt_subscribe_annual_period(inputs)
return fr_subscribe_annual_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PriceInputs */
const en_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const ru_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const id_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const pt_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const fr_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`48 $`)
};
/**
* | output |
* | --- |
* | "$48" |
*
* @param {Subscribe_Annual_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_price = /** @type {((inputs?: Subscribe_Annual_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_price(inputs)
if (locale === "ru") return ru_subscribe_annual_price(inputs)
if (locale === "id") return id_subscribe_annual_price(inputs)
if (locale === "pt") return pt_subscribe_annual_price(inputs)
return fr_subscribe_annual_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_SaveInputs */
const en_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Save 33%`)
};
const ru_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сэкономьте 33%`)
};
const id_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Hemat 33%`)
};
const pt_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Economize 33%`)
};
const fr_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Économisez 33 %`)
};
/**
* | output |
* | --- |
* | "Save 33%" |
*
* @param {Subscribe_Annual_SaveInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_save = /** @type {((inputs?: Subscribe_Annual_SaveInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_SaveInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_save(inputs)
if (locale === "ru") return ru_subscribe_annual_save(inputs)
if (locale === "id") return id_subscribe_annual_save(inputs)
if (locale === "pt") return pt_subscribe_annual_save(inputs)
return fr_subscribe_annual_save(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_AudioInputs */
const en_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unlimited audio chapters per day`)
};
const ru_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Неограниченные аудиоглавы в день`)
};
const id_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bab audio tak terbatas per hari`)
};
const pt_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capítulos de áudio ilimitados por dia`)
};
const fr_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapitres audio illimités par jour`)
};
/**
* | output |
* | --- |
* | "Unlimited audio chapters per day" |
*
* @param {Subscribe_Benefit_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_audio = /** @type {((inputs?: Subscribe_Benefit_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_audio(inputs)
if (locale === "ru") return ru_subscribe_benefit_audio(inputs)
if (locale === "id") return id_subscribe_benefit_audio(inputs)
if (locale === "pt") return pt_subscribe_benefit_audio(inputs)
return fr_subscribe_benefit_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_DownloadsInputs */
const en_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Download chapters for offline listening`)
};
const ru_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Скачивайте главы для прослушивания офлайн`)
};
const id_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unduh bab untuk didengarkan secara offline`)
};
const pt_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baixe capítulos para ouvir offline`)
};
const fr_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Télécharger des chapitres pour une écoute hors ligne`)
};
/**
* | output |
* | --- |
* | "Download chapters for offline listening" |
*
* @param {Subscribe_Benefit_DownloadsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_downloads = /** @type {((inputs?: Subscribe_Benefit_DownloadsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_DownloadsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_downloads(inputs)
if (locale === "ru") return ru_subscribe_benefit_downloads(inputs)
if (locale === "id") return id_subscribe_benefit_downloads(inputs)
if (locale === "pt") return pt_subscribe_benefit_downloads(inputs)
return fr_subscribe_benefit_downloads(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_TranslationInputs */
const en_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read in French, Indonesian, Portuguese, and Russian`)
};
const ru_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте на французском, индонезийском, португальском и русском`)
};
const id_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia`)
};
const pt_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia em francês, indonésio, português e russo`)
};
const fr_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lire en français, indonésien, portugais et russe`)
};
/**
* | output |
* | --- |
* | "Read in French, Indonesian, Portuguese, and Russian" |
*
* @param {Subscribe_Benefit_TranslationInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_translation = /** @type {((inputs?: Subscribe_Benefit_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_translation(inputs)
if (locale === "ru") return ru_subscribe_benefit_translation(inputs)
if (locale === "id") return id_subscribe_benefit_translation(inputs)
if (locale === "pt") return pt_subscribe_benefit_translation(inputs)
return fr_subscribe_benefit_translation(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_VoicesInputs */
const en_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voice selection across all TTS engines`)
};
const ru_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Выбор голоса для всех TTS-движков`)
};
const id_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Pilihan suara untuk semua mesin TTS`)
};
const pt_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seleção de voz para todos os mecanismos TTS`)
};
const fr_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sélection de voix pour tous les moteurs TTS`)
};
/**
* | output |
* | --- |
* | "Voice selection across all TTS engines" |
*
* @param {Subscribe_Benefit_VoicesInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_voices = /** @type {((inputs?: Subscribe_Benefit_VoicesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_VoicesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_voices(inputs)
if (locale === "ru") return ru_subscribe_benefit_voices(inputs)
if (locale === "id") return id_subscribe_benefit_voices(inputs)
if (locale === "pt") return pt_subscribe_benefit_voices(inputs)
return fr_subscribe_benefit_voices(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_AnnualInputs */
const en_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start annual plan`)
};
const ru_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать годовой план`)
};
const id_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket tahunan`)
};
const pt_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano anual`)
};
const fr_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan annuel`)
};
/**
* | output |
* | --- |
* | "Start annual plan" |
*
* @param {Subscribe_Cta_AnnualInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_annual = /** @type {((inputs?: Subscribe_Cta_AnnualInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_AnnualInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_annual(inputs)
if (locale === "ru") return ru_subscribe_cta_annual(inputs)
if (locale === "id") return id_subscribe_cta_annual(inputs)
if (locale === "pt") return pt_subscribe_cta_annual(inputs)
return fr_subscribe_cta_annual(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_MonthlyInputs */
const en_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start monthly plan`)
};
const ru_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать месячный план`)
};
const id_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket bulanan`)
};
const pt_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano mensal`)
};
const fr_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan mensuel`)
};
/**
* | output |
* | --- |
* | "Start monthly plan" |
*
* @param {Subscribe_Cta_MonthlyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_monthly = /** @type {((inputs?: Subscribe_Cta_MonthlyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_MonthlyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_monthly(inputs)
if (locale === "ru") return ru_subscribe_cta_monthly(inputs)
if (locale === "id") return id_subscribe_cta_monthly(inputs)
if (locale === "pt") return pt_subscribe_cta_monthly(inputs)
return fr_subscribe_cta_monthly(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_HeadingInputs */
const en_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read more. Listen more.`)
};
const ru_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте больше. Слушайте больше.`)
};
const id_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca lebih. Dengarkan lebih.`)
};
const pt_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia mais. Ouça mais.`)
};
const fr_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lisez plus. Écoutez plus.`)
};
/**
* | output |
* | --- |
* | "Read more. Listen more." |
*
* @param {Subscribe_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_heading = /** @type {((inputs?: Subscribe_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_heading(inputs)
if (locale === "ru") return ru_subscribe_heading(inputs)
if (locale === "id") return id_subscribe_heading(inputs)
if (locale === "pt") return pt_subscribe_heading(inputs)
return fr_subscribe_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_CtaInputs */
const en_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in`)
};
const ru_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войти`)
};
const id_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk`)
};
const pt_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entrar`)
};
const fr_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Se connecter`)
};
/**
* | output |
* | --- |
* | "Sign in" |
*
* @param {Subscribe_Login_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_cta = /** @type {((inputs?: Subscribe_Login_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_cta(inputs)
if (locale === "ru") return ru_subscribe_login_cta(inputs)
if (locale === "id") return id_subscribe_login_cta(inputs)
if (locale === "pt") return pt_subscribe_login_cta(inputs)
return fr_subscribe_login_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_PromptInputs */
const en_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in to subscribe`)
};
const ru_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войдите, чтобы оформить подписку`)
};
const id_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk untuk berlangganan`)
};
const pt_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entre para assinar`)
};
const fr_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Connectez-vous pour vous abonner`)
};
/**
* | output |
* | --- |
* | "Sign in to subscribe" |
*
* @param {Subscribe_Login_PromptInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_prompt = /** @type {((inputs?: Subscribe_Login_PromptInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_PromptInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_prompt(inputs)
if (locale === "ru") return ru_subscribe_login_prompt(inputs)
if (locale === "id") return id_subscribe_login_prompt(inputs)
if (locale === "pt") return pt_subscribe_login_prompt(inputs)
return fr_subscribe_login_prompt(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_ManageInputs */
const en_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Manage subscription`)
};
const ru_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Управление подпиской`)
};
const id_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kelola langganan`)
};
const pt_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gerenciar assinatura`)
};
const fr_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gérer l'abonnement`)
};
/**
* | output |
* | --- |
* | "Manage subscription" |
*
* @param {Subscribe_ManageInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_manage = /** @type {((inputs?: Subscribe_ManageInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_ManageInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_manage(inputs)
if (locale === "ru") return ru_subscribe_manage(inputs)
if (locale === "id") return id_subscribe_manage(inputs)
if (locale === "pt") return pt_subscribe_manage(inputs)
return fr_subscribe_manage(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_LabelInputs */
const en_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Monthly`)
};
const ru_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежемесячно`)
};
const id_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bulanan`)
};
const pt_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensal`)
};
const fr_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensuel`)
};
/**
* | output |
* | --- |
* | "Monthly" |
*
* @param {Subscribe_Monthly_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_label = /** @type {((inputs?: Subscribe_Monthly_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_label(inputs)
if (locale === "ru") return ru_subscribe_monthly_label(inputs)
if (locale === "id") return id_subscribe_monthly_label(inputs)
if (locale === "pt") return pt_subscribe_monthly_label(inputs)
return fr_subscribe_monthly_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PeriodInputs */
const en_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per month`)
};
const ru_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в месяц`)
};
const id_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per bulan`)
};
const pt_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por mês`)
};
const fr_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par mois`)
};
/**
* | output |
* | --- |
* | "per month" |
*
* @param {Subscribe_Monthly_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_period = /** @type {((inputs?: Subscribe_Monthly_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_period(inputs)
if (locale === "ru") return ru_subscribe_monthly_period(inputs)
if (locale === "id") return id_subscribe_monthly_period(inputs)
if (locale === "pt") return pt_subscribe_monthly_period(inputs)
return fr_subscribe_monthly_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PriceInputs */
const en_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const ru_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const id_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const pt_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const fr_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`6 $`)
};
/**
* | output |
* | --- |
* | "$6" |
*
* @param {Subscribe_Monthly_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_price = /** @type {((inputs?: Subscribe_Monthly_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_price(inputs)
if (locale === "ru") return ru_subscribe_monthly_price(inputs)
if (locale === "id") return id_subscribe_monthly_price(inputs)
if (locale === "pt") return pt_subscribe_monthly_price(inputs)
return fr_subscribe_monthly_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Page_TitleInputs */
const en_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Go Pro — libnovel`)
};
const ru_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейти на Pro — libnovel`)
};
const id_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jadi Pro — libnovel`)
};
const pt_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seja Pro — libnovel`)
};
const fr_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passer Pro — libnovel`)
};
/**
* | output |
* | --- |
* | "Go Pro — libnovel" |
*
* @param {Subscribe_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_page_title = /** @type {((inputs?: Subscribe_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_page_title(inputs)
if (locale === "ru") return ru_subscribe_page_title(inputs)
if (locale === "id") return id_subscribe_page_title(inputs)
if (locale === "pt") return pt_subscribe_page_title(inputs)
return fr_subscribe_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_SubheadingInputs */
const en_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Upgrade to Pro and unlock the full libnovel experience.`)
};
const ru_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейдите на Pro и откройте полный опыт libnovel.`)
};
const id_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.`)
};
const pt_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Torne-se Pro e desbloqueie a experiência completa do libnovel.`)
};
const fr_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passez Pro et débloquez l'expérience libnovel complète.`)
};
/**
* | output |
* | --- |
* | "Upgrade to Pro and unlock the full libnovel experience." |
*
* @param {Subscribe_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_subheading = /** @type {((inputs?: Subscribe_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_subheading(inputs)
if (locale === "ru") return ru_subscribe_subheading(inputs)
if (locale === "id") return id_subscribe_subheading(inputs)
if (locale === "pt") return pt_subscribe_subheading(inputs)
return fr_subscribe_subheading(inputs)
});

View File

@@ -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) {

View File

@@ -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);
@@ -407,6 +407,15 @@
>
{m.nav_catalogue()}
</a>
{#if !data.isPro}
<a
href="/subscribe"
class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold transition-colors {page.url.pathname.startsWith('/subscribe') ? 'text-(--color-brand)' : 'text-(--color-brand) opacity-70 hover:opacity-100'}"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
Pro
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">

View File

@@ -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[]
};
};

View File

@@ -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" />

View File

@@ -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[]
};
};

View File

@@ -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">

View 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 });
};

View File

@@ -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'
}
});
};

View File

@@ -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(', ')}`);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -375,7 +375,7 @@
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}

View File

@@ -0,0 +1,5 @@
// Data is inherited from the root layout (user, isPro).
// This file exists only to ensure the route is treated as a page by SvelteKit.
export const load = async () => {
return {};
};

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { LayoutData } from '../$types';
import * as m from '$lib/paraglide/messages.js';
// Data flows from root layout (user, isPro)
let { data }: { data: LayoutData } = $props();
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
let checkoutError = $state('');
async function startCheckout(product: 'monthly' | 'annual') {
checkoutLoading = product;
checkoutError = '';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product })
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
return;
}
const { url } = await res.json() as { url: string };
window.location.href = url;
} catch {
checkoutError = 'Network error. Please try again.';
} finally {
checkoutLoading = null;
}
}
const benefits = [
{ icon: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3', label: () => m.subscribe_benefit_audio() },
{ icon: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z', label: () => m.subscribe_benefit_voices() },
{ icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129', label: () => m.subscribe_benefit_translation() },
{ icon: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', label: () => m.subscribe_benefit_downloads() },
];
</script>
<svelte:head>
<title>{m.subscribe_page_title()}</title>
</svelte:head>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col items-center justify-center px-4 py-16">
<!-- Hero -->
<div class="text-center mb-12 max-w-xl">
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-(--color-brand)/15 border border-(--color-brand)/30 text-(--color-brand) text-xs font-semibold uppercase tracking-wider mb-5">
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
Pro
</div>
<h1 class="text-4xl sm:text-5xl font-bold text-(--color-text) tracking-tight mb-4">
{m.subscribe_heading()}
</h1>
<p class="text-lg text-(--color-muted)">
{m.subscribe_subheading()}
</p>
</div>
<!-- Benefits list -->
<ul class="mb-12 space-y-3 w-full max-w-sm">
{#each benefits as b}
<li class="flex items-center gap-3 text-sm text-(--color-text)">
<span class="shrink-0 w-8 h-8 rounded-full bg-(--color-brand)/15 text-(--color-brand) flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d={b.icon}/>
</svg>
</span>
{b.label()}
</li>
{/each}
</ul>
{#if data.isPro}
<!-- Already Pro -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-brand)/40 rounded-xl p-6 text-center">
<p class="text-base font-semibold text-(--color-text) mb-1">{m.subscribe_already_pro()}</p>
<a
href="https://polar.sh/libnovel/portal"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-(--color-brand) hover:underline"
>
{m.subscribe_manage()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
{:else if !data.user}
<!-- Not logged in -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6 text-center">
<p class="text-sm text-(--color-muted) mb-4">{m.subscribe_login_prompt()}</p>
<a
href="/login?next=/subscribe"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
{m.subscribe_login_cta()}
</a>
</div>
{:else}
<!-- Pricing cards -->
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-4 text-center">{checkoutError}</p>
{/if}
<div class="w-full max-w-sm grid gap-4">
<!-- Annual card (featured) -->
<div class="relative bg-(--color-surface-2) border-2 border-(--color-brand) rounded-xl p-6">
<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 bg-(--color-brand) text-(--color-surface) text-xs font-bold rounded-full tracking-wide uppercase">
{m.subscribe_annual_save()}
</div>
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_annual_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_annual_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_annual_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{#if checkoutLoading === 'annual'}
<svg class="w-4 h-4 shrink-0 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"/></svg>
{/if}
{m.subscribe_cta_annual()}
</button>
</div>
<!-- Monthly card -->
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6">
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_monthly_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_monthly_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_monthly_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 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"/></svg>
{/if}
{m.subscribe_cta_monthly()}
</button>
</div>
</div>
{/if}
<p class="mt-8 text-xs text-(--color-muted) text-center max-w-xs">
Payments processed securely by <a href="https://polar.sh" target="_blank" rel="noopener noreferrer" class="underline hover:text-(--color-text)">Polar</a>. Cancel anytime.
</p>
</div>