From 08d47182457d36c5ab1d44b20063d8ca5992f6bb Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 6 Mar 2026 18:41:30 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20personal=20library=20=E2=80=94=20filter?= =?UTF-8?q?=20by=20read/saved=20books,=20add=20save=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scraper/internal/storage/pocketbase.go | 10 +++ ui/src/lib/server/pocketbase.ts | 76 +++++++++++++++++++++ ui/src/routes/api/library/[slug]/+server.ts | 34 +++++++++ ui/src/routes/books/+page.server.ts | 44 ++++++++++-- ui/src/routes/books/+page.svelte | 12 +++- ui/src/routes/books/[slug]/+page.server.ts | 11 +-- ui/src/routes/books/[slug]/+page.svelte | 46 ++++++++++++- 7 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 ui/src/routes/api/library/[slug]/+server.ts diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index ea3dde9..1c499a4 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -368,6 +368,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "created", "type": "date"}, }, }, + { + "name": "user_library", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "slug", "type": "text", "required": true}, + {"name": "saved_at", "type": "date"}, + }, + }, { "name": "scraping_tasks", "type": "base", diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index a73ef2d..e9b5ff1 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -346,6 +346,82 @@ export async function mergeSessionProgress(sessionId: string, userId: string): P log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length }); } +// ─── User library (saved books) ─────────────────────────────────────────────── + +export interface UserLibraryEntry { + id?: string; + session_id: string; + user_id?: string; + slug: string; + saved_at: string; +} + +function libraryFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; +} + +/** Returns all slugs the user has explicitly saved to their library. */ +export async function getSavedSlugs(sessionId: string, userId?: string): Promise> { + const rows = await listAll( + 'user_library', + libraryFilter(sessionId, userId) + ); + return new Set(rows.map((r) => r.slug)); +} + +/** Returns whether a specific slug is saved. */ +export async function isBookSaved( + sessionId: string, + slug: string, + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const row = await listOne('user_library', filter); + return row !== null; +} + +/** Save a book to the user's library. No-op if already saved. */ +export async function saveBook( + sessionId: string, + slug: string, + userId?: string +): Promise { + const alreadySaved = await isBookSaved(sessionId, slug, userId); + if (alreadySaved) return; + const payload: Partial = { + session_id: sessionId, + slug, + saved_at: new Date().toISOString() + }; + if (userId) payload.user_id = userId; + const res = await pbPost('/api/collections/user_library/records', payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body }); + } +} + +/** Remove a book from the user's library. */ +export async function unsaveBook( + sessionId: string, + slug: string, + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const row = await listOne('user_library', filter); + if (!row) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + // ─── Users ──────────────────────────────────────────────────────────────────── import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; diff --git a/ui/src/routes/api/library/[slug]/+server.ts b/ui/src/routes/api/library/[slug]/+server.ts new file mode 100644 index 0000000..b0f9243 --- /dev/null +++ b/ui/src/routes/api/library/[slug]/+server.ts @@ -0,0 +1,34 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { saveBook, unsaveBook } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/library/[slug] + * Save a book to the user's personal library. + */ +export const POST: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + try { + await saveBook(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('library', 'saveBook failed', { slug, err: String(e) }); + error(500, 'Failed to save book'); + } + return json({ ok: true }); +}; + +/** + * DELETE /api/library/[slug] + * Remove a book from the user's personal library. + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + try { + await unsaveBook(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('library', 'unsaveBook failed', { slug, err: String(e) }); + error(500, 'Failed to remove book'); + } + return json({ ok: true }); +}; diff --git a/ui/src/routes/books/+page.server.ts b/ui/src/routes/books/+page.server.ts index 7d1c5b7..6d0ec72 100644 --- a/ui/src/routes/books/+page.server.ts +++ b/ui/src/routes/books/+page.server.ts @@ -1,20 +1,24 @@ +import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { listBooks, allProgress } from '$lib/server/pocketbase'; +import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ locals }) => { - let books: Awaited>; + let allBooks: Awaited>; let progressList: Awaited>; + let savedSlugs: Set; try { - [books, progressList] = await Promise.all([ + [allBooks, progressList, savedSlugs] = await Promise.all([ listBooks(), - allProgress(locals.sessionId, locals.user?.id) + allProgress(locals.sessionId, locals.user?.id), + getSavedSlugs(locals.sessionId, locals.user?.id) ]); } catch (e) { - log.error('books', 'failed to load books or progress', { err: String(e) }); - books = []; + log.error('books', 'failed to load library data', { err: String(e) }); + allBooks = []; progressList = []; + savedSlugs = new Set(); } // Build a quick lookup: slug → last chapter read @@ -23,5 +27,31 @@ export const load: PageServerLoad = async ({ locals }) => { progressMap[p.slug] = p.chapter; } - return { books, progressMap }; + // Library = books the user has started reading OR explicitly saved + const progressSlugs = new Set(progressList.map((p) => p.slug)); + const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug)); + + // Sort: books with progress first (most-recently-read order is implicit via progressList), + // then saved-only books alphabetically. + const withProgress = books.filter((b) => progressSlugs.has(b.slug)); + const savedOnly = books + .filter((b) => !progressSlugs.has(b.slug)) + .sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')); + + // Re-sort withProgress by most recent progress update + const progressUpdatedMap: Record = {}; + for (const p of progressList) { + progressUpdatedMap[p.slug] = p.updated; + } + withProgress.sort((a, b) => { + const ta = progressUpdatedMap[a.slug] ?? ''; + const tb = progressUpdatedMap[b.slug] ?? ''; + return tb.localeCompare(ta); // descending — most recently read first + }); + + return { + books: [...withProgress, ...savedOnly], + progressMap, + savedSlugs: [...savedSlugs] + }; }; diff --git a/ui/src/routes/books/+page.svelte b/ui/src/routes/books/+page.svelte index 2db7d27..c54affa 100644 --- a/ui/src/routes/books/+page.svelte +++ b/ui/src/routes/books/+page.svelte @@ -21,13 +21,19 @@

