Compare commits

...

9 Commits

Author SHA1 Message Date
Admin
74ece7e94e feat: reading view modes — progress bar, paginated, spacing, width, focus
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 3m3s
Release / Gitea Release (push) Has been skipped
Five new reader settings (persisted in localStorage as reader_layout_v1):

- Read mode: Scroll (default) vs Pages — paginated mode splits content
  into viewport-height pages, navigate with tap left/right, arrow keys,
  or Prev/Next buttons. Recalculates on content change.

- Line spacing: Tight (1.55) / Normal (1.85) / Loose (2.2) via
  --reading-line-height CSS var on :root.

- Reading width: Narrow (58ch) / Normal (72ch) / Wide (90ch) via
  --reading-max-width CSS var on :root.

- Paragraph style: Spaced (default) vs Indented (text-indent: 2em,
  tight margin — book-like feel).

- Focus mode: hides audio player, language switcher, bottom nav and
  comments so only the text remains.

Scroll progress bar: thin 2px brand-colored bar fixed at top of
viewport in scroll mode, fills as you read through the chapter.

All options added to the floating settings gear panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:44 +05:00
Admin
d1b7d3e36c feat: admin image generation via Cloudflare Workers AI
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 3m3s
Release / Docker / runner (push) Successful in 2m57s
Release / Gitea Release (push) Has been skipped
- Add cfai/image.go: ImageGenClient with GenerateImage, GenerateImageFromReference, AllImageModels (9 models)
- Add handlers_image.go: GET /api/admin/image-gen/models + POST /api/admin/image-gen (JSON + multipart)
- Wire ImageGen client in main.go + server.go Dependencies
- Add admin/image-gen SvelteKit page: type toggle, model selector, prompt, reference img2img, advanced options, result panel, history, save-as-cover, download
- Add SvelteKit proxy route api/admin/image-gen forwarding to Go backend
- Add admin_nav_image_gen message key to all 5 locale files
- Add Image Gen nav link to admin layout
2026-04-04 11:46:22 +05:00
Admin
aaa008ac99 feat: add Cloudflare AI TTS engine (aura-2-en) with voice grouping in UI
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 41s
2026-04-04 11:12:55 +05:00
Admin
9806f0d894 feat: live Gitea changelog, Gitea sidebar link, release title/body extraction
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 21s
- Admin changelog page now fetches releases live from the public Gitea
  API (https://gitea.kalekber.cc) instead of a baked releases.json file
- Remove /static/releases.json from ui/.gitignore (no longer generated)
- Add Gitea to admin sidebar external tools (admin_nav_gitea i18n key,
  all 5 locales, compiled paraglide JS force-added)
- release.yaml: remove 'Fetch releases from Gitea API' step; extract
  release title and body from the tagged commit message for Gitea releases
2026-04-03 23:20:30 +05:00
Admin
e862135775 fix: commit missing paraglide feed message JS files (were gitignored, needed force-add)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m35s
Release / Docker / ui (push) Successful in 1m57s
Release / Gitea Release (push) Successful in 20s
New feed_*.js and nav_feed.js outputs from npm run paraglide were not tracked
because src/lib/paraglide/.gitignore ignores everything by default. Force-add
them so CI can run svelte-check without running paraglide compile first.

Also include recentlyUpdatedBooks fallback improvements from working tree.
2026-04-03 23:13:46 +05:00
Admin
8588475d03 feat: in-reader settings, more like this, reading history, audio filter, feed page
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m20s
Release / Docker / runner (push) Successful in 2m28s
Release / Gitea Release (push) Has been skipped
- Add floating gear button + theme/font/size drawer in chapter reader
- Add 'More like this' horizontal scroll row on book detail page
- Add reading history tab on profile page (chronological progress timeline)
- Add audio-available badge + filter toggle on catalogue (GET /api/audio/slugs)
- Fix discover card entry animation pop (split entry vs snap-back cubic-bezier)
- Add dedicated /feed page showing books followed users are reading
- Add Feed nav link (desktop + mobile) in all 5 locales
2026-04-03 22:32:37 +05:00
Admin
28fee7aee3 feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated
sorting (only changes on metadata writes) with chapters_idx sorted
by -created, so the section now reflects actual chapter activity.

Add per-section show/hide toggles on the home page, persisted in
localStorage via Svelte 5 $state. Each section header gets a small
hide button; hidden sections appear as restore chips above the footer.
Toggleable: Recently Updated, Browse by Genre, From Following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:09:48 +05:00
Admin
a888d9a0f5 fix: clean up book detail mobile layout + make genre tags linkable
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.

- Split mobile CTAs into two clear rows:
  Row 1: primary read button(s) only (Continue / Start from ch.1)
  Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
  present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line

Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
2026-04-03 21:34:38 +05:00
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

- Add settingsDirty flag, set via setTimeout(0) after initial apply so
  the save effect is blocked for the first load and only runs on real
  user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
  validation as a defensive backstop
2026-04-03 21:07:01 +05:00
63 changed files with 3706 additions and 238 deletions

View File

@@ -190,17 +190,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Fetch releases from Gitea API
run: |
set -euo pipefail
RESPONSE=$(curl -sfL \
-H "Accept: application/json" \
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
# Validate JSON before writing — fails hard if response is not a JSON array
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
echo "$RESPONSE" > ui/static/releases.json
echo "Fetched $COUNT releases"
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -278,8 +267,25 @@ jobs:
with:
fetch-depth: 0
- name: Extract release notes from tag commit
id: notes
run: |
set -euo pipefail
# Subject line (first line of commit message) → release title
SUBJECT=$(git log -1 --format="%s" "${{ gitea.sha }}")
# Body (everything after the blank line) → release body
BODY=$(git log -1 --format="%b" "${{ gitea.sha }}" | sed '/^Co-Authored-By:/d' | sed '/^[[:space:]]*$/{ N; /^\n$/d }' | sed 's/^[[:space:]]*$//' | awk 'NF || !p; {p = !NF}')
echo "title=${SUBJECT}" >> "$GITHUB_OUTPUT"
# Use a heredoc delimiter to safely handle multi-line body
{
echo "body<<RELEASE_BODY_EOF"
echo "${BODY}"
echo "RELEASE_BODY_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
generate_release_notes: true
title: ${{ steps.notes.outputs.title }}
body: ${{ steps.notes.outputs.body }}

View File

@@ -26,6 +26,7 @@ import (
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/backend"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -114,6 +115,24 @@ func run() error {
log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
}
// ── Cloudflare Workers AI (voice sample generation + audio-stream live TTS) ──
var cfaiClient cfai.Client
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend")
}
// ── Cloudflare Workers AI Image Generation ────────────────────────────────
var imageGenClient cfai.ImageGenClient
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
log.Info("cloudflare AI image generation enabled")
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
}
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
var searchIndex meili.Client
if cfg.Meilisearch.URL != "" {
@@ -163,6 +182,8 @@ func run() error {
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
Log: log,
},
)

View File

@@ -23,6 +23,7 @@ import (
"github.com/getsentry/sentry-go"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/browser"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
@@ -130,6 +131,15 @@ func run() error {
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
}
// ── Cloudflare Workers AI ────────────────────────────────────────────────
var cfaiClient cfai.Client
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voice tasks will fail")
}
// ── LibreTranslate ──────────────────────────────────────────────────────
ltClient := libretranslate.New(cfg.LibreTranslate.URL, cfg.LibreTranslate.APIKey)
if ltClient != nil {
@@ -191,6 +201,7 @@ func run() error {
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Log: log,
}

View File

@@ -44,6 +44,7 @@ import (
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -774,7 +775,13 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
// Open the TTS stream (WAV or MP3 depending on format param).
var audioStream io.ReadCloser
if format == "wav" {
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
@@ -788,7 +795,13 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
}
} else {
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
@@ -1343,6 +1356,9 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
}
key := kokoro.VoiceSampleKey(voice)
if cfai.IsCFAIVoice(voice) {
key = cfai.VoiceSampleKey(voice)
}
// Generate sample on demand when it is not in MinIO yet.
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
@@ -1352,7 +1368,13 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
mp3 []byte
err error
)
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
mp3, err = s.deps.CFAI.GenerateAudio(r.Context(), voiceSampleText, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return

View File

@@ -0,0 +1,234 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/libnovel/backend/internal/cfai"
)
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
// Returns the list of supported Cloudflare AI image generation models.
func (s *Server) handleAdminImageGenModels(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
models := s.deps.ImageGen.Models()
writeJSON(w, 0, map[string]any{"models": models})
}
// imageGenRequest is the JSON body for POST /api/admin/image-gen.
type imageGenRequest struct {
// Prompt is the text description of the desired image.
Prompt string `json:"prompt"`
// Model is the CF Workers AI model ID (e.g. "@cf/black-forest-labs/flux-2-dev").
// Defaults to the recommended model for the given type.
Model string `json:"model"`
// Type is either "cover" or "chapter".
Type string `json:"type"`
// Slug is the book slug. Required for cover; required for chapter.
Slug string `json:"slug"`
// Chapter number (1-based). Required when type == "chapter".
Chapter int `json:"chapter"`
// ReferenceImageB64 is an optional base64-encoded PNG/JPEG reference image.
// When present the img2img path is used.
ReferenceImageB64 string `json:"reference_image_b64"`
// NumSteps overrides inference steps (default 20).
NumSteps int `json:"num_steps"`
// Width / Height override output dimensions (0 = model default).
Width int `json:"width"`
Height int `json:"height"`
// Guidance overrides prompt guidance scale (0 = model default).
Guidance float64 `json:"guidance"`
// Strength for img2img: 0.01.0, default 0.75.
Strength float64 `json:"strength"`
// SaveToCover when true stores the result as the book cover in MinIO
// (overwriting any existing cover) and sets the book's cover URL.
// Only valid when type == "cover".
SaveToCover bool `json:"save_to_cover"`
}
// imageGenResponse is the JSON body returned by POST /api/admin/image-gen.
type imageGenResponse struct {
// ImageB64 is the generated image as a base64-encoded PNG string.
ImageB64 string `json:"image_b64"`
// ContentType is "image/png" or "image/jpeg".
ContentType string `json:"content_type"`
// Saved indicates whether the image was persisted to MinIO.
Saved bool `json:"saved"`
// CoverURL is the URL the cover is now served from (only set when Saved==true).
CoverURL string `json:"cover_url,omitempty"`
// Model is the model that was used.
Model string `json:"model"`
// Bytes is the raw image size in bytes.
Bytes int `json:"bytes"`
}
// handleAdminImageGen handles POST /api/admin/image-gen.
//
// Generates an image using Cloudflare Workers AI and optionally stores it.
// Multipart/form-data is also accepted so the reference image can be uploaded
// directly; otherwise the reference is expected as base64 JSON.
func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req imageGenRequest
var refImageData []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Multipart: parse JSON fields from a "json" part + optional "reference" file part.
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
if jsonPart := r.FormValue("json"); jsonPart != "" {
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
return
}
}
if f, _, err := r.FormFile("reference"); err == nil {
defer f.Close()
refImageData, _ = io.ReadAll(f)
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.ReferenceImageB64 != "" {
var decErr error
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
// Try std without padding
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
return
}
}
}
}
if strings.TrimSpace(req.Prompt) == "" {
jsonError(w, http.StatusBadRequest, "prompt is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type == "chapter" && req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
return
}
// Resolve model
model := cfai.ImageModel(req.Model)
if model == "" {
if req.Type == "cover" {
model = cfai.DefaultImageModel
} else {
model = cfai.ImageModelFlux2Klein4B
}
}
imgReq := cfai.ImageRequest{
Prompt: req.Prompt,
Model: model,
NumSteps: req.NumSteps,
Width: req.Width,
Height: req.Height,
Guidance: req.Guidance,
Strength: req.Strength,
}
s.deps.Log.Info("admin: image gen requested",
"type", req.Type, "slug", req.Slug, "chapter", req.Chapter,
"model", model, "has_reference", len(refImageData) > 0)
var imgData []byte
var genErr error
if len(refImageData) > 0 {
imgData, genErr = s.deps.ImageGen.GenerateImageFromReference(r.Context(), imgReq, refImageData)
} else {
imgData, genErr = s.deps.ImageGen.GenerateImage(r.Context(), imgReq)
}
if genErr != nil {
s.deps.Log.Error("admin: image gen failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "image generation failed: "+genErr.Error())
return
}
contentType := sniffImageContentType(imgData)
// ── Optional persistence ──────────────────────────────────────────────────
var saved bool
var coverURL string
if req.SaveToCover && req.Type == "cover" && s.deps.CoverStore != nil {
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save generated cover failed", "slug", req.Slug, "err", err)
// Non-fatal: still return the image
} else {
saved = true
coverURL = fmt.Sprintf("/api/cover/local/%s", req.Slug)
s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData))
}
}
// Encode result as base64
b64 := base64.StdEncoding.EncodeToString(imgData)
writeJSON(w, 0, imageGenResponse{
ImageB64: b64,
ContentType: contentType,
Saved: saved,
CoverURL: coverURL,
Model: string(model),
Bytes: len(imgData),
})
}
// sniffImageContentType returns the MIME type of the image bytes.
func sniffImageContentType(data []byte) string {
if len(data) >= 4 {
// PNG: 0x89 P N G
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47 {
return "image/png"
}
// JPEG: FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// WebP: RIFF....WEBP
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "image/webp"
}
}
return "image/png"
}

