Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d4a7bfd4 | ||
|
|
73a92ccf8f | ||
|
|
08361172c6 | ||
|
|
809dc8d898 | ||
|
|
e9c3426fbe | ||
|
|
8e611840d1 | ||
|
|
b9383570e3 | ||
|
|
eac9358c6f | ||
|
|
9cb11bc5e4 | ||
|
|
7196f8e930 | ||
|
|
a771405db8 | ||
|
|
1e9a96aa0f | ||
|
|
23ae1ed500 | ||
|
|
e7cb460f9b | ||
|
|
392248e8a6 | ||
|
|
68ea2d2808 | ||
|
|
7b1df9b592 | ||
|
|
f4089fe111 | ||
|
|
87b5ad1460 | ||
|
|
168cb52ed0 | ||
|
|
e1621a3ec2 | ||
|
|
10c7a48bc6 | ||
|
|
8b597c0bd2 | ||
|
|
28cafe2aa8 | ||
|
|
65f0425b61 | ||
|
|
4e70a2981d | ||
|
|
004cb95e56 |
@@ -2,11 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
pull_request:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,8 @@
|
||||
|
||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||
backend/bin/
|
||||
backend/backend
|
||||
backend/runner
|
||||
|
||||
# ── Environment & secrets ──────────────────────────────────────────────────────
|
||||
# Secrets are managed by Doppler — never commit .env files.
|
||||
|
||||
@@ -58,9 +58,9 @@
|
||||
|
||||
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
|
||||
# Exposes prod Redis over TLS for Asynq job enqueueing from the homelab runner.
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# for redis.libnovel.cc; traffic is proxied to the local Redis sidecar.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
layer4 {
|
||||
:6380 {
|
||||
route {
|
||||
@@ -73,7 +73,7 @@
|
||||
}
|
||||
proxy {
|
||||
upstream redis:6379
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
backend/backend
BIN
backend/backend
Binary file not shown.
@@ -134,7 +134,7 @@ func run() error {
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
|
||||
}
|
||||
asynqProducer := asynqqueue.NewProducer(store, redisOpt)
|
||||
asynqProducer := asynqqueue.NewProducer(store, redisOpt, log)
|
||||
defer asynqProducer.Close() //nolint:errcheck
|
||||
producer = asynqProducer
|
||||
log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
|
||||
@@ -200,6 +200,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ 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
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ 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
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
|
||||
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
|
||||
//
|
||||
// When using Asynq, the runner no longer polls for work — Asynq delivers
|
||||
// tasks via the ServeMux handlers. The only Consumer operations the handlers
|
||||
// need are:
|
||||
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
|
||||
// - FailTask — mark PocketBase record as failed
|
||||
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
|
||||
// delivers those tasks via the ServeMux handlers. However translation tasks
|
||||
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
|
||||
// still delegate to the underlying PocketBase consumer.
|
||||
//
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
|
||||
// are all no-ops here because Asynq owns those responsibilities.
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
|
||||
// those responsibilities.
|
||||
type Consumer struct {
|
||||
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
|
||||
}
|
||||
@@ -55,10 +54,18 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
|
||||
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
|
||||
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
|
||||
return c.pb.ClaimNextTranslationTask(ctx, workerID)
|
||||
}
|
||||
|
||||
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
|
||||
return c.pb.HeartbeatTask(ctx, id)
|
||||
}
|
||||
|
||||
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
|
||||
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
|
||||
// to pending and can be reclaimed.
|
||||
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
|
||||
return c.pb.ReapStaleTasks(ctx, staleAfter)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
@@ -14,13 +15,15 @@ import (
|
||||
type Producer struct {
|
||||
pb taskqueue.Producer // underlying PocketBase producer
|
||||
client *asynq.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewProducer wraps an existing PocketBase Producer with Asynq dispatch.
|
||||
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt) *Producer {
|
||||
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt, log *slog.Logger) *Producer {
|
||||
return &Producer{
|
||||
pb: pb,
|
||||
client: asynq.NewClient(redisOpt),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +52,9 @@ func (p *Producer) CreateScrapeTask(ctx context.Context, kind, targetURL string,
|
||||
}
|
||||
if err := p.enqueue(ctx, taskType, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
return id, fmt.Errorf("asynq enqueue scrape (task still in PB): %w", err)
|
||||
p.log.Warn("asynq enqueue scrape failed (task still in PB, runner will poll)",
|
||||
"task_id", id, "err", err)
|
||||
return id, nil
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -68,7 +73,10 @@ func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int
|
||||
Voice: voice,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeAudioGenerate, payload); err != nil {
|
||||
return id, fmt.Errorf("asynq enqueue audio (task still in PB): %w", err)
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
p.log.Warn("asynq enqueue audio failed (task still in PB, runner will poll)",
|
||||
"task_id", id, "err", err)
|
||||
return id, nil
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -85,6 +93,12 @@ func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
return p.pb.CancelTask(ctx, id)
|
||||
}
|
||||
|
||||
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
|
||||
// audio tasks for slug.
|
||||
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
return p.pb.CancelAudioTasksBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
// enqueue serialises payload and dispatches it to Asynq.
|
||||
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
|
||||
143
backend/internal/backend/epub.go
Normal file
143
backend/internal/backend/epub.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type epubChapter struct {
|
||||
Number int
|
||||
Title string
|
||||
HTML string
|
||||
}
|
||||
|
||||
func generateEPUB(slug, title, author string, chapters []epubChapter) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
|
||||
// 1. mimetype — MUST be first, MUST be uncompressed (Store method)
|
||||
mw, err := w.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mw.Write([]byte("application/epub+zip"))
|
||||
|
||||
// 2. META-INF/container.xml
|
||||
addFile(w, "META-INF/container.xml", containerXML())
|
||||
|
||||
// 3. OEBPS/style.css
|
||||
addFile(w, "OEBPS/style.css", epubCSS())
|
||||
|
||||
// 4. OEBPS/content.opf
|
||||
addFile(w, "OEBPS/content.opf", contentOPF(slug, title, author, chapters))
|
||||
|
||||
// 5. OEBPS/toc.ncx
|
||||
addFile(w, "OEBPS/toc.ncx", tocNCX(slug, title, chapters))
|
||||
|
||||
// 6. Chapter files
|
||||
for _, ch := range chapters {
|
||||
name := fmt.Sprintf("OEBPS/chapter-%04d.xhtml", ch.Number)
|
||||
addFile(w, name, chapterXHTML(ch))
|
||||
}
|
||||
|
||||
w.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func addFile(w *zip.Writer, name, content string) {
|
||||
f, _ := w.Create(name)
|
||||
f.Write([]byte(content))
|
||||
}
|
||||
|
||||
func containerXML() string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
}
|
||||
|
||||
func contentOPF(slug, title, author string, chapters []epubChapter) string {
|
||||
var items, spine strings.Builder
|
||||
for _, ch := range chapters {
|
||||
id := fmt.Sprintf("ch%04d", ch.Number)
|
||||
href := fmt.Sprintf("chapter-%04d.xhtml", ch.Number)
|
||||
items.WriteString(fmt.Sprintf(` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, href))
|
||||
spine.WriteString(fmt.Sprintf(` <itemref idref="%s"/>`+"\n", id))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>%s</dc:title>
|
||||
<dc:creator>%s</dc:creator>
|
||||
<dc:identifier id="uid">%s</dc:identifier>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="css" href="style.css" media-type="text/css"/>
|
||||
%s </manifest>
|
||||
<spine toc="ncx">
|
||||
%s </spine>
|
||||
</package>`, escapeXML(title), escapeXML(author), slug, items.String(), spine.String())
|
||||
}
|
||||
|
||||
func tocNCX(slug, title string, chapters []epubChapter) string {
|
||||
var points strings.Builder
|
||||
for i, ch := range chapters {
|
||||
chTitle := ch.Title
|
||||
if chTitle == "" {
|
||||
chTitle = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
points.WriteString(fmt.Sprintf(` <navPoint id="np%d" playOrder="%d">
|
||||
<navLabel><text>%s</text></navLabel>
|
||||
<content src="chapter-%04d.xhtml"/>
|
||||
</navPoint>`+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head><meta name="dtb:uid" content="%s"/></head>
|
||||
<docTitle><text>%s</text></docTitle>
|
||||
<navMap>
|
||||
%s </navMap>
|
||||
</ncx>`, slug, escapeXML(title), points.String())
|
||||
}
|
||||
|
||||
func chapterXHTML(ch epubChapter) string {
|
||||
title := ch.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>%s</title><link rel="stylesheet" href="style.css"/></head>
|
||||
<body>
|
||||
<h1 class="chapter-title">%s</h1>
|
||||
%s
|
||||
</body>
|
||||
</html>`, escapeXML(title), escapeXML(title), ch.HTML)
|
||||
}
|
||||
|
||||
func epubCSS() string {
|
||||
return `body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 1em 2em; }
|
||||
h1.chapter-title { font-size: 1.4em; margin-bottom: 1em; }
|
||||
p { margin: 0 0 0.8em 0; text-indent: 1.5em; }
|
||||
p:first-of-type { text-indent: 0; }
|
||||
`
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
@@ -708,12 +708,17 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
// Slow path (first request): streams audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, subsequent
|
||||
// requests hit the fast path and skip TTS generation entirely.
|
||||
//
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
// Query params:
|
||||
//
|
||||
// voice (optional, defaults to DefaultVoice)
|
||||
// format (optional, "mp3" or "wav"; defaults to "mp3")
|
||||
//
|
||||
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
|
||||
// raw WAV frames to the client with lower latency at the cost of larger files.
|
||||
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
@@ -727,7 +732,17 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
format := r.URL.Query().Get("format")
|
||||
if format != "wav" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
|
||||
|
||||
// ── Fast path: already in MinIO ───────────────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
@@ -756,23 +771,39 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the TTS stream.
|
||||
// Open the TTS stream (WAV or MP3 depending on format param).
|
||||
var audioStream io.ReadCloser
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
if format == "wav" {
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
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)
|
||||
}
|
||||
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)
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
@@ -787,11 +818,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uploadDone <- s.deps.AudioStore.PutAudioStream(
|
||||
context.Background(), // use background — request ctx may cancel after client disconnects
|
||||
audioKey, pr, -1, "audio/mpeg",
|
||||
audioKey, pr, -1, contentType,
|
||||
)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1081,6 +1112,166 @@ func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin Audio ────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
|
||||
// Returns all audio jobs, optionally filtered by slug (?slug=...).
|
||||
// Sorted by started descending.
|
||||
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional slug filter.
|
||||
slugFilter := r.URL.Query().Get("slug")
|
||||
|
||||
type jobRow struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
Status string `json:"status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
rows := make([]jobRow, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if slugFilter != "" && t.Slug != slugFilter {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, jobRow{
|
||||
ID: t.ID,
|
||||
CacheKey: t.CacheKey,
|
||||
Slug: t.Slug,
|
||||
Chapter: t.Chapter,
|
||||
Voice: t.Voice,
|
||||
Status: string(t.Status),
|
||||
WorkerID: t.WorkerID,
|
||||
ErrorMessage: t.ErrorMessage,
|
||||
Started: t.Started.Format(time.RFC3339),
|
||||
Finished: t.Finished.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
|
||||
}
|
||||
|
||||
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
|
||||
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
|
||||
//
|
||||
// Enqueues one audio task per chapter in [from, to].
|
||||
// skip_existing (default true): skip chapters already cached in MinIO — use this
|
||||
// to resume a previously interrupted bulk job.
|
||||
// force: if true, enqueue even when a pending/running task already exists.
|
||||
// Max 1000 chapters per request.
|
||||
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Voice string `json:"voice"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if body.Voice == "" {
|
||||
body.Voice = s.cfg.DefaultVoice
|
||||
}
|
||||
if body.From < 1 || body.To < body.From {
|
||||
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
|
||||
return
|
||||
}
|
||||
if body.To-body.From > 999 {
|
||||
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
|
||||
return
|
||||
}
|
||||
|
||||
// skip_existing defaults to true (resume-friendly).
|
||||
skipExisting := true
|
||||
if body.SkipExisting != nil {
|
||||
skipExisting = *body.SkipExisting
|
||||
}
|
||||
|
||||
var taskIDs []string
|
||||
skipped := 0
|
||||
|
||||
for n := body.From; n <= body.To; n++ {
|
||||
// Skip chapters already cached in MinIO.
|
||||
if skipExisting {
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip chapters with an active (pending/running) task unless force=true.
|
||||
if !body.Force {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
|
||||
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
|
||||
"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
|
||||
return
|
||||
}
|
||||
taskIDs = append(taskIDs, id)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"enqueued": len(taskIDs),
|
||||
"skipped": skipped,
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
|
||||
// Body: {"slug": "..."}
|
||||
// Cancels all pending and running audio tasks for the given slug.
|
||||
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
|
||||
"slug", body.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1538,6 +1729,109 @@ func stripMarkdown(src string) string {
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// ── EPUB export ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleExportEPUB handles GET /api/export/{slug}.
|
||||
// Generates and streams an EPUB file for the book identified by slug.
|
||||
// Optional query params: from=N&to=N to limit the chapter range (default: all).
|
||||
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse optional from/to range.
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
fromN, toN := 0, 0
|
||||
if fromStr != "" {
|
||||
v, err := strconv.Atoi(fromStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
|
||||
return
|
||||
}
|
||||
fromN = v
|
||||
}
|
||||
if toStr != "" {
|
||||
v, err := strconv.Atoi(toStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
|
||||
return
|
||||
}
|
||||
toN = v
|
||||
}
|
||||
|
||||
// Fetch book metadata for title and author.
|
||||
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
|
||||
if err != nil || !inLib {
|
||||
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
|
||||
// List all chapters.
|
||||
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
|
||||
return
|
||||
}
|
||||
|
||||
// Filter chapters by from/to range.
|
||||
var filtered []epubChapter
|
||||
for _, ch := range chapters {
|
||||
if fromN > 0 && ch.Number < fromN {
|
||||
continue
|
||||
}
|
||||
if toN > 0 && ch.Number > toN {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch markdown from MinIO.
|
||||
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
|
||||
if readErr != nil {
|
||||
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
|
||||
// Skip chapters that cannot be fetched.
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using goldmark.
|
||||
md := goldmark.New()
|
||||
var htmlBuf bytes.Buffer
|
||||
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
|
||||
htmlBuf.Reset()
|
||||
htmlBuf.WriteString("<p>" + mdText + "</p>")
|
||||
}
|
||||
|
||||
filtered = append(filtered, epubChapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
HTML: htmlBuf.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
|
||||
return
|
||||
}
|
||||
|
||||
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/epub+zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(epubBytes)
|
||||
}
|
||||
|
||||
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||
|
||||
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
||||
|
||||
@@ -174,6 +174,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
@@ -185,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||
|
||||
// EPUB export
|
||||
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
|
||||
@@ -80,9 +80,14 @@ type RankingStore interface {
|
||||
|
||||
// AudioStore covers audio object storage (runner writes; backend reads).
|
||||
type AudioStore interface {
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
AudioObjectKey(slug string, n int, voice string) string
|
||||
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
AudioObjectKeyExt(slug string, n int, voice, ext string) string
|
||||
|
||||
// AudioExists returns true when the audio object is present in MinIO.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
@@ -91,7 +96,7 @@ type AudioStore interface {
|
||||
|
||||
// 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".
|
||||
// contentType should be "audio/mpeg" or "audio/wav".
|
||||
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
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) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,6 +203,11 @@ func Load() Config {
|
||||
URL: envOr("POCKET_TTS_URL", ""),
|
||||
},
|
||||
|
||||
LibreTranslate: LibreTranslate{
|
||||
URL: envOr("LIBRETRANSLATE_URL", ""),
|
||||
APIKey: envOr("LIBRETRANSLATE_API_KEY", ""),
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ type Client interface {
|
||||
// waiting for the full output. The caller must always close the ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
|
||||
// response_format:"wav". The caller must always close the ReadCloser.
|
||||
StreamAudioWAV(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)
|
||||
@@ -167,6 +172,47 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
|
||||
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
|
||||
func (c *httpClient) StreamAudioWAV(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": "wav",
|
||||
"speed": 1.0,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal wav 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 wav 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: wav stream request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("kokoro: wav 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,
|
||||
|
||||
@@ -59,6 +59,12 @@ type Client interface {
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// raw WAV audio directly from pocket-tts without any transcoding.
|
||||
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -160,6 +166,25 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
|
||||
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
|
||||
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
func (c *httpClient) StreamAudioWAV(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
|
||||
}
|
||||
return resp.Body, 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) {
|
||||
|
||||
@@ -78,7 +78,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
|
||||
// This mirrors the heartbeat file behavior from the poll() loop.
|
||||
go func() {
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
|
||||
defer heartbeatTick.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -126,6 +126,9 @@ type stubAudioStore struct {
|
||||
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
|
||||
}
|
||||
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
||||
}
|
||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
@@ -199,6 +202,14 @@ func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) StreamAudioWAV(_ 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
|
||||
}
|
||||
|
||||
@@ -109,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
|
||||
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
// Format: {slug}/{n}/{voice}.{ext}
|
||||
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
|
||||
return AudioObjectKeyExt(slug, n, voice, "mp3")
|
||||
}
|
||||
|
||||
// AvatarObjectKey returns the MinIO object key for a user avatar image.
|
||||
|
||||
@@ -26,6 +26,11 @@ import (
|
||||
// ErrNotFound is returned by single-record lookups when no record exists.
|
||||
var ErrNotFound = errors.New("storage: record not found")
|
||||
|
||||
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
|
||||
// hung PocketBase never stalls the backend/runner process indefinitely.
|
||||
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
|
||||
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// pbClient is the internal PocketBase REST admin client.
|
||||
type pbClient struct {
|
||||
baseURL string
|
||||
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pb auth: %w", err)
|
||||
}
|
||||
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
||||
}
|
||||
|
||||
@@ -74,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
"rating": meta.Rating,
|
||||
}
|
||||
// Upsert via filter: if exists PATCH, otherwise POST.
|
||||
// Use a conflict-retry pattern to handle concurrent scrapes racing to insert
|
||||
// the same slug: if POST fails (or another concurrent writer beat us to it),
|
||||
// re-fetch and PATCH instead.
|
||||
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug.
|
||||
// Re-fetch and fall through to PATCH.
|
||||
existing, err = s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
}
|
||||
@@ -376,6 +388,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return AudioObjectKeyExt(slug, n, voice, ext)
|
||||
}
|
||||
|
||||
func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||
}
|
||||
@@ -574,6 +590,28 @@ func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
|
||||
}
|
||||
cancelled := 0
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if patchErr := s.pb.patch(ctx,
|
||||
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
}
|
||||
return cancelled, nil
|
||||
}
|
||||
|
||||
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
|
||||
|
||||
@@ -36,6 +36,10 @@ type Producer interface {
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
|
||||
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
|
||||
// Returns the number of tasks cancelled.
|
||||
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// Consumer is the read/claim side of the task queue used by the runner.
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
|
||||
BIN
backend/runner
BIN
backend/runner
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 — Page Not Found — LibNovel</title>
|
||||
<title>404 — Page Not Found — libnovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +27,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -114,7 +113,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>500 — Internal Error — LibNovel</title>
|
||||
<title>500 — Internal Error — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +28,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -147,7 +147,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>502 — Service Unavailable — LibNovel</title>
|
||||
<title>502 — Service Unavailable — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +28,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -126,7 +126,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Under Maintenance — LibNovel</title>
|
||||
<title>Under Maintenance — libnovel</title>
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -28,11 +29,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
/* ── Main ── */
|
||||
main {
|
||||
@@ -129,7 +129,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>504 — Gateway Timeout — LibNovel</title>
|
||||
<title>504 — Gateway Timeout — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +28,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -126,7 +126,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -126,6 +126,26 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Redis (Asynq task queue — accessed locally by backend, remotely by homelab runner) ──
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--requirepass "${REDIS_PASSWORD}"
|
||||
# No public port — backend reaches it via internal network.
|
||||
# Homelab runner reaches it via Caddy TLS proxy on :6380 → redis:6379.
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Backend API ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
image: kalekber/libnovel-backend:${GIT_TAG:-latest}
|
||||
@@ -151,6 +171,8 @@ services:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# No public port — all traffic is routed via Caddy.
|
||||
expose:
|
||||
- "8080"
|
||||
@@ -164,10 +186,9 @@ services:
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "backend"
|
||||
# Asynq task queue — backend enqueues jobs to homelab Redis via Caddy TLS proxy.
|
||||
# Set to "rediss://:password@redis.libnovel.cc:6380" in Doppler prd config.
|
||||
# Leave empty to fall back to PocketBase polling.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
|
||||
# Homelab runner connects to the same Redis via Caddy TLS proxy on :6380.
|
||||
REDIS_ADDR: "redis:6379"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||||
@@ -269,6 +290,7 @@ services:
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
AUTH_SECRET: "${AUTH_SECRET}"
|
||||
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
# Valkey
|
||||
VALKEY_ADDR: "valkey:6379"
|
||||
@@ -382,12 +404,10 @@ services:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
- "6380:6380" # Redis TCP proxy (TLS) for homelab → Asynq
|
||||
- "6380:6380" # Redis TCP proxy (TLS) for homelab runner → Asynq
|
||||
environment:
|
||||
DOMAIN: "${DOMAIN}"
|
||||
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}"
|
||||
# Homelab Redis address — Caddy TCP-proxies inbound :6380 to this.
|
||||
HOMELAB_REDIS_ADDR: "${HOMELAB_REDIS_ADDR:?HOMELAB_REDIS_ADDR required for Redis TCP proxy}"
|
||||
env_file:
|
||||
- path: ./crowdsec/.crowdsec.env
|
||||
required: false
|
||||
@@ -421,6 +441,7 @@ volumes:
|
||||
pb_data:
|
||||
meili_data:
|
||||
valkey_data:
|
||||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
|
||||
@@ -289,6 +289,48 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Redis (Asynq task queue) ────────────────────────────────────────────────
|
||||
# Dedicated Redis instance for Asynq job dispatch.
|
||||
# The prod backend enqueues jobs via redis.libnovel.cc:6380 (Caddy TLS proxy →
|
||||
# host:6379). The runner reads from this instance directly on the Docker network.
|
||||
# Port is bound to 0.0.0.0:6379 so the Caddy layer4 proxy on prod can reach it.
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── LibreTranslate ──────────────────────────────────────────────────────────
|
||||
# Self-hosted machine translation. Runner connects via http://libretranslate:5000.
|
||||
# Only English → configured target languages are loaded to save RAM.
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LT_API_KEYS: "true"
|
||||
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
|
||||
LT_LOAD_ONLY: "en,ru,id,pt,fr"
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
expose:
|
||||
- "5000"
|
||||
volumes:
|
||||
- libretranslate_data:/app/db
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5000/languages"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── Valkey ──────────────────────────────────────────────────────────────────
|
||||
# Used by GlitchTip for task queuing.
|
||||
valkey:
|
||||
@@ -469,6 +511,8 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
libretranslate_data:
|
||||
valkey_data:
|
||||
uptime_kuma_data:
|
||||
gotify_data:
|
||||
|
||||
@@ -11,25 +11,10 @@
|
||||
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
# - Redis service for Asynq task queue (local to homelab, exposed to prod via Caddy TCP proxy)
|
||||
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
|
||||
# - LibreTranslate service for machine translation (internal network only)
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--requirepass "${REDIS_PASSWORD}"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
@@ -43,22 +28,13 @@ services:
|
||||
volumes:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- libretranslate_db:/app/db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
|
||||
runner:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
- libretranslate
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
@@ -91,9 +67,10 @@ services:
|
||||
LIBRETRANSLATE_URL: "http://libretranslate:5000"
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis (local service) ───────────────────────────────────────
|
||||
# The runner connects to the local Redis sidecar.
|
||||
REDIS_ADDR: "redis:6379"
|
||||
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
|
||||
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
|
||||
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
# ── Runner tuning ───────────────────────────────────────────────────────
|
||||
@@ -117,6 +94,5 @@ services:
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
libretranslate_models:
|
||||
libretranslate_db:
|
||||
|
||||
@@ -259,6 +259,22 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "discovery_votes" '{
|
||||
"name":"discovery_votes","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"action", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "book_ratings" '{
|
||||
"name":"book_ratings","type":"base","fields":[
|
||||
{"name":"session_id","type":"text", "required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"rating", "type":"number", "required":true}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
@@ -274,5 +290,6 @@ add_field "app_users" "oauth_provider" "text"
|
||||
add_field "app_users" "oauth_id" "text"
|
||||
add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
|
||||
log "done"
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Tools",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Translation",
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
"admin_nav_logs": "Logs",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Push",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Outils",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Traduction",
|
||||
"admin_nav_changelog": "Modifications",
|
||||
"admin_nav_feedback": "Retours",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
"admin_nav_uptime": "Disponibilité",
|
||||
"admin_nav_push": "Notifications",
|
||||
|
||||
"admin_scrape_status_idle": "Inactif",
|
||||
"admin_scrape_full_catalogue": "Catalogue complet",
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
|
||||
"admin_pages_label": "Halaman",
|
||||
"admin_tools_label": "Alat",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Terjemahan",
|
||||
"admin_nav_changelog": "Perubahan",
|
||||
"admin_nav_feedback": "Masukan",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notifikasi",
|
||||
|
||||
"admin_scrape_status_idle": "Menunggu",
|
||||
"admin_scrape_full_catalogue": "Katalog penuh",
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
|
||||
"admin_pages_label": "Páginas",
|
||||
"admin_tools_label": "Ferramentas",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Áudio",
|
||||
"admin_nav_translation": "Tradução",
|
||||
"admin_nav_changelog": "Alterações",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notificações",
|
||||
|
||||
"admin_scrape_status_idle": "Ocioso",
|
||||
"admin_scrape_full_catalogue": "Catálogo completo",
|
||||
|
||||
@@ -357,6 +357,16 @@
|
||||
|
||||
"admin_pages_label": "Страницы",
|
||||
"admin_tools_label": "Инструменты",
|
||||
"admin_nav_scrape": "Скрейпинг",
|
||||
"admin_nav_audio": "Аудио",
|
||||
"admin_nav_translation": "Перевод",
|
||||
"admin_nav_changelog": "Изменения",
|
||||
"admin_nav_feedback": "Отзывы",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
"admin_nav_uptime": "Мониторинг",
|
||||
"admin_nav_push": "Уведомления",
|
||||
|
||||
"admin_scrape_status_idle": "Ожидание",
|
||||
"admin_scrape_full_catalogue": "Полный каталог",
|
||||
|
||||
@@ -147,6 +147,15 @@ html {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE / Edge legacy */
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
@@ -75,6 +75,13 @@ class AudioStore {
|
||||
*/
|
||||
seekRequest = $state<number | null>(null);
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||||
sleepUntil = $state(0);
|
||||
|
||||
/** When true, pause after the current chapter ends instead of navigating. */
|
||||
sleepAfterChapter = $state(false);
|
||||
|
||||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, navigates to the next chapter when the current one ends
|
||||
|
||||
@@ -681,6 +681,53 @@
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Sleep timer ────────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let _tick = $state(0);
|
||||
$effect(() => {
|
||||
if (!audioStore.sleepUntil) return;
|
||||
const id = setInterval(() => { _tick++; }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
_tick; // subscribe to tick updates
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
// Currently: no timer active at all
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
return;
|
||||
}
|
||||
// Currently: end-of-chapter mode — move to 15m
|
||||
if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
// Currently: timed mode — cycle to next or turn off
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return '';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -887,6 +934,30 @@
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepAfterChapter
|
||||
? 'Stop after this chapter'
|
||||
: audioStore.sleepUntil
|
||||
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
|
||||
: 'Sleep timer off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepAfterChapter}
|
||||
End Ch.
|
||||
{:else if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
|
||||
57
ui/src/lib/components/StarRating.svelte
Normal file
57
ui/src/lib/components/StarRating.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rating: number; // current user rating 0–5 (0 = unrated)
|
||||
avg?: number; // average rating
|
||||
count?: number; // total ratings
|
||||
readonly?: boolean; // display-only mode
|
||||
size?: 'sm' | 'md';
|
||||
onrate?: (r: number) => void;
|
||||
}
|
||||
|
||||
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
|
||||
|
||||
let hovered = $state(0);
|
||||
|
||||
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
onmouseenter={() => { if (!readonly) hovered = star; }}
|
||||
onmouseleave={() => { if (!readonly) hovered = 0; }}
|
||||
onclick={() => { if (!readonly) onrate?.(star); }}
|
||||
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
|
||||
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
|
||||
>
|
||||
<svg
|
||||
class="{starSize} transition-colors"
|
||||
fill={star <= display ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button[disabled] { pointer-events: none; }
|
||||
svg { color: #f59e0b; }
|
||||
</style>
|
||||
@@ -328,6 +328,16 @@ export * from './user_following_label.js'
|
||||
export * from './user_no_books.js'
|
||||
export * from './admin_pages_label.js'
|
||||
export * from './admin_tools_label.js'
|
||||
export * from './admin_nav_scrape.js'
|
||||
export * from './admin_nav_audio.js'
|
||||
export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
export * from './admin_nav_logs.js'
|
||||
export * from './admin_nav_uptime.js'
|
||||
export * from './admin_nav_push.js'
|
||||
export * from './admin_scrape_status_idle.js'
|
||||
export * from './admin_scrape_full_catalogue.js'
|
||||
export * from './admin_scrape_single_book.js'
|
||||
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_analytics.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_analytics.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_AnalyticsInputs */
|
||||
|
||||
const en_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytics`);
|
||||
const ru_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Аналитика`);
|
||||
const id_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analitik`);
|
||||
const pt_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Análise`);
|
||||
const fr_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytique`);
|
||||
|
||||
export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AnalyticsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_analytics(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_analytics(inputs)
|
||||
if (locale === "id") return id_admin_nav_analytics(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_analytics(inputs)
|
||||
return fr_admin_nav_analytics(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_audio.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_audio.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_AudioInputs */
|
||||
|
||||
const en_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
|
||||
const ru_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Аудио`);
|
||||
const id_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
|
||||
const pt_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Áudio`);
|
||||
const fr_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
|
||||
|
||||
export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_audio(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_audio(inputs)
|
||||
if (locale === "id") return id_admin_nav_audio(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_audio(inputs)
|
||||
return fr_admin_nav_audio(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_changelog.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_changelog.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ChangelogInputs */
|
||||
|
||||
const en_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Changelog`);
|
||||
const ru_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Изменения`);
|
||||
const id_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Perubahan`);
|
||||
const pt_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Alterações`);
|
||||
const fr_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Modifications`);
|
||||
|
||||
export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ChangelogInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_changelog(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_changelog(inputs)
|
||||
if (locale === "id") return id_admin_nav_changelog(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_changelog(inputs)
|
||||
return fr_admin_nav_changelog(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_errors.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_errors.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ErrorsInputs */
|
||||
|
||||
const en_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Errors`);
|
||||
const ru_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Ошибки`);
|
||||
const id_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Kesalahan`);
|
||||
const pt_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erros`);
|
||||
const fr_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erreurs`);
|
||||
|
||||
export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ErrorsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_errors(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_errors(inputs)
|
||||
if (locale === "id") return id_admin_nav_errors(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_errors(inputs)
|
||||
return fr_admin_nav_errors(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_feedback.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_feedback.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_FeedbackInputs */
|
||||
|
||||
const en_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
|
||||
const ru_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Отзывы`);
|
||||
const id_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Masukan`);
|
||||
const pt_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
|
||||
const fr_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Retours`);
|
||||
|
||||
export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_FeedbackInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_feedback(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_feedback(inputs)
|
||||
if (locale === "id") return id_admin_nav_feedback(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_feedback(inputs)
|
||||
return fr_admin_nav_feedback(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_logs.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_logs.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_LogsInputs */
|
||||
|
||||
const en_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
|
||||
const ru_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Логи`);
|
||||
const id_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Log`);
|
||||
const pt_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
|
||||
const fr_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Journaux`);
|
||||
|
||||
export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_LogsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_logs(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_logs(inputs)
|
||||
if (locale === "id") return id_admin_nav_logs(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_logs(inputs)
|
||||
return fr_admin_nav_logs(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_push.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_push.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_PushInputs */
|
||||
|
||||
const en_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Push`);
|
||||
const ru_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Уведомления`);
|
||||
const id_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifikasi`);
|
||||
const pt_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notificações`);
|
||||
const fr_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifications`);
|
||||
|
||||
export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_PushInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_push(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_push(inputs)
|
||||
if (locale === "id") return id_admin_nav_push(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_push(inputs)
|
||||
return fr_admin_nav_push(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_scrape.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_scrape.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ScrapeInputs */
|
||||
|
||||
const en_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
|
||||
const ru_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Скрейпинг`);
|
||||
const id_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
|
||||
const pt_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
|
||||
const fr_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
|
||||
|
||||
export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ScrapeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_scrape(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_scrape(inputs)
|
||||
if (locale === "id") return id_admin_nav_scrape(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_scrape(inputs)
|
||||
return fr_admin_nav_scrape(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_translation.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_translation.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_TranslationInputs */
|
||||
|
||||
const en_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Translation`);
|
||||
const ru_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Перевод`);
|
||||
const id_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Terjemahan`);
|
||||
const pt_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Tradução`);
|
||||
const fr_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Traduction`);
|
||||
|
||||
export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_translation(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_translation(inputs)
|
||||
if (locale === "id") return id_admin_nav_translation(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_translation(inputs)
|
||||
return fr_admin_nav_translation(inputs)
|
||||
});
|
||||
21
ui/src/lib/paraglide/messages/admin_nav_uptime.js
Normal file
21
ui/src/lib/paraglide/messages/admin_nav_uptime.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_UptimeInputs */
|
||||
|
||||
const en_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
|
||||
const ru_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Мониторинг`);
|
||||
const id_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
|
||||
const pt_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
|
||||
const fr_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Disponibilité`);
|
||||
|
||||
export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_UptimeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_uptime(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_uptime(inputs)
|
||||
if (locale === "id") return id_admin_nav_uptime(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_uptime(inputs)
|
||||
return fr_admin_nav_uptime(inputs)
|
||||
});
|
||||
@@ -258,8 +258,26 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
|
||||
// Build filter: slug='a' || slug='b' || ...
|
||||
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
|
||||
const books = await listAll<Book>('books', filter, '+title');
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
return books;
|
||||
|
||||
// Deduplicate by slug — PocketBase may have multiple records for the same
|
||||
// slug if the scraper ran concurrently or the upsert raced. First record wins.
|
||||
const seen = new Set<string>();
|
||||
const deduped = books.filter((b) => {
|
||||
if (seen.has(b.slug)) return false;
|
||||
seen.add(b.slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (deduped.length !== books.length) {
|
||||
log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
|
||||
requested: slugArr.length,
|
||||
raw: books.length,
|
||||
deduped: deduped.length
|
||||
});
|
||||
} else {
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||
@@ -524,7 +542,7 @@ export async function unsaveBook(
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
@@ -985,12 +1003,79 @@ export interface UserSession {
|
||||
session_id: string; // the auth session ID embedded in the token
|
||||
user_agent: string;
|
||||
ip: string;
|
||||
device_fingerprint: string;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session record on login. Returns the record ID.
|
||||
* Generate a short device fingerprint from user-agent + IP.
|
||||
* SHA-256 of the concatenation, first 16 hex chars.
|
||||
*/
|
||||
function deviceFingerprint(userAgent: string, ip: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${userAgent}::${ip}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a session record on login.
|
||||
* - If a session already exists for this user + device fingerprint, touch it and
|
||||
* return the existing authSessionId (so the caller can reuse the same token).
|
||||
* - Otherwise create a new record.
|
||||
* Returns `{ authSessionId, recordId }`.
|
||||
*/
|
||||
export async function upsertUserSession(
|
||||
userId: string,
|
||||
authSessionId: string,
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<{ authSessionId: string; recordId: string }> {
|
||||
const fp = deviceFingerprint(userAgent, ip);
|
||||
|
||||
// Look for an existing session from the same device
|
||||
const existing = await listOne<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && device_fingerprint="${fp}"`
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Touch last_seen and return the existing authSessionId
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||
}).catch(() => {});
|
||||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||||
}
|
||||
|
||||
// Create a new session record
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
device_fingerprint: fp,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale/excess sessions in the background
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return { authSessionId, recordId: rec.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use upsertUserSession instead.
|
||||
* Kept temporarily so callers can be migrated incrementally.
|
||||
*/
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
@@ -998,24 +1083,8 @@ export async function createUserSession(
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return rec.id;
|
||||
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1052,20 +1121,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days,
|
||||
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
|
||||
* Called on login so the list self-cleans without a separate cron job.
|
||||
*/
|
||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const stale = await listAll<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
||||
);
|
||||
if (stale.length === 0) return;
|
||||
async function pruneStaleUserSessions(
|
||||
userId: string,
|
||||
days = 30,
|
||||
maxSessions = 10
|
||||
): Promise<void> {
|
||||
const token = await getToken();
|
||||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const toDelete = new Set<string>();
|
||||
|
||||
// Mark stale sessions
|
||||
for (const s of all) {
|
||||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||||
}
|
||||
|
||||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||||
const remaining = all.filter((s) => !toDelete.has(s.id));
|
||||
if (remaining.length > maxSessions) {
|
||||
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
|
||||
}
|
||||
|
||||
if (toDelete.size === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
[...toDelete].map((id) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
@@ -1644,3 +1730,352 @@ export async function getSubscriptionFeed(
|
||||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||||
}
|
||||
|
||||
// ─── Discovery ────────────────────────────────────────────────────────────────
|
||||
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
|
||||
// - session_id (text, required)
|
||||
// - user_id (text, optional)
|
||||
// - slug (text, required)
|
||||
// - action (text, required) — one of: like | skip | nope | read_now
|
||||
|
||||
export interface DiscoveryVote {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
action: 'like' | 'skip' | 'nope' | 'read_now';
|
||||
}
|
||||
|
||||
export interface DiscoveryPrefs {
|
||||
genres: string[];
|
||||
status: 'either' | 'ongoing' | 'completed';
|
||||
}
|
||||
|
||||
function parseGenresLocal(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
if (!genres) return [];
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
action: DiscoveryVote['action'],
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
|
||||
const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
|
||||
} else {
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
const filter = discoveryFilter(sessionId, userId);
|
||||
const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookRating {
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
rating: number; // 1–5
|
||||
}
|
||||
|
||||
export async function getBookRating(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<number> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
|
||||
return row?.rating ?? 0;
|
||||
}
|
||||
|
||||
export async function getBookAvgRating(
|
||||
slug: string
|
||||
): Promise<{ avg: number; count: number }> {
|
||||
const rows = await listAll<BookRating>('book_ratings', `slug="${slug}"`).catch(() => []);
|
||||
if (!rows.length) return { avg: 0, count: 0 };
|
||||
const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length;
|
||||
return { avg: Math.round(avg * 10) / 10, count: rows.length };
|
||||
}
|
||||
|
||||
export async function setBookRating(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
rating: number,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
|
||||
const payload: Partial<BookRating> = { session_id: sessionId, slug, rating };
|
||||
if (userId) payload.user_id = userId;
|
||||
if (existing) {
|
||||
await pbPatch(`/api/collections/book_ratings/records/${existing.id}`, payload);
|
||||
} else {
|
||||
await pbPost('/api/collections/book_ratings/records', payload);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
|
||||
export async function updateBookShelf(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
shelf: ShelfName,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
|
||||
if (!existing) {
|
||||
// Save + set shelf in one shot
|
||||
const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
|
||||
if (userId) payload.user_id = userId;
|
||||
await pbPost('/api/collections/user_library/records', payload);
|
||||
} else {
|
||||
await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShelfMap(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<Record<string, ShelfName>> {
|
||||
const filter = userId
|
||||
? `session_id="${sessionId}" || user_id="${userId}"`
|
||||
: `session_id="${sessionId}"`;
|
||||
const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
|
||||
const map: Record<string, ShelfName> = {};
|
||||
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getBooksForDiscovery(
|
||||
sessionId: string,
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
|
||||
if (prefs?.genres?.length) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const genreFiltered = candidates.filter((b) => {
|
||||
const genres = parseGenresLocal(b.genres);
|
||||
return genres.some((g) => preferred.has(g.toLowerCase()));
|
||||
});
|
||||
if (genreFiltered.length >= 5) candidates = genreFiltered;
|
||||
}
|
||||
|
||||
if (prefs?.status && prefs.status !== 'either') {
|
||||
const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.rating;
|
||||
cur.count += 1;
|
||||
ratingMap.set(r.slug, cur);
|
||||
}
|
||||
const avgRating = (slug: string) => {
|
||||
const e = ratingMap.get(slug);
|
||||
return e && e.count > 0 ? e.sum / e.count : 0;
|
||||
};
|
||||
|
||||
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
|
||||
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
|
||||
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
|
||||
|
||||
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
|
||||
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
|
||||
let start = 0;
|
||||
while (start < candidates.length) {
|
||||
let end = start + 1;
|
||||
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
|
||||
for (let i = end - 1; i > start; i--) {
|
||||
const j = start + Math.floor(Math.random() * (i - start + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
|
||||
return candidates.slice(0, 50);
|
||||
}
|
||||
|
||||
// ─── Discovery history ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface VotedBook {
|
||||
slug: string;
|
||||
action: DiscoveryVote['action'];
|
||||
votedAt: string;
|
||||
book?: Book;
|
||||
}
|
||||
|
||||
export async function getVotedBooks(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<VotedBook[]> {
|
||||
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId),
|
||||
'-created'
|
||||
).catch(() => []);
|
||||
|
||||
if (!votes.length) return [];
|
||||
|
||||
const slugs = [...new Set(votes.map((v) => v.slug))];
|
||||
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
|
||||
return votes.map((v) => ({
|
||||
slug: v.slug,
|
||||
action: v.action,
|
||||
votedAt: v.created,
|
||||
book: bookMap.get(v.slug)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function undoDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
|
||||
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserStats {
|
||||
totalChaptersRead: number;
|
||||
booksReading: number;
|
||||
booksCompleted: number;
|
||||
booksPlanToRead: number;
|
||||
booksDropped: number;
|
||||
topGenres: string[]; // top 3 by frequency
|
||||
avgRatingGiven: number; // 0 if no ratings
|
||||
streak: number; // consecutive days with progress
|
||||
}
|
||||
|
||||
export async function getUserStats(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<UserStats> {
|
||||
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||
|
||||
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
|
||||
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
|
||||
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
|
||||
listAll<BookRating>('book_ratings', filter).catch(() => []),
|
||||
listBooks().catch(() => [] as Book[])
|
||||
]);
|
||||
|
||||
// shelf counts
|
||||
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
|
||||
for (const r of libraryRows) {
|
||||
const s = r.shelf || 'reading';
|
||||
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
|
||||
}
|
||||
|
||||
// top genres from books in progress/library
|
||||
const libSlugs = new Set(libraryRows.map((r) => r.slug));
|
||||
const progSlugs = new Set(progressRows.map((r) => r.slug));
|
||||
const allSlugs = new Set([...libSlugs, ...progSlugs]);
|
||||
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
|
||||
const genreFreq = new Map<string, number>();
|
||||
for (const slug of allSlugs) {
|
||||
const book = bookMap.get(slug);
|
||||
if (!book) continue;
|
||||
for (const g of parseGenresLocal(book.genres)) {
|
||||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const topGenres = [...genreFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
|
||||
// avg rating given
|
||||
const avgRatingGiven =
|
||||
ratingRows.length > 0
|
||||
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
|
||||
: 0;
|
||||
|
||||
// reading streak: count consecutive calendar days (UTC) with a progress update
|
||||
const days = new Set(
|
||||
progressRows
|
||||
.filter((r) => r.updated)
|
||||
.map((r) => r.updated.slice(0, 10))
|
||||
);
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||||
else if (i > 0) break; // gap — stop
|
||||
}
|
||||
|
||||
return {
|
||||
totalChaptersRead: progressRows.length,
|
||||
booksReading: shelfCounts.reading,
|
||||
booksCompleted: shelfCounts.completed,
|
||||
booksPlanToRead: shelfCounts.plan_to_read,
|
||||
booksDropped: shelfCounts.dropped,
|
||||
topGenres,
|
||||
avgRatingGiven,
|
||||
streak
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
* Product IDs (Polar dashboard):
|
||||
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
|
||||
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
|
||||
*
|
||||
* Webhook event data shapes (Polar v1 API):
|
||||
* subscription.* → data.customer_id, data.product_id, data.status, data.customer.email
|
||||
* order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
|
||||
export const POLAR_PRO_PRODUCT_IDS = new Set([
|
||||
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
|
||||
@@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo
|
||||
|
||||
// ─── Subscription event handler ───────────────────────────────────────────────
|
||||
|
||||
interface PolarCustomer {
|
||||
email?: string;
|
||||
external_id?: string; // our app_users.id if set on the customer
|
||||
}
|
||||
|
||||
interface PolarSubscription {
|
||||
id: string;
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | ...
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
customer_email?: string;
|
||||
user_id?: string; // Polar user id (not our user id)
|
||||
customer?: PolarCustomer; // nested object — email lives here
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app_user for a Polar customer.
|
||||
* Priority: polar_customer_id → email → customer.external_id (our user ID)
|
||||
*/
|
||||
async function resolveUser(customer_id: string, customer?: PolarCustomer) {
|
||||
const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase');
|
||||
|
||||
// 1. By stored polar_customer_id (fastest on repeat events)
|
||||
const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (byCustomerId) return byCustomerId;
|
||||
|
||||
// 2. By email (most common first-time path)
|
||||
const email = customer?.email;
|
||||
if (email) {
|
||||
const byEmail = await getUserByEmail(email).catch(() => null);
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
// 3. By external_id = our user ID (if set via Polar API on customer creation)
|
||||
const externalId = customer?.external_id;
|
||||
if (externalId) {
|
||||
const byId = await getUserById(externalId).catch(() => null);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Polar subscription event.
|
||||
* Finds the matching app_user by email and updates role + polar fields.
|
||||
* Finds the matching app_user and updates role + polar fields.
|
||||
*/
|
||||
export async function handleSubscriptionEvent(
|
||||
eventType: string,
|
||||
subscription: PolarSubscription
|
||||
): Promise<void> {
|
||||
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
|
||||
const { id: subId, status, product_id, customer_id, customer } = subscription;
|
||||
|
||||
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
|
||||
log.info('polar', 'subscription event', {
|
||||
eventType, subId, status, product_id,
|
||||
customer_email: customer?.email
|
||||
});
|
||||
|
||||
if (!customer_email) {
|
||||
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by their polar_customer_id first (faster on repeat events), then by email
|
||||
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (!user) {
|
||||
const { getUserByEmail } = await import('$lib/server/pocketbase');
|
||||
user = await getUserByEmail(customer_email).catch(() => null);
|
||||
}
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
|
||||
if (!user) {
|
||||
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
|
||||
log.warn('polar', 'no app_user found for polar customer', {
|
||||
customer_email: customer?.email,
|
||||
customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,5 +135,60 @@ export async function handleSubscriptionEvent(
|
||||
polar_subscription_id: isActive ? subId : ''
|
||||
});
|
||||
|
||||
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
|
||||
log.info('polar', 'user role updated', {
|
||||
userId: user.id, username: user.username, newRole, status
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Order event handler ──────────────────────────────────────────────────────
|
||||
|
||||
interface PolarOrder {
|
||||
id: string;
|
||||
status: string;
|
||||
billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update"
|
||||
product_id: string | null;
|
||||
customer_id: string;
|
||||
subscription_id: string | null;
|
||||
customer?: PolarCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order.created — used for initial subscription purchases.
|
||||
* We only act on subscription_create billing_reason to avoid double-processing
|
||||
* (subscription.active will also fire, but this ensures we catch edge cases).
|
||||
*/
|
||||
export async function handleOrderCreated(order: PolarOrder): Promise<void> {
|
||||
const { id: orderId, billing_reason, product_id, customer_id, customer } = order;
|
||||
|
||||
log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email });
|
||||
|
||||
// Only handle new subscription purchases here; renewals are handled by subscription.updated
|
||||
if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') {
|
||||
log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) {
|
||||
log.debug('polar', 'order.created — product not a pro product', { product_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
if (!user) {
|
||||
log.warn('polar', 'order.created — no app_user found', {
|
||||
customer_email: customer?.email, customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upgrade if not already pro/admin — subscription.active will do a full sync too
|
||||
if (user.role !== 'pro' && user.role !== 'admin') {
|
||||
await patchUser(user.id, {
|
||||
role: 'pro',
|
||||
polar_customer_id: customer_id
|
||||
});
|
||||
log.info('polar', 'order.created — user upgraded to pro', {
|
||||
userId: user.id, username: user.username
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chapterDrawerOpen && activeChapterEl) {
|
||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
// The single <audio> element that persists across navigations.
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
@@ -155,6 +170,23 @@
|
||||
audioStore.seekRequest = null;
|
||||
});
|
||||
|
||||
// Sleep timer — fires once when time is up
|
||||
$effect(() => {
|
||||
const until = audioStore.sleepUntil;
|
||||
if (!until) return;
|
||||
const ms = until - Date.now();
|
||||
if (ms <= 0) {
|
||||
audioStore.sleepUntil = 0;
|
||||
if (audioStore.isPlaying) audioStore.toggleRequest++;
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
audioStore.sleepUntil = 0;
|
||||
if (audioStore.isPlaying) audioStore.toggleRequest++;
|
||||
}, ms);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
@@ -257,6 +289,12 @@
|
||||
onended={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
// If sleep-after-chapter is set, just pause instead of navigating
|
||||
if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
// Don't navigate — just let it end. Audio is already stopped (ended).
|
||||
return;
|
||||
}
|
||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||
// Capture values synchronously before any async work — the AudioPlayer
|
||||
// component will unmount during navigation, but we've already read what
|
||||
@@ -302,21 +340,18 @@
|
||||
>
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/discover"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
@@ -417,6 +452,18 @@
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{m.nav_feedback()}
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
|
||||
@@ -478,6 +525,13 @@
|
||||
>
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/discover"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
onclick={() => (menuOpen = false)}
|
||||
@@ -490,9 +544,12 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text) flex items-center justify-between"
|
||||
>
|
||||
{m.nav_feedback()} ↗
|
||||
{m.nav_feedback()}
|
||||
<svg class="w-3.5 h-3.5 opacity-50" 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>
|
||||
<a
|
||||
href="/profile"
|
||||
@@ -665,6 +722,7 @@
|
||||
</div>
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
|
||||
8
ui/src/routes/admin/+layout.server.ts
Normal file
8
ui/src/routes/admin/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
@@ -3,30 +3,48 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
{ href: '/admin/audio', label: 'Audio' },
|
||||
{ href: '/admin/translation', label: 'Translation' },
|
||||
{ href: '/admin/changelog', label: 'Changelog' }
|
||||
{ href: '/admin/scrape', label: () => m.admin_nav_scrape() },
|
||||
{ href: '/admin/audio', label: () => m.admin_nav_audio() },
|
||||
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
|
||||
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' },
|
||||
{ href: 'https://errors.libnovel.cc', label: 'Errors' },
|
||||
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' },
|
||||
{ href: 'https://logs.libnovel.cc', label: 'Logs' },
|
||||
{ href: 'https://uptime.libnovel.cc', label: 'Uptime' },
|
||||
{ href: 'https://push.libnovel.cc', label: 'Push' }
|
||||
{ href: 'https://feedback.libnovel.cc', label: () => m.admin_nav_feedback() },
|
||||
{ href: 'https://errors.libnovel.cc', label: () => m.admin_nav_errors() },
|
||||
{ href: 'https://analytics.libnovel.cc', label: () => m.admin_nav_analytics() },
|
||||
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
|
||||
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
|
||||
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
{#if sidebarOpen}
|
||||
<button
|
||||
class="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
aria-label="Close sidebar"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] gap-0">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
|
||||
<aside
|
||||
class="
|
||||
fixed top-0 left-0 h-full z-50 w-56 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6
|
||||
bg-(--color-surface) transition-transform duration-200
|
||||
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
md:relative md:translate-x-0 md:w-48 md:z-auto md:top-auto md:h-auto
|
||||
"
|
||||
>
|
||||
<!-- Internal pages -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
@@ -34,12 +52,13 @@
|
||||
{#each internalLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{page.url.pathname.startsWith(link.href)
|
||||
? 'bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
|
||||
>
|
||||
{link.label}
|
||||
{link.label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -56,7 +75,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
|
||||
>
|
||||
{link.label}
|
||||
{link.label()}
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" 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" />
|
||||
@@ -68,7 +87,19 @@
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 min-w-0 px-8 py-6">
|
||||
<main class="flex-1 min-w-0 px-4 py-6 md:px-8">
|
||||
<!-- Mobile nav toggle -->
|
||||
<button
|
||||
onclick={() => (sidebarOpen = true)}
|
||||
class="md:hidden mb-4 flex items-center gap-2 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
Admin menu
|
||||
</button>
|
||||
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
6
ui/src/routes/admin/+page.server.ts
Normal file
6
ui/src/routes/admin/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(302, '/admin/scrape');
|
||||
};
|
||||
73
ui/src/routes/api/auth/debug-login/+server.ts
Normal file
73
ui/src/routes/api/auth/debug-login/+server.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getUserByUsername, createUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* GET /api/auth/debug-login?token=<DEBUG_LOGIN_TOKEN>&username=<username>
|
||||
*
|
||||
* One-shot debug bypass: verifies a shared secret token, then mints a real
|
||||
* auth cookie for the given user (defaults to the first admin account) and
|
||||
* redirects to /.
|
||||
*
|
||||
* Requires DEBUG_LOGIN_TOKEN env var to be set. Disabled (404) when the var
|
||||
* is absent or empty.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const debugToken = env.DEBUG_LOGIN_TOKEN ?? '';
|
||||
if (!debugToken) {
|
||||
error(404, 'Not found');
|
||||
}
|
||||
|
||||
const provided = url.searchParams.get('token') ?? '';
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (provided.length !== debugToken.length) {
|
||||
log.warn('api/auth/debug-login', 'bad token attempt');
|
||||
error(401, 'Invalid token');
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < debugToken.length; i++) {
|
||||
diff |= provided.charCodeAt(i) ^ debugToken.charCodeAt(i);
|
||||
}
|
||||
if (diff !== 0) {
|
||||
log.warn('api/auth/debug-login', 'bad token attempt');
|
||||
error(401, 'Invalid token');
|
||||
}
|
||||
|
||||
const username = url.searchParams.get('username') ?? 'kamil_alekber_2e99';
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) {
|
||||
error(404, `User '${username}' not found`);
|
||||
}
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'debug';
|
||||
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/debug-login', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
log.info('api/auth/debug-login', 'debug login used', { username: user.username, ip });
|
||||
|
||||
const next = url.searchParams.get('next') ?? '/';
|
||||
redirect(302, next);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { loginUser, mergeSessionProgress, upsertUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
@@ -48,16 +48,20 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
const candidateSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
let authSessionId = candidateSessionId;
|
||||
try {
|
||||
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
|
||||
} catch (e) {
|
||||
log.warn('api/auth/login', 'upsertUserSession failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
|
||||
106
ui/src/routes/api/checkout/+server.ts
Normal file
106
ui/src/routes/api/checkout/+server.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||
|
||||
const POLAR_API_BASE = 'https://api.polar.sh';
|
||||
|
||||
const PRICE_IDS: Record<string, string> = {
|
||||
monthly: '9c0eea36-4f4a-4fd6-970b-d176588d4771',
|
||||
annual: '5a5be04e-f252-4a30-8f8b-858b40ec33e4'
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/checkout
|
||||
* Body: { product: 'monthly' | 'annual' }
|
||||
*
|
||||
* Creates a Polar server-side checkout session with:
|
||||
* - external_customer_id = locals.user.id (so webhooks can match back to us)
|
||||
* - customer_email locked to the logged-in user's email (email field disabled in UI)
|
||||
* - allow_discount_codes: true
|
||||
* - success_url redirects to /profile?subscribed=1
|
||||
*
|
||||
* Returns: { url: string }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
const apiToken = env.POLAR_API_TOKEN;
|
||||
if (!apiToken) {
|
||||
log.error('checkout', 'POLAR_API_TOKEN not set');
|
||||
error(500, 'Checkout unavailable');
|
||||
}
|
||||
|
||||
let product: string;
|
||||
try {
|
||||
const body = await request.json() as { product?: unknown };
|
||||
product = String(body?.product ?? '');
|
||||
} catch {
|
||||
error(400, 'Invalid request body');
|
||||
}
|
||||
|
||||
const priceId = PRICE_IDS[product];
|
||||
if (!priceId) {
|
||||
error(400, `Unknown product: ${product}. Use 'monthly' or 'annual'.`);
|
||||
}
|
||||
|
||||
// Fetch the user's email from PocketBase (not in the auth token)
|
||||
let email: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
email = record?.email ?? null;
|
||||
} catch (e) {
|
||||
log.warn('checkout', 'failed to fetch user email (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Create a server-side checkout session on Polar
|
||||
// https://docs.polar.sh/api-reference/checkouts/create
|
||||
const payload = {
|
||||
product_price_id: priceId,
|
||||
allow_discount_codes: true,
|
||||
success_url: 'https://libnovel.cc/profile?subscribed=1',
|
||||
customer_external_id: locals.user.id,
|
||||
...(email ? { customer_email: email } : {})
|
||||
};
|
||||
|
||||
log.info('checkout', 'creating polar checkout session', {
|
||||
userId: locals.user.id,
|
||||
product,
|
||||
email: email ?? '(none)'
|
||||
});
|
||||
|
||||
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('checkout', 'polar checkout creation failed', {
|
||||
status: res.status,
|
||||
body: text.slice(0, 500)
|
||||
});
|
||||
error(502, 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const data = await res.json() as { url?: string; id?: string };
|
||||
const checkoutUrl = data?.url;
|
||||
|
||||
if (!checkoutUrl) {
|
||||
log.error('checkout', 'polar response missing url', { data: JSON.stringify(data).slice(0, 200) });
|
||||
error(502, 'Invalid checkout response from Polar');
|
||||
}
|
||||
|
||||
log.info('checkout', 'checkout session created', {
|
||||
userId: locals.user.id,
|
||||
checkoutId: data?.id,
|
||||
product
|
||||
});
|
||||
|
||||
return json({ url: checkoutUrl });
|
||||
};
|
||||
39
ui/src/routes/api/discover/vote/+server.ts
Normal file
39
ui/src/routes/api/discover/vote/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook } from '$lib/server/pocketbase';
|
||||
|
||||
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
|
||||
type Action = (typeof VALID_ACTIONS)[number];
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body.slug !== 'string' || !VALID_ACTIONS.includes(body.action)) {
|
||||
error(400, 'Expected { slug, action }');
|
||||
}
|
||||
const action = body.action as Action;
|
||||
try {
|
||||
await upsertDiscoveryVote(locals.sessionId, body.slug, action, locals.user?.id);
|
||||
if (action === 'like' || action === 'read_now') {
|
||||
await saveBook(locals.sessionId, body.slug, locals.user?.id);
|
||||
}
|
||||
} catch (e) {
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
// DELETE /api/discover/vote → clear all (deck reset)
|
||||
// DELETE /api/discover/vote?slug=... → undo single vote
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
try {
|
||||
if (slug) {
|
||||
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
|
||||
} else {
|
||||
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
||||
}
|
||||
} catch {
|
||||
error(500, 'Failed to clear votes');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
29
ui/src/routes/api/export/[slug]/+server.ts
Normal file
29
ui/src/routes/api/export/[slug]/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug } = params;
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (from) qs.set('from', from);
|
||||
if (to) qs.set('to', to);
|
||||
const query = qs.size ? `?${qs}` : '';
|
||||
|
||||
const res = await backendFetch(`/api/export/${encodeURIComponent(slug)}${query}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
error(res.status as Parameters<typeof error>[0], text || 'Export failed');
|
||||
}
|
||||
|
||||
const bytes = await res.arrayBuffer();
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': 'application/epub+zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}.epub"`
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
|
||||
import { saveBook, unsaveBook, updateBookShelf } from '$lib/server/pocketbase';
|
||||
import type { ShelfName } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -32,3 +33,17 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/library/[slug]
|
||||
* Update the shelf category for a saved book.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const shelf = body?.shelf ?? '';
|
||||
const VALID = ['', 'plan_to_read', 'completed', 'dropped'];
|
||||
if (!VALID.includes(shelf)) error(400, 'invalid shelf');
|
||||
await updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
24
ui/src/routes/api/ratings/[slug]/+server.ts
Normal file
24
ui/src/routes/api/ratings/[slug]/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBookRating, getBookAvgRating, setBookRating } from '$lib/server/pocketbase';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
const [userRating, avg] = await Promise.all([
|
||||
getBookRating(locals.sessionId, slug, locals.user?.id),
|
||||
getBookAvgRating(slug)
|
||||
]);
|
||||
return json({ userRating, avg: avg.avg, count: avg.count });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const rating = body?.rating;
|
||||
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
|
||||
error(400, 'rating must be 1–5');
|
||||
}
|
||||
await setBookRating(locals.sessionId, slug, rating, locals.user?.id);
|
||||
const avg = await getBookAvgRating(slug);
|
||||
return json({ ok: true, avg: avg.avg, count: avg.count });
|
||||
};
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent, handleOrderCreated } from '$lib/server/polar';
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/polar
|
||||
*
|
||||
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
|
||||
* Signature is verified via HMAC-SHA256 before any processing.
|
||||
*
|
||||
* Handled events:
|
||||
* subscription.created — new subscription (status may be "active" or "trialing")
|
||||
* subscription.active — subscription became active (e.g. after payment)
|
||||
* subscription.updated — catch-all: cancellations, renewals, plan changes
|
||||
* subscription.canceled — cancel_at_period_end=true, still active until period end
|
||||
* subscription.revoked — access ended, downgrade to free
|
||||
* order.created — purchase / subscription_create: fast-path upgrade
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const rawBody = await request.text();
|
||||
@@ -30,14 +38,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'subscription.created':
|
||||
case 'subscription.active':
|
||||
case 'subscription.updated':
|
||||
case 'subscription.canceled':
|
||||
case 'subscription.revoked':
|
||||
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
break;
|
||||
|
||||
case 'order.created':
|
||||
// One-time purchases — no role change needed for now
|
||||
log.info('polar', 'order.created (no action)', { orderId: data.id });
|
||||
await handleOrderCreated(data as unknown as Parameters<typeof handleOrderCreated>[0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
linkOAuthToUser
|
||||
} from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { upsertUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
type Provider = 'google' | 'github';
|
||||
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
|
||||
|
||||
// ─── Handler ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies, locals, request }) => {
|
||||
const provider = params.provider as Provider;
|
||||
if (provider !== 'google' && provider !== 'github') {
|
||||
error(404, 'Unknown OAuth provider');
|
||||
@@ -226,21 +226,19 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
// ── Create session + auth cookie ──────────────────────────────────────────
|
||||
// ── Create / reuse session + auth cookie ─────────────────────────────────
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
const candidateSessionId = randomBytes(16).toString('hex');
|
||||
let authSessionId: string;
|
||||
|
||||
// Reuse existing session if the user is already logged in as the same user
|
||||
if (locals.user?.id === user.id && locals.user?.authSessionId) {
|
||||
authSessionId = locals.user.authSessionId;
|
||||
// Just touch the existing session to update last_seen
|
||||
touchUserSession(authSessionId).catch(() => {});
|
||||
} else {
|
||||
authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = ''; // not available in RequestHandler — omit
|
||||
const ip = '';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
try {
|
||||
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
|
||||
} catch (err) {
|
||||
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
|
||||
authSessionId = candidateSessionId;
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs, getShelfMap } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
||||
let savedSlugs: Set<string> = new Set();
|
||||
let shelfMap: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
[progressList, savedSlugs] = await Promise.all([
|
||||
[progressList, savedSlugs, shelfMap] = await Promise.all([
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id),
|
||||
getShelfMap(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load library data', { err: String(e) });
|
||||
@@ -46,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
books: [...withProgress, ...savedOnly],
|
||||
progressMap,
|
||||
savedSlugs: [...savedSlugs]
|
||||
savedSlugs: [...savedSlugs],
|
||||
shelfMap
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,32 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
let activeShelf = $state<Shelf | 'all'>('all');
|
||||
|
||||
const shelfLabels: Record<string, string> = {
|
||||
all: 'All',
|
||||
'': 'Reading',
|
||||
plan_to_read: 'Plan to Read',
|
||||
completed: 'Completed',
|
||||
dropped: 'Dropped'
|
||||
};
|
||||
|
||||
const shelfMap = $derived(data.shelfMap as Record<string, string>);
|
||||
const filteredBooks = $derived(
|
||||
activeShelf === 'all'
|
||||
? data.books
|
||||
: data.books.filter((b) => (shelfMap[b.slug] ?? '') === activeShelf)
|
||||
);
|
||||
|
||||
const shelfCounts = $derived({
|
||||
all: data.books.length,
|
||||
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
|
||||
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
|
||||
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
|
||||
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -37,10 +63,29 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shelf tabs -->
|
||||
<div class="flex gap-1 flex-wrap mb-4">
|
||||
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
|
||||
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeShelf = shelf)}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
|
||||
{activeShelf === shelf
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each data.books as book}
|
||||
{#each filteredBooks as book}
|
||||
{@const lastChapter = data.progressMap[book.slug]}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
{@const bookShelf = shelfMap[book.slug] ?? ''}
|
||||
<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"
|
||||
@@ -85,6 +130,11 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bookShelf && activeShelf === 'all'}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) self-start">
|
||||
{shelfLabels[bookShelf] ?? bookShelf}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||
|
||||
@@ -15,13 +15,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (book) {
|
||||
// Book is in the library — normal path
|
||||
let chapters, progress, saved, readersThisWeek;
|
||||
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
|
||||
try {
|
||||
[chapters, progress, saved, readersThisWeek] = await Promise.all([
|
||||
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id),
|
||||
countReadersThisWeek(slug)
|
||||
countReadersThisWeek(slug),
|
||||
getBookRating(locals.sessionId, slug, locals.user?.id),
|
||||
getBookAvgRating(slug)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load book page data', { slug, err: String(e) });
|
||||
@@ -35,6 +37,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
readersThisWeek,
|
||||
userRating: userRating ?? 0,
|
||||
ratingAvg: ratingAvg ?? { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
@@ -58,6 +62,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: false,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
userRating: 0,
|
||||
ratingAvg: { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
@@ -95,6 +101,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
userRating: 0,
|
||||
ratingAvg: { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import StarRating from '$lib/components/StarRating.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { ShelfName } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -17,6 +19,37 @@
|
||||
let saved = $state(untrack(() => data.saved));
|
||||
let saving = $state(false);
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
let userRating = $state(data.userRating ?? 0);
|
||||
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
|
||||
|
||||
async function rate(r: number) {
|
||||
userRating = r;
|
||||
try {
|
||||
const res = await fetch(`/api/ratings/${encodeURIComponent(data.book?.slug ?? '')}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating: r })
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
ratingAvg = { avg: body.avg, count: body.count };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Shelf ─────────────────────────────────────────────────────────────────
|
||||
let currentShelf = $state<ShelfName>('');
|
||||
|
||||
async function setShelf(shelf: ShelfName) {
|
||||
currentShelf = shelf;
|
||||
await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shelf })
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSave() {
|
||||
if (saving || !data.book) return;
|
||||
saving = true;
|
||||
@@ -286,6 +319,31 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ratings + shelf — desktop -->
|
||||
<div class="hidden sm:flex items-center gap-3 flex-wrap mt-1">
|
||||
<StarRating
|
||||
rating={userRating}
|
||||
avg={ratingAvg.avg}
|
||||
count={ratingAvg.count}
|
||||
onrate={rate}
|
||||
size="md"
|
||||
/>
|
||||
{#if saved}
|
||||
<div class="relative">
|
||||
<select
|
||||
value={currentShelf}
|
||||
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
|
||||
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
|
||||
>
|
||||
<option value="">Reading</option>
|
||||
<option value="plan_to_read">Plan to Read</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -346,10 +404,55 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Ratings + shelf — mobile -->
|
||||
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
|
||||
<StarRating
|
||||
rating={userRating}
|
||||
avg={ratingAvg.avg}
|
||||
count={ratingAvg.count}
|
||||
onrate={rate}
|
||||
size="sm"
|
||||
/>
|
||||
{#if saved}
|
||||
<div class="relative">
|
||||
<select
|
||||
value={currentShelf}
|
||||
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
|
||||
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
|
||||
>
|
||||
<option value="">Reading</option>
|
||||
<option value="plan_to_read">Plan to Read</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Download row ══ -->
|
||||
{#if data.inLib && chapterList.length > 0}
|
||||
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
|
||||
<svg class="w-4 h-4 text-(--color-muted) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">Download</p>
|
||||
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/export/{book.slug}"
|
||||
download="{book.slug}.epub"
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
|
||||
>
|
||||
.epub
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
||||
<a
|
||||
|
||||
17
ui/src/routes/discover/+page.server.ts
Normal file
17
ui/src/routes/discover/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksForDiscovery, getVotedBooks } from '$lib/server/pocketbase';
|
||||
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
let prefs: DiscoveryPrefs | undefined;
|
||||
const prefsParam = url.searchParams.get('prefs');
|
||||
if (prefsParam) {
|
||||
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const [books, votedBooks] = await Promise.all([
|
||||
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
|
||||
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
|
||||
]);
|
||||
return { books, votedBooks };
|
||||
};
|
||||
698
ui/src/routes/discover/+page.svelte
Normal file
698
ui/src/routes/discover/+page.svelte
Normal file
@@ -0,0 +1,698 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { Book, VotedBook } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Preferences (localStorage) ──────────────────────────────────────────────
|
||||
interface Prefs {
|
||||
genres: string[];
|
||||
status: 'either' | 'ongoing' | 'completed';
|
||||
onboarded: boolean;
|
||||
}
|
||||
const PREFS_KEY = 'discover_prefs_v1';
|
||||
const GENRES = [
|
||||
'Martial Arts', 'Xianxia', 'Xuanhuan', 'Wuxia', 'Cultivation',
|
||||
'Romance', 'Action', 'Adventure', 'Fantasy', 'System',
|
||||
'Harem', 'Historical', 'Comedy', 'Drama', 'Mystery',
|
||||
'Sci-Fi', 'Horror', 'Slice of Life'
|
||||
];
|
||||
|
||||
function loadPrefs(): Prefs {
|
||||
if (!browser) return { genres: [], status: 'either', onboarded: false };
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
if (raw) return JSON.parse(raw) as Prefs;
|
||||
} catch { /* ignore */ }
|
||||
return { genres: [], status: 'either', onboarded: false };
|
||||
}
|
||||
function savePrefs(p: Prefs) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
let prefs = $state<Prefs>(loadPrefs());
|
||||
let showOnboarding = $state(!prefs.onboarded);
|
||||
|
||||
// Onboarding temp state
|
||||
let tempGenres = $state<string[]>([...prefs.genres]);
|
||||
let tempStatus = $state<Prefs['status']>(prefs.status);
|
||||
|
||||
function finishOnboarding(skip = false) {
|
||||
if (!skip) {
|
||||
prefs = { genres: tempGenres, status: tempStatus, onboarded: true };
|
||||
} else {
|
||||
prefs = { ...prefs, onboarded: true };
|
||||
}
|
||||
savePrefs(prefs);
|
||||
showOnboarding = false;
|
||||
}
|
||||
|
||||
// ── Book deck (client-side filtered) ───────────────────────────────────────
|
||||
function parseBookGenres(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
if (!genres) return [];
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
let deck = $derived.by(() => {
|
||||
let books = data.books as Book[];
|
||||
if (prefs.onboarded && prefs.genres.length > 0) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const filtered = books.filter((b) => {
|
||||
const g = parseBookGenres(b.genres);
|
||||
return g.some((genre) => preferred.has(genre.toLowerCase()));
|
||||
});
|
||||
if (filtered.length >= 5) books = filtered;
|
||||
}
|
||||
if (prefs.onboarded && prefs.status !== 'either') {
|
||||
const sf = books.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||||
if (sf.length >= 3) books = sf;
|
||||
}
|
||||
return books;
|
||||
});
|
||||
|
||||
// ── Card state ──────────────────────────────────────────────────────────────
|
||||
let idx = $state(0);
|
||||
let isDragging = $state(false);
|
||||
let animating = $state(false);
|
||||
let offsetX = $state(0);
|
||||
let offsetY = $state(0);
|
||||
let transitioning = $state(false);
|
||||
let showPreview = $state(false);
|
||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
||||
let activeTab = $state<'discover' | 'history'>('discover');
|
||||
|
||||
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
|
||||
|
||||
// Keep in sync if server data refreshes
|
||||
$effect(() => {
|
||||
votedBooks = data.votedBooks ?? [];
|
||||
});
|
||||
|
||||
async function undoVote(slug: string) {
|
||||
// Optimistic update
|
||||
votedBooks = votedBooks.filter((v) => v.slug !== slug);
|
||||
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
let startX = 0, startY = 0, hasMoved = false;
|
||||
|
||||
let currentBook = $derived(deck[idx] as Book | undefined);
|
||||
let nextBook = $derived(deck[idx + 1] as Book | undefined);
|
||||
let nextNextBook = $derived(deck[idx + 2] as Book | undefined);
|
||||
let deckEmpty = $derived(!currentBook);
|
||||
let totalRemaining = $derived(Math.max(0, deck.length - idx));
|
||||
|
||||
// Which direction/indicator to show
|
||||
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | 'nope' | null => {
|
||||
if (!isDragging) return null;
|
||||
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
|
||||
const threshold = 20;
|
||||
if (ax > ay) {
|
||||
if (offsetX > threshold) return 'like';
|
||||
if (offsetX < -threshold) return 'skip';
|
||||
} else {
|
||||
if (offsetY < -threshold) return 'read_now';
|
||||
if (offsetY > threshold) return 'nope';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const indicatorOpacity = $derived(
|
||||
isDragging
|
||||
? Math.min(1, Math.max(Math.abs(offsetX), Math.abs(offsetY)) / 60)
|
||||
: 0
|
||||
);
|
||||
const rotation = $derived(isDragging ? Math.max(-18, Math.min(18, offsetX / 12)) : 0);
|
||||
|
||||
let cardEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (animating || !currentBook) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
hasMoved = false;
|
||||
isDragging = true;
|
||||
transitioning = false;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
offsetX = e.clientX - startX;
|
||||
offsetY = e.clientY - startY;
|
||||
if (Math.abs(offsetX) > 5 || Math.abs(offsetY) > 5) hasMoved = true;
|
||||
}
|
||||
|
||||
async function onPointerUp(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
const THRESHOLD_X = 90;
|
||||
const THRESHOLD_Y = 70;
|
||||
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
|
||||
|
||||
if (ax > ay && ax > THRESHOLD_X) {
|
||||
await doAction(offsetX > 0 ? 'like' : 'skip');
|
||||
} else if (ay > ax && ay > THRESHOLD_Y) {
|
||||
await doAction(offsetY < 0 ? 'read_now' : 'nope');
|
||||
} else if (!hasMoved) {
|
||||
// Tap without drag → preview
|
||||
showPreview = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
} else {
|
||||
// Snap back
|
||||
transitioning = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
await delay(320);
|
||||
transitioning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
|
||||
|
||||
type VoteAction = 'like' | 'skip' | 'nope' | 'read_now';
|
||||
|
||||
const flyTargets: Record<VoteAction, { x: number; y: number }> = {
|
||||
like: { x: 1300, y: -80 },
|
||||
skip: { x: -1300, y: -80 },
|
||||
read_now: { x: 30, y: -1300 },
|
||||
nope: { x: 0, y: 1300 }
|
||||
};
|
||||
|
||||
async function doAction(action: VoteAction) {
|
||||
if (animating || !currentBook) return;
|
||||
animating = true;
|
||||
const book = currentBook;
|
||||
|
||||
// Record vote (fire and forget)
|
||||
fetch('/api/discover/vote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: book.slug, action })
|
||||
});
|
||||
|
||||
// Fly out
|
||||
transitioning = true;
|
||||
const target = flyTargets[action];
|
||||
offsetX = target.x;
|
||||
offsetY = target.y;
|
||||
|
||||
await delay(360);
|
||||
|
||||
// Advance
|
||||
voted = { slug: book.slug, action };
|
||||
idx++;
|
||||
transitioning = false;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
animating = false;
|
||||
showPreview = false;
|
||||
|
||||
if (action === 'read_now') {
|
||||
goto(`/books/${book.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDeck() {
|
||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||
votedBooks = [];
|
||||
idx = 0;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
|
||||
{#if showOnboarding}
|
||||
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-bold text-(--color-text) mb-1">What do you like to read?</h2>
|
||||
<p class="text-sm text-(--color-muted)">We'll show you books you'll actually enjoy. Skip to see everything.</p>
|
||||
</div>
|
||||
|
||||
<!-- Genre pills -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Genres</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each GENRES as genre}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (tempGenres.includes(genre)) {
|
||||
tempGenres = tempGenres.filter((g) => g !== genre);
|
||||
} else {
|
||||
tempGenres = [...tempGenres, genre];
|
||||
}
|
||||
}}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-all
|
||||
{tempGenres.includes(genre)
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:border-(--color-border) hover:text-(--color-text)'}"
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Status</p>
|
||||
<div class="flex gap-2">
|
||||
{#each (['either', 'ongoing', 'completed'] as const) as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (tempStatus = s)}
|
||||
class="flex-1 py-2 rounded-xl text-sm font-medium border transition-all
|
||||
{tempStatus === s
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:text-(--color-text)'}"
|
||||
>
|
||||
{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Start Discovering
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
||||
{#if showPreview && currentBook}
|
||||
{@const previewBook = currentBook!}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
||||
role="presentation"
|
||||
onclick={() => (showPreview = false)}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showPreview = false; }}
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Cover strip -->
|
||||
<div class="relative h-40 overflow-hidden">
|
||||
{#if previewBook.cover}
|
||||
<img src={previewBook.cover} alt={previewBook.title} class="w-full h-full object-cover object-top" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-12 h-12 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-5">
|
||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
|
||||
{#if previewBook.author}
|
||||
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
|
||||
{/if}
|
||||
{#if previewBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
{#each parseBookGenres(previewBook.genres).slice(0, 4) as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
{#if previewBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
|
||||
{/if}
|
||||
{#if previewBook.total_chapters}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('skip'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('read_now'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
Read Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('like'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors"
|
||||
>
|
||||
Add ♥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
|
||||
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-4 pt-8 pb-6 select-none">
|
||||
<!-- Header -->
|
||||
<div class="w-full max-w-sm flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab switcher -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'discover')}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'history')}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
History {#if votedBooks.length}({votedBooks.length}){/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'discover'}
|
||||
{#if deckEmpty}
|
||||
<!-- Empty state -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">
|
||||
📚
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}
|
||||
Try adjusting your preferences to see more.
|
||||
{:else}
|
||||
Check your library for books you liked.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const book = currentBook!}
|
||||
<!-- Card stack -->
|
||||
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
|
||||
|
||||
<!-- Back card 2 -->
|
||||
{#if nextNextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
|
||||
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextNextBook.cover}
|
||||
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back card 1 -->
|
||||
{#if nextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
|
||||
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextBook.cover}
|
||||
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active card -->
|
||||
{#if currentBook}
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="
|
||||
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
|
||||
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
|
||||
touch-action: none;
|
||||
"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<!-- Cover image -->
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 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}
|
||||
|
||||
<!-- Bottom gradient + info -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
|
||||
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
|
||||
{#if book.author}
|
||||
<p class="text-white/70 text-sm mb-2">{book.author}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
{/if}
|
||||
{#if book.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIKE indicator (right swipe) -->
|
||||
<div
|
||||
class="absolute top-8 right-6 px-3 py-1.5 rounded-lg border-2 border-green-400 rotate-[-15deg] pointer-events-none"
|
||||
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-green-400 font-black text-lg tracking-widest">LIKE</span>
|
||||
</div>
|
||||
|
||||
<!-- SKIP indicator (left swipe) -->
|
||||
<div
|
||||
class="absolute top-8 left-6 px-3 py-1.5 rounded-lg border-2 border-red-400 rotate-[15deg] pointer-events-none"
|
||||
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-red-400 font-black text-lg tracking-widest">SKIP</span>
|
||||
</div>
|
||||
|
||||
<!-- READ NOW indicator (swipe up) -->
|
||||
<div
|
||||
class="absolute top-8 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-blue-400 pointer-events-none"
|
||||
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-blue-400 font-black text-lg tracking-widest">READ NOW</span>
|
||||
</div>
|
||||
|
||||
<!-- NOPE indicator (swipe down) -->
|
||||
<div
|
||||
class="absolute bottom-28 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-(--color-muted) pointer-events-none"
|
||||
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="w-full max-w-sm flex items-center justify-center gap-4 mt-6">
|
||||
<!-- Skip (left) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('skip')}
|
||||
disabled={animating}
|
||||
title="Skip"
|
||||
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-red-400 hover:bg-red-400/10 hover:border-red-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Read Now (up) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('read_now')}
|
||||
disabled={animating}
|
||||
title="Read Now"
|
||||
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-blue-400 hover:bg-blue-400/10 hover:border-blue-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Preview (center) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPreview = true)}
|
||||
disabled={animating}
|
||||
title="Details"
|
||||
class="w-10 h-10 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Like (right) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('like')}
|
||||
disabled={animating}
|
||||
title="Add to Library"
|
||||
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-success) hover:bg-green-400/10 hover:border-green-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Nope (down) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('nope')}
|
||||
disabled={animating}
|
||||
title="Never show again"
|
||||
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-muted)/60 hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Swipe hint (shown briefly) -->
|
||||
<p class="mt-4 text-xs text-(--color-muted)/50 text-center">
|
||||
Swipe or tap buttons · Tap card for details
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'history'}
|
||||
<div class="w-full max-w-sm space-y-2">
|
||||
{#if !votedBooks.length}
|
||||
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
|
||||
{:else}
|
||||
{#each votedBooks as v (v.slug)}
|
||||
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
|
||||
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
|
||||
<!-- Cover thumbnail -->
|
||||
{#if v.book?.cover}
|
||||
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
|
||||
{v.book?.title ?? v.slug}
|
||||
</a>
|
||||
{#if v.book?.author}
|
||||
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
|
||||
{/if}
|
||||
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
|
||||
</div>
|
||||
|
||||
<!-- Undo button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => undoVote(v.slug)}
|
||||
title="Undo"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
|
||||
aria-label="Undo vote for {v.book?.title ?? v.slug}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
|
||||
>
|
||||
Clear all history
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
@@ -10,24 +10,40 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
let email: string | null = null;
|
||||
let polarCustomerId: string | null = null;
|
||||
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
|
||||
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||
email = record?.email ?? null;
|
||||
polarCustomerId = record?.polar_customer_id ?? null;
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
[sessions, stats] = await Promise.all([
|
||||
listUserSessions(locals.user.id),
|
||||
getUserStats(locals.sessionId, locals.user.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
email,
|
||||
polarCustomerId,
|
||||
stats: stats ?? {
|
||||
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||
avgRatingGiven: 0, streak: 0
|
||||
},
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
|
||||
@@ -4,12 +4,46 @@
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||||
// Customer portal: always link to the org portal
|
||||
const manageUrl = `https://polar.sh/libnovel/portal`;
|
||||
|
||||
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
|
||||
let checkoutError = $state('');
|
||||
|
||||
async function startCheckout(product: 'monthly' | 'annual') {
|
||||
checkoutLoading = product;
|
||||
checkoutError = '';
|
||||
try {
|
||||
const res = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json() as { url: string };
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
checkoutError = 'Network error. Please try again.';
|
||||
} finally {
|
||||
checkoutLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
// Show a welcome banner when Polar redirects back with ?subscribed=1
|
||||
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
|
||||
|
||||
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
@@ -150,6 +184,9 @@
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||
let activeTab = $state<'profile' | 'stats'>('profile');
|
||||
|
||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
type Session = {
|
||||
id: string;
|
||||
@@ -218,6 +255,17 @@
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||
|
||||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||||
{#if justSubscribed}
|
||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.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>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="relative shrink-0">
|
||||
@@ -272,6 +320,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
|
||||
{#each (['profile', 'stats'] as const) as tab}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab)}
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{activeTab === tab
|
||||
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{tab === 'profile' ? 'Profile' : 'Stats'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'profile'}
|
||||
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||
{#if !data.isPro}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||
@@ -287,17 +352,34 @@
|
||||
<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>
|
||||
{#if checkoutError}
|
||||
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||||
{/if}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('monthly')}
|
||||
disabled={checkoutLoading !== null}
|
||||
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 disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'monthly'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('annual')}
|
||||
disabled={checkoutLoading !== null}
|
||||
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 disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'annual'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{:else}
|
||||
{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>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -307,7 +389,7 @@
|
||||
<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"
|
||||
<a href={manageUrl} 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>
|
||||
@@ -503,5 +585,77 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Reading overview -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
|
||||
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
|
||||
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
|
||||
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
|
||||
] as stat}
|
||||
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Streak + rating -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
|
||||
<p class="text-xs text-(--color-muted)">day streak</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
|
||||
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0">⭐</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-(--color-text) tabular-nums">
|
||||
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted)">avg rating given</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top genres -->
|
||||
{#if data.stats.topGenres.length > 0}
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each data.stats.topGenres as genre, i}
|
||||
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
|
||||
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
{#if i === 0}<span class="text-xs">🏆</span>{/if}
|
||||
{genre}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Dropped books (only if any) -->
|
||||
{#if data.stats.booksDropped > 0}
|
||||
<p class="text-xs text-(--color-muted) text-center">
|
||||
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''} —
|
||||
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user