Compare commits
8 Commits
v2.5.36
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19aeb90403 | ||
|
|
06d4a7bfd4 | ||
|
|
73a92ccf8f | ||
|
|
08361172c6 | ||
|
|
809dc8d898 | ||
|
|
e9c3426fbe | ||
|
|
8e611840d1 | ||
|
|
b9383570e3 |
@@ -2,11 +2,8 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
tags-ignore:
|
||||||
- "backend/**"
|
- "v*"
|
||||||
- "ui/**"
|
|
||||||
- ".gitea/workflows/ci.yaml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
paths:
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
- "ui/**"
|
- "ui/**"
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import (
|
|||||||
|
|
||||||
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
|
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
|
||||||
//
|
//
|
||||||
// When using Asynq, the runner no longer polls for work — Asynq delivers
|
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
|
||||||
// tasks via the ServeMux handlers. The only Consumer operations the handlers
|
// delivers those tasks via the ServeMux handlers. However translation tasks
|
||||||
// need are:
|
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
|
||||||
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
|
// still delegate to the underlying PocketBase consumer.
|
||||||
// - FailTask — mark PocketBase record as failed
|
|
||||||
//
|
//
|
||||||
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
|
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
|
||||||
// are all no-ops here because Asynq owns those responsibilities.
|
// those responsibilities.
|
||||||
type Consumer struct {
|
type Consumer struct {
|
||||||
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
|
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
|
return domain.AudioTask{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
|
||||||
return domain.TranslationTask{}, false, nil
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1729,6 +1729,109 @@ func stripMarkdown(src string) string {
|
|||||||
return strings.TrimSpace(src)
|
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 ───────────────────────────────────────────
|
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||||
|
|
||||||
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||||
|
|
||||||
|
// EPUB export
|
||||||
|
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
||||||
|
|
||||||
// Reading progress
|
// Reading progress
|
||||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||||
|
|||||||
@@ -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.
|
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
|
||||||
// This mirrors the heartbeat file behavior from the poll() loop.
|
// This mirrors the heartbeat file behavior from the poll() loop.
|
||||||
go func() {
|
go func() {
|
||||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
|
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
|
||||||
defer heartbeatTick.Stop()
|
defer heartbeatTick.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import (
|
|||||||
// ErrNotFound is returned by single-record lookups when no record exists.
|
// ErrNotFound is returned by single-record lookups when no record exists.
|
||||||
var ErrNotFound = errors.New("storage: record not found")
|
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.
|
// pbClient is the internal PocketBase REST admin client.
|
||||||
type pbClient struct {
|
type pbClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := pbHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("pb auth: %w", err)
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := pbHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,20 +28,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||||
- libretranslate_db:/app/db
|
- libretranslate_db:/app/db
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/languages'); sys.exit(0)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 120s
|
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
image: kalekber/libnovel-runner:latest
|
image: kalekber/libnovel-runner:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 135s
|
stop_grace_period: 135s
|
||||||
depends_on:
|
depends_on:
|
||||||
libretranslate:
|
- libretranslate
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||||
|
|||||||
@@ -267,6 +267,14 @@ create "discovery_votes" '{
|
|||||||
{"name":"action", "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) ─
|
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||||
add_field "audio_jobs" "heartbeat_at" "date"
|
add_field "audio_jobs" "heartbeat_at" "date"
|
||||||
@@ -282,5 +290,6 @@ add_field "app_users" "oauth_provider" "text"
|
|||||||
add_field "app_users" "oauth_id" "text"
|
add_field "app_users" "oauth_id" "text"
|
||||||
add_field "app_users" "polar_customer_id" "text"
|
add_field "app_users" "polar_customer_id" "text"
|
||||||
add_field "app_users" "polar_subscription_id" "text"
|
add_field "app_users" "polar_subscription_id" "text"
|
||||||
|
add_field "user_library" "shelf" "text"
|
||||||
|
|
||||||
log "done"
|
log "done"
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class AudioStore {
|
|||||||
*/
|
*/
|
||||||
seekRequest = $state<number | null>(null);
|
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 ────────────────────────────────────────────────────────────
|
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* When true, navigates to the next chapter when the current one ends
|
* When true, navigates to the next chapter when the current one ends
|
||||||
|
|||||||
@@ -681,6 +681,53 @@
|
|||||||
const sec = Math.floor(s % 60);
|
const sec = Math.floor(s % 60);
|
||||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeyDown} />
|
<svelte:window onkeydown={handleKeyDown} />
|
||||||
@@ -887,6 +934,30 @@
|
|||||||
{m.reader_auto_next()}
|
{m.reader_auto_next()}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
<!-- 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>
|
||||||
@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
|
|||||||
const BOOKS_CACHE_KEY = 'books:all';
|
const BOOKS_CACHE_KEY = 'books:all';
|
||||||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||||
|
|
||||||
|
const RATINGS_CACHE_KEY = 'book_ratings:all';
|
||||||
|
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||||
|
|
||||||
|
const HOME_STATS_CACHE_KEY = 'home:stats';
|
||||||
|
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
|
||||||
|
|
||||||
|
async function getAllRatings(): Promise<BookRating[]> {
|
||||||
|
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
|
||||||
|
if (cached) return cached;
|
||||||
|
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
||||||
|
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
|
||||||
|
return ratings;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBooks(): Promise<Book[]> {
|
export async function listBooks(): Promise<Book[]> {
|
||||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -282,7 +296,11 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
|
|||||||
|
|
||||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||||
export async function invalidateBooksCache(): Promise<void> {
|
export async function invalidateBooksCache(): Promise<void> {
|
||||||
await cache.invalidate(BOOKS_CACHE_KEY);
|
await Promise.all([
|
||||||
|
cache.invalidate(BOOKS_CACHE_KEY),
|
||||||
|
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||||
|
cache.invalidatePattern('books:recent:*')
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBook(slug: string): Promise<Book | null> {
|
export async function getBook(slug: string): Promise<Book | null> {
|
||||||
@@ -290,7 +308,12 @@ export async function getBook(slug: string): Promise<Book | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||||
return listN<Book>('books', limit, '', '-meta_updated');
|
const key = `books:recent:${limit}`;
|
||||||
|
const cached = await cache.get<Book[]>(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
||||||
|
await cache.set(key, books, 5 * 60); // 5 minutes
|
||||||
|
return books;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeStats {
|
export interface HomeStats {
|
||||||
@@ -299,11 +322,19 @@ export interface HomeStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getHomeStats(): Promise<HomeStats> {
|
export async function getHomeStats(): Promise<HomeStats> {
|
||||||
|
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
|
||||||
|
if (cached) return cached;
|
||||||
const [totalBooks, totalChapters] = await Promise.all([
|
const [totalBooks, totalChapters] = await Promise.all([
|
||||||
countCollection('books'),
|
countCollection('books'),
|
||||||
countCollection('chapters_idx')
|
countCollection('chapters_idx')
|
||||||
]);
|
]);
|
||||||
return { totalBooks, totalChapters };
|
const stats = { totalBooks, totalChapters };
|
||||||
|
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateHomeStatsCache(): Promise<void> {
|
||||||
|
await cache.invalidate(HOME_STATS_CACHE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||||
@@ -542,7 +573,7 @@ export async function unsaveBook(
|
|||||||
|
|
||||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||||
|
|
||||||
function hashPassword(password: string): string {
|
function hashPassword(password: string): string {
|
||||||
const salt = randomBytes(16).toString('hex');
|
const salt = randomBytes(16).toString('hex');
|
||||||
@@ -1003,12 +1034,79 @@ export interface UserSession {
|
|||||||
session_id: string; // the auth session ID embedded in the token
|
session_id: string; // the auth session ID embedded in the token
|
||||||
user_agent: string;
|
user_agent: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
device_fingerprint: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_seen: 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(
|
export async function createUserSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1016,24 +1114,8 @@ export async function createUserSession(
|
|||||||
userAgent: string,
|
userAgent: string,
|
||||||
ip: string
|
ip: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const now = new Date().toISOString();
|
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
|
||||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
return recordId;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1070,20 +1152,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.
|
* Called on login so the list self-cleans without a separate cron job.
|
||||||
*/
|
*/
|
||||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
async function pruneStaleUserSessions(
|
||||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
userId: string,
|
||||||
const stale = await listAll<UserSession>(
|
days = 30,
|
||||||
'user_sessions',
|
maxSessions = 10
|
||||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
): Promise<void> {
|
||||||
);
|
|
||||||
if (stale.length === 0) return;
|
|
||||||
const token = await getToken();
|
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(
|
await Promise.all(
|
||||||
stale.map((s) =>
|
[...toDelete].map((id) =>
|
||||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
@@ -1734,6 +1833,93 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
}
|
||||||
|
await cache.invalidate(RATINGS_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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(
|
export async function getBooksForDiscovery(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
@@ -1761,11 +1947,167 @@ export async function getBooksForDiscovery(
|
|||||||
if (sf.length >= 3) candidates = sf;
|
if (sf.length >= 3) candidates = sf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fisher-Yates shuffle
|
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||||
for (let i = candidates.length - 1; i > 0; i--) {
|
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
const ratingRows = await getAllRatings();
|
||||||
|
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||||
|
for (const r of ratingRows) {
|
||||||
|
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||||
|
cur.sum += r.rating;
|
||||||
|
cur.count += 1;
|
||||||
|
ratingMap.set(r.slug, cur);
|
||||||
|
}
|
||||||
|
const avgRating = (slug: string) => {
|
||||||
|
const e = ratingMap.get(slug);
|
||||||
|
return e && e.count > 0 ? e.sum / e.count : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
|
||||||
|
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
|
||||||
|
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
|
||||||
|
|
||||||
|
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
|
||||||
|
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
|
||||||
|
let start = 0;
|
||||||
|
while (start < candidates.length) {
|
||||||
|
let end = start + 1;
|
||||||
|
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
|
||||||
|
for (let i = end - 1; i > start; i--) {
|
||||||
|
const j = start + Math.floor(Math.random() * (i - start + 1));
|
||||||
|
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates.slice(0, 50);
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,21 @@
|
|||||||
|
|
||||||
// Chapter list drawer state for the mini-player
|
// Chapter list drawer state for the mini-player
|
||||||
let chapterDrawerOpen = $state(false);
|
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.
|
// The single <audio> element that persists across navigations.
|
||||||
// AudioPlayer components in chapter pages control it via audioStore.
|
// AudioPlayer components in chapter pages control it via audioStore.
|
||||||
@@ -155,6 +170,23 @@
|
|||||||
audioStore.seekRequest = null;
|
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) ─────────────────────────
|
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||||
let audioTimeSaveTimer = 0;
|
let audioTimeSaveTimer = 0;
|
||||||
function saveAudioTime() {
|
function saveAudioTime() {
|
||||||
@@ -257,6 +289,12 @@
|
|||||||
onended={() => {
|
onended={() => {
|
||||||
audioStore.isPlaying = false;
|
audioStore.isPlaying = false;
|
||||||
saveAudioTime();
|
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) {
|
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||||
// Capture values synchronously before any async work — the AudioPlayer
|
// Capture values synchronously before any async work — the AudioPlayer
|
||||||
// component will unmount during navigation, but we've already read what
|
// component will unmount during navigation, but we've already read what
|
||||||
@@ -314,15 +352,6 @@
|
|||||||
>
|
>
|
||||||
{m.nav_catalogue()}
|
{m.nav_catalogue()}
|
||||||
</a>
|
</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">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<!-- Theme dropdown (desktop) -->
|
<!-- Theme dropdown (desktop) -->
|
||||||
<div class="hidden sm:block relative">
|
<div class="hidden sm:block relative">
|
||||||
@@ -423,6 +452,18 @@
|
|||||||
{m.nav_admin_panel()}
|
{m.nav_admin_panel()}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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>
|
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||||
<form method="POST" action="/logout">
|
<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">
|
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
|
||||||
@@ -503,9 +544,12 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onclick={() => (menuOpen = false)}
|
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>
|
||||||
<a
|
<a
|
||||||
href="/profile"
|
href="/profile"
|
||||||
@@ -678,6 +722,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#each audioStore.chapters as ch (ch.number)}
|
{#each audioStore.chapters as ch (ch.number)}
|
||||||
<a
|
<a
|
||||||
|
use:setIfActive={ch.number === audioStore.chapter}
|
||||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||||
onclick={() => (chapterDrawerOpen = false)}
|
onclick={() => (chapterDrawerOpen = false)}
|
||||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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 { createAuthToken } from '../../../../hooks.server';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
import { randomBytes } from 'node:crypto';
|
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) })
|
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 userAgent = request.headers.get('user-agent') ?? '';
|
||||||
const ip =
|
const ip =
|
||||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||||
request.headers.get('x-real-ip') ??
|
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);
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
|
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
|
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
|
||||||
type Action = (typeof VALID_ACTIONS)[number];
|
type Action = (typeof VALID_ACTIONS)[number];
|
||||||
@@ -22,9 +22,16 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
// DELETE /api/discover/vote → clear all (deck reset)
|
||||||
|
// DELETE /api/discover/vote?slug=... → undo single vote
|
||||||
|
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||||
|
const slug = url.searchParams.get('slug');
|
||||||
try {
|
try {
|
||||||
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
if (slug) {
|
||||||
|
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
|
||||||
|
} else {
|
||||||
|
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
error(500, 'Failed to clear votes');
|
error(500, 'Failed to clear votes');
|
||||||
}
|
}
|
||||||
|
|||||||
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 { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,3 +33,17 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
|||||||
}
|
}
|
||||||
return json({ ok: true });
|
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 });
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
linkOAuthToUser
|
linkOAuthToUser
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { createAuthToken } from '../../../../hooks.server';
|
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';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
type Provider = 'google' | 'github';
|
type Provider = 'google' | 'github';
|
||||||
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
|
|||||||
|
|
||||||
// ─── Handler ──────────────────────────────────────────────────────────────────
|
// ─── 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;
|
const provider = params.provider as Provider;
|
||||||
if (provider !== 'google' && provider !== 'github') {
|
if (provider !== 'google' && provider !== 'github') {
|
||||||
error(404, 'Unknown OAuth provider');
|
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) })
|
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;
|
let authSessionId: string;
|
||||||
|
try {
|
||||||
// Reuse existing session if the user is already logged in as the same user
|
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
|
||||||
if (locals.user?.id === user.id && locals.user?.authSessionId) {
|
} catch (err) {
|
||||||
authSessionId = locals.user.authSessionId;
|
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
|
||||||
// Just touch the existing session to update last_seen
|
authSessionId = candidateSessionId;
|
||||||
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) })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
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 { log } from '$lib/server/logger';
|
||||||
import type { Book } from '$lib/server/pocketbase';
|
import type { Book } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
||||||
let savedSlugs: Set<string> = new Set();
|
let savedSlugs: Set<string> = new Set();
|
||||||
|
let shelfMap: Record<string, string> = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[progressList, savedSlugs] = await Promise.all([
|
[progressList, savedSlugs, shelfMap] = await Promise.all([
|
||||||
allProgress(locals.sessionId, locals.user?.id),
|
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) {
|
} catch (e) {
|
||||||
log.error('books', 'failed to load library data', { err: String(e) });
|
log.error('books', 'failed to load library data', { err: String(e) });
|
||||||
@@ -46,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
return {
|
return {
|
||||||
books: [...withProgress, ...savedOnly],
|
books: [...withProgress, ...savedOnly],
|
||||||
progressMap,
|
progressMap,
|
||||||
savedSlugs: [...savedSlugs]
|
savedSlugs: [...savedSlugs],
|
||||||
|
shelfMap
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,32 @@
|
|||||||
return [];
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -37,10 +63,29 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
<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 lastChapter = data.progressMap[book.slug]}
|
||||||
{@const genres = parseGenres(book.genres)}
|
{@const genres = parseGenres(book.genres)}
|
||||||
|
{@const bookShelf = shelfMap[book.slug] ?? ''}
|
||||||
<a
|
<a
|
||||||
href="/books/{book.slug}"
|
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"
|
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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{#if genres.length > 0}
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
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 { log } from '$lib/server/logger';
|
||||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||||
|
|
||||||
@@ -15,13 +15,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
if (book) {
|
if (book) {
|
||||||
// Book is in the library — normal path
|
// Book is in the library — normal path
|
||||||
let chapters, progress, saved, readersThisWeek;
|
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
|
||||||
try {
|
try {
|
||||||
[chapters, progress, saved, readersThisWeek] = await Promise.all([
|
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
|
||||||
listChapterIdx(slug),
|
listChapterIdx(slug),
|
||||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||||
isBookSaved(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) {
|
} catch (e) {
|
||||||
log.error('books', 'failed to load book page data', { slug, err: String(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,
|
saved,
|
||||||
lastChapter: progress?.chapter ?? null,
|
lastChapter: progress?.chapter ?? null,
|
||||||
readersThisWeek,
|
readersThisWeek,
|
||||||
|
userRating: userRating ?? 0,
|
||||||
|
ratingAvg: ratingAvg ?? { avg: 0, count: 0 },
|
||||||
isAdmin: locals.user?.role === 'admin',
|
isAdmin: locals.user?.role === 'admin',
|
||||||
isLoggedIn: !!locals.user,
|
isLoggedIn: !!locals.user,
|
||||||
currentUserId: locals.user?.id ?? '',
|
currentUserId: locals.user?.id ?? '',
|
||||||
@@ -58,6 +62,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
inLib: false,
|
inLib: false,
|
||||||
saved: false,
|
saved: false,
|
||||||
lastChapter: null,
|
lastChapter: null,
|
||||||
|
userRating: 0,
|
||||||
|
ratingAvg: { avg: 0, count: 0 },
|
||||||
isAdmin: locals.user?.role === 'admin',
|
isAdmin: locals.user?.role === 'admin',
|
||||||
isLoggedIn: !!locals.user,
|
isLoggedIn: !!locals.user,
|
||||||
currentUserId: locals.user?.id ?? '',
|
currentUserId: locals.user?.id ?? '',
|
||||||
@@ -95,6 +101,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
inLib: true,
|
inLib: true,
|
||||||
saved: false,
|
saved: false,
|
||||||
lastChapter: null,
|
lastChapter: null,
|
||||||
|
userRating: 0,
|
||||||
|
ratingAvg: { avg: 0, count: 0 },
|
||||||
isAdmin: locals.user?.role === 'admin',
|
isAdmin: locals.user?.role === 'admin',
|
||||||
isLoggedIn: !!locals.user,
|
isLoggedIn: !!locals.user,
|
||||||
currentUserId: locals.user?.id ?? '',
|
currentUserId: locals.user?.id ?? '',
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||||
|
import StarRating from '$lib/components/StarRating.svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { ShelfName } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -17,6 +19,37 @@
|
|||||||
let saved = $state(untrack(() => data.saved));
|
let saved = $state(untrack(() => data.saved));
|
||||||
let saving = $state(false);
|
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() {
|
async function toggleSave() {
|
||||||
if (saving || !data.book) return;
|
if (saving || !data.book) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
@@ -286,6 +319,31 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -346,10 +404,55 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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>
|
</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 ══ -->
|
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getBooksForDiscovery } from '$lib/server/pocketbase';
|
import { getBooksForDiscovery, getVotedBooks } from '$lib/server/pocketbase';
|
||||||
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
|
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
@@ -9,6 +9,9 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []);
|
const [books, votedBooks] = await Promise.all([
|
||||||
return { books };
|
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
|
||||||
|
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
|
||||||
|
]);
|
||||||
|
return { books, votedBooks };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { Book } from '$lib/server/pocketbase';
|
import type { Book, VotedBook } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -83,6 +83,20 @@
|
|||||||
let transitioning = $state(false);
|
let transitioning = $state(false);
|
||||||
let showPreview = $state(false);
|
let showPreview = $state(false);
|
||||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
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 startX = 0, startY = 0, hasMoved = false;
|
||||||
|
|
||||||
@@ -116,7 +130,43 @@
|
|||||||
|
|
||||||
let cardEl = $state<HTMLDivElement | null>(null);
|
let cardEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
|
||||||
|
let cardEntering = $state(false);
|
||||||
|
let entryTransition = $state(false);
|
||||||
|
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function startEntryAnimation() {
|
||||||
|
if (entryCleanup) clearTimeout(entryCleanup);
|
||||||
|
cardEntering = true;
|
||||||
|
entryTransition = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
cardEntering = false;
|
||||||
|
entryCleanup = setTimeout(() => { entryTransition = false; }, 400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEntryAnimation() {
|
||||||
|
if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; }
|
||||||
|
cardEntering = false;
|
||||||
|
entryTransition = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTransform = $derived(
|
||||||
|
cardEntering
|
||||||
|
? 'scale(0.95) translateY(13px)'
|
||||||
|
: `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTransition = $derived(
|
||||||
|
isDragging
|
||||||
|
? 'none'
|
||||||
|
: (transitioning || entryTransition)
|
||||||
|
? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
|
||||||
|
: 'none'
|
||||||
|
);
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
cancelEntryAnimation();
|
||||||
if (animating || !currentBook) return;
|
if (animating || !currentBook) return;
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
@@ -202,13 +252,15 @@
|
|||||||
|
|
||||||
if (action === 'read_now') {
|
if (action === 'read_now') {
|
||||||
goto(`/books/${book.slug}`);
|
goto(`/books/${book.slug}`);
|
||||||
|
} else {
|
||||||
|
startEntryAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetDeck() {
|
async function resetDeck() {
|
||||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||||
|
votedBooks = [];
|
||||||
idx = 0;
|
idx = 0;
|
||||||
// Reload page to get fresh server data
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -290,19 +342,25 @@
|
|||||||
|
|
||||||
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
||||||
{#if showPreview && currentBook}
|
{#if showPreview && currentBook}
|
||||||
|
{@const previewBook = currentBook!}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
||||||
|
role="presentation"
|
||||||
onclick={() => (showPreview = false)}
|
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="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
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()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<!-- Cover strip -->
|
<!-- Cover strip -->
|
||||||
<div class="relative h-40 overflow-hidden">
|
<div class="relative h-40 overflow-hidden">
|
||||||
{#if currentBook.cover}
|
{#if previewBook.cover}
|
||||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover object-top" />
|
<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>
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||||
@@ -314,22 +372,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{currentBook.title}</h3>
|
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
|
||||||
{#if currentBook.author}
|
{#if previewBook.author}
|
||||||
<p class="text-sm text-(--color-muted) mb-3">{currentBook.author}</p>
|
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if currentBook.summary}
|
{#if previewBook.summary}
|
||||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{currentBook.summary}</p>
|
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-2 mb-5">
|
<div class="flex flex-wrap gap-2 mb-5">
|
||||||
{#each parseBookGenres(currentBook.genres).slice(0, 4) as genre}
|
{#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>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if currentBook.status}
|
{#if previewBook.status}
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{currentBook.status}</span>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if currentBook.total_chapters}
|
{#if previewBook.total_chapters}
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{currentBook.total_chapters} ch.</span>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -383,6 +441,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{#if deckEmpty}
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||||
@@ -414,6 +493,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const book = currentBook!}
|
||||||
<!-- Card stack -->
|
<!-- Card stack -->
|
||||||
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
|
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
|
||||||
|
|
||||||
@@ -451,8 +531,8 @@
|
|||||||
bind:this={cardEl}
|
bind:this={cardEl}
|
||||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||||
style="
|
style="
|
||||||
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
|
transform: {activeTransform};
|
||||||
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
|
transition: {activeTransition};
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
"
|
"
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
@@ -461,8 +541,8 @@
|
|||||||
onpointercancel={onPointerUp}
|
onpointercancel={onPointerUp}
|
||||||
>
|
>
|
||||||
<!-- Cover image -->
|
<!-- Cover image -->
|
||||||
{#if currentBook.cover}
|
{#if book.cover}
|
||||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
<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">
|
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -474,19 +554,19 @@
|
|||||||
<!-- Bottom gradient + info -->
|
<!-- 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 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">
|
<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">{currentBook.title}</h2>
|
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
|
||||||
{#if currentBook.author}
|
{#if book.author}
|
||||||
<p class="text-white/70 text-sm mb-2">{currentBook.author}</p>
|
<p class="text-white/70 text-sm mb-2">{book.author}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-1.5 items-center">
|
<div class="flex flex-wrap gap-1.5 items-center">
|
||||||
{#each parseBookGenres(currentBook.genres).slice(0, 2) as genre}
|
{#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>
|
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if currentBook.status}
|
{#if book.status}
|
||||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{currentBook.status}</span>
|
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if currentBook.total_chapters}
|
{#if book.total_chapters}
|
||||||
<span class="text-xs text-white/50 ml-auto">{currentBook.total_chapters} ch.</span>
|
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,4 +679,58 @@
|
|||||||
Swipe or tap buttons · Tap card for details
|
Swipe or tap buttons · Tap card for details
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
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 { resolveAvatarUrl } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||||
let email: string | null = null;
|
let email: string | null = null;
|
||||||
let polarCustomerId: 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
|
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
@@ -25,9 +26,12 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sessions = await listUserSessions(locals.user.id);
|
[sessions, stats] = await Promise.all([
|
||||||
|
listUserSessions(locals.user.id),
|
||||||
|
getUserStats(locals.sessionId, locals.user.id)
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +39,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
email,
|
email,
|
||||||
polarCustomerId,
|
polarCustomerId,
|
||||||
|
stats: stats ?? {
|
||||||
|
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
|
||||||
|
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
|
||||||
|
avgRatingGiven: 0, streak: 0
|
||||||
|
},
|
||||||
sessions: sessions.map((s) => ({
|
sessions: sessions.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
user_agent: s.user_agent,
|
user_agent: s.user_agent,
|
||||||
|
|||||||
@@ -184,6 +184,9 @@
|
|||||||
}, 800) as unknown as number;
|
}, 800) as unknown as number;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tab ──────────────────────────────────────────────────────────────────────
|
||||||
|
let activeTab = $state<'profile' | 'stats'>('profile');
|
||||||
|
|
||||||
// ── Sessions ─────────────────────────────────────────────────────────────────
|
// ── Sessions ─────────────────────────────────────────────────────────────────
|
||||||
type Session = {
|
type Session = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -317,6 +320,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ─────────────────────────────────────────────────────────── -->
|
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
|
||||||
{#if !data.isPro}
|
{#if !data.isPro}
|
||||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
|
||||||
@@ -565,5 +585,77 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user