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
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:
@@ -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';
|
||||
|
||||
34
ui/src/routes/api/library/[slug]/+server.ts
Normal file
34
ui/src/routes/api/library/[slug]/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user