Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2a4edba43 | ||
|
|
4e7f8c6266 | ||
|
|
b0a4cb8b3d | ||
|
|
f136ce6a60 | ||
|
|
3bd1112a63 | ||
|
|
278e292956 | ||
|
|
76de5eb491 | ||
|
|
c6597c8d19 | ||
|
|
e8d7108753 | ||
|
|
90dbecfa17 | ||
|
|
2deb306419 | ||
|
|
fd283bf6c6 |
@@ -15,6 +15,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -195,6 +196,10 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -222,6 +223,10 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ package backend
|
||||
// handleBrowse, handleSearch
|
||||
// handleGetRanking, handleGetCover
|
||||
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
|
||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
|
||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
|
||||
// handleVoices
|
||||
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
||||
// handlePresignAvatarUpload, handlePresignAvatar
|
||||
@@ -703,6 +703,139 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, presignURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleAudioStream handles GET /api/audio-stream/{slug}/{n}.
|
||||
//
|
||||
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
|
||||
// (same as handleAudioProxy) — the client plays from storage immediately.
|
||||
//
|
||||
// Slow path (first request): streams MP3 audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, any
|
||||
// pending audio_jobs task for this key is marked done. Subsequent requests hit
|
||||
// the fast path and skip TTS generation entirely.
|
||||
//
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid chapter")
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
|
||||
// ── Fast path: already in MinIO ───────────────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: PresignAudio failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, presignURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Slow path: generate + stream + save ───────────────────────────────────
|
||||
|
||||
// Read the chapter text.
|
||||
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: ReadChapter failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "chapter not found")
|
||||
return
|
||||
}
|
||||
text := stripMarkdown(raw)
|
||||
if text == "" {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Open the TTS stream.
|
||||
var audioStream io.ReadCloser
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
defer audioStream.Close()
|
||||
|
||||
// Tee: every byte read from audioStream is written to both the HTTP
|
||||
// response and a pipe that feeds the MinIO upload goroutine.
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// MinIO upload runs concurrently. Size -1 triggers multipart upload.
|
||||
uploadDone := make(chan error, 1)
|
||||
go func() {
|
||||
uploadDone <- s.deps.AudioStore.PutAudioStream(
|
||||
context.Background(), // use background — request ctx may cancel after client disconnects
|
||||
audioKey, pr, -1, "audio/mpeg",
|
||||
)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
|
||||
tee := io.TeeReader(audioStream, pw)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, readErr := tee.Read(buf)
|
||||
if nr > 0 {
|
||||
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
|
||||
// Client disconnected — abort upload pipe so goroutine exits.
|
||||
pw.CloseWithError(writeErr)
|
||||
<-uploadDone
|
||||
return
|
||||
}
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
s.deps.Log.Warn("handleAudioStream: read error mid-stream", "err", readErr)
|
||||
pw.CloseWithError(readErr)
|
||||
<-uploadDone
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Signal end of stream to the MinIO upload goroutine.
|
||||
pw.Close()
|
||||
if uploadErr := <-uploadDone; uploadErr != nil {
|
||||
s.deps.Log.Error("handleAudioStream: MinIO upload failed", "key", audioKey, "err", uploadErr)
|
||||
// Audio was already streamed to the client — just log; don't error.
|
||||
// The next request will re-stream since the object is absent.
|
||||
}
|
||||
// Note: we do not call FinishAudioTask here — the backend has no Consumer.
|
||||
// handleAudioStatus fast-paths on AudioExists, so the UI will see "done"
|
||||
// on its next poll as soon as the MinIO object is present.
|
||||
}
|
||||
|
||||
// ── Translation ────────────────────────────────────────────────────────────────
|
||||
|
||||
// supportedTranslationLangs is the set of target locales the backend accepts.
|
||||
|
||||
@@ -161,6 +161,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
|
||||
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
|
||||
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
// Streaming audio: serves from MinIO if cached, else streams live TTS
|
||||
// while simultaneously uploading to MinIO for future requests.
|
||||
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
|
||||
|
||||
// Translation task creation (backend creates task; runner executes via LibreTranslate)
|
||||
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
|
||||
@@ -199,7 +202,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
Addr: s.cfg.Addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ package bookstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
@@ -87,6 +88,11 @@ type AudioStore interface {
|
||||
|
||||
// PutAudio stores raw audio bytes under the given MinIO object key.
|
||||
PutAudio(ctx context.Context, key string, data []byte) error
|
||||
|
||||
// PutAudioStream uploads audio from r to MinIO under key.
|
||||
// size must be the exact byte length of r, or -1 to use multipart upload.
|
||||
// contentType should be "audio/mpeg".
|
||||
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
// PresignStore generates short-lived URLs — used exclusively by the backend.
|
||||
|
||||
@@ -2,6 +2,7 @@ package bookstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -54,6 +55,9 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PresignStore
|
||||
func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {
|
||||
|
||||
@@ -21,6 +21,12 @@ type Client interface {
|
||||
// GenerateAudio synthesises text using voice and returns raw MP3 bytes.
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
|
||||
// StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams
|
||||
// MP3-encoded audio incrementally. Uses the kokoro-fastapi streaming mode
|
||||
// (stream:true), which delivers MP3 frames as they are generated without
|
||||
// waiting for the full output. The caller must always close the ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
||||
// on error — callers should treat an empty list as "service unavailable".
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
@@ -118,6 +124,49 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// StreamAudioMP3 calls POST /v1/audio/speech with stream:true and returns an
|
||||
// io.ReadCloser that delivers MP3 frames as kokoro generates them.
|
||||
// kokoro-fastapi emits raw MP3 bytes when stream mode is enabled — no download
|
||||
// redirect; the response body IS the audio stream.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "af_bella"
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(map[string]any{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "mp3",
|
||||
"speed": 1.0,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal stream request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build stream request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: stream request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("kokoro: stream returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
||||
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// so callers receive MP3 bytes — the same format as the kokoro client — and the
|
||||
// rest of the pipeline does not need to care which TTS engine was used.
|
||||
//
|
||||
// StreamAudioMP3 is the streaming variant: it returns an io.ReadCloser that
|
||||
// yields MP3-encoded audio incrementally as pocket-tts generates it, without
|
||||
// buffering the full output.
|
||||
//
|
||||
// Predefined voices (pass the bare name as the voice parameter):
|
||||
//
|
||||
// alba, marius, javert, jean, fantine, cosette, eponine, azelma,
|
||||
@@ -50,6 +54,11 @@ type Client interface {
|
||||
// Voice must be one of the predefined pocket-tts voice names.
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
|
||||
// StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams
|
||||
// MP3-encoded audio incrementally via a live ffmpeg transcode pipe.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -79,14 +88,97 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
// ── Build multipart form ──────────────────────────────────────────────────
|
||||
resp, err := c.postTTS(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
wavData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: read response body: %w", err)
|
||||
}
|
||||
|
||||
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
|
||||
mp3Data, err := wavToMP3(ctx, wavData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
|
||||
}
|
||||
return mp3Data, nil
|
||||
}
|
||||
|
||||
// StreamAudioMP3 posts to POST /tts and returns an io.ReadCloser that delivers
|
||||
// MP3 bytes as pocket-tts generates WAV frames. ffmpeg runs as a subprocess
|
||||
// with stdin connected to the live WAV stream and stdout piped to the caller.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("pockettts: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
resp, err := c.postTTS(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start ffmpeg: read WAV from stdin (the live HTTP body), write MP3 to stdout.
|
||||
cmd := exec.CommandContext(ctx,
|
||||
"ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-i", "pipe:0", // WAV from stdin
|
||||
"-f", "mp3", // output format
|
||||
"-q:a", "2", // VBR ~190 kbps
|
||||
"pipe:1", // MP3 to stdout
|
||||
)
|
||||
cmd.Stdin = resp.Body
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("pockettts: start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
// Close the write end of the pipe when ffmpeg exits, propagating any error.
|
||||
go func() {
|
||||
waitErr := cmd.Wait()
|
||||
resp.Body.Close()
|
||||
if waitErr != nil {
|
||||
pw.CloseWithError(fmt.Errorf("ffmpeg: %w (stderr: %s)", waitErr, stderrBuf.String()))
|
||||
} else {
|
||||
pw.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
voices := make([]string, 0, len(PredefinedVoices))
|
||||
for v := range PredefinedVoices {
|
||||
voices = append(voices, v)
|
||||
}
|
||||
return voices, nil
|
||||
}
|
||||
|
||||
// postTTS sends a multipart POST /tts request and returns the raw response.
|
||||
// The caller is responsible for closing resp.Body.
|
||||
func (c *httpClient) postTTS(ctx context.Context, text, voice string) (*http.Response, error) {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
|
||||
if err := mw.WriteField("text", text); err != nil {
|
||||
return nil, fmt.Errorf("pockettts: write text field: %w", err)
|
||||
}
|
||||
// pocket-tts accepts a predefined voice name as voice_url.
|
||||
if err := mw.WriteField("voice_url", voice); err != nil {
|
||||
return nil, fmt.Errorf("pockettts: write voice_url field: %w", err)
|
||||
}
|
||||
@@ -105,34 +197,12 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("pockettts: server returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
wavData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: read response body: %w", err)
|
||||
}
|
||||
|
||||
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
|
||||
mp3Data, err := wavToMP3(ctx, wavData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
|
||||
}
|
||||
return mp3Data, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
voices := make([]string, 0, len(PredefinedVoices))
|
||||
for v := range PredefinedVoices {
|
||||
voices = append(voices, v)
|
||||
}
|
||||
return voices, nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// wavToMP3 converts raw WAV bytes to MP3 using ffmpeg.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package runner_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -129,6 +131,10 @@ func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
return s.putErr
|
||||
}
|
||||
func (s *stubAudioStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
s.putCalled.Add(1)
|
||||
return s.putErr
|
||||
}
|
||||
|
||||
// stubNovelScraper satisfies scraper.NovelScraper minimally.
|
||||
type stubNovelScraper struct {
|
||||
@@ -185,6 +191,14 @@ func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
|
||||
return s.data, s.genErr
|
||||
}
|
||||
|
||||
func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
s.called.Add(1)
|
||||
if s.genErr != nil {
|
||||
return nil, s.genErr
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return []string{"af_bella"}, nil
|
||||
}
|
||||
|
||||
@@ -155,6 +155,14 @@ func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType st
|
||||
return err
|
||||
}
|
||||
|
||||
// putObjectStream uploads from r with known size (or -1 for multipart).
|
||||
func (m *minioClient) putObjectStream(ctx context.Context, bucket, key, contentType string, r io.Reader, size int64) error {
|
||||
_, err := m.client.PutObject(ctx, bucket, key, r, size,
|
||||
minio.PutObjectOptions{ContentType: contentType},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) {
|
||||
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -383,6 +384,10 @@ func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
|
||||
}
|
||||
|
||||
func (s *Store) PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
return s.mc.putObjectStream(ctx, s.mc.bucketAudio, key, contentType, r, size)
|
||||
}
|
||||
|
||||
// ── PresignStore ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Slate",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Reading settings",
|
||||
"profile_voice_label": "Default voice",
|
||||
"profile_speed_label": "Playback speed",
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"profile_theme_amber": "Ambre",
|
||||
"profile_theme_slate": "Ardoise",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Paramètres de lecture",
|
||||
"profile_voice_label": "Voix par défaut",
|
||||
"profile_speed_label": "Vitesse de lecture",
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Abu-abu",
|
||||
"profile_theme_rose": "Mawar",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Pengaturan membaca",
|
||||
"profile_voice_label": "Suara default",
|
||||
"profile_speed_label": "Kecepatan pemutaran",
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"profile_theme_amber": "Âmbar",
|
||||
"profile_theme_slate": "Ardósia",
|
||||
"profile_theme_rose": "Rosa",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Configurações de leitura",
|
||||
"profile_voice_label": "Voz padrão",
|
||||
"profile_speed_label": "Velocidade de reprodução",
|
||||
@@ -160,6 +160,9 @@
|
||||
"profile_theme_amber": "Янтарь",
|
||||
"profile_theme_slate": "Сланец",
|
||||
"profile_theme_rose": "Роза",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Настройки чтения",
|
||||
"profile_voice_label": "Голос по умолчанию",
|
||||
"profile_speed_label": "Скорость воспроизведения",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && svelte-kit sync || echo ''",
|
||||
"prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && node -e \"const fs=require('fs'),f='./src/lib/paraglide/messages.js',c=fs.readFileSync(f,'utf8').split('\\n').filter(l=>!l.includes('export * as m')&&!l.includes('enabling auto-import')).join('\\n');fs.writeFileSync(f,c)\" && svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "ru", "id", "pt-BR", "fr"],
|
||||
"locales": ["en", "ru", "id", "pt", "fr"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
|
||||
],
|
||||
|
||||
@@ -55,6 +55,48 @@
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Light amber theme ────────────────────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--color-brand: #d97706; /* amber-600 */
|
||||
--color-brand-dim: #b45309; /* amber-700 */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f4f4f5; /* zinc-100 */
|
||||
--color-surface-3: #e4e4e7; /* zinc-200 */
|
||||
--color-muted: #71717a; /* zinc-500 */
|
||||
--color-text: #18181b; /* zinc-900 */
|
||||
--color-border: #d4d4d8; /* zinc-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
/* ── Light slate theme ────────────────────────────────────────────────── */
|
||||
[data-theme="light-slate"] {
|
||||
--color-brand: #4f46e5; /* indigo-600 */
|
||||
--color-brand-dim: #4338ca; /* indigo-700 */
|
||||
--color-surface: #f8fafc; /* slate-50 */
|
||||
--color-surface-2: #f1f5f9; /* slate-100 */
|
||||
--color-surface-3: #e2e8f0; /* slate-200 */
|
||||
--color-muted: #64748b; /* slate-500 */
|
||||
--color-text: #0f172a; /* slate-900 */
|
||||
--color-border: #cbd5e1; /* slate-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
/* ── Light rose theme ─────────────────────────────────────────────────── */
|
||||
[data-theme="light-rose"] {
|
||||
--color-brand: #e11d48; /* rose-600 */
|
||||
--color-brand-dim: #be123c; /* rose-700 */
|
||||
--color-surface: #fff1f2; /* rose-50 */
|
||||
--color-surface-2: #ffe4e6; /* rose-100 */
|
||||
--color-surface-3: #fecdd3; /* rose-200 */
|
||||
--color-muted: #9f1239; /* rose-800 at 60% */
|
||||
--color-text: #0f0a0b; /* near black */
|
||||
--color-border: #fda4af; /* rose-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -141,7 +141,7 @@ export function parseAuthToken(token: string): { id: string; username: string; r
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getTextDirection(locale: string): string {
|
||||
// All supported locales (en, ru, id, pt-BR, fr) are LTR
|
||||
// All supported locales (en, ru, id, pt, fr) are LTR
|
||||
return 'ltr';
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
let {
|
||||
slug,
|
||||
chapter = 0,
|
||||
isLoggedIn = false,
|
||||
currentUserId = ''
|
||||
}: {
|
||||
slug: string;
|
||||
chapter?: number; // 0 = book-level, N = chapter N
|
||||
isLoggedIn?: boolean;
|
||||
currentUserId?: string;
|
||||
} = $props();
|
||||
@@ -47,7 +49,7 @@
|
||||
loadError = '';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
|
||||
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}${chapter > 0 ? `&chapter=${chapter}` : ''}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -85,7 +87,7 @@
|
||||
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: text })
|
||||
body: JSON.stringify({ body: text, ...(chapter > 0 ? { chapter } : {}) })
|
||||
});
|
||||
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -197,8 +197,9 @@ async function countCollection(collection: string, filter = ''): Promise<number>
|
||||
return (data as { totalItems: number }).totalItems ?? 0;
|
||||
}
|
||||
|
||||
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
|
||||
async function listOne<T>(collection: string, filter: string, sort = ''): Promise<T | null> {
|
||||
const params = new URLSearchParams({ perPage: '1', filter });
|
||||
if (sort) params.set('sort', sort);
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
@@ -1012,6 +1013,8 @@ export async function createUserSession(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1048,6 +1051,28 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* 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;
|
||||
const token = await getToken();
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a specific session by its PocketBase record ID.
|
||||
* Only allows deletion if the session belongs to the given userId.
|
||||
@@ -1106,6 +1131,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
export interface PBBookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
chapter?: number; // 0 or absent = book-level; N = chapter N
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
@@ -1126,25 +1152,26 @@ export interface CommentVote {
|
||||
export type CommentSort = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* List top-level comments for a book.
|
||||
* List top-level comments for a book or a specific chapter.
|
||||
* chapter=0 (default) → book-level comments only
|
||||
* chapter=N → comments for chapter N only
|
||||
* sort='top' → by net score (upvotes − downvotes) desc, then newest
|
||||
* sort='new' → newest first (default)
|
||||
* Replies (parent_id != "") are NOT included — fetch them separately.
|
||||
*/
|
||||
export async function listComments(
|
||||
slug: string,
|
||||
sort: CommentSort = 'new'
|
||||
sort: CommentSort = 'new',
|
||||
chapter = 0
|
||||
): Promise<PBBookComment[]> {
|
||||
const token = await getToken();
|
||||
const slugEsc = slug.replace(/"/g, '\\"');
|
||||
// Only top-level comments (parent_id is empty or missing)
|
||||
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
|
||||
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
|
||||
// PocketBase doesn't support computed sort fields. For 'new' we push the
|
||||
// sort down to the DB so large result sets are still paged correctly.
|
||||
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
|
||||
const chapterFilter = chapter > 0
|
||||
? `&&chapter=${chapter}`
|
||||
: `&&(chapter=0||chapter=null)`;
|
||||
const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
@@ -1155,13 +1182,32 @@ export async function listComments(
|
||||
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
|
||||
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
|
||||
if (scoreB !== scoreA) return scoreB - scoreA;
|
||||
// tie-break: newest first
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unique readers for a book in the last 7 days.
|
||||
* Uses progress.updated timestamp; counts both session-based and user-based.
|
||||
*/
|
||||
export async function countReadersThisWeek(slug: string): Promise<number> {
|
||||
const token = await getToken();
|
||||
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
|
||||
// Deduplicate: prefer user_id when present, fall back to session_id
|
||||
const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* List replies (1-level deep) for a single parent comment.
|
||||
* Always sorted oldest-first so the conversation reads naturally.
|
||||
@@ -1187,7 +1233,8 @@ export async function createComment(
|
||||
body: string,
|
||||
userId: string | undefined,
|
||||
username: string,
|
||||
parentId?: string
|
||||
parentId?: string,
|
||||
chapter = 0
|
||||
): Promise<PBBookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
@@ -1195,6 +1242,7 @@ export async function createComment(
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
chapter,
|
||||
body,
|
||||
user_id: userId ?? '',
|
||||
username,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
class="min-h-screen bg-(--color-surface) text-(--color-text) flex flex-col items-center justify-center px-6 py-16 font-sans"
|
||||
>
|
||||
<!-- Large status code -->
|
||||
<p class="text-[8rem] sm:text-[11rem] font-black leading-none bg-(--color-surface-2) select-none tabular-nums">
|
||||
<p class="text-[8rem] sm:text-[11rem] font-black leading-none bg-(--color-surface) select-none tabular-nums">
|
||||
{code}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -35,20 +35,23 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
log.warn('layout', 'failed to load settings', { err: String(e) });
|
||||
}
|
||||
|
||||
// If user is logged in and has a non-English locale saved, ensure the
|
||||
// PARAGLIDE_LOCALE cookie is set so the locale persists after refresh.
|
||||
// If user is logged in, keep the PARAGLIDE_LOCALE cookie in sync with
|
||||
// the saved locale so it persists across page loads and navigations.
|
||||
if (locals.user) {
|
||||
const savedLocale = settings.locale ?? 'en';
|
||||
if (savedLocale !== 'en') {
|
||||
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
|
||||
if (currentCookieLocale !== savedLocale) {
|
||||
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
|
||||
path: '/',
|
||||
maxAge: 34560000,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false
|
||||
});
|
||||
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
|
||||
if (savedLocale === 'en') {
|
||||
// Clear the cookie when the user's locale is English (the default)
|
||||
if (currentCookieLocale) {
|
||||
cookies.delete('PARAGLIDE_LOCALE', { path: '/' });
|
||||
}
|
||||
} else if (currentCookieLocale !== savedLocale) {
|
||||
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
|
||||
path: '/',
|
||||
maxAge: 34560000,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@
|
||||
let langMenuOpen = $state(false);
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'amber', color: '#f59e0b' },
|
||||
{ id: 'slate', color: '#818cf8' },
|
||||
{ id: 'rose', color: '#fb7185' },
|
||||
{ id: 'amber', color: '#f59e0b' },
|
||||
{ id: 'slate', color: '#818cf8' },
|
||||
{ id: 'rose', color: '#fb7185' },
|
||||
{ id: 'light', color: '#d97706', light: true },
|
||||
{ id: 'light-slate', color: '#4f46e5', light: true },
|
||||
{ id: 'light-rose', color: '#e11d48', light: true },
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
@@ -316,12 +319,15 @@
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Theme dots (desktop) -->
|
||||
<div class="hidden sm:flex items-center gap-1 mr-1">
|
||||
{#each THEMES as t}
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
<span class="w-px h-3 bg-(--color-border) mx-0.5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
@@ -498,13 +504,16 @@
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each THEMES as t}
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
<span class="w-px h-4 bg-(--color-border) mx-0.5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
@@ -10,194 +10,220 @@
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
|
||||
// counting how many times the same book appears (= new chapters added).
|
||||
const dedupedRecent = $derived.by(() => {
|
||||
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
|
||||
for (const book of data.recentlyUpdated) {
|
||||
if (seen.has(book.slug)) {
|
||||
seen.get(book.slug)!.count++;
|
||||
} else {
|
||||
seen.set(book.slug, { book, count: 1 });
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
});
|
||||
|
||||
const GENRES = [
|
||||
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
|
||||
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||
];
|
||||
|
||||
// Hero = first continue-reading item; shelf = the rest
|
||||
const heroBook = $derived(data.continueReading[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueReading.slice(1));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Reading -->
|
||||
{#if data.continueReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
<section class="mb-10">
|
||||
<a
|
||||
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
|
||||
{#if heroBook.book.cover}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" 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>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.continueReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{chapter}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
|
||||
{#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-10 h-10" 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}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</span>
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
|
||||
{#if shelfBooks.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<a href="/books/{book.slug}/chapters/{chapter}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#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 bg-(--color-surface-3) 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="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}
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
||||
<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>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
||||
{#each GENRES as genre}
|
||||
<a href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
|
||||
{genre}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
|
||||
{#if dedupedRecent.length > 0}
|
||||
<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>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each dedupedRecent as { book, count }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#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 bg-(--color-surface-3) 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="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}
|
||||
{#if count > 1}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-success)/90 text-black font-bold px-1.5 py-0.5 rounded">
|
||||
+{count} ch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Updated -->
|
||||
{#if data.recentlyUpdated.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.recentlyUpdated as book}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#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-10 h-10" 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>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) self-start">{book.status}</span>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
{m.home_discover_novels()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
||||
{#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>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- From Subscriptions -->
|
||||
<!-- ── From Following ────────────────────────────────────────────────────────── -->
|
||||
{#if data.subscriptionFeed.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#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-10 h-10" 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>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">
|
||||
{m.home_via_reader({ username: readerUsername })}
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 1) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
<a href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<div class="aspect-[2/3] overflow-hidden">
|
||||
{#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 bg-(--color-surface-3) 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="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>
|
||||
<div class="p-2 flex flex-col gap-0.5">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
<p class="text-xs text-(--color-muted) truncate">{m.home_via_reader({ username: readerUsername })}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
|
||||
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
<a href="/catalogue" class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg hover:bg-(--color-brand-dim) transition-colors">
|
||||
{m.home_discover_novels()}
|
||||
</a>
|
||||
</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>
|
||||
<span class="w-px h-4 bg-(--color-border)"></span>
|
||||
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const sortParam = url.searchParams.get('sort') ?? 'new';
|
||||
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
|
||||
const chapter = parseInt(url.searchParams.get('chapter') ?? '0', 10) || 0;
|
||||
|
||||
try {
|
||||
const topLevel = await listComments(slug, sort);
|
||||
const topLevel = await listComments(slug, sort, chapter);
|
||||
|
||||
// Fetch replies for all top-level comments in parallel
|
||||
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
|
||||
@@ -75,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) error(401, 'Login required to comment');
|
||||
|
||||
const { slug } = params;
|
||||
let body: { body?: string; parent_id?: string };
|
||||
let body: { body?: string; parent_id?: string; chapter?: number };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -86,8 +87,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!text) error(400, 'Comment body is required');
|
||||
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
|
||||
|
||||
// Enforce 1-level depth: parent_id must be a top-level comment
|
||||
const parentId = body.parent_id?.trim() || undefined;
|
||||
const chapter = typeof body.chapter === 'number' ? body.chapter : 0;
|
||||
|
||||
try {
|
||||
const comment = await createComment(
|
||||
@@ -95,7 +96,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
text,
|
||||
locals.user.id,
|
||||
locals.user.username,
|
||||
parentId
|
||||
parentId,
|
||||
chapter
|
||||
);
|
||||
return json(comment, { status: 201 });
|
||||
} catch (e) {
|
||||
|
||||
@@ -44,13 +44,13 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
// theme is optional — if provided it must be a known value
|
||||
const validThemes = ['amber', 'slate', 'rose'];
|
||||
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
|
||||
if (body.theme !== undefined && !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
|
||||
const validLocales = ['en', 'ru', 'id', 'pt-BR', 'fr'];
|
||||
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
|
||||
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
|
||||
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||
|
||||
@@ -15,12 +15,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (book) {
|
||||
// Book is in the library — normal path
|
||||
let chapters, progress, saved;
|
||||
let chapters, progress, saved, readersThisWeek;
|
||||
try {
|
||||
[chapters, progress, saved] = await Promise.all([
|
||||
[chapters, progress, saved, readersThisWeek] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id)
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id),
|
||||
countReadersThisWeek(slug)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load book page data', { slug, err: String(e) });
|
||||
@@ -33,6 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
readersThisWeek,
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
{#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>
|
||||
{/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">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||
{data.readersThisWeek} reading this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Summary with expand toggle -->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -337,3 +338,13 @@
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chapter comments -->
|
||||
<div class="mt-12">
|
||||
<CommentsSection
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
isLoggedIn={!!page.data.user}
|
||||
currentUserId={page.data.user?.id ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { untrack, getContext } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -16,14 +15,12 @@
|
||||
let avatarError = $state('');
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
// Crop modal state
|
||||
let cropFile = $state<File | null>(null);
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
// Reset input so the same file can be re-selected after cancel
|
||||
if (fileInput) fileInput.value = '';
|
||||
cropFile = file;
|
||||
}
|
||||
@@ -33,7 +30,6 @@
|
||||
avatarUploading = true;
|
||||
avatarError = '';
|
||||
try {
|
||||
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
|
||||
const res = await fetch('/api/profile/avatar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': mimeType },
|
||||
@@ -57,95 +53,104 @@
|
||||
cropFile = null;
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────────
|
||||
// ── Voices ───────────────────────────────────────────────────────────────────
|
||||
let voices = $state<Voice[]>([]);
|
||||
let voicesLoaded = $state(false);
|
||||
|
||||
// Derived: voices grouped by engine
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
|
||||
// Load voices on mount
|
||||
$effect(() => {
|
||||
fetch('/api/voices')
|
||||
.then((r) => r.json())
|
||||
.then((d: { voices: Voice[] }) => {
|
||||
voices = d.voices ?? [];
|
||||
voicesLoaded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
voicesLoaded = true;
|
||||
});
|
||||
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
|
||||
.catch(() => { voicesLoaded = true; });
|
||||
});
|
||||
|
||||
// Mirror from audioStore so sliders feel live
|
||||
// ── Settings state ───────────────────────────────────────────────────────────
|
||||
let voice = $state(audioStore.voice);
|
||||
let speed = $state(audioStore.speed);
|
||||
let autoNext = $state(audioStore.autoNext);
|
||||
|
||||
// Keep in sync when layout changes them externally
|
||||
$effect(() => {
|
||||
voice = audioStore.voice;
|
||||
speed = audioStore.speed;
|
||||
autoNext = audioStore.autoNext;
|
||||
});
|
||||
|
||||
// ── Theme + Font ─────────────────────────────────────────────────────────────
|
||||
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
|
||||
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
|
||||
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
|
||||
|
||||
const THEMES: { id: string; label: () => string; swatch: string }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||||
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||||
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
|
||||
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
|
||||
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
|
||||
];
|
||||
|
||||
const FONTS = [
|
||||
{ id: 'system', label: () => m.profile_font_system() },
|
||||
{ id: 'serif', label: () => m.profile_font_serif() },
|
||||
{ id: 'mono', label: () => m.profile_font_mono() },
|
||||
{ id: 'serif', label: () => m.profile_font_serif() },
|
||||
{ id: 'mono', label: () => m.profile_font_mono() },
|
||||
];
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: 0.9, label: () => m.profile_text_size_sm() },
|
||||
{ value: 1.0, label: () => m.profile_text_size_md() },
|
||||
{ value: 0.9, label: () => m.profile_text_size_sm() },
|
||||
{ value: 1.0, label: () => m.profile_text_size_md() },
|
||||
{ value: 1.15, label: () => m.profile_text_size_lg() },
|
||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||
{ value: 1.3, label: () => m.profile_text_size_xl() },
|
||||
];
|
||||
|
||||
let settingsSaving = $state(false);
|
||||
let settingsSaved = $state(false);
|
||||
// ── Auto-save ────────────────────────────────────────────────────────────────
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved';
|
||||
let saveStatus = $state<SaveStatus>('idle');
|
||||
let saveTimer = 0;
|
||||
let savedTimer = 0;
|
||||
let initialized = false;
|
||||
|
||||
async function saveSettings() {
|
||||
settingsSaving = true;
|
||||
settingsSaved = false;
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme, fontFamily: selectedFontFamily, fontSize: selectedFontSize })
|
||||
});
|
||||
// Sync to audioStore so the player picks up changes immediately
|
||||
audioStore.autoNext = autoNext;
|
||||
audioStore.voice = voice;
|
||||
audioStore.speed = speed;
|
||||
// Apply theme + font live via context
|
||||
if (settingsCtx) {
|
||||
settingsCtx.current = selectedTheme;
|
||||
settingsCtx.fontFamily = selectedFontFamily;
|
||||
settingsCtx.fontSize = selectedFontSize;
|
||||
}
|
||||
await invalidateAll();
|
||||
settingsSaved = true;
|
||||
setTimeout(() => (settingsSaved = false), 2500);
|
||||
} finally {
|
||||
settingsSaving = false;
|
||||
$effect(() => {
|
||||
// Read all settings deps to subscribe
|
||||
const t = selectedTheme;
|
||||
const ff = selectedFontFamily;
|
||||
const fs = selectedFontSize;
|
||||
const v = voice;
|
||||
const sp = speed;
|
||||
const an = autoNext;
|
||||
|
||||
// Apply context immediately (font/theme previews live without waiting for save)
|
||||
if (settingsCtx) {
|
||||
settingsCtx.current = t;
|
||||
settingsCtx.fontFamily = ff;
|
||||
settingsCtx.fontSize = fs;
|
||||
}
|
||||
}
|
||||
audioStore.voice = v;
|
||||
audioStore.autoNext = an;
|
||||
|
||||
// ── Sessions ────────────────────────────────────────────────────────────────
|
||||
if (!initialized) { initialized = true; return; }
|
||||
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
saveStatus = 'saving';
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
|
||||
});
|
||||
saveStatus = 'saved';
|
||||
clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
|
||||
} catch {
|
||||
saveStatus = 'idle';
|
||||
}
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
type Session = {
|
||||
id: string;
|
||||
user_agent: string;
|
||||
@@ -164,19 +169,12 @@
|
||||
revokeError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
revokeError = 'Failed to end session. Please try again.';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
|
||||
if (session.is_current) {
|
||||
// Ended our own session — submit the logout form to clear the cookie
|
||||
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
||||
if (logoutForm) {
|
||||
logoutForm.submit();
|
||||
}
|
||||
if (logoutForm) logoutForm.submit();
|
||||
return;
|
||||
}
|
||||
// Remove from local list
|
||||
sessions = sessions.filter((s) => s.id !== session.id);
|
||||
} catch {
|
||||
revokeError = 'Network error. Please try again.';
|
||||
@@ -188,18 +186,12 @@
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function parseUA(ua: string): string {
|
||||
if (!ua) return 'Unknown browser';
|
||||
// Very lightweight UA display — just show the most meaningful part
|
||||
if (/Mobile/i.test(ua)) {
|
||||
const match = ua.match(/\(([^)]+)\)/);
|
||||
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
|
||||
@@ -218,24 +210,20 @@
|
||||
|
||||
{#if cropFile && browser}
|
||||
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
||||
<AvatarCropModal
|
||||
file={cropFile}
|
||||
onconfirm={handleCropConfirm}
|
||||
oncancel={handleCropCancel}
|
||||
/>
|
||||
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Hidden logout form used when user ends their own session -->
|
||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||
|
||||
<div class="max-w-xl mx-auto space-y-10">
|
||||
<div class="flex items-center gap-5">
|
||||
<!-- Avatar -->
|
||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||
|
||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
|
||||
title={m.profile_change_avatar()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
@@ -248,7 +236,6 @@
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if avatarUploading}
|
||||
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -263,97 +250,96 @@
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||||
{#if data.isPro}
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 tracking-wide uppercase">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
|
||||
</svg>
|
||||
{m.profile_plan_pro()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
||||
{m.profile_plan_free()}
|
||||
</span>
|
||||
{/if}
|
||||
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
|
||||
</div>
|
||||
|
||||
{#if data.isPro}
|
||||
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p>
|
||||
<a
|
||||
href="https://polar.sh/libnovel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline"
|
||||
>
|
||||
{m.profile_manage_subscription()}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-(--color-text) mb-3">{m.profile_upgrade_heading()}</p>
|
||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
|
||||
</svg>
|
||||
{m.profile_upgrade_monthly()}
|
||||
</a>
|
||||
<a
|
||||
href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
|
||||
>
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
|
||||
{#if data.isPro}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||
{m.profile_plan_pro()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||
{#if !data.isPro}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
|
||||
</div>
|
||||
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
|
||||
{m.profile_plan_free()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||
{m.profile_upgrade_monthly()}
|
||||
</a>
|
||||
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
|
||||
{m.profile_manage_subscription()}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
|
||||
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Section header with auto-save indicator -->
|
||||
<div class="flex items-center justify-between px-6 py-4">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
|
||||
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
|
||||
{#if saveStatus === 'saving'}
|
||||
{m.profile_saving()}…
|
||||
{:else if saveStatus === 'saved'}
|
||||
✓ {m.profile_saved()}
|
||||
{:else}
|
||||
{m.profile_saved()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
{#each THEMES as t}
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTheme = t.id)}
|
||||
@@ -363,26 +349,25 @@
|
||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
|
||||
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
||||
{t.label()}
|
||||
{#if selectedTheme === t.id}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Font family -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each FONTS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontFamily = f.id)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontFamily === 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)'}"
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontFamily === 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={selectedFontFamily === f.id}
|
||||
>
|
||||
{f.label()}
|
||||
@@ -391,14 +376,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Text size -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each FONT_SIZES as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedFontSize = s.value)}
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontSize === 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)'}"
|
||||
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
|
||||
{selectedFontSize === 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={selectedFontSize === s.value}
|
||||
>
|
||||
{s.label()}
|
||||
@@ -407,115 +396,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={settingsSaving}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
|
||||
|
||||
<!-- Voice -->
|
||||
<div class="space-y-1.5">
|
||||
<!-- TTS voice -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
||||
{#if !voicesLoaded}
|
||||
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
|
||||
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
||||
{:else if voices.length === 0}
|
||||
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
||||
<option>{m.common_loading()}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
id="voice-select"
|
||||
bind:value={voice}
|
||||
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)"
|
||||
>
|
||||
<select id="voice-select" bind:value={voice}
|
||||
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}>{v.id}</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}>{v.id}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
|
||||
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
id="speed-range"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3.0"
|
||||
step="0.1"
|
||||
bind:value={speed}
|
||||
style="accent-color: var(--color-brand);"
|
||||
class="w-full"
|
||||
/>
|
||||
<!-- Playback speed -->
|
||||
<div class="px-6 py-5 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
|
||||
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
|
||||
style="accent-color: var(--color-brand);" class="w-full" />
|
||||
<div class="flex justify-between text-xs text-(--color-muted)">
|
||||
<span>0.5x</span>
|
||||
<span>3.0x</span>
|
||||
<span>0.5x</span><span>3.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
|
||||
<!-- Auto-advance -->
|
||||
<div class="px-6 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoNext}
|
||||
onclick={() => (autoNext = !autoNext)}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
|
||||
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={settingsSaving}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
|
||||
</div>
|
||||
|
||||
{#if revokeError}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
{revokeError}
|
||||
</div>
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
|
||||
{/if}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
@@ -547,7 +494,7 @@
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||
{session.is_current
|
||||
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||
</button>
|
||||
@@ -556,4 +503,5 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user