Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ece7e94e | ||
|
|
d1b7d3e36c | ||
|
|
aaa008ac99 | ||
|
|
9806f0d894 | ||
|
|
e862135775 | ||
|
|
8588475d03 | ||
|
|
28fee7aee3 | ||
|
|
a888d9a0f5 | ||
|
|
ac7b686fba |
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
234
backend/internal/backend/handlers_image.go
Normal file
234
backend/internal/backend/handlers_image.go
Normal 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.0–1.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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
214
backend/internal/cfai/client.go
Normal file
214
backend/internal/cfai/client.go
Normal 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
|
||||
}
|
||||
312
backend/internal/cfai/image.go
Normal file
312
backend/internal/cfai/image.go
Normal 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: 1–20.
|
||||
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
|
||||
}
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
3
ui/.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_gitea.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_gitea.js
Normal 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)
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_browse_cta.js
Normal file
44
ui/src/lib/paraglide/messages/feed_browse_cta.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_chapters_label.js
Normal file
44
ui/src/lib/paraglide/messages/feed_chapters_label.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_empty_body.js
Normal file
44
ui/src/lib/paraglide/messages/feed_empty_body.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_empty_heading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_empty_heading.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_find_users_cta.js
Normal file
44
ui/src/lib/paraglide/messages/feed_find_users_cta.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_heading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_heading.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_not_logged_in.js
Normal file
44
ui/src/lib/paraglide/messages/feed_not_logged_in.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_page_title.js
Normal file
44
ui/src/lib/paraglide/messages/feed_page_title.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_reader_label.js
Normal file
44
ui/src/lib/paraglide/messages/feed_reader_label.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_subheading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_subheading.js
Normal 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)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/nav_feed.js
Normal file
44
ui/src/lib/paraglide/messages/nav_feed.js
Normal 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)
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) };
|
||||
|
||||
28
ui/src/routes/admin/image-gen/+page.server.ts
Normal file
28
ui/src/routes/admin/image-gen/+page.server.ts
Normal 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[] };
|
||||
}
|
||||
};
|
||||
676
ui/src/routes/admin/image-gen/+page.svelte
Normal file
676
ui/src/routes/admin/image-gen/+page.svelte
Normal 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 →
|
||||
<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>
|
||||
46
ui/src/routes/api/admin/image-gen/+server.ts
Normal file
46
ui/src/routes/api/admin/image-gen/+server.ts
Normal 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 });
|
||||
};
|
||||
16
ui/src/routes/api/audio/slugs/+server.ts
Normal file
16
ui/src/routes/api/audio/slugs/+server.ts
Normal 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' } }
|
||||
);
|
||||
};
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
ui/src/routes/feed/+page.server.ts
Normal file
18
ui/src/routes/feed/+page.server.ts
Normal 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 };
|
||||
};
|
||||
122
ui/src/routes/feed/+page.svelte
Normal file
122
ui/src/routes/feed/+page.svelte
Normal 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}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user