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

@@ -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

View File

@@ -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) -->

View 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>

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; // 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,

View File

@@ -170,6 +170,23 @@
audioStore.seekRequest = null;
});
// Sleep timer — fires once when time is up
$effect(() => {
const until = audioStore.sleepUntil;
if (!until) return;
const ms = until - Date.now();
if (ms <= 0) {
audioStore.sleepUntil = 0;
if (audioStore.isPlaying) audioStore.toggleRequest++;
return;
}
const id = setTimeout(() => {
audioStore.sleepUntil = 0;
if (audioStore.isPlaying) audioStore.toggleRequest++;
}, ms);
return () => clearTimeout(id);
});
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
let audioTimeSaveTimer = 0;
function saveAudioTime() {

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ params, url }) => {
const { slug } = params;
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
const qs = new URLSearchParams();
if (from) qs.set('from', from);
if (to) qs.set('to', to);
const query = qs.size ? `?${qs}` : '';
const res = await backendFetch(`/api/export/${encodeURIComponent(slug)}${query}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
error(res.status as Parameters<typeof error>[0], text || 'Export failed');
}
const bytes = await res.arrayBuffer();
return new Response(bytes, {
headers: {
'Content-Type': 'application/epub+zip',
'Content-Disposition': `attachment; filename="${slug}.epub"`
}
});
};

View File

@@ -1,6 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
import { saveBook, unsaveBook, updateBookShelf } from '$lib/server/pocketbase';
import type { ShelfName } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
@@ -32,3 +33,17 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
}
return json({ ok: true });
};
/**
* PATCH /api/library/[slug]
* Update the shelf category for a saved book.
*/
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const body = await request.json().catch(() => null);
const shelf = body?.shelf ?? '';
const VALID = ['', 'plan_to_read', 'completed', 'dropped'];
if (!VALID.includes(shelf)) error(400, 'invalid shelf');
await updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id);
return json({ ok: true });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookRating, getBookAvgRating, setBookRating } from '$lib/server/pocketbase';
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
const [userRating, avg] = await Promise.all([
getBookRating(locals.sessionId, slug, locals.user?.id),
getBookAvgRating(slug)
]);
return json({ userRating, avg: avg.avg, count: avg.count });
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const body = await request.json().catch(() => null);
const rating = body?.rating;
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
error(400, 'rating must be 1–5');
}
await setBookRating(locals.sessionId, slug, rating, locals.user?.id);
const avg = await getBookAvgRating(slug);
return json({ ok: true, avg: avg.avg, count: avg.count });
};

View File

@@ -1,16 +1,18 @@
import type { PageServerLoad } from './$types';
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
import { getBooksBySlugs, allProgress, getSavedSlugs, getShelfMap } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals }) => {
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
let savedSlugs: Set<string> = new Set();
let shelfMap: Record<string, string> = {};
try {
[progressList, savedSlugs] = await Promise.all([
[progressList, savedSlugs, shelfMap] = await Promise.all([
allProgress(locals.sessionId, locals.user?.id),
getSavedSlugs(locals.sessionId, locals.user?.id)
getSavedSlugs(locals.sessionId, locals.user?.id),
getShelfMap(locals.sessionId, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load library data', { err: String(e) });
@@ -46,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
books: [...withProgress, ...savedOnly],
progressMap,
savedSlugs: [...savedSlugs]
savedSlugs: [...savedSlugs],
shelfMap
};
};

View File

@@ -14,6 +14,32 @@
return [];
}
}
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
let activeShelf = $state<Shelf | 'all'>('all');
const shelfLabels: Record<string, string> = {
all: 'All',
'': 'Reading',
plan_to_read: 'Plan to Read',
completed: 'Completed',
dropped: 'Dropped'
};
const shelfMap = $derived(data.shelfMap as Record<string, string>);
const filteredBooks = $derived(
activeShelf === 'all'
? data.books
: data.books.filter((b) => (shelfMap[b.slug] ?? '') === activeShelf)
);
const shelfCounts = $derived({
all: data.books.length,
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
});
</script>
<svelte:head>
@@ -37,10 +63,29 @@
</p>
</div>
{:else}
<!-- Shelf tabs -->
<div class="flex gap-1 flex-wrap mb-4">
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each data.books as book}
{#each filteredBooks as book}
{@const lastChapter = data.progressMap[book.slug]}
{@const genres = parseGenres(book.genres)}
{@const bookShelf = shelfMap[book.slug] ?? ''}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
@@ -85,6 +130,11 @@
</span>
{/if}
</div>
{#if bookShelf && activeShelf === 'all'}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) self-start">
{shelfLabels[bookShelf] ?? bookShelf}
</span>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-1">

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
@@ -15,13 +15,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (book) {
// Book is in the library — normal path
let chapters, progress, saved, readersThisWeek;
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
try {
[chapters, progress, saved, readersThisWeek] = await Promise.all([
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id),
countReadersThisWeek(slug)
countReadersThisWeek(slug),
getBookRating(locals.sessionId, slug, locals.user?.id),
getBookAvgRating(slug)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
@@ -35,6 +37,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
saved,
lastChapter: progress?.chapter ?? null,
readersThisWeek,
userRating: userRating ?? 0,
ratingAvg: ratingAvg ?? { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
@@ -58,6 +62,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: false,
saved: false,
lastChapter: null,
userRating: 0,
ratingAvg: { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
@@ -95,6 +101,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: true,
saved: false,
lastChapter: null,
userRating: 0,
ratingAvg: { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',

View File

@@ -3,7 +3,9 @@
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import StarRating from '$lib/components/StarRating.svelte';
import * as m from '$lib/paraglide/messages.js';
import type { ShelfName } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
@@ -17,6 +19,37 @@
let saved = $state(untrack(() => data.saved));
let saving = $state(false);
// ── Ratings ───────────────────────────────────────────────────────────────
let userRating = $state(data.userRating ?? 0);
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
async function rate(r: number) {
userRating = r;
try {
const res = await fetch(`/api/ratings/${encodeURIComponent(data.book?.slug ?? '')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: r })
});
if (res.ok) {
const body = await res.json();
ratingAvg = { avg: body.avg, count: body.count };
}
} catch { /* ignore */ }
}
// ── Shelf ─────────────────────────────────────────────────────────────────
let currentShelf = $state<ShelfName>('');
async function setShelf(shelf: ShelfName) {
currentShelf = shelf;
await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shelf })
});
}
async function toggleSave() {
if (saving || !data.book) return;
saving = true;
@@ -286,6 +319,31 @@
</button>
{/if}
</div>
<!-- Ratings + shelf — desktop -->
<div class="hidden sm:flex items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
count={ratingAvg.count}
onrate={rate}
size="md"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
@@ -346,10 +404,55 @@
{/if}
</button>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
count={ratingAvg.count}
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════ Download row ══ -->
{#if data.inLib && chapterList.length > 0}
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
<svg class="w-4 h-4 text-(--color-muted) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text)">Download</p>
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
</div>
<a
href="/api/export/{book.slug}"
download="{book.slug}.epub"
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
>
.epub
</a>
</div>
{/if}
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
<a

View File

@@ -290,19 +290,25 @@
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
{#if showPreview && currentBook}
{@const previewBook = currentBook!}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
role="presentation"
onclick={() => (showPreview = false)}
onkeydown={(e) => { if (e.key === 'Escape') showPreview = false; }}
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Cover strip -->
<div class="relative h-40 overflow-hidden">
{#if currentBook.cover}
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover object-top" />
{#if previewBook.cover}
<img src={previewBook.cover} alt={previewBook.title} class="w-full h-full object-cover object-top" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
@@ -314,22 +320,22 @@
</div>
<div class="p-5">
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{currentBook.title}</h3>
{#if currentBook.author}
<p class="text-sm text-(--color-muted) mb-3">{currentBook.author}</p>
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
{#if previewBook.author}
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
{/if}
{#if currentBook.summary}
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{currentBook.summary}</p>
{#if previewBook.summary}
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
{/if}
<div class="flex flex-wrap gap-2 mb-5">
{#each parseBookGenres(currentBook.genres).slice(0, 4) as genre}
{#each parseBookGenres(previewBook.genres).slice(0, 4) as genre}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
{#if currentBook.status}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{currentBook.status}</span>
{#if previewBook.status}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
{/if}
{#if currentBook.total_chapters}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{currentBook.total_chapters} ch.</span>
{#if previewBook.total_chapters}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
{/if}
</div>
@@ -414,6 +420,7 @@
</div>
</div>
{:else}
{@const book = currentBook!}
<!-- Card stack -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
@@ -461,8 +468,8 @@
onpointercancel={onPointerUp}
>
<!-- Cover image -->
{#if currentBook.cover}
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -474,19 +481,19 @@
<!-- Bottom gradient + info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{currentBook.title}</h2>
{#if currentBook.author}
<p class="text-white/70 text-sm mb-2">{currentBook.author}</p>
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
{#if book.author}
<p class="text-white/70 text-sm mb-2">{book.author}</p>
{/if}
<div class="flex flex-wrap gap-1.5 items-center">
{#each parseBookGenres(currentBook.genres).slice(0, 2) as genre}
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
{/each}
{#if currentBook.status}
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{currentBook.status}</span>
{#if book.status}
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
{/if}
{#if currentBook.total_chapters}
<span class="text-xs text-white/50 ml-auto">{currentBook.total_chapters} ch.</span>
{#if book.total_chapters}
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
{/if}
</div>
</div>