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}
+ + + @@ -346,10 +404,55 @@ {/if} {/if} + + +
+ + {#if saved} +
+ +
+ {/if} +
+ +{#if data.inLib && chapterList.length > 0} +
+{/if} +
{#if showPreview && currentBook} +{@const previewBook = currentBook!}