fix: resume epub progress from the fields the API returns

The load function read progress.epub_loc and progress.progress, neither of
which BookProgressRead carries, so resuming never worked and every open
started at page one. Read epub_cfi and percentage instead.

Fail loudly when the book cannot be fetched. The previous version caught
everything and returned {status, error} that nothing consumed, so a missing
book produced a reader sitting on its spinner forever. Check response.ok
before parsing too: fetch resolves on a 404, and arrayBuffer() happily
returns the error page, which only surfaced later as an opaque epub parse
error.

Give the reader a working sidebar, a theme that survives the book's own
CSS, and an error state with a retry. The theme is registered rather than
applied as bare overrides because most EPUBs ship their own body colours
and win otherwise, which is why the page stayed white against a dark UI.
This commit is contained in:
2026-08-11 21:47:12 -04:00
parent ac5a5c75aa
commit ee46f5568c
3 changed files with 329 additions and 227 deletions
@@ -1,82 +1,135 @@
<script lang="ts">
// TODO: Add type hints to the rest of this file
import { browser } from '$app/environment';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { Book, type Rendition } from 'epubjs';
import type { DisplayedLocation } from 'epubjs/types/rendition';
import { mode } from 'mode-watcher';
import '../../../app.css';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index';
import type { DisplayedLocation } from 'epubjs/types/rendition';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
let { bookUrl, bookId, initialProgress = 0, initialEpubLoc = null } = $props();
import {
ChevronDown,
ChevronLeft,
ChevronRight,
PanelLeft,
RotateCcw,
TriangleAlert
} from '@lucide/svelte';
let {
bookUrl,
bookId,
title = '',
initialProgress = 0,
initialEpubLoc = null
}: {
bookUrl: string;
bookId: string | number;
title?: string;
initialProgress?: number;
initialEpubLoc?: string | null;
} = $props();
let epubViewer = $state<HTMLElement>();
let containerWidth = $state(0);
let containerHeight = $state(0);
let isReaderVisible = $state(false);
/** Set when loading fails, so the spinner gives way to something actionable. */
let loadError = $state<string | null>(null);
let hasNextPage = $state(true);
let hasPrevPage = $state(false);
let book: Book | undefined = $state();
let rendition = $state<Rendition>();
let chapters = $state([]);
let chapters = $state<any[]>([]);
let isMounted = $state(false);
let currentLocation = $state(initialEpubLoc);
let currentProgress = $state(initialProgress);
let currentLocation = $state(untrack(() => initialEpubLoc));
let currentProgress = $state(untrack(() => initialProgress));
let isSidebarOpen = $state(false);
let debounceTimeout = $state<NodeJS.Timeout>();
// Function to update dimensions
function updateDimensions() {
if (epubViewer) {
// Get parent element dimensions
const parent = epubViewer.parentElement;
const percent = $derived(Math.round((currentProgress ?? 0) * 100));
function updateDimensions() {
if (!epubViewer) return;
const parent = epubViewer.parentElement;
containerWidth = parent?.clientWidth! * 0.9 - 240;
containerHeight = window.innerHeight * 0.8;
}
}
// Handle window resize
function handleResize() {
updateDimensions();
if (rendition) {
rendition.resize(containerWidth, containerHeight);
}
rendition?.resize(containerWidth, containerHeight);
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
await prevPage();
} else if (e.key === 'ArrowRight') {
await nextPage();
}
if (e.key === 'ArrowLeft') await prevPage();
else if (e.key === 'ArrowRight') await nextPage();
}
async function nextPage() {
if (hasNextPage) await rendition!.next();
if (hasNextPage) await rendition?.next();
}
async function prevPage() {
if (hasPrevPage) await rendition!.prev();
if (hasPrevPage) await rendition?.prev();
}
/**
* Push the book's own page onto the theme, so reading in dark mode is not a
* white slab. epub.js renders into an iframe, so app CSS cannot reach it —
* the values have to be handed over explicitly.
*/
function applyReaderTheme() {
if (!rendition) return;
try {
const styles = getComputedStyle(document.documentElement);
const bg = styles.getPropertyValue('--card').trim();
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return;
// A registered theme rather than bare overrides: most EPUBs ship their
// own body background and colour, and an unprioritised override loses
// to them — which is why the page stayed white against a dark UI.
rendition.themes.register('chitai', {
body: { background: `${bg} !important`, color: `${fg} !important` },
'p, div, span, li, td, th, h1, h2, h3, h4, h5, h6': { color: `${fg} !important` },
a: { color: 'inherit !important' }
});
rendition.themes.select('chitai');
} catch (error) {
// Theming is a nicety; never let it take the reader down.
console.warn('Could not apply reader theme', error);
}
}
/**
* Runs when the rendition appears and again on every light/dark flip. It
* deliberately does not wait for isReaderVisible: at first paint that gate
* meant the theme was read before ModeWatcher had put `.dark` on <html>, so
* the book was styled from the light palette while the UI went dark.
*/
$effect(() => {
mode.current;
if (rendition) applyReaderTheme();
});
async function setUserBookProgress() {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
await fetch(`/api/books/progress/${bookId}`, {
try {
const response = await fetch(`/api/books/progress/${bookId}`, {
method: 'POST',
body: JSON.stringify({
percentage: currentProgress,
@@ -84,84 +137,83 @@
completed: currentProgress === 1
})
});
// A failed save must not interrupt reading, but it should not be
// invisible either.
if (!response.ok) console.error('Could not save reading progress', response.status);
} catch (error) {
console.error('Could not save reading progress', error);
}
}, 3000);
}
const getChapters = async (book: Book) => {
await book.ready;
// Get spine items (basic chapter structure)
const spineItems = book.spine.items.map((item, index) => ({
id: item.idref,
href: item.href,
index: index,
index,
label: item.label || `Chapter ${index + 1}`,
cfi: book.spine.get(index).cfiBase // Get CFI from spine
cfi: book.spine.get(index).cfiBase
}));
// Get table of contents for better labels
const toc = await book.loaded.navigation;
// Combine spine items with TOC information
const chapters = toc.toc.map((chapter) => {
return toc.toc.map((chapter) => {
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
return {
...spineItem,
label: chapter.label || spineItem?.label,
subitems: chapter.subitems,
subitems: chapter.subitems ?? [],
href: chapter.href,
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
};
});
return chapters;
};
async function navigateToChapter(chapter: any) {
try {
if (!rendition || !book) return;
// Ensure book is ready
if (!rendition || !book || !chapter.href) return;
await book.ready;
// Try different navigation methods
if (chapter.href) {
await rendition.display(chapter.href);
} else {
console.error('No valid navigation target found for chapter:', chapter);
}
} catch (error) {
console.error('Error navigating to chapter:', error);
}
}
onMount(async () => {
if (browser) {
// Initial setup
updateDimensions();
async function loadBook() {
loadError = null;
isReaderVisible = false;
try {
// Fetch the EPUB file
updateDimensions();
const response = await fetch(bookUrl);
// fetch only rejects on network failure — a 404 or 500 still resolves,
// and arrayBuffer() happily returns the error page. Without this check
// the failure only surfaced as an epub parse error, caught below and
// logged, leaving the spinner running forever.
if (!response.ok) {
throw new Error(`The server returned ${response.status} for this file.`);
}
const arrayBuffer = await response.arrayBuffer();
// Create book from array buffer
book = new Book();
await book.open(arrayBuffer, 'binary');
// Generate locations if they do not exist in localStorage
let existingLocations = localStorage.getItem(`${bookId}-locations`);
let locations;
if (existingLocations) {
locations = JSON.parse(existingLocations);
book.locations.load(locations);
// Locations are expensive to generate, so they are cached per book
const cached = localStorage.getItem(`${bookId}-locations`);
if (cached) {
book.locations.load(JSON.parse(cached));
} else {
locations = await book.locations.generate(1600);
// Save locations to localStorage
const locations = await book.locations.generate(1600);
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
}
await book.ready;
// Render the book to the viewer element
rendition = book.renderTo('epub-viewer', {
width: containerWidth,
height: containerHeight,
@@ -171,30 +223,17 @@
flow: 'paginated'
});
// Set the key listener on the iframe element
let keyListener = async function (e: any) {
// Left Key
if ((e.keyCode || e.which) == 37) {
await prevPage();
}
// Right Key
if ((e.keyCode || e.which) == 39) {
await nextPage();
}
};
rendition.on('keydown', async (e: any) => {
if ((e.keyCode || e.which) === 37) await prevPage();
if ((e.keyCode || e.which) === 39) await nextPage();
});
// Add resize listener
window.addEventListener('resize', handleResize);
// Add Key listener
rendition.on('keydown', keyListener);
// Listen to location changes
rendition.on('locationChanged', async (location: DisplayedLocation) => {
if (!location?.start) return;
currentLocation = rendition!.currentLocation().start.cfi;
currentProgress = book?.locations.percentageFromCfi(currentLocation);
const cfi = rendition!.currentLocation().start.cfi;
currentLocation = cfi;
currentProgress = book?.locations.percentageFromCfi(cfi) ?? 0;
await setUserBookProgress();
@@ -204,69 +243,69 @@
chapters = await getChapters(book);
let initialLocationCfi =
currentLocation || book.locations.cfiFromPercentage(currentProgress);
const startAt = currentLocation || book.locations.cfiFromPercentage(currentProgress);
await (startAt ? rendition.display(startAt) : rendition.display());
if (initialLocationCfi) {
await rendition.display(initialLocationCfi);
} else {
await rendition.display();
}
isMounted = true;
applyReaderTheme();
isReaderVisible = true;
} catch (error) {
console.error('Error loading EPUB', error);
loadError = error instanceof Error ? error.message : 'The file could not be opened.';
}
}
onMount(async () => {
if (!browser) return;
window.addEventListener('resize', handleResize);
await loadBook();
});
onDestroy(() => {
if (browser) {
if (!browser) return;
window.removeEventListener('resize', handleResize);
book.destroy();
}
clearTimeout(debounceTimeout);
// Guarded: a failed load leaves `book` undefined, and this threw on the
// way out, replacing the real error with a second one.
book?.destroy();
});
</script>
<svelte:document onkeydown={handleKeydown} />
<Sidebar.Provider bind:open={isSidebarOpen}>
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
<Sidebar.Header />
<Sidebar.Root class={isReaderVisible ? '' : 'hidden'}>
<Sidebar.Header class="px-4 py-3">
<p class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Chapters</p>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter}
{#each chapters as chapter (chapter.href)}
{#if chapter.subitems.length > 0}
<Collapsible.Root class="group/collapsible">
<div class="flex w-full items-center gap-1">
<Sidebar.MenuItem class="min-w-0 flex-1">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Collapsible.Trigger class="flex-shrink-0 p-2">
<ChevronDown
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
class="size-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each chapter.subitems as subchapter}
{#each chapter.subitems as subchapter (subchapter.href)}
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(subchapter)}
>
<span class="block truncate" title={subchapter.label}
>{subchapter.label}</span
onclick={() => navigateToChapter(subchapter)}
>
<span class="block truncate" title={subchapter.label}>
{subchapter.label}
</span>
</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
{/each}
@@ -275,10 +314,7 @@
</Collapsible.Root>
{:else}
<Sidebar.MenuItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
<span class="block truncate">{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
@@ -286,68 +322,119 @@
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.Group />
</Sidebar.Content>
<Sidebar.Footer />
</Sidebar.Root>
<main class="flex w-full overflow-hidden">
{#if browser}
{#if !isReaderVisible}
<div class="absolute inset-0 flex items-center justify-center">
<main class="flex h-screen w-full flex-col overflow-hidden bg-background">
<!-- Reader chrome: somewhere to go back to, what you are reading, how far in -->
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} size-8"
onclick={() => (isSidebarOpen = !isSidebarOpen)}
disabled={!isReaderVisible}
>
<PanelLeft class="size-4" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom"><p>Chapters</p></Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<a
href="/book/{bookId}"
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
title={title}
>
{title || 'Reader'}
</a>
{#if isReaderVisible}
<span class="hidden items-center gap-2 sm:flex">
<span class="h-1 w-24 overflow-hidden rounded-full bg-muted-foreground/25">
<span class="block h-full bg-flag" style="width: {percent}%;"></span>
</span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
</span>
{/if}
<ThemeToggle />
</header>
{#if loadError}
<!-- Previously this state was a console.error and a spinner that never stopped -->
<div class="flex flex-1 items-center justify-center p-6">
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<TriangleAlert class="size-8 text-destructive" />
<h2 class="font-serif text-lg">This book wouldn't open</h2>
<p class="text-sm text-muted-foreground">{loadError}</p>
<div class="mt-2 flex gap-2">
<Button onclick={loadBook}>
<RotateCcw class="size-4" />
Try again
</Button>
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
Back to book
</a>
</div>
</div>
</div>
{:else if !isReaderVisible}
<div class="flex flex-1 items-center justify-center gap-3">
<Spinner />
<span class="text-sm text-muted-foreground">Opening…</span>
</div>
{/if}
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
<div class="flex">
<Sidebar.Trigger />
</div>
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
<ChevronLeft
<!-- Kept mounted even while loading: epub.js renders into #epub-viewer -->
<div class="flex flex-1 items-center justify-center {isReaderVisible ? '' : 'hidden'}">
<Button
variant="ghost"
size="icon"
class="size-10 shrink-0"
disabled={!hasPrevPage}
onclick={prevPage}
class={hasPrevPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
aria-label="Previous page"
>
<ChevronLeft class="size-5" />
</Button>
<div
id="epub-viewer"
bind:this={epubViewer}
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
class="epub-content mx-4 rounded-lg border bg-card p-8 shadow-sm"
></div>
<ChevronRight
<Button
variant="ghost"
size="icon"
class="size-10 shrink-0"
disabled={!hasNextPage}
onclick={nextPage}
class={hasNextPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
aria-label="Next page"
>
<ChevronRight class="size-5" />
</Button>
</div>
</div>
{/if}
</main>
</Sidebar.Provider>
<style>
.epub-content {
position: relative;
background: white;
}
/* Position separator relative to epub content */
/* Gutter between the two pages when the spread is side by side */
@media (min-width: 1209px) {
.epub-content:after {
/* Calculate position based on content width */
--separator-position: calc(var(--content-width, 100%) / 2);
.epub-content::after {
position: absolute;
width: 1px;
border-right: 1px #000 solid;
border-right: 1px solid var(--border);
height: 90%;
z-index: 1;
left: 50%;
transform: translateX(-50%);
top: 5%;
opacity: 0.15;
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
content: '';
}
}
@@ -1,21 +1,24 @@
<script>
<script lang="ts">
import { page } from '$app/state';
import EpubReader from '$lib/components/reader/epub-reader.svelte';
let { data } = $props();
let fileId = page.params.fileId;
let bookId = page.params.bookId;
const fileId = page.params.fileId!;
const bookId = page.params.bookId!;
// Make sure this endpoint returns the complete EPUB file
let bookUrl = `/api/books/download/${bookId}/${fileId}`;
// Streams the file through the proxy so the browser fetches it directly
const bookUrl = `/api/books/download/${bookId}/${fileId}`;
</script>
<div class="h-screen w-screen">
<svelte:head>
<title>{data.title} — Chitai</title>
</svelte:head>
<EpubReader
{bookUrl}
{bookId}
initialProgress={data.bookProgress?.progress}
initialEpubLoc={data.bookProgress?.epub_loc}
title={data.title}
initialProgress={data.initialProgress}
initialEpubLoc={data.initialEpubLoc}
/>
</div>
@@ -1,17 +1,29 @@
export async function load({ fetch, params, parent }) {
try {
import { error } from '@sveltejs/kit';
export async function load({ fetch, params }) {
// Fail loudly. The previous version caught everything and returned
// {status, error}, which nothing consumed — so a missing book produced a
// reader that sat on its spinner forever.
const response = await fetch(`/api/books/${params.bookId}`);
const result = await response.json();
let bookProgress = result.progress;
if (!response.ok) {
error(response.status, 'This book could not be loaded.');
}
const book = await response.json();
const file = book.files?.find((f: { id: number }) => String(f.id) === params.fileId);
if (!file) {
error(404, 'That file is not part of this book.');
}
return {
bookProgress
};
} catch (error) {
console.error('Error fetching book: ', error);
return {
status: error.status || 500,
error: error.message
title: book.title,
filename: file.filename,
// epub_cfi and percentage are what BookProgressRead actually carries;
// this used to read progress.epub_loc and progress.progress, so resuming
// never worked.
initialEpubLoc: book.progress?.epub_cfi ?? null,
initialProgress: book.progress?.percentage ?? 0
};
}
}