Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2d1a2603 | ||
|
|
b0d8c02787 | ||
|
|
5b4c1db931 | ||
|
|
0c54c59586 | ||
|
|
0e5eb84097 | ||
|
|
6ef82a1d12 | ||
|
|
7a418ee62b | ||
|
|
d4f35a4899 | ||
|
|
6559a8c015 | ||
|
|
05bfd110b8 | ||
|
|
bfd0ad8fb7 | ||
|
|
4b7fcf432b | ||
|
|
c4a0256f6e | ||
|
|
18f490f790 | ||
|
|
6456e8cf5d | ||
|
|
25150c2284 | ||
|
|
0e0a70a786 | ||
|
|
bdbe48ce1a | ||
|
|
87c541b178 | ||
|
|
0b82d96798 | ||
|
|
a2dd0681d2 | ||
|
|
ad50bd21ea | ||
|
|
6572e7c849 | ||
|
|
74ece7e94e |
14
.githooks/pre-commit
Executable file
14
.githooks/pre-commit
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-recompile paraglide messages when ui/messages/*.json files are staged.
|
||||
# Prevents svelte-check / CI failures caused by stale generated JS files.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STAGED=$(git diff --cached --name-only)
|
||||
|
||||
if echo "$STAGED" | grep -q '^ui/messages/'; then
|
||||
echo "[pre-commit] ui/messages/*.json changed — recompiling paraglide..."
|
||||
(cd ui && npm run paraglide --silent)
|
||||
git add -f ui/src/lib/paraglide/messages/
|
||||
echo "[pre-commit] paraglide output re-staged."
|
||||
fi
|
||||
@@ -133,6 +133,15 @@ func run() error {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
|
||||
}
|
||||
|
||||
// ── Cloudflare Workers AI Text Generation ─────────────────────────────────
|
||||
var textGenClient cfai.TextGenClient
|
||||
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
||||
textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
|
||||
log.Info("cloudflare AI text generation enabled")
|
||||
} else {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable")
|
||||
}
|
||||
|
||||
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
|
||||
var searchIndex meili.Client
|
||||
if cfg.Meilisearch.URL != "" {
|
||||
@@ -184,6 +193,8 @@ func run() error {
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
ImageGen: imageGenClient,
|
||||
TextGen: textGenClient,
|
||||
BookWriter: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -195,7 +195,7 @@ func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
|
||||
// Non-fatal: still return the image
|
||||
} else {
|
||||
saved = true
|
||||
coverURL = fmt.Sprintf("/api/cover/local/%s", req.Slug)
|
||||
coverURL = fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug)
|
||||
s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData))
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,62 @@ func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// saveCoverRequest is the JSON body for POST /api/admin/image-gen/save-cover.
|
||||
type saveCoverRequest struct {
|
||||
// Slug is the book slug whose cover should be overwritten.
|
||||
Slug string `json:"slug"`
|
||||
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
|
||||
ImageB64 string `json:"image_b64"`
|
||||
}
|
||||
|
||||
// handleAdminImageGenSaveCover handles POST /api/admin/image-gen/save-cover.
|
||||
//
|
||||
// Accepts a pre-generated image as base64 and stores it as the book cover in
|
||||
// MinIO, replacing the existing one. Does not call Cloudflare AI at all.
|
||||
func (s *Server) handleAdminImageGenSaveCover(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.CoverStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req saveCoverRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if req.ImageB64 == "" {
|
||||
jsonError(w, http.StatusBadRequest, "image_b64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
|
||||
if err != nil {
|
||||
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
contentType := sniffImageContentType(imgData)
|
||||
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
|
||||
s.deps.Log.Error("admin: save-cover failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "save cover: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: cover saved via image-gen", "slug", req.Slug, "bytes", len(imgData))
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"saved": true,
|
||||
"cover_url": fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug),
|
||||
"bytes": len(imgData),
|
||||
})
|
||||
}
|
||||
|
||||
// sniffImageContentType returns the MIME type of the image bytes.
|
||||
func sniffImageContentType(data []byte) string {
|
||||
if len(data) >= 4 {
|
||||
|
||||
486
backend/internal/backend/handlers_textgen.go
Normal file
486
backend/internal/backend/handlers_textgen.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"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) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
models := s.deps.TextGen.Models()
|
||||
writeJSON(w, 0, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// ── Chapter names ─────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesRequest struct {
|
||||
// Slug is the book slug whose chapters to process.
|
||||
Slug string `json:"slug"`
|
||||
// Pattern is a free-text description of the desired naming convention,
|
||||
// e.g. "Chapter {n}: {brief scene description}".
|
||||
Pattern string `json:"pattern"`
|
||||
// Model is the CF Workers AI model ID. Defaults to the recommended model when empty.
|
||||
Model string `json:"model"`
|
||||
// MaxTokens limits response length (0 = model default).
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
type proposedChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
// OldTitle is the current title stored in the database.
|
||||
OldTitle string `json:"old_title"`
|
||||
// NewTitle is the AI-proposed replacement.
|
||||
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.
|
||||
//
|
||||
// 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)")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenChapterNamesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Pattern) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing chapter list.
|
||||
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
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, ` +
|
||||
`and a naming pattern template. ` +
|
||||
`Your job: produce one new title for every chapter, following the pattern exactly. ` +
|
||||
`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (2–5 word) scene hint derived from the existing title. ` +
|
||||
`RULES: ` +
|
||||
`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
|
||||
`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
|
||||
`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
|
||||
`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
|
||||
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||
|
||||
// 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)
|
||||
|
||||
sseWrite := func(evt chapterNamesBatchEvent) {
|
||||
b, _ := json.Marshal(evt)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
chaptersDone := 0
|
||||
for i, batch := range batches {
|
||||
if r.Context().Err() != nil {
|
||||
return // client disconnected
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
sseWrite(chapterNamesBatchEvent{
|
||||
Batch: i + 1,
|
||||
TotalBatches: totalBatches,
|
||||
ChaptersDone: chaptersDone,
|
||||
TotalChapters: len(chapters),
|
||||
Model: string(model),
|
||||
Chapters: result,
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// It tolerates markdown fences and surrounding prose.
|
||||
type rawChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func parseChapterTitlesJSON(raw string) []rawChapterTitle {
|
||||
// Strip markdown fences if present.
|
||||
s := raw
|
||||
if idx := strings.Index(s, "```json"); idx >= 0 {
|
||||
s = s[idx+7:]
|
||||
} else if idx := strings.Index(s, "```"); idx >= 0 {
|
||||
s = s[idx+3:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
// Find the JSON array boundaries.
|
||||
start := strings.Index(s, "[")
|
||||
end := strings.LastIndex(s, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var out []rawChapterTitle
|
||||
json.Unmarshal([]byte(s), &out) //nolint:errcheck
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Apply chapter names ───────────────────────────────────────────────────────
|
||||
|
||||
// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.
|
||||
type applyChapterNamesRequest struct {
|
||||
// Slug is the book slug to update.
|
||||
Slug string `json:"slug"`
|
||||
// Chapters is the list of chapters to save (number + new_title pairs).
|
||||
// The UI may modify individual titles before confirming.
|
||||
Chapters []applyChapterEntry `json:"chapters"`
|
||||
}
|
||||
|
||||
type applyChapterEntry struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyChapterNames handles POST /api/admin/text-gen/chapter-names/apply.
|
||||
//
|
||||
// Persists the confirmed chapter titles to PocketBase chapters_idx.
|
||||
func (s *Server) handleAdminTextGenApplyChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req applyChapterNamesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if len(req.Chapters) == 0 {
|
||||
jsonError(w, http.StatusBadRequest, "chapters is required")
|
||||
return
|
||||
}
|
||||
|
||||
refs := make([]domain.ChapterRef, 0, len(req.Chapters))
|
||||
for _, ch := range req.Chapters {
|
||||
if ch.Number <= 0 {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, domain.ChapterRef{
|
||||
Number: ch.Number,
|
||||
Title: strings.TrimSpace(ch.Title),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.deps.BookWriter.WriteChapterRefs(r.Context(), req.Slug, refs); err != nil {
|
||||
s.deps.Log.Error("admin: apply chapter names failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write chapter refs: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: chapter names applied", "slug", req.Slug, "count", len(refs))
|
||||
writeJSON(w, 0, map[string]any{"updated": len(refs)})
|
||||
}
|
||||
|
||||
// ── Book description ──────────────────────────────────────────────────────────
|
||||
|
||||
// textGenDescriptionRequest is the JSON body for POST /api/admin/text-gen/description.
|
||||
type textGenDescriptionRequest struct {
|
||||
// Slug is the book slug whose description to regenerate.
|
||||
Slug string `json:"slug"`
|
||||
// Instructions is an optional free-text hint for the AI,
|
||||
// e.g. "Write a 3-sentence blurb, avoid spoilers, dramatic tone."
|
||||
Instructions string `json:"instructions"`
|
||||
// Model is the CF Workers AI model ID. Defaults to recommended when empty.
|
||||
Model string `json:"model"`
|
||||
// MaxTokens limits response length (0 = model default).
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenDescriptionResponse is the JSON body returned by POST /api/admin/text-gen/description.
|
||||
type textGenDescriptionResponse struct {
|
||||
// OldDescription is the current summary stored in the database.
|
||||
OldDescription string `json:"old_description"`
|
||||
// NewDescription is the AI-proposed replacement.
|
||||
NewDescription string `json:"new_description"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenDescription handles POST /api/admin/text-gen/description.
|
||||
//
|
||||
// Reads the current book metadata, sends it to the LLM, and returns a proposed
|
||||
// new description. Does NOT persist anything — the user must confirm via
|
||||
// POST /api/admin/text-gen/description/apply.
|
||||
func (s *Server) handleAdminTextGenDescription(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Load current book metadata.
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
systemPrompt := `You are a book description writer for a web novel platform. ` +
|
||||
`Given a book's title, author, genres, and current description, write an improved ` +
|
||||
`description that accurately captures the story. ` +
|
||||
`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`
|
||||
|
||||
instructions := strings.TrimSpace(req.Instructions)
|
||||
if instructions == "" {
|
||||
instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging."
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
|
||||
meta.Title,
|
||||
meta.Author,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Status,
|
||||
meta.Summary,
|
||||
instructions,
|
||||
)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen description requested",
|
||||
"slug", req.Slug, "model", model)
|
||||
|
||||
newDesc, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: req.MaxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen description failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenDescriptionResponse{
|
||||
OldDescription: meta.Summary,
|
||||
NewDescription: strings.TrimSpace(newDesc),
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Apply description ─────────────────────────────────────────────────────────
|
||||
|
||||
// applyDescriptionRequest is the JSON body for POST /api/admin/text-gen/description/apply.
|
||||
type applyDescriptionRequest struct {
|
||||
// Slug is the book slug to update.
|
||||
Slug string `json:"slug"`
|
||||
// Description is the new summary text to persist.
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyDescription handles POST /api/admin/text-gen/description/apply.
|
||||
//
|
||||
// Updates only the summary field in PocketBase, leaving all other book metadata
|
||||
// unchanged.
|
||||
func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req applyDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Description) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "description is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing metadata so we can write it back with only summary changed.
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
meta.Summary = strings.TrimSpace(req.Description)
|
||||
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("admin: apply description failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
|
||||
writeJSON(w, 0, map[string]any{"updated": true})
|
||||
}
|
||||
@@ -76,6 +76,12 @@ type Dependencies struct {
|
||||
// ImageGen is the Cloudflare Workers AI image generation client.
|
||||
// If nil, image generation endpoints return 503.
|
||||
ImageGen cfai.ImageGenClient
|
||||
// TextGen is the Cloudflare Workers AI text generation client.
|
||||
// If nil, text generation endpoints return 503.
|
||||
TextGen cfai.TextGenClient
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -189,6 +195,17 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Admin image generation endpoints
|
||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
|
||||
// Admin text generation endpoints (chapter names + book description)
|
||||
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
@@ -35,6 +35,11 @@ type BookWriter interface {
|
||||
|
||||
// ChapterExists returns true if the markdown object for ref already exists.
|
||||
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug,
|
||||
// keeping only one record per chapter number (the one with the latest
|
||||
// updated timestamp). Returns the number of duplicate records deleted.
|
||||
DeduplicateChapters(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// BookReader is the read side used by the backend to serve content.
|
||||
|
||||
@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
|
||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
// RankingStore
|
||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
// response. There is no 100-second Cloudflare proxy timeout because we are
|
||||
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
|
||||
// homelab tunnel.
|
||||
//
|
||||
// The aura-2-en model enforces a hard 2 000-character limit per request.
|
||||
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
|
||||
// and concatenates the resulting MP3 frames.
|
||||
package cfai
|
||||
|
||||
import (
|
||||
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
|
||||
}
|
||||
|
||||
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
|
||||
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
|
||||
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("cfai: empty text")
|
||||
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
speaker = "luna"
|
||||
}
|
||||
|
||||
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
|
||||
var combined []byte
|
||||
for _, chunk := range chunks {
|
||||
part, err := c.generateChunk(ctx, chunk, speaker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combined = append(combined, part...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
|
||||
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"speaker": speaker,
|
||||
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
return mp3, nil
|
||||
}
|
||||
|
||||
// splitText splits src into chunks of at most maxChars characters each.
|
||||
// It tries to break at paragraph boundaries first, then at sentence-ending
|
||||
// punctuation (. ! ?), and falls back to the nearest space.
|
||||
func splitText(src string, maxChars int) []string {
|
||||
if len(src) <= maxChars {
|
||||
return []string{src}
|
||||
}
|
||||
|
||||
var chunks []string
|
||||
remaining := src
|
||||
|
||||
for len(remaining) > 0 {
|
||||
if len(remaining) <= maxChars {
|
||||
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||
break
|
||||
}
|
||||
|
||||
// Search window: the first maxChars bytes of remaining.
|
||||
// Use byte length here because the API limit is in bytes/chars for ASCII;
|
||||
// for safety we operate on rune-aware slices.
|
||||
window := remaining
|
||||
if len(window) > maxChars {
|
||||
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
|
||||
window = runeSlice(remaining, maxChars)
|
||||
}
|
||||
|
||||
cut := -1
|
||||
|
||||
// 1. Prefer paragraph break (\n\n or \n).
|
||||
if i := strings.LastIndex(window, "\n\n"); i > 0 {
|
||||
cut = i + 2
|
||||
} else if i := strings.LastIndex(window, "\n"); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
|
||||
// 2. Fall back to sentence-ending punctuation followed by a space.
|
||||
if cut < 0 {
|
||||
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
|
||||
if i := strings.LastIndex(window, punct); i > 0 {
|
||||
candidate := i + len(punct)
|
||||
if cut < 0 || candidate > cut {
|
||||
cut = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Last resort: nearest space.
|
||||
if cut < 0 {
|
||||
if i := strings.LastIndex(window, " "); i > 0 {
|
||||
cut = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hard cut at maxChars runes if no boundary found.
|
||||
if cut < 0 {
|
||||
cut = len(window)
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(remaining[:cut])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
remaining = remaining[cut:]
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// runeSlice returns the first n runes of s as a string.
|
||||
func runeSlice(s string, n int) string {
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return s[:i]
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
mp3, err := c.GenerateAudio(ctx, text, voice)
|
||||
|
||||
239
backend/internal/cfai/text.go
Normal file
239
backend/internal/cfai/text.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Text generation via Cloudflare Workers AI LLM models.
|
||||
//
|
||||
// API reference:
|
||||
//
|
||||
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
|
||||
// Authorization: Bearer {apiToken}
|
||||
// Content-Type: application/json
|
||||
//
|
||||
// Request body (all models):
|
||||
//
|
||||
// { "messages": [{"role":"system","content":"..."},{"role":"user","content":"..."}] }
|
||||
//
|
||||
// Response (wrapped):
|
||||
//
|
||||
// { "result": { "response": "..." }, "success": true }
|
||||
package cfai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TextModel identifies a Cloudflare Workers AI text generation model.
|
||||
type TextModel string
|
||||
|
||||
const (
|
||||
// TextModelGemma4 — Google Gemma 4, 256k context.
|
||||
TextModelGemma4 TextModel = "@cf/google/gemma-4-26b-a4b-it"
|
||||
// TextModelLlama4Scout — Meta Llama 4 Scout 17B, multimodal.
|
||||
TextModelLlama4Scout TextModel = "@cf/meta/llama-4-scout-17b-16e-instruct"
|
||||
// TextModelLlama33_70B — Meta Llama 3.3 70B, fast fp8.
|
||||
TextModelLlama33_70B TextModel = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
||||
// TextModelQwen3_30B — Qwen3 30B MoE, function calling.
|
||||
TextModelQwen3_30B TextModel = "@cf/qwen/qwen3-30b-a3b-fp8"
|
||||
// TextModelMistralSmall — Mistral Small 3.1 24B, 128k context.
|
||||
TextModelMistralSmall TextModel = "@cf/mistralai/mistral-small-3.1-24b-instruct"
|
||||
// TextModelQwQ32B — Qwen QwQ 32B reasoning model.
|
||||
TextModelQwQ32B TextModel = "@cf/qwen/qwq-32b"
|
||||
// TextModelDeepSeekR1 — DeepSeek R1 distill Qwen 32B.
|
||||
TextModelDeepSeekR1 TextModel = "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b"
|
||||
// TextModelGemma3_12B — Google Gemma 3 12B, 80k context.
|
||||
TextModelGemma3_12B TextModel = "@cf/google/gemma-3-12b-it"
|
||||
// TextModelGPTOSS120B — OpenAI gpt-oss-120b, high reasoning.
|
||||
TextModelGPTOSS120B TextModel = "@cf/openai/gpt-oss-120b"
|
||||
// TextModelGPTOSS20B — OpenAI gpt-oss-20b, lower latency.
|
||||
TextModelGPTOSS20B TextModel = "@cf/openai/gpt-oss-20b"
|
||||
// TextModelNemotron3 — NVIDIA Nemotron 3 120B, agentic.
|
||||
TextModelNemotron3 TextModel = "@cf/nvidia/nemotron-3-120b-a12b"
|
||||
// TextModelLlama32_3B — Meta Llama 3.2 3B, lightweight.
|
||||
TextModelLlama32_3B TextModel = "@cf/meta/llama-3.2-3b-instruct"
|
||||
|
||||
// DefaultTextModel is the default model used when none is specified.
|
||||
DefaultTextModel = TextModelLlama4Scout
|
||||
)
|
||||
|
||||
// TextModelInfo describes a single text generation model.
|
||||
type TextModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Provider string `json:"provider"`
|
||||
ContextSize int `json:"context_size"` // max context in tokens
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// AllTextModels returns metadata about every supported text generation model.
|
||||
func AllTextModels() []TextModelInfo {
|
||||
return []TextModelInfo{
|
||||
{
|
||||
ID: string(TextModelGemma4), Label: "Gemma 4 26B", Provider: "Google",
|
||||
ContextSize: 256000,
|
||||
Description: "Google's most intelligent open model family. 256k context, function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama4Scout), Label: "Llama 4 Scout 17B", Provider: "Meta",
|
||||
ContextSize: 131000,
|
||||
Description: "Natively multimodal, 16 experts. Good all-purpose model with function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama33_70B), Label: "Llama 3.3 70B (fp8 fast)", Provider: "Meta",
|
||||
ContextSize: 24000,
|
||||
Description: "Llama 3.3 70B quantized to fp8 for speed. Excellent instruction following.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelQwen3_30B), Label: "Qwen3 30B MoE", Provider: "Qwen",
|
||||
ContextSize: 32768,
|
||||
Description: "MoE architecture with strong reasoning and instruction following.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelMistralSmall), Label: "Mistral Small 3.1 24B", Provider: "MistralAI",
|
||||
ContextSize: 128000,
|
||||
Description: "Strong text performance with 128k context and function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelQwQ32B), Label: "QwQ 32B (reasoning)", Provider: "Qwen",
|
||||
ContextSize: 24000,
|
||||
Description: "Reasoning model — thinks before answering. Slower but more accurate.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelDeepSeekR1), Label: "DeepSeek R1 32B", Provider: "DeepSeek",
|
||||
ContextSize: 80000,
|
||||
Description: "R1-distilled reasoning model. Outperforms o1-mini on many benchmarks.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGemma3_12B), Label: "Gemma 3 12B", Provider: "Google",
|
||||
ContextSize: 80000,
|
||||
Description: "Multimodal, 128k context, multilingual (140+ languages).",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGPTOSS120B), Label: "GPT-OSS 120B", Provider: "OpenAI",
|
||||
ContextSize: 128000,
|
||||
Description: "OpenAI open-weight model for production, general purpose, high reasoning.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGPTOSS20B), Label: "GPT-OSS 20B", Provider: "OpenAI",
|
||||
ContextSize: 128000,
|
||||
Description: "OpenAI open-weight model for lower latency and specialized use cases.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelNemotron3), Label: "Nemotron 3 120B", Provider: "NVIDIA",
|
||||
ContextSize: 256000,
|
||||
Description: "Hybrid MoE with leading accuracy for multi-agent applications.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama32_3B), Label: "Llama 3.2 3B", Provider: "Meta",
|
||||
ContextSize: 80000,
|
||||
Description: "Lightweight model for simple tasks. Fast and cheap.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TextMessage is a single message in a chat conversation.
|
||||
type TextMessage struct {
|
||||
Role string `json:"role"` // "system" or "user"
|
||||
Content string `json:"content"` // message text
|
||||
}
|
||||
|
||||
// TextRequest is the input to Generate.
|
||||
type TextRequest struct {
|
||||
// Model is the CF Workers AI model ID. Defaults to DefaultTextModel when empty.
|
||||
Model TextModel
|
||||
// Messages is the conversation history (system + user messages).
|
||||
Messages []TextMessage
|
||||
// MaxTokens limits the output length (0 = model default).
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// TextGenClient generates text via Cloudflare Workers AI LLM models.
|
||||
type TextGenClient interface {
|
||||
// Generate sends a chat-style request and returns the model's response text.
|
||||
Generate(ctx context.Context, req TextRequest) (string, error)
|
||||
|
||||
// Models returns metadata about all supported text generation models.
|
||||
Models() []TextModelInfo
|
||||
}
|
||||
|
||||
// textGenHTTPClient is the concrete CF AI text generation client.
|
||||
type textGenHTTPClient struct {
|
||||
accountID string
|
||||
apiToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewTextGen returns a TextGenClient for the given Cloudflare account.
|
||||
func NewTextGen(accountID, apiToken string) TextGenClient {
|
||||
return &textGenHTTPClient{
|
||||
accountID: accountID,
|
||||
apiToken: apiToken,
|
||||
http: &http.Client{Timeout: 5 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sends messages to the model and returns the response text.
|
||||
func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (string, error) {
|
||||
if req.Model == "" {
|
||||
req.Model = DefaultTextModel
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"messages": req.Messages,
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
body["max_tokens"] = req.MaxTokens
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
|
||||
c.accountID, string(req.Model))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
msg := string(errBody)
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return "", fmt.Errorf("cfai/text: model %s returned %d: %s", req.Model, resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
// CF AI wraps responses: { "result": { "response": "..." }, "success": true }
|
||||
var wrapper struct {
|
||||
Result struct {
|
||||
Response string `json:"response"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
|
||||
return "", fmt.Errorf("cfai/text: decode response: %w", err)
|
||||
}
|
||||
if !wrapper.Success {
|
||||
return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors)
|
||||
}
|
||||
return wrapper.Result.Response, nil
|
||||
}
|
||||
|
||||
// Models returns all supported text generation model metadata.
|
||||
func (c *textGenHTTPClient) Models() []TextModelInfo {
|
||||
return AllTextModels()
|
||||
}
|
||||
@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||
type stubBookReader struct {
|
||||
text string
|
||||
|
||||
@@ -130,7 +130,23 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
|
||||
insertPayload := map[string]any{
|
||||
"slug": slug,
|
||||
"number": ref.Number,
|
||||
"title": ref.Title,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug+number.
|
||||
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
|
||||
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil || len(items) == 0 {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
@@ -139,6 +155,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
// DeduplicateChapters removes duplicate chapters_idx records for slug.
|
||||
// For each chapter number that has more than one record, it keeps the record
|
||||
// with the latest "updated" timestamp and deletes the rest.
|
||||
// Returns the number of records deleted.
|
||||
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
|
||||
}
|
||||
|
||||
type record struct {
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// Group records by chapter number.
|
||||
byNumber := make(map[int][]record)
|
||||
for _, raw := range items {
|
||||
var rec record
|
||||
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
|
||||
continue
|
||||
}
|
||||
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, recs := range byNumber {
|
||||
if len(recs) <= 1 {
|
||||
continue
|
||||
}
|
||||
// Keep the record with the latest Updated timestamp; delete the rest.
|
||||
keep := 0
|
||||
for i := 1; i < len(recs); i++ {
|
||||
if recs[i].Updated > recs[keep].Updated {
|
||||
keep = i
|
||||
}
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i == keep {
|
||||
continue
|
||||
}
|
||||
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
|
||||
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// ── BookReader ────────────────────────────────────────────────────────────────
|
||||
|
||||
type pbBook struct {
|
||||
|
||||
@@ -19,6 +19,9 @@ x-infra-env: &infra-env
|
||||
MEILI_API_KEY: "${MEILI_MASTER_KEY}"
|
||||
# Valkey
|
||||
VALKEY_ADDR: "valkey:6379"
|
||||
# Cloudflare AI (TTS + image generation)
|
||||
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
|
||||
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
|
||||
|
||||
services:
|
||||
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
|
||||
@@ -307,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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
justfile
7
justfile
@@ -122,6 +122,13 @@ secrets-env:
|
||||
secrets-dashboard:
|
||||
doppler open dashboard
|
||||
|
||||
# ── Developer setup ───────────────────────────────────────────────────────────
|
||||
|
||||
# One-time dev setup: configure git to use committed hooks in .githooks/
|
||||
setup:
|
||||
git config core.hooksPath .githooks
|
||||
@echo "Git hooks configured (.githooks/pre-commit active)."
|
||||
|
||||
# ── Gitea CI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Validate workflow files
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -302,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Queuing\u2026",
|
||||
"book_detail_scrape_range": "Scrape range",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Book Cover",
|
||||
"book_detail_admin_chapter_cover": "Chapter Cover",
|
||||
"book_detail_admin_chapter_n": "Chapter #",
|
||||
"book_detail_admin_description": "Description",
|
||||
"book_detail_admin_chapter_names": "Chapter Names",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Voice",
|
||||
"book_detail_admin_generate": "Generate",
|
||||
"book_detail_admin_save_cover": "Save Cover",
|
||||
"book_detail_admin_saving": "Saving…",
|
||||
"book_detail_admin_saved": "Saved",
|
||||
"book_detail_admin_apply": "Apply",
|
||||
"book_detail_admin_applying": "Applying…",
|
||||
"book_detail_admin_applied": "Applied",
|
||||
"book_detail_admin_discard": "Discard",
|
||||
"book_detail_admin_enqueue_audio": "Enqueue Audio",
|
||||
"book_detail_admin_cancel_audio": "Cancel",
|
||||
"book_detail_admin_enqueued": "Enqueued {enqueued}, skipped {skipped}",
|
||||
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
|
||||
"book_detail_scraping_home": "\u2190 Home",
|
||||
"book_detail_rescrape_book": "Rescrape book",
|
||||
@@ -348,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})",
|
||||
@@ -363,6 +401,7 @@
|
||||
"admin_nav_translation": "Translation",
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -302,6 +302,24 @@
|
||||
"book_detail_range_queuing": "En file d'attente…",
|
||||
"book_detail_scrape_range": "Plage d'extraction",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Couverture du livre",
|
||||
"book_detail_admin_chapter_cover": "Couverture du chapitre",
|
||||
"book_detail_admin_chapter_n": "Chapitre n°",
|
||||
"book_detail_admin_description": "Description",
|
||||
"book_detail_admin_chapter_names": "Noms des chapitres",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Voix",
|
||||
"book_detail_admin_generate": "Générer",
|
||||
"book_detail_admin_save_cover": "Enregistrer la couverture",
|
||||
"book_detail_admin_saving": "Enregistrement…",
|
||||
"book_detail_admin_saved": "Enregistré",
|
||||
"book_detail_admin_apply": "Appliquer",
|
||||
"book_detail_admin_applying": "Application…",
|
||||
"book_detail_admin_applied": "Appliqué",
|
||||
"book_detail_admin_discard": "Ignorer",
|
||||
"book_detail_admin_enqueue_audio": "Mettre en file audio",
|
||||
"book_detail_admin_cancel_audio": "Annuler",
|
||||
"book_detail_admin_enqueued": "{enqueued} en file, {skipped} ignorés",
|
||||
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
|
||||
"book_detail_scraping_home": "← Accueil",
|
||||
"book_detail_rescrape_book": "Réextraire le livre",
|
||||
@@ -348,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})",
|
||||
@@ -363,6 +401,7 @@
|
||||
"admin_nav_translation": "Traduction",
|
||||
"admin_nav_changelog": "Modifications",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Retours",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
|
||||
@@ -302,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Mengantri…",
|
||||
"book_detail_scrape_range": "Rentang scrape",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Sampul Buku",
|
||||
"book_detail_admin_chapter_cover": "Sampul Bab",
|
||||
"book_detail_admin_chapter_n": "Bab #",
|
||||
"book_detail_admin_description": "Deskripsi",
|
||||
"book_detail_admin_chapter_names": "Nama Bab",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Suara",
|
||||
"book_detail_admin_generate": "Buat",
|
||||
"book_detail_admin_save_cover": "Simpan Sampul",
|
||||
"book_detail_admin_saving": "Menyimpan…",
|
||||
"book_detail_admin_saved": "Tersimpan",
|
||||
"book_detail_admin_apply": "Terapkan",
|
||||
"book_detail_admin_applying": "Menerapkan…",
|
||||
"book_detail_admin_applied": "Diterapkan",
|
||||
"book_detail_admin_discard": "Buang",
|
||||
"book_detail_admin_enqueue_audio": "Antre Audio",
|
||||
"book_detail_admin_cancel_audio": "Batal",
|
||||
"book_detail_admin_enqueued": "Diantre {enqueued}, dilewati {skipped}",
|
||||
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
|
||||
"book_detail_scraping_home": "← Beranda",
|
||||
"book_detail_rescrape_book": "Scrape ulang buku",
|
||||
@@ -348,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})",
|
||||
@@ -363,6 +401,7 @@
|
||||
"admin_nav_translation": "Terjemahan",
|
||||
"admin_nav_changelog": "Perubahan",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Masukan",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
|
||||
@@ -302,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Na fila…",
|
||||
"book_detail_scrape_range": "Intervalo de extração",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Capa do Livro",
|
||||
"book_detail_admin_chapter_cover": "Capa do Capítulo",
|
||||
"book_detail_admin_chapter_n": "Capítulo nº",
|
||||
"book_detail_admin_description": "Descrição",
|
||||
"book_detail_admin_chapter_names": "Nomes dos Capítulos",
|
||||
"book_detail_admin_audio_tts": "Áudio TTS",
|
||||
"book_detail_admin_voice": "Voz",
|
||||
"book_detail_admin_generate": "Gerar",
|
||||
"book_detail_admin_save_cover": "Salvar Capa",
|
||||
"book_detail_admin_saving": "Salvando…",
|
||||
"book_detail_admin_saved": "Salvo",
|
||||
"book_detail_admin_apply": "Aplicar",
|
||||
"book_detail_admin_applying": "Aplicando…",
|
||||
"book_detail_admin_applied": "Aplicado",
|
||||
"book_detail_admin_discard": "Descartar",
|
||||
"book_detail_admin_enqueue_audio": "Enfileirar Áudio",
|
||||
"book_detail_admin_cancel_audio": "Cancelar",
|
||||
"book_detail_admin_enqueued": "{enqueued} enfileirados, {skipped} ignorados",
|
||||
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
|
||||
"book_detail_scraping_home": "← Início",
|
||||
"book_detail_rescrape_book": "Reextrair livro",
|
||||
@@ -348,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})",
|
||||
@@ -363,6 +401,7 @@
|
||||
"admin_nav_translation": "Tradução",
|
||||
"admin_nav_changelog": "Alterações",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
|
||||
@@ -302,6 +302,24 @@
|
||||
"book_detail_range_queuing": "В очереди…",
|
||||
"book_detail_scrape_range": "Диапазон глав",
|
||||
"book_detail_admin": "Администрирование",
|
||||
"book_detail_admin_book_cover": "Обложка книги",
|
||||
"book_detail_admin_chapter_cover": "Обложка главы",
|
||||
"book_detail_admin_chapter_n": "Глава №",
|
||||
"book_detail_admin_description": "Описание",
|
||||
"book_detail_admin_chapter_names": "Названия глав",
|
||||
"book_detail_admin_audio_tts": "Аудио TTS",
|
||||
"book_detail_admin_voice": "Голос",
|
||||
"book_detail_admin_generate": "Сгенерировать",
|
||||
"book_detail_admin_save_cover": "Сохранить обложку",
|
||||
"book_detail_admin_saving": "Сохранение…",
|
||||
"book_detail_admin_saved": "Сохранено",
|
||||
"book_detail_admin_apply": "Применить",
|
||||
"book_detail_admin_applying": "Применение…",
|
||||
"book_detail_admin_applied": "Применено",
|
||||
"book_detail_admin_discard": "Отменить",
|
||||
"book_detail_admin_enqueue_audio": "Поставить в очередь",
|
||||
"book_detail_admin_cancel_audio": "Отмена",
|
||||
"book_detail_admin_enqueued": "В очереди {enqueued}, пропущено {skipped}",
|
||||
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
|
||||
"book_detail_scraping_home": "← На главную",
|
||||
"book_detail_rescrape_book": "Перепарсить книгу",
|
||||
@@ -348,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})",
|
||||
@@ -363,6 +401,7 @@
|
||||
"admin_nav_translation": "Перевод",
|
||||
"admin_nav_changelog": "Изменения",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Отзывы",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
|
||||
@@ -106,12 +106,14 @@ html {
|
||||
:root {
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-size: 1.05rem;
|
||||
--reading-line-height: 1.85;
|
||||
--reading-max-width: 72ch;
|
||||
}
|
||||
|
||||
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
||||
.prose-chapter {
|
||||
max-width: 72ch;
|
||||
line-height: 1.85;
|
||||
max-width: var(--reading-max-width, 72ch);
|
||||
line-height: var(--reading-line-height, 1.85);
|
||||
font-family: var(--reading-font);
|
||||
font-size: var(--reading-size);
|
||||
color: var(--color-muted);
|
||||
@@ -134,6 +136,12 @@ html {
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
|
||||
/* Indented paragraph style — book-like, no gap, indent instead */
|
||||
.prose-chapter.para-indented p {
|
||||
text-indent: 2em;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.prose-chapter em {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -147,6 +155,31 @@ html {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ── Reading progress bar ───────────────────────────────────────────── */
|
||||
.reading-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
z-index: 100;
|
||||
background: var(--color-brand);
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ── Paginated reader ───────────────────────────────────────────────── */
|
||||
.paginated-container {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.paginated-container .prose-chapter {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
|
||||
playerStyle?: 'standard' | 'compact';
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -80,7 +82,8 @@
|
||||
nextChapter = null,
|
||||
chapters = [],
|
||||
voices = [],
|
||||
onProRequired = undefined
|
||||
onProRequired = undefined,
|
||||
playerStyle = 'standard'
|
||||
}: Props = $props();
|
||||
|
||||
// ── Derived: voices grouped by engine ──────────────────────────────────
|
||||
@@ -564,7 +567,35 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
|
||||
// use the streaming endpoint — audio starts playing within seconds while
|
||||
// generation runs and MinIO is populated concurrently.
|
||||
// Skip when enqueued=true to avoid double-generation with the async runner.
|
||||
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// CF AI (batch-only) or already enqueued by presign: keep the traditional
|
||||
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
|
||||
audioStore.status = 'generating';
|
||||
startProgress();
|
||||
|
||||
@@ -738,6 +769,24 @@
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ── Compact player helpers ─────────────────────────────────────────────────
|
||||
const playPct = $derived(
|
||||
audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
|
||||
);
|
||||
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
function cycleSpeed() {
|
||||
const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]);
|
||||
audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
|
||||
}
|
||||
|
||||
function seekFromCompactBar(e: MouseEvent) {
|
||||
if (audioStore.duration <= 0) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
audioStore.seekRequest = pct * audioStore.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -788,6 +837,121 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if playerStyle === 'compact'}
|
||||
<!-- ── Compact player ──────────────────────────────────────────────────────── -->
|
||||
<div class="mt-4 p-3 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{m.player_loading()}
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-(--color-muted)">
|
||||
<span>{m.reader_generating_narration()}</span>
|
||||
<span class="tabular-nums">{Math.round(audioStore.progress)}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {audioStore.progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<div class="space-y-2">
|
||||
<!-- Seekable progress bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
|
||||
onclick={seekFromCompactBar}
|
||||
>
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||
</div>
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Time display -->
|
||||
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
<!-- Speed cycle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-medium text-(--color-muted) hover:text-(--color-text) flex-shrink-0 tabular-nums transition-colors"
|
||||
title="Playback speed"
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if audioStore.active}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
{m.reader_load_this_chapter()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1026,3 +1190,4 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -279,6 +279,24 @@ export * from './book_detail_to_chapter.js'
|
||||
export * from './book_detail_range_queuing.js'
|
||||
export * from './book_detail_scrape_range.js'
|
||||
export * from './book_detail_admin.js'
|
||||
export * from './book_detail_admin_book_cover.js'
|
||||
export * from './book_detail_admin_chapter_cover.js'
|
||||
export * from './book_detail_admin_chapter_n.js'
|
||||
export * from './book_detail_admin_description.js'
|
||||
export * from './book_detail_admin_chapter_names.js'
|
||||
export * from './book_detail_admin_audio_tts.js'
|
||||
export * from './book_detail_admin_voice.js'
|
||||
export * from './book_detail_admin_generate.js'
|
||||
export * from './book_detail_admin_save_cover.js'
|
||||
export * from './book_detail_admin_saving.js'
|
||||
export * from './book_detail_admin_saved.js'
|
||||
export * from './book_detail_admin_apply.js'
|
||||
export * from './book_detail_admin_applying.js'
|
||||
export * from './book_detail_admin_applied.js'
|
||||
export * from './book_detail_admin_discard.js'
|
||||
export * from './book_detail_admin_enqueue_audio.js'
|
||||
export * from './book_detail_admin_cancel_audio.js'
|
||||
export * from './book_detail_admin_enqueued.js'
|
||||
export * from './book_detail_scraping_progress.js'
|
||||
export * from './book_detail_scraping_home.js'
|
||||
export * from './book_detail_rescrape_book.js'
|
||||
@@ -321,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'
|
||||
@@ -333,6 +371,8 @@ export * from './admin_nav_scrape.js'
|
||||
export * from './admin_nav_audio.js'
|
||||
export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_image_gen.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_image_gen.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Image_GenInputs */
|
||||
|
||||
const en_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const id_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Image Gen" |
|
||||
*
|
||||
* @param {Admin_Nav_Image_GenInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_image_gen = /** @type {((inputs?: Admin_Nav_Image_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Image_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_image_gen(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_image_gen(inputs)
|
||||
if (locale === "id") return id_admin_nav_image_gen(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_image_gen(inputs)
|
||||
return fr_admin_nav_image_gen(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Text_GenInputs */
|
||||
|
||||
const en_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const id_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Text Gen" |
|
||||
*
|
||||
* @param {Admin_Nav_Text_GenInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_text_gen = /** @type {((inputs?: Admin_Nav_Text_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Text_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_text_gen(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_text_gen(inputs)
|
||||
if (locale === "id") return id_admin_nav_text_gen(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_text_gen(inputs)
|
||||
return fr_admin_nav_text_gen(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_applied.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_applied.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_AppliedInputs */
|
||||
|
||||
const en_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Applied`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применено`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Diterapkan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicado`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Appliqué`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Applied" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_AppliedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_applied = /** @type {((inputs?: Book_Detail_Admin_AppliedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_AppliedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_applied(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_applied(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_applied(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_applied(inputs)
|
||||
return fr_book_detail_admin_applied(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_apply.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_apply.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_ApplyInputs */
|
||||
|
||||
const en_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Apply`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применить`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Terapkan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Appliquer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Apply" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_ApplyInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_apply = /** @type {((inputs?: Book_Detail_Admin_ApplyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_apply(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_apply(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_apply(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_apply(inputs)
|
||||
return fr_book_detail_admin_apply(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_applying.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_applying.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_ApplyingInputs */
|
||||
|
||||
const en_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Applying…`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применение…`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Menerapkan…`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicando…`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Application…`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Applying…" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_ApplyingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_applying = /** @type {((inputs?: Book_Detail_Admin_ApplyingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_applying(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_applying(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_applying(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_applying(inputs)
|
||||
return fr_book_detail_admin_applying(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_audio_tts.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_audio_tts.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Audio_TtsInputs */
|
||||
|
||||
const en_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Аудио TTS`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Áudio TTS`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Audio TTS" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Audio_TtsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_audio_tts = /** @type {((inputs?: Book_Detail_Admin_Audio_TtsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Audio_TtsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_audio_tts(inputs)
|
||||
return fr_book_detail_admin_audio_tts(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Book_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Book Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Обложка книги`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Sampul Buku`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capa do Livro`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Couverture du livre`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Book Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Book_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_book_cover = /** @type {((inputs?: Book_Detail_Admin_Book_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Book_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_book_cover(inputs)
|
||||
return fr_book_detail_admin_book_cover(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Cancel_AudioInputs */
|
||||
|
||||
const en_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Cancel`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отмена`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Batal`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Cancelar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Annuler`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Cancel" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Cancel_AudioInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_cancel_audio = /** @type {((inputs?: Book_Detail_Admin_Cancel_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Cancel_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_cancel_audio(inputs)
|
||||
return fr_book_detail_admin_cancel_audio(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Обложка главы`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Sampul Bab`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capa do Capítulo`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Couverture du chapitre`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_cover = /** @type {((inputs?: Book_Detail_Admin_Chapter_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_cover(inputs)
|
||||
return fr_book_detail_admin_chapter_cover(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_chapter_n.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_chapter_n.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_NInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter #`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Глава №`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Bab #`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capítulo nº`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapitre n°`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter #" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_NInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_n = /** @type {((inputs?: Book_Detail_Admin_Chapter_NInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_n(inputs)
|
||||
return fr_book_detail_admin_chapter_n(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_NamesInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter Names`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Названия глав`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nama Bab`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nomes dos Capítulos`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Noms des chapitres`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter Names" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_NamesInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_names = /** @type {((inputs?: Book_Detail_Admin_Chapter_NamesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NamesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_names(inputs)
|
||||
return fr_book_detail_admin_chapter_names(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_DescriptionInputs */
|
||||
|
||||
const en_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Description`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Описание`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Deskripsi`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Descrição`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Description`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Description" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_DescriptionInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_description = /** @type {((inputs?: Book_Detail_Admin_DescriptionInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DescriptionInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_description(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_description(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_description(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_description(inputs)
|
||||
return fr_book_detail_admin_description(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_discard.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_discard.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_DiscardInputs */
|
||||
|
||||
const en_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Discard`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отменить`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Buang`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Descartar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Ignorer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Discard" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_DiscardInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_discard = /** @type {((inputs?: Book_Detail_Admin_DiscardInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DiscardInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_discard(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_discard(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_discard(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_discard(inputs)
|
||||
return fr_book_detail_admin_discard(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Enqueue_AudioInputs */
|
||||
|
||||
const en_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enqueue Audio`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Поставить в очередь`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Antre Audio`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enfileirar Áudio`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Mettre en file audio`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Enqueue Audio" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Enqueue_AudioInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_enqueue_audio = /** @type {((inputs?: Book_Detail_Admin_Enqueue_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Enqueue_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_enqueue_audio(inputs)
|
||||
return fr_book_detail_admin_enqueue_audio(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_enqueued.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_enqueued.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{ enqueued: NonNullable<unknown>, skipped: NonNullable<unknown> }} Book_Detail_Admin_EnqueuedInputs */
|
||||
|
||||
const en_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`Enqueued ${i?.enqueued}, skipped ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`В очереди ${i?.enqueued}, пропущено ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`Diantre ${i?.enqueued}, dilewati ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.enqueued} enfileirados, ${i?.skipped} ignorados`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.enqueued} en file, ${i?.skipped} ignorés`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Enqueued {enqueued}, skipped {skipped}" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_EnqueuedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_enqueued = /** @type {((inputs: Book_Detail_Admin_EnqueuedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_EnqueuedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_enqueued(inputs)
|
||||
return fr_book_detail_admin_enqueued(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_generate.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_generate.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_GenerateInputs */
|
||||
|
||||
const en_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Generate`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сгенерировать`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Buat`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gerar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Générer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Generate" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_GenerateInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_generate = /** @type {((inputs?: Book_Detail_Admin_GenerateInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_GenerateInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_generate(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_generate(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_generate(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_generate(inputs)
|
||||
return fr_book_detail_admin_generate(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Save_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Save Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранить обложку`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Simpan Sampul`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvar Capa`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistrer la couverture`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Save Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Save_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_save_cover = /** @type {((inputs?: Book_Detail_Admin_Save_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Save_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_save_cover(inputs)
|
||||
return fr_book_detail_admin_save_cover(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_saved.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_saved.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_SavedInputs */
|
||||
|
||||
const en_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Saved`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранено`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tersimpan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvo`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistré`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Saved" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_SavedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_saved = /** @type {((inputs?: Book_Detail_Admin_SavedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_saved(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_saved(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_saved(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_saved(inputs)
|
||||
return fr_book_detail_admin_saved(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_saving.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_saving.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_SavingInputs */
|
||||
|
||||
const en_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Saving…`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранение…`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Menyimpan…`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvando…`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistrement…`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Saving…" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_SavingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_saving = /** @type {((inputs?: Book_Detail_Admin_SavingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_saving(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_saving(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_saving(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_saving(inputs)
|
||||
return fr_book_detail_admin_saving(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_voice.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_voice.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_VoiceInputs */
|
||||
|
||||
const en_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voice`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Голос`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Suara`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voz`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voix`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Voice" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_VoiceInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_voice = /** @type {((inputs?: Book_Detail_Admin_VoiceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_VoiceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_voice(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_voice(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_voice(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_voice(inputs)
|
||||
return fr_book_detail_admin_voice(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_already_pro.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_already_pro.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_annual_label.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_annual_label.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_annual_period.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_annual_period.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_annual_price.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_annual_price.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_annual_save.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_annual_save.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_benefit_audio.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_benefit_audio.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_benefit_downloads.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_benefit_downloads.js
Normal 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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_benefit_voices.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_benefit_voices.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_cta_annual.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_cta_annual.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_cta_monthly.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_cta_monthly.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_heading.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_heading.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_login_cta.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_login_cta.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_login_prompt.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_login_prompt.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_manage.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_manage.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_monthly_label.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_monthly_label.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_monthly_period.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_monthly_period.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_monthly_price.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_monthly_price.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_page_title.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_page_title.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/subscribe_subheading.js
Normal file
44
ui/src/lib/paraglide/messages/subscribe_subheading.js
Normal 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)
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
theme: row.theme ?? 'amber',
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size ?? 1.0
|
||||
fontSize: row.font_size || 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||
currentFontSize = data.settings.fontSize ?? 1.0;
|
||||
currentFontSize = data.settings.fontSize || 1.0;
|
||||
// Mark dirty only after the synchronous apply is done so the save
|
||||
// effect doesn't fire for this initial load.
|
||||
setTimeout(() => { settingsDirty = true; }, 0);
|
||||
@@ -192,6 +192,50 @@
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
// ── MediaSession action handlers ────────────────────────────────────────────
|
||||
// Without explicit handlers, iOS Safari loses lock-screen resume ability after
|
||||
// ~1 minute of pause because it falls back to its own default which doesn't
|
||||
// satisfy the user-gesture requirement for <audio>.play().
|
||||
// Handlers registered here call audioEl directly so iOS trusts the gesture.
|
||||
$effect(() => {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator) || !audioEl) return;
|
||||
const el = audioEl; // capture for closure
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
el.play().catch(() => {});
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
el.pause();
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekbackward', (d) => {
|
||||
el.currentTime = Math.max(0, el.currentTime - (d.seekOffset ?? 15));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('seekforward', (d) => {
|
||||
el.currentTime = Math.min(el.duration || 0, el.currentTime + (d.seekOffset ?? 30));
|
||||
});
|
||||
// previoustrack / nexttrack fall back to skip ±30s if no chapter nav available
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
el.currentTime = Math.max(0, el.currentTime - 30);
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
el.currentTime = Math.min(el.duration || 0, el.currentTime + 30);
|
||||
});
|
||||
} catch { /* some browsers don't support these */ }
|
||||
|
||||
return () => {
|
||||
(['play', 'pause', 'seekbackward', 'seekforward'] as MediaSessionAction[]).forEach((a) => {
|
||||
try { navigator.mediaSession.setActionHandler(a, null); } catch { /* ignore */ }
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// Keep playbackState in sync so iOS lock screen shows the right button state
|
||||
$effect(() => {
|
||||
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.playbackState = audioStore.isPlaying ? 'playing' : 'paused';
|
||||
});
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
@@ -363,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">
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
{ href: '/admin/audio', label: () => m.admin_nav_audio() },
|
||||
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
|
||||
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
id: string;
|
||||
@@ -11,18 +12,36 @@ export interface ImageModelInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BookSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) {
|
||||
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { models: (data.models ?? []) as ImageModelInfo[] };
|
||||
} catch (e) {
|
||||
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
|
||||
return { models: [] as ImageModelInfo[] };
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as ImageModelInfo[];
|
||||
})(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,26 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { ImageModelInfo } from './+page.server';
|
||||
import type { ImageModelInfo, BookSummary } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────────────────────
|
||||
type ImageType = 'cover' | 'chapter';
|
||||
|
||||
const CONFIG_KEY = 'admin_image_gen_config_v1';
|
||||
|
||||
interface SavedConfig {
|
||||
selectedModel: string;
|
||||
numSteps: number;
|
||||
guidance: number;
|
||||
strength: number;
|
||||
width: number;
|
||||
height: number;
|
||||
showAdvanced: boolean;
|
||||
}
|
||||
|
||||
function loadConfig(): Partial<SavedConfig> {
|
||||
if (!browser) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
if (!browser) return;
|
||||
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
const saved = loadConfig();
|
||||
|
||||
let imageType = $state<ImageType>('cover');
|
||||
let slug = $state('');
|
||||
let chapter = $state<number>(1);
|
||||
let selectedModel = $state('');
|
||||
let selectedModel = $state(saved.selectedModel ?? '');
|
||||
let prompt = $state('');
|
||||
let referenceFile = $state<File | null>(null);
|
||||
let referencePreviewUrl = $state('');
|
||||
let useCoverAsRef = $state(false);
|
||||
|
||||
// Advanced
|
||||
let showAdvanced = $state(false);
|
||||
let numSteps = $state(20);
|
||||
let guidance = $state(7.5);
|
||||
let strength = $state(0.75);
|
||||
let width = $state(1024);
|
||||
let height = $state(1024);
|
||||
let showAdvanced = $state(saved.showAdvanced ?? false);
|
||||
let numSteps = $state(saved.numSteps ?? 20);
|
||||
let guidance = $state(saved.guidance ?? 7.5);
|
||||
let strength = $state(saved.strength ?? 0.75);
|
||||
let width = $state(saved.width ?? 1024);
|
||||
let height = $state(saved.height ?? 1024);
|
||||
|
||||
// Persist config on change
|
||||
$effect(() => {
|
||||
void selectedModel; void numSteps; void guidance; void strength;
|
||||
void width; void height; void showAdvanced;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
const books = data.books as BookSummary[];
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
let bookSuggestions = $derived(
|
||||
slugInput.trim().length === 0
|
||||
? []
|
||||
: books
|
||||
.filter((b) =>
|
||||
b.slug.includes(slugInput.toLowerCase()) ||
|
||||
b.title.toLowerCase().includes(slugInput.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
function selectBook(b: BookSummary) {
|
||||
selectedBook = b;
|
||||
slug = b.slug;
|
||||
slugInput = b.slug;
|
||||
slugFocused = false;
|
||||
// Reset cover-as-ref if no cover
|
||||
if (!b.cover) useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onSlugInput() {
|
||||
slug = slugInput;
|
||||
// If user edits away from selected book slug, deselect
|
||||
if (selectedBook && slugInput !== selectedBook.slug) {
|
||||
selectedBook = null;
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
// When useCoverAsRef toggled on, load the book cover as reference
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
if (!useCoverAsRef || !selectedBook?.cover) {
|
||||
if (useCoverAsRef) useCoverAsRef = false;
|
||||
return;
|
||||
}
|
||||
// Fetch the cover image and set as referenceFile
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(selectedBook!.cover);
|
||||
const blob = await res.blob();
|
||||
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
|
||||
handleReferenceFile(file);
|
||||
} catch {
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Generation state ─────────────────────────────────────────────────────────
|
||||
let generating = $state(false);
|
||||
@@ -52,16 +146,10 @@
|
||||
// ── Model helpers ────────────────────────────────────────────────────────────
|
||||
const models = data.models as ImageModelInfo[];
|
||||
|
||||
let filteredModels = $derived(
|
||||
referenceFile
|
||||
? models // show all; warn on ones without ref support
|
||||
: models
|
||||
);
|
||||
|
||||
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
|
||||
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
|
||||
let otherModels = $derived(
|
||||
filteredModels.filter(
|
||||
models.filter(
|
||||
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
|
||||
)
|
||||
);
|
||||
@@ -74,12 +162,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reset model selection when type changes if current selection no longer fits
|
||||
$effect(() => {
|
||||
void imageType; // track
|
||||
void imageType;
|
||||
const preferred = imageType === 'cover' ? coverModels : chapterModels;
|
||||
if (preferred.length > 0) {
|
||||
// only auto-switch if current model isn't in preferred list for this type
|
||||
const current = models.find((m) => m.id === selectedModel);
|
||||
if (!current || !current.recommended_for.includes(imageType)) {
|
||||
selectedModel = preferred[0].id;
|
||||
@@ -90,14 +176,21 @@
|
||||
// ── Prompt templates ────────────────────────────────────────────────────────
|
||||
let promptTemplate = $derived(
|
||||
imageType === 'cover'
|
||||
? `Book cover for "${slug || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
|
||||
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
|
||||
);
|
||||
|
||||
function applyTemplate() {
|
||||
prompt = promptTemplate;
|
||||
}
|
||||
|
||||
function injectDescription() {
|
||||
const desc = selectedBook?.summary?.trim();
|
||||
if (!desc) return;
|
||||
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
|
||||
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
|
||||
}
|
||||
|
||||
// ── Reference image handling ─────────────────────────────────────────────────
|
||||
let dragOver = $state(false);
|
||||
|
||||
@@ -110,17 +203,22 @@
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
handleReferenceFile(input.files?.[0] ?? null);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
handleReferenceFile(file);
|
||||
useCoverAsRef = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReference() {
|
||||
handleReferenceFile(null);
|
||||
useCoverAsRef = false;
|
||||
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
|
||||
if (input) input.value = '';
|
||||
}
|
||||
@@ -219,28 +317,11 @@
|
||||
saveSuccess = false;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
prompt: prompt.trim(),
|
||||
model: result.model,
|
||||
type: 'cover',
|
||||
slug: result.slug,
|
||||
num_steps: numSteps,
|
||||
guidance,
|
||||
strength,
|
||||
width,
|
||||
height,
|
||||
save_to_cover: true
|
||||
};
|
||||
|
||||
// Re-generate with save_to_cover=true (backend saves atomically)
|
||||
// Alternatively, we could add a separate save endpoint.
|
||||
// For now we pass the same prompt + model to re-generate and save.
|
||||
// TODO: A lighter approach would be a dedicated save endpoint that accepts
|
||||
// the base64 payload. For now re-gen is acceptable given admin-only usage.
|
||||
const res = await fetch('/api/admin/image-gen', {
|
||||
const b64 = result.imageSrc.split(',')[1];
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify({ slug: result.slug, image_b64: b64 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -324,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'}
|
||||
@@ -401,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"
|
||||
@@ -419,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
|
||||
@@ -432,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}
|
||||
@@ -546,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" />
|
||||
|
||||
42
ui/src/routes/admin/text-gen/+page.server.ts
Normal file
42
ui/src/routes/admin/text-gen/+page.server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
label: string;
|
||||
provider: string;
|
||||
context_size: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as TextModelInfo[];
|
||||
})(),
|
||||
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[]
|
||||
};
|
||||
};
|
||||
709
ui/src/routes/admin/text-gen/+page.svelte
Normal file
709
ui/src/routes/admin/text-gen/+page.svelte
Normal file
@@ -0,0 +1,709 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
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>(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(saved.chPattern ?? 'Chapter {n}: {scene}');
|
||||
let chGenerating = $state(false);
|
||||
let chError = $state('');
|
||||
|
||||
interface ProposedChapter {
|
||||
number: number;
|
||||
old_title: string;
|
||||
new_title: string;
|
||||
edited: string;
|
||||
}
|
||||
let chProposals = $state<ProposedChapter[]>([]);
|
||||
let chUsedModel = $state('');
|
||||
|
||||
let chBatchProgress = $state('');
|
||||
let chBatchWarnings = $state<string[]>([]);
|
||||
|
||||
let chApplying = $state(false);
|
||||
let chApplyError = $state('');
|
||||
let chApplySuccess = $state(false);
|
||||
|
||||
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 = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: chSlug.trim(),
|
||||
pattern: chPattern.trim(),
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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.';
|
||||
} finally {
|
||||
chGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChapterNames() {
|
||||
if (!chCanApply) return;
|
||||
chApplying = true;
|
||||
chApplyError = '';
|
||||
chApplySuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: chSlug.trim(),
|
||||
chapters: chProposals.map((p) => ({ number: p.number, title: p.edited.trim() || p.new_title }))
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
chApplyError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
chApplySuccess = true;
|
||||
} catch {
|
||||
chApplyError = 'Network error.';
|
||||
} finally {
|
||||
chApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description state ─────────────────────────────────────────────────────────
|
||||
let dAC = makeBookAC();
|
||||
let dSlug = $state('');
|
||||
let dInstructions = $state(saved.dInstructions ?? '');
|
||||
let dGenerating = $state(false);
|
||||
let dError = $state('');
|
||||
|
||||
let dOldDesc = $state('');
|
||||
let dNewDesc = $state('');
|
||||
let dUsedModel = $state('');
|
||||
|
||||
let dApplying = $state(false);
|
||||
let dApplyError = $state('');
|
||||
let dApplySuccess = $state(false);
|
||||
|
||||
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;
|
||||
dError = '';
|
||||
dOldDesc = '';
|
||||
dNewDesc = '';
|
||||
dApplySuccess = false;
|
||||
dApplyError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: dSlug.trim(),
|
||||
instructions: dInstructions.trim(),
|
||||
model: selectedModel
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
dError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
dOldDesc = body.old_description ?? '';
|
||||
dNewDesc = body.new_description ?? '';
|
||||
dUsedModel = body.model ?? '';
|
||||
} catch {
|
||||
dError = 'Network error.';
|
||||
} finally {
|
||||
dGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDescription() {
|
||||
if (!dCanApply) return;
|
||||
dApplying = true;
|
||||
dApplyError = '';
|
||||
dApplySuccess = false;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: dSlug.trim(),
|
||||
description: dNewDesc.trim()
|
||||
})
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
dApplyError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
return;
|
||||
}
|
||||
dApplySuccess = true;
|
||||
} catch {
|
||||
dApplyError = 'Network error.';
|
||||
} finally {
|
||||
dApplying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Text Gen — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Text Generation</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
Generate chapter titles and book descriptions using Cloudflare Workers AI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model selector (shared) -->
|
||||
<div class="space-y-1 max-w-md">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="model-select">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="model-select"
|
||||
bind:value={selectedModel}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
>
|
||||
{#each models as m}
|
||||
<option value={m.id}>{m.label} — {m.provider} ({fmtCtx(m.context_size)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedModelInfo}
|
||||
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tab toggle -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
{#each (['chapters', 'description'] as const) as t}
|
||||
<button
|
||||
onclick={() => (activeTab = t)}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === t
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{t === 'chapters' ? 'Chapter Names' : 'Description'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Chapter names panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'chapters'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<!-- Left: form -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<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">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-pattern">
|
||||
Naming pattern
|
||||
</label>
|
||||
<input
|
||||
id="ch-pattern"
|
||||
type="text"
|
||||
bind:value={chPattern}
|
||||
placeholder="e.g. Chapter [n]: [brief scene description]"
|
||||
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)"
|
||||
/>
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Describe the desired chapter title format. The AI will rename every chapter to match.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={generateChapterNames}
|
||||
disabled={!chCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if chGenerating}
|
||||
<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" />
|
||||
</svg>
|
||||
Generating…
|
||||
{:else}
|
||||
Generate proposals
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if chError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: proposals -->
|
||||
<div class="space-y-4">
|
||||
{#if chProposals.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
{chProposals.length} proposals
|
||||
{#if chUsedModel}
|
||||
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#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 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">
|
||||
{#each chProposals as proposal}
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono text-(--color-muted) w-8 shrink-0">#{proposal.number}</span>
|
||||
<p class="text-xs text-(--color-muted) line-through truncate flex-1" title={proposal.old_title}>
|
||||
{proposal.old_title}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={proposal.edited}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-2.5 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button
|
||||
onclick={applyChapterNames}
|
||||
disabled={!chCanApply}
|
||||
class="flex-1 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
|
||||
</button>
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if chApplyError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chApplyError}</p>
|
||||
{/if}
|
||||
{#if chApplySuccess}
|
||||
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">
|
||||
{chProposals.length} chapter titles saved successfully.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if chGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
|
||||
<div class="text-center space-y-3">
|
||||
<svg class="w-8 h-8 animate-spin mx-auto text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
{chBatchProgress || 'Generating chapter titles…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
|
||||
<p class="text-sm text-(--color-muted)">Proposed titles will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Description panel ──────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'description'}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<!-- Left: form -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
|
||||
Book slug
|
||||
</label>
|
||||
<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">
|
||||
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-instructions">
|
||||
Instructions <span class="normal-case font-normal text-(--color-muted)">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="d-instructions"
|
||||
bind:value={dInstructions}
|
||||
rows="3"
|
||||
placeholder="e.g. Write a 3-sentence blurb, avoid spoilers, dramatic tone."
|
||||
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) resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={generateDescription}
|
||||
disabled={!dCanGenerate}
|
||||
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if dGenerating}
|
||||
<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" />
|
||||
</svg>
|
||||
Generating…
|
||||
{:else}
|
||||
Generate description
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if dError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: result -->
|
||||
<div class="space-y-4">
|
||||
{#if dNewDesc || dOldDesc}
|
||||
<div class="space-y-4">
|
||||
{#if dOldDesc}
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Current description</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3">
|
||||
<p class="text-sm text-(--color-muted) whitespace-pre-wrap">{dOldDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dNewDesc}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Proposed description
|
||||
{#if dUsedModel}
|
||||
<span class="normal-case font-normal">· {dUsedModel.split('/').pop()}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={dNewDesc}
|
||||
rows="6"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={applyDescription}
|
||||
disabled={!dCanApply}
|
||||
class="flex-1 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
|
||||
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{dApplying ? 'Saving…' : dApplySuccess ? 'Saved ✓' : 'Apply description'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { dNewDesc = ''; dOldDesc = ''; dApplySuccess = 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)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if dApplyError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dApplyError}</p>
|
||||
{/if}
|
||||
{#if dApplySuccess}
|
||||
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Description saved successfully.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if dGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
|
||||
<div class="text-center space-y-3">
|
||||
<svg class="w-8 h-8 animate-spin mx-auto text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
<p class="text-sm text-(--color-muted)">Generating description…</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
|
||||
<p class="text-sm text-(--color-muted)">Proposed description will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
34
ui/src/routes/api/admin/audio/bulk/+server.ts
Normal file
34
ui/src/routes/api/admin/audio/bulk/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* POST /api/admin/audio/bulk
|
||||
*
|
||||
* Admin-only proxy to the Go backend's audio bulk-enqueue endpoint.
|
||||
* Body: { slug, voice?, from, to, skip_existing?, force? }
|
||||
* Response 202: { enqueued, skipped, task_ids }
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/audio/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/audio/bulk', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
34
ui/src/routes/api/admin/audio/cancel-bulk/+server.ts
Normal file
34
ui/src/routes/api/admin/audio/cancel-bulk/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* POST /api/admin/audio/cancel-bulk
|
||||
*
|
||||
* Admin-only proxy to cancel all pending/running audio tasks for a slug.
|
||||
* Body: { slug }
|
||||
* Response 200: { cancelled }
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/audio/cancel-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/audio/cancel-bulk', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/dedup-chapters/[slug]
|
||||
*
|
||||
* Admin-only proxy to the Go backend's dedup endpoint.
|
||||
* Removes duplicate chapters_idx records for a book, keeping the latest
|
||||
* record per chapter number. Returns { slug, deleted }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
35
ui/src/routes/api/admin/image-gen/save-cover/+server.ts
Normal file
35
ui/src/routes/api/admin/image-gen/save-cover/+server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/admin/image-gen/save-cover
|
||||
*
|
||||
* Admin-only proxy: persists a pre-generated base64 image as a book cover in
|
||||
* MinIO without re-calling Cloudflare AI.
|
||||
*
|
||||
* Body: { slug: string, image_b64: string }
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/image-gen/save-cover', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
50
ui/src/routes/api/admin/text-gen/chapter-names/+server.ts
Normal file
50
ui/src/routes/api/admin/text-gen/chapter-names/+server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/chapter-names
|
||||
*
|
||||
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
|
||||
* The backend streams SSE events; this handler pipes the stream through
|
||||
* directly so the browser can consume it without buffering.
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/chapter-names', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/chapter-names/apply
|
||||
*
|
||||
* Admin-only proxy to persist confirmed chapter titles to PocketBase.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/chapter-names/apply', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
33
ui/src/routes/api/admin/text-gen/description/+server.ts
Normal file
33
ui/src/routes/api/admin/text-gen/description/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/description
|
||||
*
|
||||
* Admin-only proxy to the Go backend's book description generation endpoint.
|
||||
* Returns an AI-proposed description; does NOT persist anything.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/description', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* POST /api/admin/text-gen/description/apply
|
||||
*
|
||||
* Admin-only proxy to persist the confirmed book description to PocketBase.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/text-gen/description/apply', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
17
ui/src/routes/api/admin/text-gen/models/+server.ts
Normal file
17
ui/src/routes/api/admin/text-gen/models/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/text-gen/models');
|
||||
} catch {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
134
ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts
Normal file
134
ui/src/routes/api/audio-stream/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
const FREE_DAILY_AUDIO_LIMIT = 3;
|
||||
|
||||
function dailyAudioKey(identifier: string): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `audio:daily:${identifier}:${today}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the number of audio chapters a user/session has generated today,
|
||||
* and increment the counter. Shared logic with POST /api/audio/[slug]/[n].
|
||||
*
|
||||
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
|
||||
*/
|
||||
async function incrementDailyAudioCount(identifier: string): Promise<number> {
|
||||
const key = dailyAudioKey(identifier);
|
||||
const now = new Date();
|
||||
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
|
||||
try {
|
||||
const raw = await cache.get<number>(key);
|
||||
const current = (raw ?? 0) + 1;
|
||||
await cache.set(key, current, ttl);
|
||||
return current;
|
||||
} catch {
|
||||
// On cache failure, fail open (don't block audio for cache errors)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/audio-stream/[slug]/[n]?voice=...
|
||||
*
|
||||
* Proxies the backend's streaming TTS endpoint to the browser.
|
||||
*
|
||||
* Fast path: if audio already in MinIO, backend sends a 302 → fetch follows it
|
||||
* and we stream the MinIO audio through.
|
||||
*
|
||||
* Slow path (Kokoro/PocketTTS): backend streams audio bytes as they are
|
||||
* generated. The browser receives the first bytes within seconds and can start
|
||||
* playing before generation completes. MinIO upload happens concurrently
|
||||
* server-side so subsequent requests use the cached fast path.
|
||||
*
|
||||
* Slow path (CF AI): backend buffers the full response (batch API limitation)
|
||||
* before sending — effectively the same as the old POST+poll approach but
|
||||
* without the separate progress-bar flow. Prefer the traditional POST route
|
||||
* for CF AI voices to preserve the generating UI.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
|
||||
// ── Paywall: 3 audio chapters/day for free users ─────────────────────────
|
||||
// Only count when the audio is not already cached — same rule as POST route.
|
||||
if (!locals.isPro) {
|
||||
const statusRes = await backendFetch(
|
||||
`/api/audio/status/${slug}/${chapter}${voice ? `?voice=${encodeURIComponent(voice)}` : ''}`
|
||||
).catch(() => null);
|
||||
const statusData = statusRes?.ok
|
||||
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
|
||||
: {};
|
||||
|
||||
if (statusData.status !== 'done') {
|
||||
const identifier = locals.user?.id ?? locals.sessionId;
|
||||
const count = await incrementDailyAudioCount(identifier);
|
||||
if (count > FREE_DAILY_AUDIO_LIMIT) {
|
||||
log.info('polar', 'free audio stream limit reached', { identifier, count });
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
|
||||
{ status: 402, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({ format: 'mp3' });
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
// fetch() follows the backend's 302 (MinIO fast path) automatically.
|
||||
const backendRes = await backendFetch(`/api/audio-stream/${slug}/${chapter}?${qs}`);
|
||||
|
||||
if (!backendRes.ok) {
|
||||
const text = await backendRes.text().catch(() => '');
|
||||
log.error('audio-stream', 'backend stream failed', {
|
||||
slug,
|
||||
chapter,
|
||||
status: backendRes.status,
|
||||
body: text
|
||||
});
|
||||
error(backendRes.status as Parameters<typeof error>[0], text || 'Audio stream failed');
|
||||
}
|
||||
|
||||
// Stream the response body directly — no buffering.
|
||||
return new Response(backendRes.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': backendRes.headers.get('Content-Type') ?? 'audio/mpeg',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* HEAD /api/audio-stream/[slug]/[n]?voice=...
|
||||
*
|
||||
* Paywall pre-check without incrementing the daily counter or triggering
|
||||
* any generation. The AudioPlayer uses this to surface the upgrade CTA
|
||||
* before pointing the <audio> element at the streaming URL.
|
||||
*
|
||||
* Returns 402 if the user has already hit their daily limit, 200 otherwise.
|
||||
*/
|
||||
export const HEAD: RequestHandler = async ({ locals }) => {
|
||||
if (locals.isPro) {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
const identifier = locals.user?.id ?? locals.sessionId;
|
||||
const count = (await cache.get<number>(dailyAudioKey(identifier))) ?? 0;
|
||||
// count >= limit means the next GET would exceed the limit after increment
|
||||
if (count >= FREE_DAILY_AUDIO_LIMIT) {
|
||||
return new Response(null, { status: 402 });
|
||||
}
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,301 @@
|
||||
// ── Admin panel expand/collapse ───────────────────────────────────────────
|
||||
let adminOpen = $state(false);
|
||||
|
||||
// ── Admin: book cover generation ──────────────────────────────────────────
|
||||
let coverGenerating = $state(false);
|
||||
let coverPreview = $state<string | null>(null);
|
||||
let coverSaving = $state(false);
|
||||
let coverResult = $state<'saved' | 'error' | ''>('');
|
||||
|
||||
async function generateCover() {
|
||||
const slug = data.book?.slug;
|
||||
if (coverGenerating || !slug) return;
|
||||
coverGenerating = true;
|
||||
coverPreview = null;
|
||||
coverResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
||||
} else {
|
||||
coverResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
coverResult = 'error';
|
||||
} finally {
|
||||
coverGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCover() {
|
||||
const slug = data.book?.slug;
|
||||
if (coverSaving || !coverPreview || !slug) return;
|
||||
coverSaving = true;
|
||||
coverResult = '';
|
||||
try {
|
||||
const b64 = coverPreview.replace(/^data:[^;]+;base64,/, '');
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, image_b64: b64 })
|
||||
});
|
||||
if (res.ok) {
|
||||
coverResult = 'saved';
|
||||
coverPreview = null;
|
||||
await invalidateAll();
|
||||
} else {
|
||||
coverResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
coverResult = 'error';
|
||||
} finally {
|
||||
coverSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: chapter cover generation ───────────────────────────────────────
|
||||
let chapterCoverN = $state('1');
|
||||
let chapterCoverGenerating = $state(false);
|
||||
let chapterCoverPreview = $state<string | null>(null);
|
||||
let chapterCoverResult = $state<'error' | ''>('');
|
||||
|
||||
async function generateChapterCover() {
|
||||
const slug = data.book?.slug;
|
||||
if (chapterCoverGenerating || !slug) return;
|
||||
const n = parseInt(chapterCoverN, 10);
|
||||
if (!n || n < 1) return;
|
||||
chapterCoverGenerating = true;
|
||||
chapterCoverPreview = null;
|
||||
chapterCoverResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
|
||||
} else {
|
||||
chapterCoverResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
chapterCoverResult = 'error';
|
||||
} finally {
|
||||
chapterCoverGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: description generation ─────────────────────────────────────────
|
||||
let descGenerating = $state(false);
|
||||
let descPreview = $state('');
|
||||
let descApplying = $state(false);
|
||||
let descResult = $state<'applied' | 'error' | ''>('');
|
||||
|
||||
async function generateDesc() {
|
||||
const slug = data.book?.slug;
|
||||
if (descGenerating || !slug) return;
|
||||
descGenerating = true;
|
||||
descPreview = '';
|
||||
descResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
descPreview = d.new_description ?? '';
|
||||
} else {
|
||||
descResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
descResult = 'error';
|
||||
} finally {
|
||||
descGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyDesc() {
|
||||
const slug = data.book?.slug;
|
||||
if (descApplying || !descPreview || !slug) return;
|
||||
descApplying = true;
|
||||
descResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/description/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, description: descPreview })
|
||||
});
|
||||
if (res.ok) {
|
||||
descResult = 'applied';
|
||||
descPreview = '';
|
||||
await invalidateAll();
|
||||
} else {
|
||||
descResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
descResult = 'error';
|
||||
} finally {
|
||||
descApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: chapter names generation ───────────────────────────────────────
|
||||
let chapNamesGenerating = $state(false);
|
||||
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
|
||||
let chapNamesApplying = $state(false);
|
||||
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
||||
|
||||
async function generateChapNames() {
|
||||
const slug = data.book?.slug;
|
||||
if (chapNamesGenerating || !slug) return;
|
||||
chapNamesGenerating = true;
|
||||
chapNamesPreview = [];
|
||||
chapNamesResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
chapNamesPreview = d.chapters ?? [];
|
||||
} else {
|
||||
chapNamesResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
chapNamesResult = 'error';
|
||||
} finally {
|
||||
chapNamesGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChapNames() {
|
||||
const slug = data.book?.slug;
|
||||
if (chapNamesApplying || chapNamesPreview.length === 0 || !slug) return;
|
||||
chapNamesApplying = true;
|
||||
chapNamesResult = '';
|
||||
try {
|
||||
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, chapters })
|
||||
});
|
||||
if (res.ok) {
|
||||
chapNamesResult = 'applied';
|
||||
chapNamesPreview = [];
|
||||
await invalidateAll();
|
||||
} else {
|
||||
chapNamesResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
chapNamesResult = 'error';
|
||||
} finally {
|
||||
chapNamesApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: audio TTS bulk enqueue ─────────────────────────────────────────
|
||||
interface Voice { id: string; engine: string; lang: string; gender: string }
|
||||
let audioVoices = $state<Voice[]>([]);
|
||||
let audioVoicesLoaded = $state(false);
|
||||
let audioVoice = $state('af_bella');
|
||||
let audioFrom = $state('1');
|
||||
let audioTo = $state('');
|
||||
let audioEnqueuing = $state(false);
|
||||
let audioResult = $state<{ enqueued: number; skipped: number } | null>(null);
|
||||
let audioError = $state('');
|
||||
|
||||
// Load voices lazily when admin panel opens
|
||||
$effect(() => {
|
||||
if (!adminOpen || audioVoicesLoaded) return;
|
||||
fetch('/api/voices')
|
||||
.then((r) => r.json())
|
||||
.then((d: { voices: Voice[] }) => {
|
||||
audioVoices = d.voices ?? [];
|
||||
audioVoicesLoaded = true;
|
||||
})
|
||||
.catch(() => { audioVoicesLoaded = true; });
|
||||
});
|
||||
|
||||
function voiceLabel(v: Voice): string {
|
||||
if (v.engine === 'cfai') {
|
||||
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
||||
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + ' (CF AI)';
|
||||
}
|
||||
if (v.engine === 'pocket-tts') {
|
||||
return v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + ' (Pocket)';
|
||||
}
|
||||
// Kokoro
|
||||
const langMap: Record<string, string> = {
|
||||
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
|
||||
ef: 'ES', em: 'ES', ff: 'FR', hf: 'IN', hm: 'IN',
|
||||
'if': 'IT', im: 'IT', jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
|
||||
};
|
||||
const prefix = v.id.slice(0, 2);
|
||||
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
||||
return `${name} (${lang})`;
|
||||
}
|
||||
|
||||
async function enqueueAudio() {
|
||||
const slug = data.book?.slug;
|
||||
if (audioEnqueuing || !slug) return;
|
||||
const from = parseInt(audioFrom, 10);
|
||||
const to = audioTo ? parseInt(audioTo, 10) : (data.book?.total_chapters ?? 1);
|
||||
if (!from || from < 1) return;
|
||||
audioEnqueuing = true;
|
||||
audioResult = null;
|
||||
audioError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/audio/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, voice: audioVoice, from, to })
|
||||
});
|
||||
if (res.ok || res.status === 202) {
|
||||
const d = await res.json();
|
||||
audioResult = { enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
audioError = d.error ?? 'Failed to enqueue';
|
||||
}
|
||||
} catch {
|
||||
audioError = 'Network error';
|
||||
} finally {
|
||||
audioEnqueuing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelAudio() {
|
||||
const slug = data.book?.slug;
|
||||
if (!slug) return;
|
||||
audioResult = null;
|
||||
audioError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/audio/cancel-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
audioError = `Cancelled ${d.cancelled ?? 0} task(s).`;
|
||||
}
|
||||
} catch {
|
||||
audioError = 'Cancel failed';
|
||||
}
|
||||
}
|
||||
|
||||
// ── "More like this" ─────────────────────────────────────────────────────
|
||||
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
|
||||
let similarBooks = $state<SimilarBook[]>([]);
|
||||
@@ -520,78 +815,336 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if adminOpen}
|
||||
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-4">
|
||||
<!-- Rescrape -->
|
||||
{#if adminOpen}
|
||||
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
|
||||
<!-- Rescrape -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={rescrape}
|
||||
disabled={scraping}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{#if scraping}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{m.book_detail_rescraping()}
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{m.book_detail_rescrape_book()}
|
||||
{/if}
|
||||
</button>
|
||||
{#if scrapeResult}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeFrom}
|
||||
placeholder="1"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<input
|
||||
id="range-to"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeTo}
|
||||
placeholder="end"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={scrapeRange}
|
||||
disabled={rangeScraping || !rangeFrom}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{rangeScraping || !rangeFrom
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
|
||||
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
|
||||
</button>
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Book cover generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={rescrape}
|
||||
disabled={scraping}
|
||||
onclick={generateCover}
|
||||
disabled={coverGenerating}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
{coverGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
{#if scraping}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{m.book_detail_rescraping()}
|
||||
{#if coverGenerating}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{m.book_detail_rescrape_book()}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if scrapeResult}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{#if coverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{:else if coverResult === 'saved'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeFrom}
|
||||
placeholder="1"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
{#if coverPreview}
|
||||
<div class="flex items-start gap-3 mt-1">
|
||||
<img src={coverPreview} alt="Cover preview" class="w-24 rounded border border-(--color-border)" />
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<button
|
||||
onclick={saveCover}
|
||||
disabled={coverSaving}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{coverSaving ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
|
||||
>
|
||||
{coverSaving ? m.book_detail_admin_saving() : m.book_detail_admin_save_cover()}
|
||||
</button>
|
||||
<button onclick={() => (coverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter cover generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
|
||||
<div class="flex items-end gap-3 flex-wrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
|
||||
<input
|
||||
id="range-to"
|
||||
id="ch-cover-n"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeTo}
|
||||
placeholder="end"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
bind:value={chapterCoverN}
|
||||
placeholder="1"
|
||||
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={scrapeRange}
|
||||
disabled={rangeScraping || !rangeFrom}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{rangeScraping || !rangeFrom
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
|
||||
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
onclick={generateChapterCover}
|
||||
disabled={chapterCoverGenerating || !chapterCoverN}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{chapterCoverGenerating || !chapterCoverN ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
|
||||
{#if chapterCoverGenerating}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{#if chapterCoverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if chapterCoverPreview}
|
||||
<div class="flex items-start gap-3 mt-1">
|
||||
<img src={chapterCoverPreview} alt="Chapter cover preview" class="w-24 rounded border border-(--color-border)" />
|
||||
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors pt-1">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Description generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={generateDesc}
|
||||
disabled={descGenerating}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{descGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
{#if descGenerating}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if descResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{:else if descResult === 'applied'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if descPreview}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea
|
||||
bind:value={descPreview}
|
||||
rows="5"
|
||||
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={applyDesc}
|
||||
disabled={descApplying}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{descApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
|
||||
>
|
||||
{descApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()}
|
||||
</button>
|
||||
<button onclick={() => (descPreview = '')} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter names generation -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={generateChapNames}
|
||||
disabled={chapNamesGenerating}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{chapNamesGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
{#if chapNamesGenerating}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if chapNamesResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{:else if chapNamesResult === 'applied'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if chapNamesPreview.length > 0}
|
||||
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
|
||||
{#each chapNamesPreview as ch}
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
|
||||
<span class="text-(--color-text) truncate">{ch.new_title}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={applyChapNames}
|
||||
disabled={chapNamesApplying}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{chapNamesApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
|
||||
>
|
||||
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
|
||||
</button>
|
||||
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class="border-(--color-border)" />
|
||||
|
||||
<!-- Audio TTS bulk enqueue -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Voice selector -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audio-voice" class="text-xs text-(--color-muted)">{m.book_detail_admin_voice()}</label>
|
||||
<select
|
||||
id="audio-voice"
|
||||
bind:value={audioVoice}
|
||||
class="px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
>
|
||||
{#if !audioVoicesLoaded}
|
||||
<option value="af_bella">af_bella (loading…)</option>
|
||||
{:else}
|
||||
{#each audioVoices.filter(v => v.engine === 'kokoro') as v}
|
||||
<option value={v.id}>{voiceLabel(v)}</option>
|
||||
{/each}
|
||||
{#each audioVoices.filter(v => v.engine === 'pocket-tts') as v}
|
||||
<option value={v.id}>{voiceLabel(v)}</option>
|
||||
{/each}
|
||||
{#each audioVoices.filter(v => v.engine === 'cfai') as v}
|
||||
<option value={v.id}>{voiceLabel(v)}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Chapter range -->
|
||||
<div class="flex items-end gap-3 flex-wrap">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audio-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="audio-from"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={audioFrom}
|
||||
placeholder="1"
|
||||
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audio-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<input
|
||||
id="audio-to"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={audioTo}
|
||||
placeholder="end"
|
||||
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={enqueueAudio}
|
||||
disabled={audioEnqueuing || !audioFrom}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{audioEnqueuing || !audioFrom ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{#if audioEnqueuing}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v6m0 0l-2-2m2 2l2-2M6.343 17.657a8 8 0 010-11.314"/></svg>
|
||||
{/if}
|
||||
{m.book_detail_admin_enqueue_audio()}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelAudio}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium text-(--color-muted) hover:text-(--color-danger) transition-colors border border-(--color-border)"
|
||||
>
|
||||
{m.book_detail_admin_cancel_audio()}
|
||||
</button>
|
||||
</div>
|
||||
{#if audioResult}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_enqueued({ enqueued: audioResult.enqueued, skipped: audioResult.skipped })}</span>
|
||||
{/if}
|
||||
{#if audioError}
|
||||
<span class="text-xs text-(--color-muted)">{audioError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
||||
}
|
||||
const markdown = await res.text();
|
||||
html = marked(markdown) as string;
|
||||
html = await marked(markdown);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
// Don't hard-fail — show empty content with error message
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<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';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
|
||||
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' },
|
||||
@@ -56,15 +60,152 @@
|
||||
if (settingsCtx) settingsCtx.fontSize = v;
|
||||
}
|
||||
|
||||
// Translation state
|
||||
// ── Layout / reading-view prefs (localStorage) ──────────────────────────────
|
||||
type ReadMode = 'scroll' | 'paginated';
|
||||
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'compact';
|
||||
|
||||
interface LayoutPrefs {
|
||||
readMode: ReadMode;
|
||||
lineSpacing: LineSpacing;
|
||||
readWidth: ReadWidth;
|
||||
paraStyle: ParaStyle;
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v1';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
try {
|
||||
const raw = localStorage.getItem(LAYOUT_KEY);
|
||||
if (raw) return { ...DEFAULT_LAYOUT, ...JSON.parse(raw) as Partial<LayoutPrefs> };
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_LAYOUT;
|
||||
}
|
||||
|
||||
let layout = $state<LayoutPrefs>(loadLayout());
|
||||
|
||||
function setLayout<K extends keyof LayoutPrefs>(key: K, value: LayoutPrefs[K]) {
|
||||
layout = { ...layout, [key]: value };
|
||||
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
||||
}
|
||||
|
||||
// ── Listening settings helpers ───────────────────────────────────────────────
|
||||
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
|
||||
const sleepSettingsLabel = $derived(
|
||||
audioStore.sleepAfterChapter
|
||||
? 'End Ch.'
|
||||
: audioStore.sleepUntil > Date.now()
|
||||
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
|
||||
: 'Off'
|
||||
);
|
||||
|
||||
function toggleSleepFromSettings() {
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
} else if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
} else {
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply reading CSS vars whenever layout changes
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
document.documentElement.style.setProperty('--reading-line-height', String(LINE_HEIGHTS[layout.lineSpacing]));
|
||||
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
|
||||
});
|
||||
|
||||
// ── Scroll progress bar ──────────────────────────────────────────────────────
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || layout.readMode === 'paginated') { scrollProgress = 0; return; }
|
||||
function onScroll() {
|
||||
const el = document.documentElement;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
scrollProgress = max > 0 ? el.scrollTop / max : 0;
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
// ── Paginated mode ───────────────────────────────────────────────────────────
|
||||
let pageIndex = $state(0);
|
||||
let totalPages = $state(1);
|
||||
let paginatedContainerEl = $state<HTMLDivElement | null>(null);
|
||||
let paginatedContentEl = $state<HTMLDivElement | null>(null);
|
||||
let containerH = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html changes or container is bound
|
||||
void html; void paginatedContainerEl; void paginatedContentEl;
|
||||
requestAnimationFrame(() => {
|
||||
if (!paginatedContainerEl || !paginatedContentEl) return;
|
||||
const h = paginatedContainerEl.clientHeight;
|
||||
if (h > 0) {
|
||||
containerH = h;
|
||||
totalPages = Math.max(1, Math.ceil(paginatedContentEl.scrollHeight / h));
|
||||
pageIndex = Math.min(pageIndex, totalPages - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reset page index when chapter changes
|
||||
$effect(() => { void data.chapter.number; pageIndex = 0; });
|
||||
|
||||
function handlePaginatedClick(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
if (e.clientX - rect.left > rect.width / 2) {
|
||||
if (pageIndex < totalPages - 1) pageIndex++;
|
||||
} else {
|
||||
if (pageIndex > 0) pageIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard nav for paginated mode
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (layout.readMode !== 'paginated') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (pageIndex < totalPages - 1) pageIndex++;
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||
e.preventDefault();
|
||||
if (pageIndex > 0) pageIndex--;
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
// ── Translation state
|
||||
const SUPPORTED_LANGS = [
|
||||
{ code: 'ru', label: 'RU' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
{ 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() {
|
||||
@@ -154,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(
|
||||
@@ -163,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();
|
||||
}
|
||||
@@ -189,6 +331,11 @@
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode) -->
|
||||
{#if layout.readMode === 'scroll'}
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
@@ -235,8 +382,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Language switcher (not shown for preview chapters) -->
|
||||
{#if !data.isPreview}
|
||||
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-(--color-muted) text-xs">Read in:</span>
|
||||
|
||||
@@ -292,8 +439,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
<!-- Audio player (hidden in focus mode) -->
|
||||
{#if !data.isPreview && !layout.focusMode}
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated: sign-in prompt -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
@@ -331,6 +478,7 @@
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -353,13 +501,66 @@
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else if layout.readMode === 'paginated'}
|
||||
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
onclick={handlePaginatedClick}
|
||||
>
|
||||
<div
|
||||
bind:this={paginatedContentEl}
|
||||
class="prose-chapter {layout.paraStyle === 'indented' ? 'para-indented' : ''}"
|
||||
style="transform: translateY({containerH > 0 ? -(pageIndex * containerH) : 0}px);"
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page indicator + nav -->
|
||||
<div class="flex items-center justify-between mt-4 select-none">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex > 0) pageIndex--; }}
|
||||
disabled={pageIndex === 0}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-(--color-muted) tabular-nums">
|
||||
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
||||
disabled={pageIndex === totalPages - 1}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tap hint -->
|
||||
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
|
||||
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav -->
|
||||
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
|
||||
{#if !(layout.focusMode && layout.readMode === 'paginated')}
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
||||
{#if data.prev}
|
||||
<a
|
||||
@@ -381,7 +582,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter comments -->
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
@@ -390,23 +590,16 @@
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
|
||||
{#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 -->
|
||||
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
|
||||
<!-- 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"/>
|
||||
@@ -414,73 +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}
|
||||
<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}
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
|
||||
</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}
|
||||
<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}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<!-- 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}
|
||||
<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}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
|
||||
|
||||
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
||||
{#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
|
||||
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>Focus mode</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
</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
|
||||
type="button"
|
||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<span>Auto-next chapter</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSleepFromSettings}
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
>
|
||||
<span>Sleep timer</span>
|
||||
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
|
||||
</button>
|
||||
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<p class="text-[11px] text-(--color-muted)/50 text-center mt-3">Changes save automatically</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
|
||||
5
ui/src/routes/subscribe/+page.server.ts
Normal file
5
ui/src/routes/subscribe/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
162
ui/src/routes/subscribe/+page.svelte
Normal file
162
ui/src/routes/subscribe/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user