From 08361172c65b8844aa48adfd14edc037792fdb2c Mon Sep 17 00:00:00 2001
From: Admin
Date: Thu, 2 Apr 2026 21:50:04 +0500
Subject: [PATCH] feat: ratings, shelves, sleep timer, EPUB export + fix TS
errors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Ratings (1–5 stars)**
- New `book_ratings` PB collection (session_id, user_id, slug, rating)
- `getBookRating`, `getBookAvgRating`, `setBookRating` in pocketbase.ts
- GET/POST /api/ratings/[slug] API route
- StarRating.svelte component with hover, animated stars, avg display
- Star rating shown on book detail page (desktop + mobile)
**Plan-to-Read shelf**
- `shelf` field added to `user_library` (reading/plan_to_read/completed/dropped)
- `updateBookShelf`, `getShelfMap` in pocketbase.ts
- PATCH /api/library/[slug] for shelf updates
- Shelf selector dropdown on book detail page (only when saved)
- Shelf tabs on library page to filter by category
**Sleep timer**
- `sleepUntil` state added to AudioStore
- Layout handles timer lifecycle (survives chapter navigation)
- Cycles Off → 15m → 30m → 45m → 60m → Off
- Shows live countdown in AudioPlayer when active
**EPUB export**
- Go backend: GET /api/export/{slug}?from=N&to=N
- Generates valid EPUB2 zip (mimetype uncompressed, OPF, NCX, XHTML chapters)
- Markdown → HTML via goldmark
- SvelteKit proxy at /api/export/[slug]
- Download button on book detail page (only when in library)
**Fix TS errors**
- discover/+page.svelte: currentBook possibly undefined (use {@const book})
- cardEl now $state for reactive binding
Co-Authored-By: Claude Sonnet 4.6
---
backend/internal/backend/epub.go | 143 ++++++++++++++++++++
backend/internal/backend/handlers.go | 103 ++++++++++++++
backend/internal/backend/server.go | 3 +
scripts/pb-init-v3.sh | 9 ++
ui/src/lib/audio.svelte.ts | 4 +
ui/src/lib/components/AudioPlayer.svelte | 59 ++++++++
ui/src/lib/components/StarRating.svelte | 57 ++++++++
ui/src/lib/server/pocketbase.ts | 86 ++++++++++++
ui/src/routes/+layout.svelte | 17 +++
ui/src/routes/api/export/[slug]/+server.ts | 29 ++++
ui/src/routes/api/library/[slug]/+server.ts | 17 ++-
ui/src/routes/api/ratings/[slug]/+server.ts | 24 ++++
ui/src/routes/books/+page.server.ts | 11 +-
ui/src/routes/books/+page.svelte | 52 ++++++-
ui/src/routes/books/[slug]/+page.server.ts | 16 ++-
ui/src/routes/books/[slug]/+page.svelte | 103 ++++++++++++++
ui/src/routes/discover/+page.svelte | 51 ++++---
17 files changed, 752 insertions(+), 32 deletions(-)
create mode 100644 backend/internal/backend/epub.go
create mode 100644 ui/src/lib/components/StarRating.svelte
create mode 100644 ui/src/routes/api/export/[slug]/+server.ts
create mode 100644 ui/src/routes/api/ratings/[slug]/+server.ts
diff --git a/backend/internal/backend/epub.go b/backend/internal/backend/epub.go
new file mode 100644
index 0000000..cca5cf4
--- /dev/null
+++ b/backend/internal/backend/epub.go
@@ -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 `
+
+
+
+
+`
+}
+
+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(` `+"\n", id, href))
+ spine.WriteString(fmt.Sprintf(` `+"\n", id))
+ }
+ return fmt.Sprintf(`
+
+
+ %s
+ %s
+ %s
+ en
+
+
+
+
+%s
+
+%s
+`, 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(`
+ %s
+
+ `+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
+ }
+ return fmt.Sprintf(`
+
+
+
+ %s
+
+%s
+`, 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(`
+
+
+%s
+
+%s
+%s
+
+`, 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
+}
diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go
index 2376bab..3cf7d11 100644
--- a/backend/internal/backend/handlers.go
+++ b/backend/internal/backend/handlers.go
@@ -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("" + mdText + "
")
+ }
+
+ 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
diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go
index 696f79d..1973885 100644
--- a/backend/internal/backend/server.go
+++ b/backend/internal/backend/server.go
@@ -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)
diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh
index 5331097..81d0b46 100755
--- a/scripts/pb-init-v3.sh
+++ b/scripts/pb-init-v3.sh
@@ -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"
diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts
index de4f9a0..38fbd96 100644
--- a/ui/src/lib/audio.svelte.ts
+++ b/ui/src/lib/audio.svelte.ts
@@ -75,6 +75,10 @@ class AudioStore {
*/
seekRequest = $state(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
diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte
index 0b216f6..b1361b3 100644
--- a/ui/src/lib/components/AudioPlayer.svelte
+++ b/ui/src/lib/components/AudioPlayer.svelte
@@ -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`;
+ }
@@ -887,6 +928,24 @@
{m.reader_auto_next()}
{/if}
+
+
+
diff --git a/ui/src/lib/components/StarRating.svelte b/ui/src/lib/components/StarRating.svelte
new file mode 100644
index 0000000..cff6e21
--- /dev/null
+++ b/ui/src/lib/components/StarRating.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+ {#each [1,2,3,4,5] as star}
+
+ {/each}
+
+ {#if avg && count}
+
{avg} ({count})
+ {:else if avg}
+
{avg}
+ {/if}
+
+
+
diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts
index faae223..6baca47 100644
--- a/ui/src/lib/server/pocketbase.ts
+++ b/ui/src/lib/server/pocketbase.ts
@@ -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 {
+ const filter = userId
+ ? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
+ : `session_id="${sessionId}" && slug="${slug}"`;
+ const row = await listOne('book_ratings', filter).catch(() => null);
+ return row?.rating ?? 0;
+}
+
+export async function getBookAvgRating(
+ slug: string
+): Promise<{ avg: number; count: number }> {
+ const rows = await listAll('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 {
+ const filter = userId
+ ? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
+ : `session_id="${sessionId}" && slug="${slug}"`;
+ const existing = await listOne('book_ratings', filter).catch(() => null);
+ const payload: Partial = { 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 {
+ 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 = { 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> {
+ 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 = {};
+ for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
+ return map;
+}
+
export async function getBooksForDiscovery(
sessionId: string,
userId?: string,
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index c47b2e4..59b2882 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -170,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() {
diff --git a/ui/src/routes/api/export/[slug]/+server.ts b/ui/src/routes/api/export/[slug]/+server.ts
new file mode 100644
index 0000000..8954b94
--- /dev/null
+++ b/ui/src/routes/api/export/[slug]/+server.ts
@@ -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[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"`
+ }
+ });
+};
diff --git a/ui/src/routes/api/library/[slug]/+server.ts b/ui/src/routes/api/library/[slug]/+server.ts
index b0f9243..a40f4e6 100644
--- a/ui/src/routes/api/library/[slug]/+server.ts
+++ b/ui/src/routes/api/library/[slug]/+server.ts
@@ -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 });
+};
diff --git a/ui/src/routes/api/ratings/[slug]/+server.ts b/ui/src/routes/api/ratings/[slug]/+server.ts
new file mode 100644
index 0000000..a7154c7
--- /dev/null
+++ b/ui/src/routes/api/ratings/[slug]/+server.ts
@@ -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 });
+};
diff --git a/ui/src/routes/books/+page.server.ts b/ui/src/routes/books/+page.server.ts
index 58d117c..fa82318 100644
--- a/ui/src/routes/books/+page.server.ts
+++ b/ui/src/routes/books/+page.server.ts
@@ -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> = [];
let savedSlugs: Set = new Set();
+ let shelfMap: Record = {};
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
};
};
diff --git a/ui/src/routes/books/+page.svelte b/ui/src/routes/books/+page.svelte
index e61220c..948386b 100644
--- a/ui/src/routes/books/+page.svelte
+++ b/ui/src/routes/books/+page.svelte
@@ -14,6 +14,32 @@
return [];
}
}
+
+ type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
+ let activeShelf = $state('all');
+
+ const shelfLabels: Record = {
+ all: 'All',
+ '': 'Reading',
+ plan_to_read: 'Plan to Read',
+ completed: 'Completed',
+ dropped: 'Dropped'
+ };
+
+ const shelfMap = $derived(data.shelfMap as Record);
+ 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,
+ });
@@ -37,10 +63,29 @@
{:else}
+
+
+ {#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
+ {#if shelfCounts[shelf] > 0 || shelf === 'all'}
+
+ {/if}
+ {/each}
+
+
- {#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] ?? ''}
{/if}
+ {#if bookShelf && activeShelf === 'all'}
+
+ {shelfLabels[bookShelf] ?? bookShelf}
+
+ {/if}
{#if genres.length > 0}
diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts
index f9e7ae3..a2c3237 100644
--- a/ui/src/routes/books/[slug]/+page.server.ts
+++ b/ui/src/routes/books/[slug]/+page.server.ts
@@ -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 ?? '',
diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte
index afbc749..3756b8f 100644
--- a/ui/src/routes/books/[slug]/+page.svelte
+++ b/ui/src/routes/books/[slug]/+page.svelte
@@ -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('');
+
+ 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 @@
{/if}
+
+
+
+
+ {#if saved}
+
+
+
+ {/if}
+
@@ -346,10 +404,55 @@
{/if}
{/if}
+
+
+
+
+ {#if saved}
+
+
+
+ {/if}
+
+
+{#if data.inLib && chapterList.length > 0}
+
+
+
+
Download
+
All {chapterList.length} chapters as EPUB
+
+
+ .epub
+
+
+{/if}
+
{#if showPreview && currentBook}
+{@const previewBook = currentBook!}
(showPreview = false)}
+ onkeydown={(e) => { if (e.key === 'Escape') showPreview = false; }}
>
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
>
- {#if currentBook.cover}
-

+ {#if previewBook.cover}
+
{:else}
@@ -314,22 +320,22 @@
-
{currentBook.title}
- {#if currentBook.author}
-
{currentBook.author}
+
{previewBook.title}
+ {#if previewBook.author}
+
{previewBook.author}
{/if}
- {#if currentBook.summary}
-
{currentBook.summary}
+ {#if previewBook.summary}
+
{previewBook.summary}
{/if}
- {#each parseBookGenres(currentBook.genres).slice(0, 4) as genre}
+ {#each parseBookGenres(previewBook.genres).slice(0, 4) as genre}
{genre}
{/each}
- {#if currentBook.status}
- {currentBook.status}
+ {#if previewBook.status}
+ {previewBook.status}
{/if}
- {#if currentBook.total_chapters}
- {currentBook.total_chapters} ch.
+ {#if previewBook.total_chapters}
+ {previewBook.total_chapters} ch.
{/if}
@@ -414,6 +420,7 @@
{:else}
+ {@const book = currentBook!}
@@ -461,8 +468,8 @@
onpointercancel={onPointerUp}
>
- {#if currentBook.cover}
-

+ {#if book.cover}
+

{:else}