View File

@@ -30,6 +30,7 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -69,6 +70,12 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
// audio generation is done by the runner).
PocketTTS pockettts.Client
// CFAI is the Cloudflare Workers AI TTS client (used for voice sample
// generation and audio-stream live TTS; audio task generation is done by the runner).
CFAI cfai.Client
// ImageGen is the Cloudflare Workers AI image generation client.
// If nil, image generation endpoints return 503.
ImageGen cfai.ImageGenClient
// Log is the structured logger.
Log *slog.Logger
}
@@ -179,6 +186,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Admin image generation endpoints
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -338,6 +349,23 @@ func (s *Server) voices(ctx context.Context) []domain.Voice {
}
}
// ── Cloudflare AI voices ──────────────────────────────────────────────────
if s.deps.CFAI != nil {
for _, speaker := range cfai.Speakers() {
gender := "m"
if cfai.IsFemale(speaker) {
gender = "f"
}
result = append(result, domain.Voice{
ID: cfai.VoiceID(speaker),
Engine: "cfai",
Lang: "en",
Gender: gender,
})
}
s.deps.Log.Info("backend: loaded CF AI voices", "count", len(cfai.Speakers()))
}
s.voiceMu.Lock()
s.cachedVoices = result
s.voiceMu.Unlock()

View File

@@ -0,0 +1,214 @@
// Package cfai provides a client for Cloudflare Workers AI Text-to-Speech models.
//
// The Cloudflare Workers AI REST API is used to run TTS models:
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
// { "text": "...", "speaker": "luna" }
//
// → 200 audio/mpeg — raw MP3 bytes
//
// Currently supported model: @cf/deepgram/aura-2-en (40 English speakers).
// Voice IDs are prefixed with "cfai:" to distinguish them from Kokoro/pocket-tts
// voices (e.g. "cfai:luna", "cfai:orion").
//
// The API is batch-only (no streaming), so GenerateAudio waits for the full
// 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.
package cfai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
// DefaultModel is the Cloudflare Workers AI TTS model used by default.
DefaultModel = "@cf/deepgram/aura-2-en"
// voicePrefix is the prefix used to namespace CF AI voice IDs.
voicePrefix = "cfai:"
)
// aura2Speakers is the exhaustive list of speakers supported by aura-2-en.
var aura2Speakers = []string{
"amalthea", "andromeda", "apollo", "arcas", "aries", "asteria",
"athena", "atlas", "aurora", "callista", "cora", "cordelia",
"delia", "draco", "electra", "harmonia", "helena", "hera",
"hermes", "hyperion", "iris", "janus", "juno", "jupiter",
"luna", "mars", "minerva", "neptune", "odysseus", "ophelia",
"orion", "orpheus", "pandora", "phoebe", "pluto", "saturn",
"thalia", "theia", "vesta", "zeus",
}
// femaleSpeakers is the set of aura-2-en speaker names that are female voices.
var femaleSpeakers = map[string]struct{}{
"amalthea": {}, "andromeda": {}, "aries": {}, "asteria": {},
"athena": {}, "aurora": {}, "callista": {}, "cora": {},
"cordelia": {}, "delia": {}, "electra": {}, "harmonia": {},
"helena": {}, "hera": {}, "iris": {}, "juno": {},
"luna": {}, "minerva": {}, "ophelia": {}, "pandora": {},
"phoebe": {}, "thalia": {}, "theia": {}, "vesta": {},
}
// IsCFAIVoice reports whether voice is served by the Cloudflare AI client.
// CF AI voices use the "cfai:" prefix, e.g. "cfai:luna".
func IsCFAIVoice(voice string) bool {
return strings.HasPrefix(voice, voicePrefix)
}
// SpeakerName strips the "cfai:" prefix and returns the bare speaker name.
// If voice is not a CF AI voice the original string is returned unchanged.
func SpeakerName(voice string) string {
return strings.TrimPrefix(voice, voicePrefix)
}
// VoiceID returns the full voice ID (with prefix) for a bare speaker name.
func VoiceID(speaker string) string {
return voicePrefix + speaker
}
// VoiceSampleKey returns the MinIO object key for a CF AI voice sample MP3.
func VoiceSampleKey(voice string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
}
// IsFemale reports whether the given CF AI voice ID (with or without prefix)
// is a female speaker.
func IsFemale(voice string) bool {
speaker := SpeakerName(voice)
_, ok := femaleSpeakers[speaker]
return ok
}
// Speakers returns all available bare speaker names for aura-2-en.
func Speakers() []string {
out := make([]string, len(aura2Speakers))
copy(out, aura2Speakers)
return out
}
// Client is the interface for interacting with Cloudflare Workers AI TTS.
type Client interface {
// GenerateAudio synthesises text using the given voice (e.g. "cfai:luna")
// and returns raw MP3 bytes.
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
// StreamAudioMP3 is not natively supported by the CF AI batch API.
// It buffers the full response and returns an io.ReadCloser over the bytes,
// so callers can use it like a stream without special-casing.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV is not natively supported; the CF AI model returns MP3.
// This method returns the same MP3 bytes wrapped as an io.ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns all available voice IDs (with the "cfai:" prefix).
ListVoices(ctx context.Context) ([]string, error)
}
// httpClient is the concrete CF AI HTTP client.
type httpClient struct {
accountID string
apiToken string
model string
http *http.Client
}
// New returns a Client for the given Cloudflare account and API token.
// model defaults to DefaultModel when empty.
func New(accountID, apiToken, model string) Client {
if model == "" {
model = DefaultModel
}
return &httpClient{
accountID: accountID,
apiToken: apiToken,
model: model,
http: &http.Client{Timeout: 5 * time.Minute},
}
}
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("cfai: empty text")
}
speaker := SpeakerName(voice)
if speaker == "" {
speaker = "luna"
}
body, err := json.Marshal(map[string]any{
"text": text,
"speaker": speaker,
})
if err != nil {
return nil, fmt.Errorf("cfai: marshal request: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, c.model)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cfai: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cfai: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("cfai: server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
mp3, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai: read response: %w", err)
}
return mp3, nil
}
// 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)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(mp3)), nil
}
// StreamAudioWAV generates audio (MP3) and wraps it as an io.ReadCloser.
// Note: the CF AI aura-2-en model returns MP3 regardless of the method name.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
return c.StreamAudioMP3(ctx, text, voice)
}
// ListVoices returns all available CF AI voice IDs (with the "cfai:" prefix).
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
ids := make([]string, len(aura2Speakers))
for i, s := range aura2Speakers {
ids[i] = VoiceID(s)
}
return ids, nil
}

View File

