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';
|
||||
|
||||
Reference in New Issue
Block a user