Library

-

{data.books?.length ?? 0} books

+

+ {data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''} +

{#if !data.books?.length}
-

No books scraped yet.

-

Use the scraper API to add books.

+

Your library is empty.

+

+ Books you start reading or save from + Discover + will appear here. +

{:else}
diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index b808661..67a2ccf 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 } from '$lib/server/pocketbase'; +import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import { env } from '$env/dynamic/private'; @@ -24,11 +24,12 @@ export const load: PageServerLoad = async ({ params, locals }) => { if (book) { // Book is in the library — normal path - let chapters, progress; + let chapters, progress, saved; try { - [chapters, progress] = await Promise.all([ + [chapters, progress, saved] = await Promise.all([ listChapterIdx(slug), - getProgress(locals.sessionId, slug, locals.user?.id) + getProgress(locals.sessionId, slug, locals.user?.id), + isBookSaved(locals.sessionId, slug, locals.user?.id) ]); } catch (e) { log.error('books', 'failed to load book page data', { slug, err: String(e) }); @@ -40,6 +41,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { chapters, previewChapters: null as PreviewChapter[] | null, inLib: true, + saved, lastChapter: progress?.chapter ?? null, isAdmin: locals.user?.role === 'admin' }; @@ -89,6 +91,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { chapters: [], previewChapters: preview.chapters, inLib: preview.in_lib, + saved: false, lastChapter: null, isAdmin: locals.user?.role === 'admin' }; diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index e409d0d..7db8b02 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -5,6 +5,22 @@ let { data }: { data: PageData } = $props(); + // ── Save / unsave ───────────────────────────────────────────────────────── + let saved = $state(data.saved); + let saving = $state(false); + + async function toggleSave() { + if (saving) return; + saving = true; + try { + const method = saved ? 'DELETE' : 'POST'; + const res = await fetch(`/api/library/${encodeURIComponent(data.book.slug)}`, { method }); + if (res.ok) saved = !saved; + } finally { + saving = false; + } + } + function parseGenres(genres: string[] | string): string[] { if (Array.isArray(genres)) return genres; try { @@ -175,7 +191,7 @@

{data.book.summary}

{/if} -