@@ -0,0 +1,312 @@
// Image generation via Cloudflare Workers AI text-to-image models.
//
// API reference:
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Text-only request (all models):
//
// { "prompt": "...", "num_steps": 20 }
//
// Reference-image request:
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
//
// All models return raw PNG bytes on success (Content-Type: image/png).
//
// Recommended models for LibNovel:
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
// - Chapter images (speed): flux-2-klein-4b, flux-1-schnell
// - With reference image: flux-2-dev, flux-2-klein-9b, sd-v1-5-img2img
package cfai
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
_ "image/jpeg" // register JPEG decoder
_ "image/png" // register PNG decoder
"io"
"net/http"
"time"
)
// ImageModel identifies a Cloudflare Workers AI text-to-image model.
type ImageModel string
const (
// ImageModelFlux2Dev — best quality, multi-reference. Recommended for covers.
ImageModelFlux2Dev ImageModel = "@cf/black-forest-labs/flux-2-dev"
// ImageModelFlux2Klein9B — 9B params, multi-reference. Good for covers.
ImageModelFlux2Klein9B ImageModel = "@cf/black-forest-labs/flux-2-klein-9b"
// ImageModelFlux2Klein4B — ultra-fast, unified gen+edit. Recommended for chapters.
ImageModelFlux2Klein4B ImageModel = "@cf/black-forest-labs/flux-2-klein-4b"
// ImageModelFlux1Schnell — fastest, text-only. Good for quick illustrations.
ImageModelFlux1Schnell ImageModel = "@cf/black-forest-labs/flux-1-schnell"
// ImageModelSDXLLightning — fast 1024px generation.
ImageModelSDXLLightning ImageModel = "@cf/bytedance/stable-diffusion-xl-lightning"
// ImageModelSD15Img2Img — explicit img2img with flat RGBA reference.
ImageModelSD15Img2Img ImageModel = "@cf/runwayml/stable-diffusion-v1-5-img2img"
// ImageModelSDXLBase — Stability AI SDXL base.
ImageModelSDXLBase ImageModel = "@cf/stabilityai/stable-diffusion-xl-base-1.0"
// ImageModelLucidOrigin — Leonardo AI; strong prompt adherence.
ImageModelLucidOrigin ImageModel = "@cf/leonardo/lucid-origin"
// ImageModelPhoenix10 — Leonardo AI; accurate text rendering.
ImageModelPhoenix10 ImageModel = "@cf/leonardo/phoenix-1.0"
// DefaultImageModel is the default model for book-cover generation.
DefaultImageModel = ImageModelFlux2Dev
)
// ImageModelInfo describes a single image generation model.
type ImageModelInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Provider string `json:"provider"`
SupportsRef bool `json:"supports_ref"`
RecommendedFor []string `json:"recommended_for"` // "cover" and/or "chapter"
Description string `json:"description"`
}
// AllImageModels returns metadata about every supported image model.
func AllImageModels() []ImageModelInfo {
return []ImageModelInfo{
{
ID: string(ImageModelFlux2Dev), Label: "FLUX.2 Dev", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"cover"},
Description: "Best quality; multi-reference editing. Recommended for book covers.",
},
{
ID: string(ImageModelFlux2Klein9B), Label: "FLUX.2 Klein 9B", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"cover"},
Description: "9B parameters with multi-reference support.",
},
{
ID: string(ImageModelFlux2Klein4B), Label: "FLUX.2 Klein 4B", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"chapter"},
Description: "Ultra-fast unified gen+edit. Recommended for chapter images.",
},
{
ID: string(ImageModelFlux1Schnell), Label: "FLUX.1 Schnell", Provider: "Black Forest Labs",
SupportsRef: false, RecommendedFor: []string{"chapter"},
Description: "Fastest inference. Good for quick chapter illustrations.",
},
{
ID: string(ImageModelSDXLLightning), Label: "SDXL Lightning", Provider: "ByteDance",
SupportsRef: false, RecommendedFor: []string{"chapter"},
Description: "Lightning-fast 1024px images in a few steps.",
},
{
ID: string(ImageModelSD15Img2Img), Label: "SD 1.5 img2img", Provider: "RunwayML",
SupportsRef: true, RecommendedFor: []string{"cover", "chapter"},
Description: "Explicit img2img: generates from a reference image + prompt.",
},
{
ID: string(ImageModelSDXLBase), Label: "SDXL Base 1.0", Provider: "Stability AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Stable Diffusion XL base model.",
},
{
ID: string(ImageModelLucidOrigin), Label: "Lucid Origin", Provider: "Leonardo AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Highly prompt-responsive; strong graphic design and HD renders.",
},
{
ID: string(ImageModelPhoenix10), Label: "Phoenix 1.0", Provider: "Leonardo AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Exceptional prompt adherence; accurate text rendering.",
},
}
}
// ImageRequest is the input to GenerateImage / GenerateImageFromReference.
type ImageRequest struct {
// Prompt is the text description of the desired image.
Prompt string
// Model is the CF Workers AI model. Defaults to DefaultImageModel when empty.
Model ImageModel
// NumSteps controls inference quality (default 20). Range: 120.
NumSteps int
// Width and Height in pixels. 0 = model default (typically 1024x1024).
Width, Height int
// Guidance controls prompt adherence (default 7.5).
Guidance float64
// Strength for img2img: 0.0 = copy reference, 1.0 = ignore reference (default 0.75).
Strength float64
}
// ImageGenClient generates images via Cloudflare Workers AI.
type ImageGenClient interface {
// GenerateImage creates an image from a text prompt only.
// Returns raw PNG bytes.
GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error)
// GenerateImageFromReference creates an image from a text prompt + reference image.
// refImage should be PNG or JPEG bytes. Returns raw PNG bytes.
GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error)
// Models returns metadata about all supported image models.
Models() []ImageModelInfo
}
// imageGenHTTPClient is the concrete CF AI image generation client.
type imageGenHTTPClient struct {
accountID string
apiToken string
http *http.Client
}
// NewImageGen returns an ImageGenClient for the given Cloudflare account.
func NewImageGen(accountID, apiToken string) ImageGenClient {
return &imageGenHTTPClient{
accountID: accountID,
apiToken: apiToken,
http: &http.Client{Timeout: 5 * time.Minute},
}
}
// GenerateImage generates an image from text only.
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
req = applyImageDefaults(req)
body := map[string]any{
"prompt": req.Prompt,
"num_steps": req.NumSteps,
}
if req.Width > 0 {
body["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
}
// GenerateImageFromReference generates an image from a text prompt + reference image.
func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) {
if len(refImage) == 0 {
return c.GenerateImage(ctx, req)
}
req = applyImageDefaults(req)
var body map[string]any
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
if err != nil {
return nil, fmt.Errorf("cfai/image: decode reference: %w", err)
}
strength := req.Strength
if strength <= 0 {
strength = 0.75
}
body = map[string]any{
"prompt": req.Prompt,
"image": pixels,
"strength": strength,
"num_steps": req.NumSteps,
}
} else {
b64 := base64.StdEncoding.EncodeToString(refImage)
body = map[string]any{
"prompt": req.Prompt,
"image_b64": b64,
"num_steps": req.NumSteps,
}
if req.Strength > 0 {
body["strength"] = req.Strength
}
}
if req.Width > 0 {
body["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
}
// Models returns all supported image model metadata.
func (c *imageGenHTTPClient) Models() []ImageModelInfo {
return AllImageModels()
}
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(model))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("cfai/image: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cfai/image: 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 nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
}
return data, nil
}
func applyImageDefaults(req ImageRequest) ImageRequest {
if req.Model == "" {
req.Model = DefaultImageModel
}
if req.NumSteps <= 0 {
req.NumSteps = 20
}
return req
}
// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array
// required by the stable-diffusion-v1-5-img2img model.
func decodeImageToRGBA(data []byte) ([]uint8, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
bounds := img.Bounds()
w := bounds.Max.X - bounds.Min.X
h := bounds.Max.Y - bounds.Min.Y
pixels := make([]uint8, w*h*4)
idx := 0
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA()
pixels[idx] = uint8(r >> 8)
pixels[idx+1] = uint8(g >> 8)
pixels[idx+2] = uint8(b >> 8)
pixels[idx+3] = uint8(a >> 8)
idx += 4
}
}
return pixels, nil
}

View File

