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:
@@ -75,6 +75,10 @@ class AudioStore {
|
||||
*/
|
||||
seekRequest = $state<number | null>(null);
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||||
sleepUntil = $state(0);
|
||||
|
||||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, navigates to the next chapter when the current one ends
|
||||
|
||||
@@ -681,6 +681,47 @@
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Sleep timer ────────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let _tick = $state(0);
|
||||
$effect(() => {
|
||||
if (!audioStore.sleepUntil) return;
|
||||
const id = setInterval(() => { _tick++; }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
_tick; // subscribe to tick updates
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
if (!audioStore.sleepUntil) {
|
||||
// Start at first option (15 min)
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex(m => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
// Was at max or past last — turn off
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return '';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -887,6 +928,24 @@
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
|
||||
57
ui/src/lib/components/StarRating.svelte
Normal file
57
ui/src/lib/components/StarRating.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rating: number; // current user rating 0–5 (0 = unrated)
|
||||
avg?: number; // average rating
|
||||
count?: number; // total ratings
|
||||
readonly?: boolean; // display-only mode
|
||||
size?: 'sm' | 'md';
|
||||
onrate?: (r: number) => void;
|
||||
}
|
||||
|
||||
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
|
||||
|
||||
let hovered = $state(0);
|
||||
|
||||
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
onmouseenter={() => { if (!readonly) hovered = star; }}
|
||||
onmouseleave={() => { if (!readonly) hovered = 0; }}
|
||||
onclick={() => { if (!readonly) onrate?.(star); }}
|
||||
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
|
||||
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
|
||||
>
|
||||
<svg
|
||||
class="{starSize} transition-colors"
|
||||
fill={star <= display ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button[disabled] { pointer-events: none; }
|
||||
svg { color: #f59e0b; }
|
||||
</style>
|
||||
@@ -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