Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08361172c6 | ||
|
|
809dc8d898 | ||
|
|
e9c3426fbe | ||
|
|
8e611840d1 |
@@ -10,14 +10,13 @@ import (
|
||||
|
||||
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
|
||||
//
|
||||
// When using Asynq, the runner no longer polls for work — Asynq delivers
|
||||
// tasks via the ServeMux handlers. The only Consumer operations the handlers
|
||||
// need are:
|
||||
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
|
||||
// - FailTask — mark PocketBase record as failed
|
||||
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
|
||||
// delivers those tasks via the ServeMux handlers. However translation tasks
|
||||
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
|
||||
// still delegate to the underlying PocketBase consumer.
|
||||
//
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
|
||||
// are all no-ops here because Asynq owns those responsibilities.
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
|
||||
// those responsibilities.
|
||||
type Consumer struct {
|
||||
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
|
||||
}
|
||||
@@ -55,10 +54,18 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
|
||||
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
|
||||
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
|
||||
return c.pb.ClaimNextTranslationTask(ctx, workerID)
|
||||
}
|
||||
|
||||
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
|
||||
return c.pb.HeartbeatTask(ctx, id)
|
||||
}
|
||||
|
||||
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
|
||||
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
|
||||
// to pending and can be reclaimed.
|
||||
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
|
||||
return c.pb.ReapStaleTasks(ctx, staleAfter)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ── EPUB export ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleExportEPUB handles GET /api/export/{slug}.
|
||||
// Generates and streams an EPUB file for the book identified by slug.
|
||||
// Optional query params: from=N&to=N to limit the chapter range (default: all).
|
||||
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse optional from/to range.
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
fromN, toN := 0, 0
|
||||
if fromStr != "" {
|
||||
v, err := strconv.Atoi(fromStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
|
||||
return
|
||||
}
|
||||
fromN = v
|
||||
}
|
||||
if toStr != "" {
|
||||
v, err := strconv.Atoi(toStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
|
||||
return
|
||||
}
|
||||
toN = v
|
||||
}
|
||||
|
||||
// Fetch book metadata for title and author.
|
||||
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
|
||||
if err != nil || !inLib {
|
||||
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
|
||||
// List all chapters.
|
||||
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
|
||||
return
|
||||
}
|
||||
|
||||
// Filter chapters by from/to range.
|
||||
var filtered []epubChapter
|
||||
for _, ch := range chapters {
|
||||
if fromN > 0 && ch.Number < fromN {
|
||||
continue
|
||||
}
|
||||
if toN > 0 && ch.Number > toN {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch markdown from MinIO.
|
||||
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
|
||||
if readErr != nil {
|
||||
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
|
||||
// Skip chapters that cannot be fetched.
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using goldmark.
|
||||
md := goldmark.New()
|
||||
var htmlBuf bytes.Buffer
|
||||
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
|
||||
htmlBuf.Reset()
|
||||
htmlBuf.WriteString("<p>" + mdText + "</p>")
|
||||
}
|
||||
|
||||
filtered = append(filtered, epubChapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
HTML: htmlBuf.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
|
||||
return
|
||||
}
|
||||
|
||||
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/epub+zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(epubBytes)
|
||||
}
|
||||
|
||||
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||
|
||||
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
||||
|
||||
@@ -190,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||
|
||||
// EPUB export
|
||||
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
|
||||
@@ -78,7 +78,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
|
||||
// This mirrors the heartbeat file behavior from the poll() loop.
|
||||
go func() {
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
|
||||
defer heartbeatTick.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -26,6 +26,11 @@ import (
|
||||
// ErrNotFound is returned by single-record lookups when no record exists.
|
||||
var ErrNotFound = errors.New("storage: record not found")
|
||||
|
||||
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
|
||||
// hung PocketBase never stalls the backend/runner process indefinitely.
|
||||
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
|
||||
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// pbClient is the internal PocketBase REST admin client.
|
||||
type pbClient struct {
|
||||
baseURL string
|
||||
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pb auth: %w", err)
|
||||
}
|
||||
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
||||
}
|
||||
|
||||
@@ -28,20 +28,13 @@ services:
|
||||
volumes:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- 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:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
- libretranslate
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
|
||||
@@ -267,6 +267,14 @@ create "discovery_votes" '{
|
||||
{"name":"action", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "book_ratings" '{
|
||||
"name":"book_ratings","type":"base","fields":[
|
||||
{"name":"session_id","type":"text", "required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"rating", "type":"number", "required":true}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
@@ -282,5 +290,6 @@ add_field "app_users" "oauth_provider" "text"
|
||||
add_field "app_users" "oauth_id" "text"
|
||||
add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
|
||||
log "done"
|
||||
|
||||
@@ -75,6 +75,10 @@ class AudioStore {
|
||||
*/
|
||||
seekRequest = $state<number | null>(null);
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||||
sleepUntil = $state(0);
|
||||
|
||||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, navigates to the next chapter when the current one ends
|
||||
|
||||
@@ -681,6 +681,47 @@
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Sleep timer ────────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let _tick = $state(0);
|
||||
$effect(() => {
|
||||
if (!audioStore.sleepUntil) return;
|
||||
const id = setInterval(() => { _tick++; }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
_tick; // subscribe to tick updates
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
if (!audioStore.sleepUntil) {
|
||||
// Start at first option (15 min)
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
// Was at max or past last — turn off
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return '';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -887,6 +928,24 @@
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
|
||||
57
ui/src/lib/components/StarRating.svelte
Normal file
57
ui/src/lib/components/StarRating.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rating: number; // current user rating 0–5 (0 = unrated)
|
||||
avg?: number; // average rating
|
||||
count?: number; // total ratings
|
||||
readonly?: boolean; // display-only mode
|
||||
size?: 'sm' | 'md';
|
||||
onrate?: (r: number) => void;
|
||||
}
|
||||
|
||||
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
|
||||
|
||||
let hovered = $state(0);
|
||||
|
||||
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
onmouseenter={() => { if (!readonly) hovered = star; }}
|
||||
onmouseleave={() => { if (!readonly) hovered = 0; }}
|
||||
onclick={() => { if (!readonly) onrate?.(star); }}
|
||||
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
|
||||
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
|
||||
>
|
||||
<svg
|
||||
class="{starSize} transition-colors"
|
||||
fill={star <= display ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button[disabled] { pointer-events: none; }
|
||||
svg { color: #f59e0b; }
|
||||
</style>
|
||||
@@ -1734,6 +1734,92 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
|
||||
export async function updateBookShelf(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
shelf: ShelfName,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
|
||||
if (!existing) {
|
||||
// Save + set shelf in one shot
|
||||
const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
|
||||
if (userId) payload.user_id = userId;
|
||||
await pbPost('/api/collections/user_library/records', payload);
|
||||
} else {
|
||||
await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShelfMap(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<Record<string, ShelfName>> {
|
||||
const filter = userId
|
||||
? `session_id="${sessionId}" || user_id="${userId}"`
|
||||
: `session_id="${sessionId}"`;
|
||||
const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
|
||||
const map: Record<string, ShelfName> = {};
|
||||
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getBooksForDiscovery(
|
||||
sessionId: string,
|
||||
userId?: string,
|
||||
|
||||
@@ -33,6 +33,21 @@
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chapterDrawerOpen && activeChapterEl) {
|
||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
// The single <audio> element that persists across navigations.
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
@@ -155,6 +170,23 @@
|
||||
audioStore.seekRequest = null;
|
||||
});
|
||||
|
||||
// Sleep timer — fires once when time is up
|
||||
$effect(() => {
|
||||
const until = audioStore.sleepUntil;
|
||||
if (!until) return;
|
||||
const ms = until - Date.now();
|
||||
if (ms <= 0) {
|
||||
audioStore.sleepUntil = 0;
|
||||
if (audioStore.isPlaying) audioStore.toggleRequest++;
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
audioStore.sleepUntil = 0;
|
||||
if (audioStore.isPlaying) audioStore.toggleRequest++;
|
||||
}, ms);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
@@ -314,15 +346,6 @@
|
||||
>
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
@@ -423,6 +446,18 @@
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
{m.nav_feedback()}
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
|
||||
@@ -503,9 +538,12 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text) flex items-center justify-between"
|
||||
>
|
||||
{m.nav_feedback()} ↗
|
||||
{m.nav_feedback()}
|
||||
<svg class="w-3.5 h-3.5 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
@@ -678,6 +716,7 @@
|
||||
</div>
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
|
||||
29
ui/src/routes/api/export/[slug]/+server.ts
Normal file
29
ui/src/routes/api/export/[slug]/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug } = params;
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (from) qs.set('from', from);
|
||||
if (to) qs.set('to', to);
|
||||
const query = qs.size ? `?${qs}` : '';
|
||||
|
||||
const res = await backendFetch(`/api/export/${encodeURIComponent(slug)}${query}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
error(res.status as Parameters<typeof error>[0], text || 'Export failed');
|
||||
}
|
||||
|
||||
const bytes = await res.arrayBuffer();
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': 'application/epub+zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}.epub"`
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
|
||||
import { saveBook, unsaveBook, updateBookShelf } from '$lib/server/pocketbase';
|
||||
import type { ShelfName } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -32,3 +33,17 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/library/[slug]
|
||||
* Update the shelf category for a saved book.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const shelf = body?.shelf ?? '';
|
||||
const VALID = ['', 'plan_to_read', 'completed', 'dropped'];
|
||||
if (!VALID.includes(shelf)) error(400, 'invalid shelf');
|
||||
await updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
24
ui/src/routes/api/ratings/[slug]/+server.ts
Normal file
24
ui/src/routes/api/ratings/[slug]/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBookRating, getBookAvgRating, setBookRating } from '$lib/server/pocketbase';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
const [userRating, avg] = await Promise.all([
|
||||
getBookRating(locals.sessionId, slug, locals.user?.id),
|
||||
getBookAvgRating(slug)
|
||||
]);
|
||||
return json({ userRating, avg: avg.avg, count: avg.count });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const rating = body?.rating;
|
||||
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
|
||||
error(400, 'rating must be 1–5');
|
||||
}
|
||||
await setBookRating(locals.sessionId, slug, rating, locals.user?.id);
|
||||
const avg = await getBookAvgRating(slug);
|
||||
return json({ ok: true, avg: avg.avg, count: avg.count });
|
||||
};
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { getBooksBySlugs, allProgress, getSavedSlugs, getShelfMap } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
|
||||
let savedSlugs: Set<string> = new Set();
|
||||
let shelfMap: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
[progressList, savedSlugs] = await Promise.all([
|
||||
[progressList, savedSlugs, shelfMap] = await Promise.all([
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id),
|
||||
getShelfMap(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load library data', { err: String(e) });
|
||||
@@ -46,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
books: [...withProgress, ...savedOnly],
|
||||
progressMap,
|
||||
savedSlugs: [...savedSlugs]
|
||||
savedSlugs: [...savedSlugs],
|
||||
shelfMap
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,32 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
let activeShelf = $state<Shelf | 'all'>('all');
|
||||
|
||||
const shelfLabels: Record<string, string> = {
|
||||
all: 'All',
|
||||
'': 'Reading',
|
||||
plan_to_read: 'Plan to Read',
|
||||
completed: 'Completed',
|
||||
dropped: 'Dropped'
|
||||
};
|
||||
|
||||
const shelfMap = $derived(data.shelfMap as Record<string, string>);
|
||||
const filteredBooks = $derived(
|
||||
activeShelf === 'all'
|
||||
? data.books
|
||||
: data.books.filter((b) => (shelfMap[b.slug] ?? '') === activeShelf)
|
||||
);
|
||||
|
||||
const shelfCounts = $derived({
|
||||
all: data.books.length,
|
||||
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
|
||||
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
|
||||
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
|
||||
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -37,10 +63,29 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shelf tabs -->
|
||||
<div class="flex gap-1 flex-wrap mb-4">
|
||||
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
|
||||
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeShelf = shelf)}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
|
||||
{activeShelf === shelf
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each data.books as book}
|
||||
{#each filteredBooks as book}
|
||||
{@const lastChapter = data.progressMap[book.slug]}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
{@const bookShelf = shelfMap[book.slug] ?? ''}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
@@ -85,6 +130,11 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bookShelf && activeShelf === 'all'}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) self-start">
|
||||
{shelfLabels[bookShelf] ?? bookShelf}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||
|
||||
@@ -15,13 +15,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (book) {
|
||||
// Book is in the library — normal path
|
||||
let chapters, progress, saved, readersThisWeek;
|
||||
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
|
||||
try {
|
||||
[chapters, progress, saved, readersThisWeek] = await Promise.all([
|
||||
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id),
|
||||
countReadersThisWeek(slug)
|
||||
countReadersThisWeek(slug),
|
||||
getBookRating(locals.sessionId, slug, locals.user?.id),
|
||||
getBookAvgRating(slug)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load book page data', { slug, err: String(e) });
|
||||
@@ -35,6 +37,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
readersThisWeek,
|
||||
userRating: userRating ?? 0,
|
||||
ratingAvg: ratingAvg ?? { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
@@ -58,6 +62,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: false,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
userRating: 0,
|
||||
ratingAvg: { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
@@ -95,6 +101,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
inLib: true,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
userRating: 0,
|
||||
ratingAvg: { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import StarRating from '$lib/components/StarRating.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { ShelfName } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -17,6 +19,37 @@
|
||||
let saved = $state(untrack(() => data.saved));
|
||||
let saving = $state(false);
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
let userRating = $state(data.userRating ?? 0);
|
||||
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
|
||||
|
||||
async function rate(r: number) {
|
||||
userRating = r;
|
||||
try {
|
||||
const res = await fetch(`/api/ratings/${encodeURIComponent(data.book?.slug ?? '')}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating: r })
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
ratingAvg = { avg: body.avg, count: body.count };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Shelf ─────────────────────────────────────────────────────────────────
|
||||
let currentShelf = $state<ShelfName>('');
|
||||
|
||||
async function setShelf(shelf: ShelfName) {
|
||||
currentShelf = shelf;
|
||||
await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shelf })
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSave() {
|
||||
if (saving || !data.book) return;
|
||||
saving = true;
|
||||
@@ -286,6 +319,31 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ratings + shelf — desktop -->
|
||||
<div class="hidden sm:flex items-center gap-3 flex-wrap mt-1">
|
||||
<StarRating
|
||||
rating={userRating}
|
||||
avg={ratingAvg.avg}
|
||||
count={ratingAvg.count}
|
||||
onrate={rate}
|
||||
size="md"
|
||||
/>
|
||||
{#if saved}
|
||||
<div class="relative">
|
||||
<select
|
||||
value={currentShelf}
|
||||
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
|
||||
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
|
||||
>
|
||||
<option value="">Reading</option>
|
||||
<option value="plan_to_read">Plan to Read</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -346,10 +404,55 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Ratings + shelf — mobile -->
|
||||
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
|
||||
<StarRating
|
||||
rating={userRating}
|
||||
avg={ratingAvg.avg}
|
||||
count={ratingAvg.count}
|
||||
onrate={rate}
|
||||
size="sm"
|
||||
/>
|
||||
{#if saved}
|
||||
<div class="relative">
|
||||
<select
|
||||
value={currentShelf}
|
||||
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
|
||||
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
|
||||
>
|
||||
<option value="">Reading</option>
|
||||
<option value="plan_to_read">Plan to Read</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Download row ══ -->
|
||||
{#if data.inLib && chapterList.length > 0}
|
||||
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
|
||||
<svg class="w-4 h-4 text-(--color-muted) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-(--color-text)">Download</p>
|
||||
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/export/{book.slug}"
|
||||
download="{book.slug}.epub"
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
|
||||
>
|
||||
.epub
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
||||
<a
|
||||
|
||||
@@ -290,19 +290,25 @@
|
||||
|
||||
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
||||
{#if showPreview && currentBook}
|
||||
{@const previewBook = currentBook!}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
||||
role="presentation"
|
||||
onclick={() => (showPreview = false)}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showPreview = false; }}
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Cover strip -->
|
||||
<div class="relative h-40 overflow-hidden">
|
||||
{#if currentBook.cover}
|
||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover object-top" />
|
||||
{#if previewBook.cover}
|
||||
<img src={previewBook.cover} alt={previewBook.title} class="w-full h-full object-cover object-top" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
@@ -314,22 +320,22 @@
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{currentBook.title}</h3>
|
||||
{#if currentBook.author}
|
||||
<p class="text-sm text-(--color-muted) mb-3">{currentBook.author}</p>
|
||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
|
||||
{#if previewBook.author}
|
||||
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
|
||||
{/if}
|
||||
{#if currentBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{currentBook.summary}</p>
|
||||
{#if previewBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
{#each parseBookGenres(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>
|
||||
{/each}
|
||||
{#if currentBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{currentBook.status}</span>
|
||||
{#if previewBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
|
||||
{/if}
|
||||
{#if currentBook.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>
|
||||
{#if previewBook.total_chapters}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -414,6 +420,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@const book = currentBook!}
|
||||
<!-- Card stack -->
|
||||
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
|
||||
|
||||
@@ -461,8 +468,8 @@
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<!-- Cover image -->
|
||||
{#if currentBook.cover}
|
||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -474,19 +481,19 @@
|
||||
<!-- Bottom gradient + info -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
|
||||
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{currentBook.title}</h2>
|
||||
{#if currentBook.author}
|
||||
<p class="text-white/70 text-sm mb-2">{currentBook.author}</p>
|
||||
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
|
||||
{#if book.author}
|
||||
<p class="text-white/70 text-sm mb-2">{book.author}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(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>
|
||||
{/each}
|
||||
{#if currentBook.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{currentBook.status}</span>
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
{/if}
|
||||
{#if currentBook.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{currentBook.total_chapters} ch.</span>
|
||||
{#if book.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user