@@ -66,6 +66,18 @@ type PocketTTS struct {
URL string
}
// CFAI holds credentials for Cloudflare Workers AI TTS.
type CFAI struct {
// AccountID is the Cloudflare account ID.
// An empty string disables CF AI generation.
AccountID string
// APIToken is a Workers AI API token with Workers AI Read+Edit permissions.
APIToken string
// Model is the Workers AI TTS model ID.
// Defaults to "@cf/deepgram/aura-2-en" when empty.
Model string
}
// LibreTranslate holds connection settings for a self-hosted LibreTranslate instance.
type LibreTranslate struct {
// URL is the base URL of the LibreTranslate instance, e.g. https://translate.libnovel.cc
@@ -153,6 +165,7 @@ type Config struct {
MinIO MinIO
Kokoro Kokoro
PocketTTS PocketTTS
CFAI CFAI
LibreTranslate LibreTranslate
HTTP HTTP
Runner Runner
@@ -203,6 +216,12 @@ func Load() Config {
URL: envOr("POCKET_TTS_URL", ""),
},
CFAI: CFAI{
AccountID: envOr("CFAI_ACCOUNT_ID", ""),
APIToken: envOr("CFAI_API_TOKEN", ""),
Model: envOr("CFAI_TTS_MODEL", ""),
},
LibreTranslate: LibreTranslate{
URL: envOr("LIBRETRANSLATE_URL", ""),
APIKey: envOr("LIBRETRANSLATE_API_KEY", ""),

View File

@@ -27,6 +27,7 @@ import (
"go.opentelemetry.io/otel/codes"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
@@ -112,6 +113,9 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
// If nil, pocket-tts voice tasks will fail with a clear error.
PocketTTS pockettts.Client
// CFAI is the Cloudflare Workers AI TTS client (cfai:* prefixed voices).
// If nil, CF AI voice tasks will fail with a clear error.
CFAI cfai.Client
// LibreTranslate is the machine translation client.
// If nil, translation tasks will fail with a clear error.
LibreTranslate libretranslate.Client
@@ -555,6 +559,18 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
return
}
log.Info("runner: audio generated via pocket-tts", "voice", task.Voice)
} else if cfai.IsCFAIVoice(task.Voice) {
if r.deps.CFAI == nil {
fail("cloudflare AI client not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN empty)")
return
}
var genErr error
audioData, genErr = r.deps.CFAI.GenerateAudio(ctx, text, task.Voice)
if genErr != nil {
fail(fmt.Sprintf("cfai generate: %v", genErr))
return
}
log.Info("runner: audio generated via cloudflare AI", "voice", task.Voice)
} else {
if r.deps.Kokoro == nil {
fail("kokoro client not configured (KOKORO_URL is empty)")

View File

@@ -13,6 +13,11 @@
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
#
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
services:
libretranslate:
@@ -35,6 +40,12 @@ services:
stop_grace_period: 135s
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
# directly to Caddy on prod; TLS and SNI still work normally.
extra_hosts:
- "storage.libnovel.cc:165.22.70.138"
- "pb.libnovel.cc:165.22.70.138"
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
@@ -63,6 +74,10 @@ services:
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"

3
ui/.gitignore vendored
View File

@@ -22,5 +22,4 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Generated by CI at build time — do not commit
/static/releases.json

View File

@@ -3,6 +3,7 @@
"nav_library": "Library",
"nav_catalogue": "Catalogue",
"nav_feed": "Feed",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Profile",
@@ -361,12 +362,14 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Push",
"admin_nav_gitea": "Gitea",
"admin_scrape_status_idle": "Idle",
"admin_scrape_status_running": "Running",
@@ -419,5 +422,16 @@
"profile_text_size_sm": "Small",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Large",
"profile_text_size_xl": "X-Large"
"profile_text_size_xl": "X-Large",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Following Feed",
"feed_subheading": "Books your followed users are reading",
"feed_empty_heading": "Nothing here yet",
"feed_empty_body": "Follow other readers to see what they're reading.",
"feed_not_logged_in": "Sign in to see your feed.",
"feed_reader_label": "reading",
"feed_chapters_label": "{n} chapters",
"feed_browse_cta": "Browse catalogue",
"feed_find_users_cta": "Discover readers"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
"nav_feedback": "Retour",
"nav_admin": "Admin",
"nav_profile": "Profil",
@@ -361,6 +362,7 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Traduction",
"admin_nav_changelog": "Modifications",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Retours",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
@@ -418,5 +420,17 @@
"profile_text_size_sm": "Petit",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand"
"profile_text_size_xl": "Très grand",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
"feed_empty_heading": "Rien encore",
"feed_empty_body": "Suivez d'autres lecteurs pour voir ce qu'ils lisent.",
"feed_not_logged_in": "Connectez-vous pour voir votre fil.",
"feed_reader_label": "lit",
"feed_chapters_label": "{n} chapitres",
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
"nav_feedback": "Masukan",
"nav_admin": "Admin",
"nav_profile": "Profil",
@@ -361,6 +362,7 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Terjemahan",
"admin_nav_changelog": "Perubahan",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Masukan",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
@@ -418,5 +420,17 @@
"profile_text_size_sm": "Kecil",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar"
"profile_text_size_xl": "Sangat Besar",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
"feed_empty_heading": "Belum ada apa-apa",
"feed_empty_body": "Ikuti pembaca lain untuk melihat apa yang mereka baca.",
"feed_not_logged_in": "Masuk untuk melihat umpan Anda.",
"feed_reader_label": "membaca",
"feed_chapters_label": "{n} bab",
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Perfil",
@@ -361,6 +362,7 @@
"admin_nav_audio": "Áudio",
"admin_nav_translation": "Tradução",
"admin_nav_changelog": "Alterações",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
@@ -418,5 +420,17 @@
"profile_text_size_sm": "Pequeno",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande"
"profile_text_size_xl": "Muito grande",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
"feed_empty_heading": "Nada aqui ainda",
"feed_empty_body": "Siga outros leitores para ver o que estão lendo.",
"feed_not_logged_in": "Faça login para ver seu feed.",
"feed_reader_label": "lendo",
"feed_chapters_label": "{n} capítulos",
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
"nav_feedback": "Обратная связь",
"nav_admin": "Админ",
"nav_profile": "Профиль",
@@ -361,6 +362,7 @@
"admin_nav_audio": "Аудио",
"admin_nav_translation": "Перевод",
"admin_nav_changelog": "Изменения",
"admin_nav_image_gen": "Image Gen",
"admin_nav_feedback": "Отзывы",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
@@ -418,5 +420,17 @@
"profile_text_size_sm": "Маленький",
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой"
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
"feed_empty_heading": "Пока ничего нет",
"feed_empty_body": "Подпишитесь на других читателей, чтобы видеть, что они читают.",
"feed_not_logged_in": "Войдите, чтобы видеть свою ленту.",
"feed_reader_label": "читает",
"feed_chapters_label": "{n} глав",
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea"
}

View File

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

View File

@@ -86,6 +86,7 @@
// ── Derived: voices grouped by engine ──────────────────────────────────
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
// ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false);
@@ -98,6 +99,7 @@
* Human-readable label for a voice.
* Kokoro: "af_bella" → "Bella (US F)"
* Pocket-TTS: "alba" → "Alba (EN F)"
* CF AI: "cfai:luna" → "Luna (EN F)"
* Falls back gracefully if called with a bare string (e.g. from the store default).
*/
function voiceLabel(v: Voice | string): string {
@@ -110,6 +112,14 @@
return kokoroLabelFromId(v);
}
if (v.engine === 'cfai') {
// "cfai:luna" → "Luna (EN F)"
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
const name = speaker.replace(/\b\w/g, (c) => c.toUpperCase());
const genderLabel = v.gender.toUpperCase();
return `${name} (EN ${genderLabel})`;
}
if (v.engine === 'pocket-tts') {
const langLabel = v.lang.toUpperCase().replace('-', '');
const genderLabel = v.gender.toUpperCase();
@@ -844,6 +854,16 @@
{@render voiceRow(v)}
{/each}
{/if}
<!-- Cloudflare AI section -->
{#if cfaiVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
</div>
{#each cfaiVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
</div>
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">

View File

@@ -2,6 +2,7 @@
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
export * from './nav_library.js'
export * from './nav_catalogue.js'
export * from './nav_feed.js'
export * from './nav_feedback.js'
export * from './nav_admin.js'
export * from './nav_profile.js'
@@ -338,6 +339,7 @@ export * from './admin_nav_analytics.js'
export * from './admin_nav_logs.js'
export * from './admin_nav_uptime.js'
export * from './admin_nav_push.js'
export * from './admin_nav_gitea.js'
export * from './admin_scrape_status_idle.js'
export * from './admin_scrape_full_catalogue.js'
export * from './admin_scrape_single_book.js'
@@ -383,4 +385,14 @@ export * from './profile_text_size.js'
export * from './profile_text_size_sm.js'
export * from './profile_text_size_md.js'
export * from './profile_text_size_lg.js'
export * from './profile_text_size_xl.js'
export * from './profile_text_size_xl.js'
export * from './feed_page_title.js'
export * from './feed_heading.js'
export * from './feed_subheading.js'
export * from './feed_empty_heading.js'
export * from './feed_empty_body.js'
export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_AnalyticsInputs */
const en_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytics`);
const ru_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Аналитика`);
const id_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analitik`);
const pt_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Análise`);
const fr_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytique`);
const en_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytics`)
};
const ru_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аналитика`)
};
const id_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analitik`)
};
const pt_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Análise`)
};
const fr_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytique`)
};
/**
* | output |
* | --- |
* | "Analytics" |
*
* @param {Admin_Nav_AnalyticsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AnalyticsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_analytics(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInpu
if (locale === "id") return id_admin_nav_analytics(inputs)
if (locale === "pt") return pt_admin_nav_analytics(inputs)
return fr_admin_nav_analytics(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_AudioInputs */
const en_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const ru_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Аудио`);
const id_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const pt_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Áudio`);
const fr_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const en_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const ru_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио`)
};
const id_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const pt_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Áudio`)
};
const fr_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
/**
* | output |
* | --- |
* | "Audio" |
*
* @param {Admin_Nav_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_audio(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, opti
if (locale === "id") return id_admin_nav_audio(inputs)
if (locale === "pt") return pt_admin_nav_audio(inputs)
return fr_admin_nav_audio(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ChangelogInputs */
const en_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Changelog`);
const ru_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Изменения`);
const id_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Perubahan`);
const pt_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Alterações`);
const fr_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Modifications`);
const en_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const ru_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Изменения`)
};
const id_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Perubahan`)
};
const pt_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Alterações`)
};
const fr_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Modifications`)
};
/**
* | output |
* | --- |
* | "Changelog" |
*
* @param {Admin_Nav_ChangelogInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ChangelogInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_changelog(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInpu
if (locale === "id") return id_admin_nav_changelog(inputs)
if (locale === "pt") return pt_admin_nav_changelog(inputs)
return fr_admin_nav_changelog(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ErrorsInputs */
const en_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Errors`);
const ru_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Ошибки`);
const id_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Kesalahan`);
const pt_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erros`);
const fr_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erreurs`);
const en_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Errors`)
};
const ru_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ошибки`)
};
const id_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kesalahan`)
};
const pt_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erros`)
};
const fr_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erreurs`)
};
/**
* | output |
* | --- |
* | "Errors" |
*
* @param {Admin_Nav_ErrorsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ErrorsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_errors(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, op
if (locale === "id") return id_admin_nav_errors(inputs)
if (locale === "pt") return pt_admin_nav_errors(inputs)
return fr_admin_nav_errors(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_FeedbackInputs */
const en_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
const ru_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Отзывы`);
const id_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Masukan`);
const pt_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
const fr_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Retours`);
const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отзывы`)
};
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masukan`)
};
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Retours`)
};
/**
* | output |
* | --- |
* | "Feedback" |
*
* @param {Admin_Nav_FeedbackInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_FeedbackInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_feedback(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs
if (locale === "id") return id_admin_nav_feedback(inputs)
if (locale === "pt") return pt_admin_nav_feedback(inputs)
return fr_admin_nav_feedback(inputs)
});
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_GiteaInputs */
const en_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const ru_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const id_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const pt_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const fr_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
/**
* | output |
* | --- |
* | "Gitea" |
*
* @param {Admin_Nav_GiteaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_gitea = /** @type {((inputs?: Admin_Nav_GiteaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GiteaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_gitea(inputs)
if (locale === "ru") return ru_admin_nav_gitea(inputs)
if (locale === "id") return id_admin_nav_gitea(inputs)
if (locale === "pt") return pt_admin_nav_gitea(inputs)
return fr_admin_nav_gitea(inputs)
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_LogsInputs */
const en_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
const ru_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Логи`);
const id_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Log`);
const pt_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
const fr_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Journaux`);
const en_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Logs`)
};
const ru_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Логи`)
};
const id_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Log`)
};
const pt_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Logs`)
};
const fr_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Journaux`)
};
/**
* | output |
* | --- |
* | "Logs" |
*
* @param {Admin_Nav_LogsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_LogsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_logs(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, option
if (locale === "id") return id_admin_nav_logs(inputs)
if (locale === "pt") return pt_admin_nav_logs(inputs)
return fr_admin_nav_logs(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_PushInputs */
const en_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Push`);
const ru_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Уведомления`);
const id_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifikasi`);
const pt_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notificações`);
const fr_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifications`);
const en_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Push`)
};
const ru_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Уведомления`)
};
const id_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifikasi`)
};
const pt_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notificações`)
};
const fr_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
/**
* | output |
* | --- |
* | "Push" |
*
* @param {Admin_Nav_PushInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_PushInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_push(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, option
if (locale === "id") return id_admin_nav_push(inputs)
if (locale === "pt") return pt_admin_nav_push(inputs)
return fr_admin_nav_push(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ScrapeInputs */
const en_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const ru_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Скрейпинг`);
const id_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const pt_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const fr_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const en_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const ru_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Скрейпинг`)
};
const id_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const pt_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const fr_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
/**
* | output |
* | --- |
* | "Scrape" |
*
* @param {Admin_Nav_ScrapeInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ScrapeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_scrape(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, op
if (locale === "id") return id_admin_nav_scrape(inputs)
if (locale === "pt") return pt_admin_nav_scrape(inputs)
return fr_admin_nav_scrape(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_TranslationInputs */
const en_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Translation`);
const ru_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Перевод`);
const id_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Terjemahan`);
const pt_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Tradução`);
const fr_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Traduction`);
const en_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation`)
};
const ru_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перевод`)
};
const id_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Terjemahan`)
};
const pt_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tradução`)
};
const fr_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Traduction`)
};
/**
* | output |
* | --- |
* | "Translation" |
*
* @param {Admin_Nav_TranslationInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_translation(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_Translation
if (locale === "id") return id_admin_nav_translation(inputs)
if (locale === "pt") return pt_admin_nav_translation(inputs)
return fr_admin_nav_translation(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_UptimeInputs */
const en_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const ru_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Мониторинг`);
const id_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const pt_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const fr_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Disponibilité`);
const en_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const ru_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Мониторинг`)
};
const id_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const pt_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const fr_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Disponibilité`)
};
/**
* | output |
* | --- |
* | "Uptime" |
*
* @param {Admin_Nav_UptimeInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_UptimeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_uptime(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, op
if (locale === "id") return id_admin_nav_uptime(inputs)
if (locale === "pt") return pt_admin_nav_uptime(inputs)
return fr_admin_nav_uptime(inputs)
});
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Browse_CtaInputs */
const en_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Browse catalogue`)
};
const ru_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Каталог`)
};
const id_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jelajahi katalog`)
};
const pt_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ver catálogo`)
};
const fr_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Parcourir le catalogue`)
};
/**
* | output |
* | --- |
* | "Browse catalogue" |
*
* @param {Feed_Browse_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_browse_cta = /** @type {((inputs?: Feed_Browse_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Browse_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_browse_cta(inputs)
if (locale === "ru") return ru_feed_browse_cta(inputs)
if (locale === "id") return id_feed_browse_cta(inputs)
if (locale === "pt") return pt_feed_browse_cta(inputs)
return fr_feed_browse_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{ n: NonNullable<unknown> }} Feed_Chapters_LabelInputs */
const en_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} chapters`)
};
const ru_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} глав`)
};
const id_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} bab`)
};
const pt_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} capítulos`)
};
const fr_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} chapitres`)
};
/**
* | output |
* | --- |
* | "{n} chapters" |
*
* @param {Feed_Chapters_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_chapters_label = /** @type {((inputs: Feed_Chapters_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Chapters_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_chapters_label(inputs)
if (locale === "ru") return ru_feed_chapters_label(inputs)
if (locale === "id") return id_feed_chapters_label(inputs)
if (locale === "pt") return pt_feed_chapters_label(inputs)
return fr_feed_chapters_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Empty_BodyInputs */
const en_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Follow other readers to see what they're reading.`)
};
const ru_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Подпишитесь на других читателей, чтобы видеть, что они читают.`)
};
const id_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ikuti pembaca lain untuk melihat apa yang mereka baca.`)
};
const pt_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Siga outros leitores para ver o que estão lendo.`)
};
const fr_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Suivez d'autres lecteurs pour voir ce qu'ils lisent.`)
};
/**
* | output |
* | --- |
* | "Follow other readers to see what they're reading." |
*
* @param {Feed_Empty_BodyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_empty_body = /** @type {((inputs?: Feed_Empty_BodyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_BodyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_empty_body(inputs)
if (locale === "ru") return ru_feed_empty_body(inputs)
if (locale === "id") return id_feed_empty_body(inputs)
if (locale === "pt") return pt_feed_empty_body(inputs)
return fr_feed_empty_body(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Empty_HeadingInputs */
const en_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nothing here yet`)
};
const ru_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Пока ничего нет`)
};
const id_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Belum ada apa-apa`)
};
const pt_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nada aqui ainda`)
};
const fr_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Rien encore`)
};
/**
* | output |
* | --- |
* | "Nothing here yet" |
*
* @param {Feed_Empty_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_empty_heading = /** @type {((inputs?: Feed_Empty_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_empty_heading(inputs)
if (locale === "ru") return ru_feed_empty_heading(inputs)
if (locale === "id") return id_feed_empty_heading(inputs)
if (locale === "pt") return pt_feed_empty_heading(inputs)
return fr_feed_empty_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Find_Users_CtaInputs */
const en_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Discover readers`)
};
const ru_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Найти читателей`)
};
const id_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Temukan pembaca`)
};
const pt_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Encontrar leitores`)
};
const fr_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Trouver des lecteurs`)
};
/**
* | output |
* | --- |
* | "Discover readers" |
*
* @param {Feed_Find_Users_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_find_users_cta = /** @type {((inputs?: Feed_Find_Users_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Find_Users_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_find_users_cta(inputs)
if (locale === "ru") return ru_feed_find_users_cta(inputs)
if (locale === "id") return id_feed_find_users_cta(inputs)
if (locale === "pt") return pt_feed_find_users_cta(inputs)
return fr_feed_find_users_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_HeadingInputs */
const en_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Following Feed`)
};
const ru_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента подписок`)
};
const id_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan Ikutan`)
};
const pt_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed de seguidos`)
};
const fr_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil d'abonnements`)
};
/**
* | output |
* | --- |
* | "Following Feed" |
*
* @param {Feed_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_heading = /** @type {((inputs?: Feed_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_heading(inputs)
if (locale === "ru") return ru_feed_heading(inputs)
if (locale === "id") return id_feed_heading(inputs)
if (locale === "pt") return pt_feed_heading(inputs)
return fr_feed_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Not_Logged_InInputs */
const en_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in to see your feed.`)
};
const ru_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войдите, чтобы видеть свою ленту.`)
};
const id_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk untuk melihat umpan Anda.`)
};
const pt_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Faça login para ver seu feed.`)
};
const fr_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Connectez-vous pour voir votre fil.`)
};
/**
* | output |
* | --- |
* | "Sign in to see your feed." |
*
* @param {Feed_Not_Logged_InInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_not_logged_in = /** @type {((inputs?: Feed_Not_Logged_InInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Not_Logged_InInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_not_logged_in(inputs)
if (locale === "ru") return ru_feed_not_logged_in(inputs)
if (locale === "id") return id_feed_not_logged_in(inputs)
if (locale === "pt") return pt_feed_not_logged_in(inputs)
return fr_feed_not_logged_in(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Page_TitleInputs */
const en_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
};
const ru_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента — LibNovel`)
};
const id_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan — LibNovel`)
};
const pt_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
};
const fr_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil — LibNovel`)
};
/**
* | output |
* | --- |
* | "Feed — LibNovel" |
*
* @param {Feed_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_page_title = /** @type {((inputs?: Feed_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_page_title(inputs)
if (locale === "ru") return ru_feed_page_title(inputs)
if (locale === "id") return id_feed_page_title(inputs)
if (locale === "pt") return pt_feed_page_title(inputs)
return fr_feed_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Reader_LabelInputs */
const en_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`reading`)
};
const ru_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`читает`)
};
const id_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`membaca`)
};
const pt_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`lendo`)
};
const fr_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`lit`)
};
/**
* | output |
* | --- |
* | "reading" |
*
* @param {Feed_Reader_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_reader_label = /** @type {((inputs?: Feed_Reader_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Reader_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_reader_label(inputs)
if (locale === "ru") return ru_feed_reader_label(inputs)
if (locale === "id") return id_feed_reader_label(inputs)
if (locale === "pt") return pt_feed_reader_label(inputs)
return fr_feed_reader_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_SubheadingInputs */
const en_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Books your followed users are reading`)
};
const ru_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Книги, которые читают ваши подписки`)
};
const id_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Buku yang sedang dibaca oleh pengguna yang Anda ikuti`)
};
const pt_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Livros que seus seguidos estão lendo`)
};
const fr_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Livres lus par vos abonnements`)
};
/**
* | output |
* | --- |
* | "Books your followed users are reading" |
*
* @param {Feed_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_subheading = /** @type {((inputs?: Feed_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_subheading(inputs)
if (locale === "ru") return ru_feed_subheading(inputs)
if (locale === "id") return id_feed_subheading(inputs)
if (locale === "pt") return pt_feed_subheading(inputs)
return fr_feed_subheading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Nav_FeedInputs */
const en_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed`)
};
const ru_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента`)
};
const id_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan`)
};
const pt_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed`)
};
const fr_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil`)
};
/**
* | output |
* | --- |
* | "Feed" |
*
* @param {Nav_FeedInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const nav_feed = /** @type {((inputs?: Nav_FeedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Nav_FeedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_nav_feed(inputs)
if (locale === "ru") return ru_nav_feed(inputs)
if (locale === "id") return id_nav_feed(inputs)
if (locale === "pt") return pt_nav_feed(inputs)
return fr_nav_feed(inputs)
});

View File

@@ -299,7 +299,8 @@ export async function invalidateBooksCache(): Promise<void> {
await Promise.all([
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*')
cache.invalidatePattern('books:recent:*'),
cache.invalidatePattern('books:recently-updated:*')
]);
}
@@ -312,10 +313,51 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
await cache.set(key, books, 5 * 60); // 5 minutes
await cache.set(key, books, 5 * 60);
return books;
}
/**
* Books with the most recently added chapters, ordered by chapter insertion time.
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
* This correctly reflects actual chapter activity, unlike meta_updated on books.
*/
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
const key = `books:recently-updated:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
try {
// Fetch enough recent chapter rows to find `limit` distinct books
const rows = await listN<{ slug: string; created: string }>(
'chapters_idx', limit * 25, '', '-created'
);
const seen = new Set<string>();
const slugs: string[] = [];
for (const row of rows) {
if (!seen.has(row.slug)) {
seen.add(row.slug);
slugs.push(row.slug);
if (slugs.length >= limit) break;
}
}
if (!slugs.length) return recentlyAddedBooks(limit);
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
await cache.set(key, ordered, 5 * 60);
return ordered;
} catch {
// Fall back to meta_updated sort if chapters_idx query fails
return recentlyAddedBooks(limit);
}
}
export interface HomeStats {
totalBooks: number;
totalChapters: number;
@@ -997,6 +1039,15 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
}
/**
* Returns the set of book slugs that have at least one completed audio job.
* Used by the catalogue page to show audio-available badges.
*/
export async function getSlugsWithAudio(): Promise<Set<string>> {
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
return new Set(jobs.map((j) => j.slug));
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {

View File

@@ -12,7 +12,7 @@
export interface Voice {
/** Voice identifier passed to TTS clients (e.g. "af_bella", "alba"). */
id: string;
/** TTS engine: "kokoro" | "pocket-tts". */
/** TTS engine: "kokoro" | "pocket-tts" | "cfai". */
engine: string;
/** Primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr"). */
lang: string;

View File

@@ -88,6 +88,7 @@
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
let settingsDirty = false; // true only after the first apply completes
$effect(() => {
if (data.settings) {
if (!settingsApplied) {
@@ -100,6 +101,9 @@
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
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);
}
});
@@ -114,8 +118,9 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
if (!settingsDirty) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {
@@ -340,18 +345,24 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="/discover"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/feed"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/feed') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_feed()}
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -525,20 +536,27 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="/discover"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/feed"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/feed') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_feed()}
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import {
getBooksBySlugs,
recentlyAddedBooks,
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => {
try {
[recentBooks, progressList, stats] = await Promise.all([
recentlyAddedBooks(8),
recentlyUpdatedBooks(8).catch(() => [] as Book[]),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);

View File

@@ -1,9 +1,47 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
const SECTIONS_KEY = 'home_sections_v1';
const SECTION_LABELS: Record<SectionId, string> = {
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
};
function loadHidden(): Set<SectionId> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(SECTIONS_KEY);
if (raw) return new Set(JSON.parse(raw) as SectionId[]);
} catch { /* ignore */ }
return new Set();
}
let hidden = $state<Set<SectionId>>(loadHidden());
function hide(id: SectionId) {
hidden = new Set([...hidden, id]);
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...hidden]));
}
function restore(id: SectionId) {
const next = new Set(hidden);
next.delete(id);
hidden = next;
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
}
const hiddenList = $derived(
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
);
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
@@ -121,10 +159,19 @@
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
@@ -135,13 +182,22 @@
{/each}
</div>
</section>
{/if}
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each dedupedRecent as { book, count }}
@@ -182,10 +238,16 @@
{/if}
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#if data.subscriptionFeed.length > 0}
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
@@ -221,6 +283,23 @@
</div>
{/if}
<!-- ── Hidden sections restore ───────────────────────────────────────────────── -->
{#if hiddenList.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2">
<span class="text-xs text-(--color-muted)">Hidden:</span>
{#each hiddenList as id}
<button type="button" onclick={() => restore(id)}
class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors">
<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 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{SECTION_LABELS[id]}
</button>
{/each}
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>

View File

@@ -6,7 +6,8 @@
{ href: '/admin/scrape', label: () => m.admin_nav_scrape() },
{ 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/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }
];
const externalLinks = [
@@ -15,7 +16,8 @@
{ href: 'https://analytics.libnovel.cc', label: () => m.admin_nav_analytics() },
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() }
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
];
interface Props {

View File

@@ -57,6 +57,12 @@
return `${m}m ${s % 60}s`;
}
function engineLabel(voice: string): string {
if (voice.startsWith('cfai:')) return 'CF AI';
if (!voice.includes('_')) return 'Pocket TTS';
return 'Kokoro';
}
// ── Audio jobs stats + filter ────────────────────────────────────────────────
let jobsQ = $state('');
let filteredJobs = $derived(
@@ -160,6 +166,7 @@
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
@@ -173,6 +180,7 @@
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
@@ -181,7 +189,7 @@
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
@@ -202,6 +210,7 @@
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
@@ -236,6 +245,7 @@
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Filename</th>
<th class="px-4 py-3 text-left">Updated</th>
</tr>
@@ -249,6 +259,7 @@
</td>
<td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(parts.voice)}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
@@ -267,11 +278,12 @@
<a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
{parts.slug}
</a>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(parts.voice)}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
{#if entry.filename}
<p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if}

View File

@@ -1,5 +1,3 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import type { PageServerLoad } from './$types';
export interface Release {
@@ -12,13 +10,18 @@ export interface Release {
draft: boolean;
}
export const load: PageServerLoad = async () => {
const GITEA_RELEASES_URL =
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
export const load: PageServerLoad = async ({ fetch }) => {
try {
// releases.json is baked into the image at build time by CI.
// SvelteKit Node adapter copies static/ → build/client/, so the file
// lives at <cwd>/build/client/releases.json in production.
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
const releases: Release[] = JSON.parse(raw);
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
const releases: Release[] = await res.json();
return { releases: releases.filter((r) => !r.draft) };
} catch (e) {
return { releases: [], error: String(e) };

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
id: string;
label: string;
provider: string;
supports_ref: boolean;
recommended_for: string[]; // "cover" | "chapter"
description: 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[] };
}
};

View File

@@ -0,0 +1,676 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ImageModelInfo } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state('');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
// 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);
// ── Generation state ─────────────────────────────────────────────────────────
let generating = $state(false);
let genError = $state('');
let elapsedMs = $state(0);
let elapsedInterval: ReturnType<typeof setInterval> | null = null;
// ── Result state ─────────────────────────────────────────────────────────────
interface GenResult {
imageSrc: string;
model: string;
bytes: number;
contentType: string;
saved: boolean;
coverUrl: string;
elapsedMs: number;
slug: string;
imageType: ImageType;
chapter: number;
}
let result = $state<GenResult | null>(null);
let history = $state<GenResult[]>([]);
let saving = $state(false);
let saveError = $state('');
let saveSuccess = $state(false);
// ── 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 otherModels = $derived(
filteredModels.filter(
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
)
);
// ── Auto-select default model when type changes ──────────────────────────────
$effect(() => {
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (!selectedModel && preferred.length > 0) {
selectedModel = preferred[0].id;
}
});
// Reset model selection when type changes if current selection no longer fits
$effect(() => {
void imageType; // track
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;
}
}
});
// ── 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.`
);
function applyTemplate() {
prompt = promptTemplate;
}
// ── Reference image handling ─────────────────────────────────────────────────
let dragOver = $state(false);
function handleReferenceFile(file: File | null) {
referenceFile = file;
if (referencePreviewUrl) URL.revokeObjectURL(referencePreviewUrl);
referencePreviewUrl = file ? URL.createObjectURL(file) : '';
}
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
handleReferenceFile(input.files?.[0] ?? null);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
}
function clearReference() {
handleReferenceFile(null);
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
if (input) input.value = '';
}
// ── Selected model info ──────────────────────────────────────────────────────
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
let refWarning = $derived(
referenceFile && selectedModelInfo && !selectedModelInfo.supports_ref
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
: ''
);
// ── Generate ────────────────────────────────────────────────────────────────
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
async function generate() {
if (!canGenerate) return;
generating = true;
genError = '';
result = null;
elapsedMs = 0;
saveSuccess = false;
saveError = '';
const startTs = Date.now();
elapsedInterval = setInterval(() => {
elapsedMs = Date.now() - startTs;
}, 200);
try {
const payload = {
prompt: prompt.trim(),
model: selectedModel,
type: imageType,
slug: slug.trim(),
chapter: imageType === 'chapter' ? chapter : 0,
num_steps: numSteps,
guidance,
strength,
width,
height
};
let res: Response;
if (referenceFile && selectedModelInfo?.supports_ref) {
const fd = new FormData();
fd.append('json', JSON.stringify(payload));
fd.append('reference', referenceFile);
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
genError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
const totalMs = Date.now() - startTs;
const newResult: GenResult = {
imageSrc: `data:${body.content_type};base64,${body.image_b64}`,
model: body.model,
bytes: body.bytes,
contentType: body.content_type,
saved: body.saved ?? false,
coverUrl: body.cover_url ?? '',
elapsedMs: totalMs,
slug: slug.trim(),
imageType,
chapter
};
result = newResult;
history = [newResult, ...history].slice(0, 5);
} catch {
genError = 'Network error.';
} finally {
generating = false;
if (elapsedInterval) {
clearInterval(elapsedInterval);
elapsedInterval = null;
}
}
}
// ── Save as cover ────────────────────────────────────────────────────────────
async function saveAsCover() {
if (!result || saving) return;
saving = true;
saveError = '';
saveSuccess = false;
try {
const 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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
saveError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
if (body.saved) {
saveSuccess = true;
result = { ...result, saved: true, coverUrl: body.cover_url ?? result.coverUrl };
} else {
saveError = 'Backend did not save the cover.';
}
} catch {
saveError = 'Network error.';
} finally {
saving = false;
}
}
// ── Download ─────────────────────────────────────────────────────────────────
function download() {
if (!result) return;
const a = document.createElement('a');
a.href = result.imageSrc;
const ext = result.contentType === 'image/jpeg' ? 'jpg' : 'png';
a.download =
result.imageType === 'cover'
? `${result.slug}-cover.${ext}`
: `${result.slug}-ch${result.chapter}.${ext}`;
a.click();
}
// ── Formatting helpers ───────────────────────────────────────────────────────
function fmtElapsed(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtBytes(b: number) {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(2)} MB`;
}
</script>
<svelte:head>
<title>Image Gen — Admin</title>
</svelte:head>
<div class="space-y-6 max-w-6xl">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Image Generation</h1>
<p class="text-(--color-muted) text-sm mt-1">
Generate book covers and chapter images using Cloudflare Workers AI.
</p>
</div>
<!-- Layout: form + result side by side on large screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- ── Left: Form panel ──────────────────────────────────────────────────── -->
<div class="space-y-4">
<!-- Type toggle -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each (['cover', 'chapter'] as const) as t}
<button
onclick={() => (imageType = t)}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{imageType === t
? 'bg-(--color-surface-3) text-(--color-text)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{t === 'cover' ? 'Cover' : 'Chapter Image'}
</button>
{/each}
</div>
<!-- Slug + chapter -->
<div class="flex gap-3">
<div class="flex-1 min-w-0 space-y-1">
<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)"
/>
</div>
{#if imageType === 'chapter'}
<div class="w-24 space-y-1 shrink-0">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="chapter-input">
Chapter
</label>
<input
id="chapter-input"
type="number"
bind:value={chapter}
min="1"
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)"
/>
</div>
{/if}
</div>
<!-- Model selector -->
<div class="space-y-1">
<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)"
>
{#if coverModels.length > 0}
<optgroup label="Recommended for covers">
{#each coverModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if chapterModels.length > 0}
<optgroup label="Recommended for chapters">
{#each chapterModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if otherModels.length > 0}
<optgroup label="All models">
{#each otherModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
</select>
{#if selectedModelInfo}
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
{/if}
{#if refWarning}
<p class="text-xs text-amber-400">{refWarning}</p>
{/if}
</div>
<!-- Prompt -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<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>
<textarea
id="prompt-input"
bind:value={prompt}
rows="5"
placeholder="Describe the image to generate…"
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>
<!-- 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>
{#if referenceFile && referencePreviewUrl}
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
<img
src={referencePreviewUrl}
alt="Reference"
class="w-16 h-16 object-cover rounded-md shrink-0 border border-(--color-border)"
/>
<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>
</div>
<button
onclick={clearReference}
class="text-(--color-muted) hover:text-(--color-text) transition-colors shrink-0"
aria-label="Remove reference image"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<!-- Drop zone -->
<label
class="flex flex-col items-center justify-center gap-2 p-4 border-2 border-dashed rounded-lg cursor-pointer transition-colors
{dragOver
? 'border-(--color-brand) bg-(--color-brand)/5'
: 'border-(--color-border) hover:border-(--color-brand)/50 hover:bg-(--color-surface-2)'}"
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => { dragOver = false; }}
ondrop={onDrop}
>
<svg class="w-6 h-6 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
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>
<span class="text-xs text-(--color-muted)">Drop image or <span class="text-(--color-brand)">click to browse</span></span>
<input
id="ref-file-input"
type="file"
accept="image/png,image/jpeg,image/webp"
onchange={onFileInput}
class="sr-only"
/>
</label>
{/if}
</div>
<!-- Advanced collapsible -->
<div class="border border-(--color-border) rounded-lg overflow-hidden">
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Advanced options
<svg
class="w-4 h-4 transition-transform {showAdvanced ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showAdvanced}
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Steps</label>
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
</div>
<input type="range" min="1" max="20" step="1" bind:value={numSteps}
class="w-full accent-(--color-brand)" />
</div>
<!-- guidance -->
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Guidance</label>
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
</div>
<input type="range" min="1" max="20" step="0.5" bind:value={guidance}
class="w-full accent-(--color-brand)" />
</div>
<!-- strength (only when reference present) -->
{#if referenceFile}
<div class="space-y-1">
<div class="flex justify-between">
<label class="text-xs text-(--color-muted)">Strength</label>
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" bind:value={strength}
class="w-full accent-(--color-brand)" />
<p class="text-xs text-(--color-muted)">0 = copy reference · 1 = ignore reference</p>
</div>
{/if}
<!-- width × height -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="width-input">Width</label>
<input id="width-input" type="number" min="256" max="2048" step="64" bind:value={width}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="height-input">Height</label>
<input id="height-input" type="number" min="256" max="2048" step="64" bind:value={height}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
</div>
</div>
{/if}
</div>
<!-- Generate button -->
<button
onclick={generate}
disabled={!canGenerate}
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 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" />
</svg>
Generating… {fmtElapsed(elapsedMs)}
{:else}
Generate
{/if}
</button>
{#if genError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{genError}</p>
{/if}
</div>
<!-- ── Right: Result panel ────────────────────────────────────────────────── -->
<div class="space-y-4">
{#if result}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Image -->
<img
src={result.imageSrc}
alt="Generated image"
class="w-full object-contain max-h-[36rem] bg-zinc-950"
/>
<!-- Meta bar -->
<div class="px-4 py-3 border-t border-(--color-border) space-y-3">
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<p class="text-(--color-muted)">Model</p>
<p class="text-(--color-text) font-mono truncate" title={result.model}>
{models.find((m) => m.id === result!.model)?.label ?? result.model}
</p>
</div>
<div>
<p class="text-(--color-muted)">Size</p>
<p class="text-(--color-text)">{fmtBytes(result.bytes)}</p>
</div>
<div>
<p class="text-(--color-muted)">Time</p>
<p class="text-(--color-text)">{fmtElapsed(result.elapsedMs)}</p>
</div>
</div>
{#if result.saved}
<p class="text-xs text-green-400">
Cover saved &rarr;
<a href={result.coverUrl} target="_blank" rel="noopener noreferrer"
class="underline hover:text-green-300">{result.coverUrl}</a>
</p>
{/if}
{#if saveSuccess && !result.saved}
<p class="text-xs text-green-400">Cover saved successfully.</p>
{/if}
{#if saveError}
<p class="text-xs text-(--color-danger)">{saveError}</p>
{/if}
<!-- Actions -->
<div class="flex gap-2 flex-wrap">
<button
onclick={download}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) text-xs font-medium hover:bg-zinc-600 transition-colors"
>
Download
</button>
{#if result.imageType === 'cover'}
<button
onclick={saveAsCover}
disabled={saving || result.saved}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) text-xs font-semibold
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : result.saved ? 'Saved ✓' : 'Save as cover'}
</button>
{/if}
</div>
</div>
</div>
{:else if generating}
<!-- Placeholder while generating -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-80">
<div class="text-center space-y-3">
<svg class="w-8 h-8 animate-spin mx-auto text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating… {fmtElapsed(elapsedMs)}</p>
</div>
</div>
{:else}
<!-- Empty state -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-80">
<p class="text-sm text-(--color-muted)">Generated image will appear here</p>
</div>
{/if}
<!-- History thumbnails -->
{#if history.length > 0}
<div class="space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Session history</p>
<div class="flex gap-2 flex-wrap">
{#each history as h, i}
<button
onclick={() => result = h}
class="relative rounded-md overflow-hidden border transition-colors shrink-0
{result === h ? 'border-(--color-brand)' : 'border-(--color-border) hover:border-(--color-brand)/50'}"
>
<img
src={h.imageSrc}
alt="History {i + 1}"
class="w-16 h-16 object-cover"
/>
{#if h.saved}
<span class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border border-(--color-surface)"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
/**
* POST /api/admin/image-gen
*
* Admin-only proxy to the Go backend's image generation endpoint.
* Transparently forwards the request body (JSON or multipart/form-data)
* and returns the JSON response containing the base64-encoded image.
*/
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 ct = request.headers.get('content-type') ?? '';
let res: Response;
try {
if (ct.includes('multipart/form-data')) {
// Forward raw body bytes; let the backend parse multipart
const body = await request.arrayBuffer();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': ct },
body
});
} else {
const body = await request.text();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
}
} catch (e) {
log.error('admin/image-gen', '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 });
};

View File

@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSlugsWithAudio } from '$lib/server/pocketbase';
/**
* GET /api/audio/slugs
* Returns the list of book slugs that have at least one completed audio chapter.
* Cached for 5 minutes at the CDN/proxy level.
*/
export const GET: RequestHandler = async () => {
const slugs = await getSlugsWithAudio().catch(() => new Set<string>());
return json(
{ slugs: [...slugs] },
{ headers: { 'Cache-Control': 'public, max-age=300' } }
);
};

View File

@@ -43,27 +43,27 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
// theme is optional — if provided it must be a known value
// theme is optional — if provided (and non-empty) it must be a known value
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
if (body.theme !== undefined && body.theme !== '' && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
// locale is optional — if provided (and non-empty) it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
if (body.locale !== undefined && body.locale !== '' && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
// fontFamily is optional — if provided (and non-empty) it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
if (body.fontFamily !== undefined && body.fontFamily !== '' && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}

View File

@@ -133,6 +133,24 @@
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
// ── "More like this" ─────────────────────────────────────────────────────
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
let similarBooks = $state<SimilarBook[]>([]);
$effect(() => {
const firstGenre = genres[0];
if (!firstGenre) return;
const currentSlug = data.book?.slug ?? '';
fetch(`/api/catalogue-page?genre=${encodeURIComponent(firstGenre)}&sort=popular&page=1`)
.then((r) => r.json())
.then((d: { novels?: { slug: string; title: string; cover: string | null; author: string | null }[] }) => {
similarBooks = (d.novels ?? [])
.filter((b) => b.slug !== currentSlug)
.slice(0, 10);
})
.catch(() => { /* non-critical */ });
});
// ── Auto-poll when scrape task is in flight ───────────────────────────────
$effect(() => {
if (!data.scraping || !data.taskId) return;
@@ -234,7 +252,10 @@
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
<a
href="/catalogue?genre={encodeURIComponent(genre)}"
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
>{genre}</a>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
@@ -348,65 +369,82 @@
</div>
<!-- CTA buttons — mobile only -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) 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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<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"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<div class="flex sm:hidden flex-col gap-2 mt-3">
<!-- Row 1: primary read button(s) -->
<div class="flex gap-2">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'flex-1 bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
</div>
<!-- Row 2: bookmark + shelf + stars -->
<div class="flex items-center gap-2 flex-wrap">
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
>
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-9 h-9 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<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"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
{#if saved}
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer flex-shrink-0"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
@@ -414,20 +452,6 @@
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
@@ -575,4 +599,35 @@
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
<!-- ══════════════════════════════════════════════ More like this ══ -->
{#if similarBooks.length > 0}
<div class="mt-12">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">More like this</h2>
<div class="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-none">
{#each similarBooks as b}
<a
href="/books/{b.slug}"
class="flex-shrink-0 w-24 sm:w-28 group"
>
<div class="aspect-[2/3] rounded-lg overflow-hidden bg-(--color-surface-2) border border-(--color-border) group-hover:border-zinc-500 transition-colors mb-1.5">
{#if b.cover}
<img src={b.cover} alt={b.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<p class="text-xs font-medium text-(--color-text) line-clamp-2 leading-snug">{b.title}</p>
{#if b.author}
<p class="text-xs text-(--color-muted) truncate mt-0.5">{b.author}</p>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { onMount, untrack, 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';
@@ -14,7 +15,156 @@
let fetchError = $state('');
let audioProRequired = $state(false);
// Translation state
// ── Reader settings panel ────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let settingsPanelOpen = $state(false);
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
] as const;
const READER_FONTS = [
{ id: 'system', label: 'System' },
{ id: 'serif', label: 'Serif' },
{ id: 'mono', label: 'Mono' },
] as const;
const READER_SIZES = [
{ value: 0.9, label: 'S' },
{ value: 1.0, label: 'M' },
{ value: 1.15, label: 'L' },
{ value: 1.3, label: 'XL'},
] as const;
// Mirror context values into local reactive state so the panel shows current values
let panelTheme = $state(settingsCtx?.current ?? 'amber');
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
function applyTheme(id: string) {
panelTheme = id;
if (settingsCtx) settingsCtx.current = id;
}
function applyFont(id: string) {
panelFont = id;
if (settingsCtx) settingsCtx.fontFamily = id;
}
function applySize(v: number) {
panelSize = v;
if (settingsCtx) settingsCtx.fontSize = v;
}
// ── Layout / reading-view prefs (localStorage) ──────────────────────────────
type ReadMode = 'scroll' | 'paginated';
type LineSpacing = 'compact' | 'normal' | 'relaxed';
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
interface LayoutPrefs {
readMode: ReadMode;
lineSpacing: LineSpacing;
readWidth: ReadWidth;
paraStyle: ParaStyle;
focusMode: boolean;
}
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 };
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));
}
// 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' },
@@ -147,6 +297,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
@@ -193,8 +348,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>
@@ -250,8 +405,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">
@@ -311,13 +466,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
@@ -339,7 +547,6 @@
{/if}
</div>
<!-- Chapter comments -->
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
@@ -348,3 +555,187 @@
currentUserId={page.data.user?.id ?? ''}
/>
</div>
{/if}
<!-- ── 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 -->
<button
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
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"
>
<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"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<!-- Settings drawer -->
{#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>
<!-- 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>
<!-- 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>
</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>
<!-- Divider -->
<div class="border-t border-(--color-border)"></div>
<!-- Read mode -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Read mode</p>
<div class="flex gap-1.5">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{label}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Line spacing</p>
<div class="flex gap-1.5">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Reading width -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Width</p>
<div class="flex gap-1.5">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{label}</button>
{/each}
</div>
</div>
<!-- Paragraph style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Paragraphs</p>
<div class="flex gap-1.5">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{layout.focusMode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.focusMode}
>
<span>Focus mode</span>
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
</div>
{/if}
{/if}

View File

@@ -212,6 +212,21 @@
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
});
// ── Audio-available set ───────────────────────────────────────────────────
let audioSlugs = $state<Set<string>>(new Set());
let filterAudioOnly = $state(false);
$effect(() => {
fetch('/api/audio/slugs')
.then((r) => r.json())
.then((d: { slugs: string[] }) => { audioSlugs = new Set(d.slugs ?? []); })
.catch(() => { /* non-critical */ });
});
const displayedNovels = $derived(
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
);
</script>
<svelte:head>
@@ -331,6 +346,23 @@
</button>
</div>
<!-- Audio-only filter toggle -->
{#if audioSlugs.size > 0}
<button
onclick={() => (filterAudioOnly = !filterAudioOnly)}
title="Show only books with audio"
class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
{filterAudioOnly
? 'bg-(--color-brand)/15 border-(--color-brand)/50 text-(--color-brand)'
: 'bg-(--color-surface-2) border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500'}"
>
<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.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
<span class="hidden sm:inline text-xs font-medium">Audio</span>
</button>
{/if}
<!-- Admin: refresh catalogue -->
{#if data.isAdmin}
<form
@@ -458,9 +490,9 @@
{/if}
<!-- Content -->
{#if novels.length === 0}
{#if displayedNovels.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}</p>
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : filterAudioOnly ? 'No books with audio found.' : m.catalogue_no_results()}</p>
<p class="text-sm mt-2">
{#if isSearchView}
{m.catalogue_no_results_try()}
@@ -470,6 +502,8 @@
{:else}
{m.catalogue_rank_run_scrape_user()}
{/if}
{:else if filterAudioOnly}
<button onclick={() => (filterAudioOnly = false)} class="text-(--color-brand) hover:underline">Clear audio filter</button>
{:else}
{m.catalogue_no_results_filters()}
{/if}
@@ -479,7 +513,7 @@
{:else if view === 'grid'}
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each novels as novel}
{#each displayedNovels as novel}
{@const isLoading = loadingSlug === novel.slug}
<a
href="/books/{novel.slug}"
@@ -514,6 +548,14 @@
{novel.rating}
</span>
{/if}
<!-- Audio badge -->
{#if audioSlugs.has(novel.slug)}
<span class="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/80 text-(--color-surface) font-medium" title="Audio available">
<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 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
</span>
{/if}
<!-- Loading overlay -->
{#if isLoading}
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
@@ -564,7 +606,7 @@
{:else}
<!-- ── List view ─────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
{#each novels as novel}
{#each displayedNovels as novel}
{@const isLoading = loadingSlug === novel.slug}
<div
class="flex items-center gap-4 bg-(--color-surface-2) border rounded-lg px-4 py-3 transition-colors
@@ -623,6 +665,14 @@
{#if novel.rating}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">{novel.rating}</span>
{/if}
{#if audioSlugs.has(novel.slug)}
<span class="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 font-medium">
<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 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
Audio
</span>
{/if}
{#if novel.genres?.length}
{#each novel.genres.slice(0, 3) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>

View File

@@ -160,9 +160,11 @@
const activeTransition = $derived(
isDragging
? 'none'
: (transitioning || entryTransition)
: transitioning
? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
: 'none'
: entryTransition
? 'transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
: 'none'
);
function onPointerDown(e: PointerEvent) {

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getSubscriptionFeed } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login?next=/feed');
}
const feed = await getSubscriptionFeed(locals.user.id, 50).catch((e) => {
log.error('feed', 'failed to load subscription feed', { err: String(e) });
return [] as Array<{ book: Book; readerUsername: string }>;
});
return { feed };
};

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
const feed = $derived(data.feed);
</script>
<svelte:head>
<title>{m.feed_page_title()}</title>
</svelte:head>
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-(--color-text)">{m.feed_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">{m.feed_subheading()}</p>
</div>
{#if feed.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-24 gap-4 text-center">
<div class="w-16 h-16 rounded-full bg-(--color-surface-2) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p class="text-lg font-semibold text-(--color-text)">{m.feed_empty_heading()}</p>
<p class="text-sm text-(--color-muted) mt-1 max-w-xs">{m.feed_empty_body()}</p>
</div>
<div class="flex gap-3 mt-2">
<a
href="/catalogue"
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
{m.feed_browse_cta()}
</a>
<a
href="/catalogue"
class="px-4 py-2 rounded bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm hover:border-zinc-500 transition-colors"
>
{m.feed_find_users_cta()}
</a>
</div>
</div>
{:else}
<!-- Feed grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each feed as item}
{@const book = item.book}
{@const genres = Array.isArray(book.genres) ? book.genres : (book.genres ? [book.genres] : [])}
<!-- Use div + onclick to avoid nested <a> with the username link inside -->
<div
role="link"
tabindex="0"
onclick={() => goto(`/books/${book.slug}`)}
onkeydown={(e) => { if (e.key === 'Enter') goto(`/books/${book.slug}`); }}
class="group flex gap-3 bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 hover:border-zinc-500 transition-colors cursor-pointer"
>
<!-- Cover -->
<div class="w-16 shrink-0 aspect-[2/3] rounded overflow-hidden bg-(--color-surface)">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex flex-col gap-1 min-w-0 flex-1">
<h2 class="text-sm font-semibold text-(--color-text) line-clamp-2 leading-snug group-hover:text-(--color-brand) transition-colors">
{book.title}
</h2>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<div class="flex items-center gap-1 mt-auto pt-1">
<span class="w-5 h-5 rounded-full bg-(--color-brand)/20 text-(--color-brand) text-xs font-bold flex items-center justify-center shrink-0">
{item.readerUsername[0].toUpperCase()}
</span>
<a
href="/users/{item.readerUsername}"
onclick={(e) => e.stopPropagation()}
class="text-xs text-(--color-brand) hover:underline truncate font-medium"
>
{item.readerUsername}
</a>
<span class="text-xs text-(--color-muted) shrink-0">{m.feed_reader_label()}</span>
</div>
<!-- Meta chips -->
<div class="flex items-center gap-1.5 flex-wrap mt-0.5">
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">{book.status}</span>
{/if}
{#if book.total_chapters}
<span class="text-xs text-(--color-muted)">{m.feed_chapters_label({ n: String(book.total_chapters) })}</span>
{/if}
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -1,6 +1,12 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase';
import {
listUserSessions,
getUserByUsername,
getUserStats,
allProgress,
getBooksBySlugs
} from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -34,6 +40,24 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
// Reading history — last 50 progress entries with book metadata
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
try {
const progress = await allProgress(locals.sessionId, locals.user.id);
const recent = progress.slice(0, 50);
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
const bookMap = new Map(books.map((b) => [b.slug, b]));
history = recent.map((p) => ({
slug: p.slug,
chapter: p.chapter,
updated: p.updated,
title: bookMap.get(p.slug)?.title ?? p.slug,
cover: bookMap.get(p.slug)?.cover ?? null
}));
} catch (e) {
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
}
return {
user: locals.user,
avatarUrl,
@@ -51,6 +75,7 @@ export const load: PageServerLoad = async ({ locals }) => {
created_at: s.created_at,
last_seen: s.last_seen,
is_current: s.session_id === locals.user!.authSessionId
}))
})),
history
};
};

View File

@@ -93,6 +93,30 @@
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
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()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
}
if (v.engine === 'pocket-tts') {
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return name + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
}
// Kokoro: "af_bella" → "Bella (US F)"
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();
const gender = v.gender ? v.gender.toUpperCase() : '?';
return `${name} (${lang} ${gender})`;
}
$effect(() => {
fetch('/api/voices')
@@ -185,7 +209,7 @@
});
// ── Tab ──────────────────────────────────────────────────────────────────────
let activeTab = $state<'profile' | 'stats'>('profile');
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = {
@@ -322,7 +346,7 @@
<!-- Tabs -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
{#each (['profile', 'stats'] as const) as tab}
{#each (['profile', 'stats', 'history'] as const) as tab}
<button
type="button"
onclick={() => (activeTab = tab)}
@@ -331,7 +355,7 @@
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{tab === 'profile' ? 'Profile' : 'Stats'}
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
</button>
{/each}
</div>
@@ -492,12 +516,17 @@
class="w-full bg-(--color-surface-3) 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)">
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
{#each kokoroVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
{#each pocketVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
{#if cfaiVoices.length > 0}
<optgroup label="Cloudflare AI">
{#each cfaiVoices as v}<option value={v.id}>{voiceLabel(v)}</option>{/each}
</optgroup>
{/if}
</select>
@@ -658,4 +687,59 @@
</div>
{/if}
{#if activeTab === 'history'}
<div class="space-y-2">
{#if data.history.length === 0}
<div class="py-12 text-center text-(--color-muted)">
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">No reading history yet.</p>
</div>
{:else}
{#each data.history as item}
<a
href="/books/{item.slug}/chapters/{item.chapter}"
class="flex items-center gap-3 px-4 py-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) hover:border-zinc-500 transition-colors group"
>
<!-- Cover thumbnail -->
<div class="w-8 h-11 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<!-- Title + chapter -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Chapter {item.chapter}</p>
</div>
<!-- Relative time -->
<p class="text-xs text-(--color-muted) shrink-0 tabular-nums">
{#if item.updated}
{(() => {
const ms = Date.now() - new Date(item.updated).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 60) return mins <= 1 ? 'just now' : `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(item.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
})()}
{/if}
</p>
</a>
{/each}
{/if}
</div>
{/if}
</div>