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
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:
@@ -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; // 1–5
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user