feat: ratings, shelves, sleep timer, EPUB export + fix TS errors
All checks were successful
Release / Docker / caddy (push) Successful in 38s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 57s
Release / Docker / ui (push) Successful in 1m49s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / backend (push) Successful in 3m46s
Release / Gitea Release (push) Successful in 13s

**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 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-02 21:50:04 +05:00
parent 809dc8d898
commit 08361172c6
17 changed files with 752 additions and 32 deletions

View File

@@ -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; // 15
}
export async function getBookRating(
sessionId: string,
slug: string,
userId?: string
): Promise<number> {
const filter = userId
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
: `session_id="${sessionId}" && slug="${slug}"`;
const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
return row?.rating ?? 0;
}
export async function getBookAvgRating(
slug: string
): Promise<{ avg: number; count: number }> {
const rows = await listAll<BookRating>('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<void> {
const filter = userId
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
: `session_id="${sessionId}" && slug="${slug}"`;
const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
const payload: Partial<BookRating> = { 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<void> {
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<string, unknown> = { 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<Record<string, ShelfName>> {
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<string, ShelfName> = {};
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
return map;
}
export async function getBooksForDiscovery(
sessionId: string,
userId?: string,