feat: personal library — filter by read/saved books, add save button
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
Deploy / Cleanup Preview (push) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 13s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 18s

This commit is contained in:
Admin
2026-03-06 18:41:30 +05:00
parent 60a9540ef7
commit 08d4718245
7 changed files with 218 additions and 15 deletions

View File

@@ -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",

View File

@@ -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<Set<string>> {
const rows = await listAll<UserLibraryEntry>(
'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<boolean> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const row = await listOne<UserLibraryEntry>('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<void> {
const alreadySaved = await isBookSaved(sessionId, slug, userId);
if (alreadySaved) return;
const payload: Partial<UserLibraryEntry> = {
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<void> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const row = await listOne<UserLibraryEntry & { id: string }>('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';

View File

@@ -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 });
};

View File

@@ -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<ReturnType<typeof listBooks>>;
let allBooks: Awaited<ReturnType<typeof listBooks>>;
let progressList: Awaited<ReturnType<typeof allProgress>>;
let savedSlugs: Set<string>;
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<string, string> = {};
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]
};
};

View File

@@ -21,13 +21,19 @@
<div class="mb-6">
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
<p class="text-zinc-400 text-sm mt-1">{data.books?.length ?? 0} books</p>
<p class="text-zinc-400 text-sm mt-1">
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
</p>
</div>
{#if !data.books?.length}
<div class="text-center py-20 text-zinc-500">
<p class="text-lg">No books scraped yet.</p>
<p class="text-sm mt-2">Use the scraper API to add books.</p>
<p class="text-lg">Your library is empty.</p>
<p class="text-sm mt-2">
Books you start reading or save from
<a href="/browse" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
will appear here.
</p>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">

View File

@@ -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'
};

View File

@@ -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 @@
<p class="text-zinc-400 text-sm leading-relaxed line-clamp-4 mt-1">{data.book.summary}</p>
{/if}
<div class="flex gap-3 mt-2">
<div class="flex gap-3 mt-2 flex-wrap items-center">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
@@ -192,6 +208,34 @@
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
<!-- Save / unsave button -->
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 hover:bg-red-500/20 hover:text-red-300 border border-amber-400/30 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-600 border border-zinc-600'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
{saved ? 'Saved' : 'Save'}
</button>
{/if}
</div>
</div>
</div>