Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa008ac99 | ||
|
|
9806f0d894 | ||
|
|
e862135775 | ||
|
|
8588475d03 | ||
|
|
28fee7aee3 | ||
|
|
a888d9a0f5 | ||
|
|
ac7b686fba | ||
|
|
24d73cb730 | ||
|
|
19aeb90403 | ||
|
|
06d4a7bfd4 | ||
|
|
73a92ccf8f |
@@ -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,15 @@ 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")
|
||||
}
|
||||
|
||||
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
|
||||
var searchIndex meili.Client
|
||||
if cfg.Meilisearch.URL != "" {
|
||||
@@ -163,6 +173,7 @@ func run() error {
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
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
|
||||
|
||||
@@ -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,9 @@ 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
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -338,6 +342,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
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis ─────────────────────────────────────────────────────
|
||||
REDIS_ADDR: "redis:6379"
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
KOKORO_URL: "http://kokoro-fastapi:8880"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -190,14 +190,15 @@ create "app_users" '{
|
||||
{"name":"oauth_id", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_sessions" '{
|
||||
create "user_sessions" '{
|
||||
"name":"user_sessions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_agent","type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"created_at","type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id", "type":"text","required":true},
|
||||
{"name":"user_agent", "type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"device_fingerprint", "type":"text"},
|
||||
{"name":"created_at", "type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_library" '{
|
||||
@@ -291,5 +292,6 @@ add_field "app_users" "oauth_id" "text"
|
||||
add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
add_field "user_sessions" "device_fingerprint" "text"
|
||||
|
||||
log "done"
|
||||
|
||||
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",
|
||||
@@ -367,6 +368,7 @@
|
||||
"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 +421,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",
|
||||
@@ -418,5 +419,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",
|
||||
@@ -418,5 +419,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",
|
||||
@@ -418,5 +419,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": "Профиль",
|
||||
@@ -418,5 +419,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"
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ class AudioStore {
|
||||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||||
sleepUntil = $state(0);
|
||||
|
||||
/** When true, pause after the current chapter ends instead of navigating. */
|
||||
sleepAfterChapter = $state(false);
|
||||
|
||||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, navigates to the next chapter when the current one ends
|
||||
|
||||
@@ -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();
|
||||
@@ -699,16 +709,22 @@
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
if (!audioStore.sleepUntil) {
|
||||
// Start at first option (15 min)
|
||||
// Currently: no timer active at all
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
return;
|
||||
}
|
||||
// Currently: end-of-chapter mode — move to 15m
|
||||
if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
// Currently: timed mode — cycle to next or turn off
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex(m => m >= currentMin);
|
||||
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
// Was at max or past last — turn off
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
@@ -838,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)">
|
||||
@@ -933,14 +959,20 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
|
||||
title={audioStore.sleepAfterChapter
|
||||
? 'Stop after this chapter'
|
||||
: audioStore.sleepUntil
|
||||
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
|
||||
: 'Sleep timer off'}
|
||||
>
|
||||
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepUntil}
|
||||
{#if audioStore.sleepAfterChapter}
|
||||
End Ch.
|
||||
{:else if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
|
||||
const BOOKS_CACHE_KEY = 'books:all';
|
||||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
const RATINGS_CACHE_KEY = 'book_ratings:all';
|
||||
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
const HOME_STATS_CACHE_KEY = 'home:stats';
|
||||
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
|
||||
|
||||
async function getAllRatings(): Promise<BookRating[]> {
|
||||
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
||||
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
|
||||
return ratings;
|
||||
}
|
||||
|
||||
export async function listBooks(): Promise<Book[]> {
|
||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||
if (cached) {
|
||||
@@ -282,7 +296,12 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
|
||||
|
||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||
export async function invalidateBooksCache(): Promise<void> {
|
||||
await cache.invalidate(BOOKS_CACHE_KEY);
|
||||
await Promise.all([
|
||||
cache.invalidate(BOOKS_CACHE_KEY),
|
||||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||
cache.invalidatePattern('books:recent:*'),
|
||||
cache.invalidatePattern('books:recently-updated:*')
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
@@ -290,7 +309,53 @@ export async function getBook(slug: string): Promise<Book | null> {
|
||||
}
|
||||
|
||||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
const key = `books:recent:${limit}`;
|
||||
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);
|
||||
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 {
|
||||
@@ -299,11 +364,19 @@ export interface HomeStats {
|
||||
}
|
||||
|
||||
export async function getHomeStats(): Promise<HomeStats> {
|
||||
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const [totalBooks, totalChapters] = await Promise.all([
|
||||
countCollection('books'),
|
||||
countCollection('chapters_idx')
|
||||
]);
|
||||
return { totalBooks, totalChapters };
|
||||
const stats = { totalBooks, totalChapters };
|
||||
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function invalidateHomeStatsCache(): Promise<void> {
|
||||
await cache.invalidate(HOME_STATS_CACHE_KEY);
|
||||
}
|
||||
|
||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||
@@ -542,7 +615,7 @@ export async function unsaveBook(
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
@@ -966,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 {
|
||||
@@ -1003,12 +1085,79 @@ export interface UserSession {
|
||||
session_id: string; // the auth session ID embedded in the token
|
||||
user_agent: string;
|
||||
ip: string;
|
||||
device_fingerprint: string;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session record on login. Returns the record ID.
|
||||
* Generate a short device fingerprint from user-agent + IP.
|
||||
* SHA-256 of the concatenation, first 16 hex chars.
|
||||
*/
|
||||
function deviceFingerprint(userAgent: string, ip: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${userAgent}::${ip}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a session record on login.
|
||||
* - If a session already exists for this user + device fingerprint, touch it and
|
||||
* return the existing authSessionId (so the caller can reuse the same token).
|
||||
* - Otherwise create a new record.
|
||||
* Returns `{ authSessionId, recordId }`.
|
||||
*/
|
||||
export async function upsertUserSession(
|
||||
userId: string,
|
||||
authSessionId: string,
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<{ authSessionId: string; recordId: string }> {
|
||||
const fp = deviceFingerprint(userAgent, ip);
|
||||
|
||||
// Look for an existing session from the same device
|
||||
const existing = await listOne<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && device_fingerprint="${fp}"`
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Touch last_seen and return the existing authSessionId
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||
}).catch(() => {});
|
||||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||||
}
|
||||
|
||||
// Create a new session record
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
device_fingerprint: fp,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale/excess sessions in the background
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return { authSessionId, recordId: rec.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use upsertUserSession instead.
|
||||
* Kept temporarily so callers can be migrated incrementally.
|
||||
*/
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
@@ -1016,24 +1165,8 @@ export async function createUserSession(
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return rec.id;
|
||||
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1070,20 +1203,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days,
|
||||
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
|
||||
* Called on login so the list self-cleans without a separate cron job.
|
||||
*/
|
||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const stale = await listAll<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
||||
);
|
||||
if (stale.length === 0) return;
|
||||
async function pruneStaleUserSessions(
|
||||
userId: string,
|
||||
days = 30,
|
||||
maxSessions = 10
|
||||
): Promise<void> {
|
||||
const token = await getToken();
|
||||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const toDelete = new Set<string>();
|
||||
|
||||
// Mark stale sessions
|
||||
for (const s of all) {
|
||||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||||
}
|
||||
|
||||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||||
const remaining = all.filter((s) => !toDelete.has(s.id));
|
||||
if (remaining.length > maxSessions) {
|
||||
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
|
||||
}
|
||||
|
||||
if (toDelete.size === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
[...toDelete].map((id) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
@@ -1781,6 +1931,7 @@ export async function setBookRating(
|
||||
} else {
|
||||
await pbPost('/api/collections/book_ratings/records', payload);
|
||||
}
|
||||
await cache.invalidate(RATINGS_CACHE_KEY);
|
||||
}
|
||||
|
||||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||||
@@ -1847,11 +1998,167 @@ export async function getBooksForDiscovery(
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = candidates.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.rating;
|
||||
cur.count += 1;
|
||||
ratingMap.set(r.slug, cur);
|
||||
}
|
||||
const avgRating = (slug: string) => {
|
||||
const e = ratingMap.get(slug);
|
||||
return e && e.count > 0 ? e.sum / e.count : 0;
|
||||
};
|
||||
|
||||
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
|
||||
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
|
||||
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
|
||||
|
||||
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
|
||||
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
|
||||
let start = 0;
|
||||
while (start < candidates.length) {
|
||||
let end = start + 1;
|
||||
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
|
||||
for (let i = end - 1; i > start; i--) {
|
||||
const j = start + Math.floor(Math.random() * (i - start + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
|
||||
return candidates.slice(0, 50);
|
||||
}
|
||||
|
||||
// ─── Discovery history ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface VotedBook {
|
||||
slug: string;
|
||||
action: DiscoveryVote['action'];
|
||||
votedAt: string;
|
||||
book?: Book;
|
||||
}
|
||||
|
||||
export async function getVotedBooks(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<VotedBook[]> {
|
||||
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId),
|
||||
'-created'
|
||||
).catch(() => []);
|
||||
|
||||
if (!votes.length) return [];
|
||||
|
||||
const slugs = [...new Set(votes.map((v) => v.slug))];
|
||||
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
|
||||
return votes.map((v) => ({
|
||||
slug: v.slug,
|
||||
action: v.action,
|
||||
votedAt: v.created,
|
||||
book: bookMap.get(v.slug)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function undoDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
|
||||
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserStats {
|
||||
totalChaptersRead: number;
|
||||
booksReading: number;
|
||||
booksCompleted: number;
|
||||
booksPlanToRead: number;
|
||||
booksDropped: number;
|
||||
topGenres: string[]; // top 3 by frequency
|
||||
avgRatingGiven: number; // 0 if no ratings
|
||||
streak: number; // consecutive days with progress
|
||||
}
|
||||
|
||||
export async function getUserStats(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<UserStats> {
|
||||
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||
|
||||
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
|
||||
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
|
||||
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
|
||||
listAll<BookRating>('book_ratings', filter).catch(() => []),
|
||||
listBooks().catch(() => [] as Book[])
|
||||
]);
|
||||
|
||||
// shelf counts
|
||||
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
|
||||
for (const r of libraryRows) {
|
||||
const s = r.shelf || 'reading';
|
||||
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
|
||||
}
|
||||
|
||||
// top genres from books in progress/library
|
||||
const libSlugs = new Set(libraryRows.map((r) => r.slug));
|
||||
const progSlugs = new Set(progressRows.map((r) => r.slug));
|
||||
const allSlugs = new Set([...libSlugs, ...progSlugs]);
|
||||
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
|
||||
const genreFreq = new Map<string, number>();
|
||||
for (const slug of allSlugs) {
|
||||
const book = bookMap.get(slug);
|
||||
if (!book) continue;
|
||||
for (const g of parseGenresLocal(book.genres)) {
|
||||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const topGenres = [...genreFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
|
||||
// avg rating given
|
||||
const avgRatingGiven =
|
||||
ratingRows.length > 0
|
||||
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
|
||||
: 0;
|
||||
|
||||
// reading streak: count consecutive calendar days (UTC) with a progress update
|
||||
const days = new Set(
|
||||
progressRows
|
||||
.filter((r) => r.updated)
|
||||
.map((r) => r.updated.slice(0, 10))
|
||||
);
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||||
else if (i > 0) break; // gap — stop
|
||||
}
|
||||
|
||||
return {
|
||||
totalChaptersRead: progressRows.length,
|
||||
booksReading: shelfCounts.reading,
|
||||
booksCompleted: shelfCounts.completed,
|
||||
booksPlanToRead: shelfCounts.plan_to_read,
|
||||
booksDropped: shelfCounts.dropped,
|
||||
topGenres,
|
||||
avgRatingGiven,
|
||||
streak
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -289,6 +294,12 @@
|
||||
onended={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
// If sleep-after-chapter is set, just pause instead of navigating
|
||||
if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
// Don't navigate — just let it end. Audio is already stopped (ended).
|
||||
return;
|
||||
}
|
||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||
// Capture values synchronously before any async work — the AudioPlayer
|
||||
// component will unmount during navigation, but we've already read what
|
||||
@@ -334,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">
|
||||
@@ -519,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>
|
||||
|
||||
@@ -15,7 +15,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) };
|
||||
|
||||
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' } }
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { loginUser, mergeSessionProgress, upsertUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
@@ -48,16 +48,20 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
const candidateSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
let authSessionId = candidateSessionId;
|
||||
try {
|
||||
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
|
||||
} catch (e) {
|
||||
log.warn('api/auth/login', 'upsertUserSession failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
|
||||
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook } from '$lib/server/pocketbase';
|
||||
|
||||
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
|
||||
type Action = (typeof VALID_ACTIONS)[number];
|
||||
@@ -22,9 +22,16 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
// DELETE /api/discover/vote → clear all (deck reset)
|
||||
// DELETE /api/discover/vote?slug=... → undo single vote
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
try {
|
||||
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
||||
if (slug) {
|
||||
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
|
||||
} else {
|
||||
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
||||
}
|
||||
} catch {
|
||||
error(500, 'Failed to clear votes');
|
||||
}
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
linkOAuthToUser
|
||||
} from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { upsertUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
type Provider = 'google' | 'github';
|
||||
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
|
||||
|
||||
// ─── Handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, locals, request }) => {
|
||||
const provider = params.provider as Provider;
|
||||
if (provider !== 'google' && provider !== 'github') {
|
||||
error(404, 'Unknown OAuth provider');
|
||||
@@ -226,21 +226,19 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
// ── Create session + auth cookie ──────────────────────────────────────────
|
||||
// ── Create / reuse session + auth cookie ─────────────────────────────────
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
const candidateSessionId = randomBytes(16).toString('hex');
|
||||
let authSessionId: string;
|
||||
|
||||
// Reuse existing session if the user is already logged in as the same user
|
||||
if (locals.user?.id === user.id && locals.user?.authSessionId) {
|
||||
authSessionId = locals.user.authSessionId;
|
||||
// Just touch the existing session to update last_seen
|
||||
touchUserSession(authSessionId).catch(() => {});
|
||||
} else {
|
||||
authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = ''; // not available in RequestHandler — omit
|
||||
const ip = '';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
try {
|
||||
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
|
||||
} catch (err) {
|
||||
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
|
||||
authSessionId = candidateSessionId;
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
@@ -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,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { onMount, untrack, getContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
@@ -14,6 +14,48 @@
|
||||
let fetchError = $state('');
|
||||
let audioProRequired = $state(false);
|
||||
|
||||
// ── Reader settings panel ────────────────────────────────────────────────
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let settingsPanelOpen = $state(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Translation state
|
||||
const SUPPORTED_LANGS = [
|
||||
{ code: 'ru', label: 'RU' },
|
||||
@@ -348,3 +390,97 @@
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
<!-- Backdrop -->
|
||||
{#if settingsPanelOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => (settingsPanelOpen = false)}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Gear button -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksForDiscovery } from '$lib/server/pocketbase';
|
||||
import { getBooksForDiscovery, getVotedBooks } from '$lib/server/pocketbase';
|
||||
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
@@ -9,6 +9,9 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []);
|
||||
return { books };
|
||||
const [books, votedBooks] = await Promise.all([
|
||||
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
|
||||
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
|
||||
]);
|
||||
return { books, votedBooks };
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
import type { Book, VotedBook } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -83,6 +83,20 @@
|
||||
let transitioning = $state(false);
|
||||
let showPreview = $state(false);
|
||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
||||
let activeTab = $state<'discover' | 'history'>('discover');
|
||||
|
||||
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
|
||||
|
||||
// Keep in sync if server data refreshes
|
||||
$effect(() => {
|
||||
votedBooks = data.votedBooks ?? [];
|
||||
});
|
||||
|
||||
async function undoVote(slug: string) {
|
||||
// Optimistic update
|
||||
votedBooks = votedBooks.filter((v) => v.slug !== slug);
|
||||
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
let startX = 0, startY = 0, hasMoved = false;
|
||||
|
||||
@@ -116,7 +130,45 @@
|
||||
|
||||
let cardEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
|
||||
let cardEntering = $state(false);
|
||||
let entryTransition = $state(false);
|
||||
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function startEntryAnimation() {
|
||||
if (entryCleanup) clearTimeout(entryCleanup);
|
||||
cardEntering = true;
|
||||
entryTransition = true;
|
||||
requestAnimationFrame(() => {
|
||||
cardEntering = false;
|
||||
entryCleanup = setTimeout(() => { entryTransition = false; }, 400);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEntryAnimation() {
|
||||
if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; }
|
||||
cardEntering = false;
|
||||
entryTransition = false;
|
||||
}
|
||||
|
||||
const activeTransform = $derived(
|
||||
cardEntering
|
||||
? 'scale(0.95) translateY(13px)'
|
||||
: `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)`
|
||||
);
|
||||
|
||||
const activeTransition = $derived(
|
||||
isDragging
|
||||
? 'none'
|
||||
: transitioning
|
||||
? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
|
||||
: entryTransition
|
||||
? 'transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
: 'none'
|
||||
);
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
cancelEntryAnimation();
|
||||
if (animating || !currentBook) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
@@ -202,13 +254,15 @@
|
||||
|
||||
if (action === 'read_now') {
|
||||
goto(`/books/${book.slug}`);
|
||||
} else {
|
||||
startEntryAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDeck() {
|
||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||
votedBooks = [];
|
||||
idx = 0;
|
||||
// Reload page to get fresh server data
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
@@ -389,6 +443,27 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'discover')}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'history')}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
History {#if votedBooks.length}({votedBooks.length}){/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'discover'}
|
||||
{#if deckEmpty}
|
||||
<!-- Empty state -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
@@ -458,8 +533,8 @@
|
||||
bind:this={cardEl}
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="
|
||||
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
|
||||
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
|
||||
transform: {activeTransform};
|
||||
transition: {activeTransition};
|
||||
touch-action: none;
|
||||
"
|
||||
onpointerdown={onPointerDown}
|
||||
@@ -606,4 +681,58 @@
|
||||
Swipe or tap buttons · Tap card for details
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'history'}
|
||||
<div class="w-full max-w-sm space-y-2">
|
||||
{#if !votedBooks.length}
|
||||
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
|
||||
{:else}
|
||||
{#each votedBooks as v (v.slug)}
|
||||
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
|
||||
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
|
||||
<!-- Cover thumbnail -->
|
||||
{#if v.book?.cover}
|
||||
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
|
||||
{v.book?.title ?? v.slug}
|
||||
</a>
|
||||
{#if v.book?.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
|
||||
{/if}
|
||||
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
|
||||
</div>
|
||||
|
||||
<!-- Undo button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => undoVote(v.slug)}
|
||||
title="Undo"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
|
||||
aria-label="Undo vote for {v.book?.title ?? v.slug}"
|
||||
>
|
||||
<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
|
||||
>
|
||||
Clear all history
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
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 } 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';
|
||||
|
||||
@@ -12,6 +18,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||
let email: string | null = null;
|
||||
let polarCustomerId: string | null = null;
|
||||
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
|
||||
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
@@ -25,9 +32,30 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
[sessions, stats] = await Promise.all([
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
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 {
|
||||
@@ -35,6 +63,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
avatarUrl,
|
||||
email,
|
||||
polarCustomerId,
|
||||
stats: stats ?? {
|
||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||
avgRatingGiven: 0, streak: 0
|
||||
},
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
@@ -42,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')
|
||||
@@ -184,6 +208,9 @@
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||
let activeTab = $state<'profile' | 'stats' | 'history'>('profile');
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
type Session = {
|
||||
id: string;
|
||||
@@ -317,6 +344,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
|
||||
{#each (['profile', 'stats', 'history'] as const) as tab}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab)}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === tab
|
||||
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{tab === 'profile' ? 'Profile' : tab === 'stats' ? 'Stats' : 'History'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'profile'}
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||
{#if !data.isPro}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
@@ -472,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>
|
||||
@@ -565,5 +614,132 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Reading overview -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
||||
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Streak + rating -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted)">day streak</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0">⭐</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted)">avg rating given</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top genres -->
|
||||
{#if data.stats.topGenres.length > 0}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
|
||||
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
{#if i === 0}<span class="text-xs">🏆</span>{/if}
|
||||
{genre}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dropped books (only if any) -->
|
||||
{#if data.stats.booksDropped > 0}
|
||||
<p class="text-xs text-(--color-muted) text-center">
|
||||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||||
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
</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