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:
@@ -1,272 +1,311 @@
|
||||
<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));
|
||||
|
||||
containerWidth = parent?.clientWidth! * 0.9 - 240;
|
||||
containerHeight = window.innerHeight * 0.8;
|
||||
}
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
percentage: currentProgress,
|
||||
epub_cfi: currentLocation,
|
||||
completed: currentProgress === 1
|
||||
})
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`/api/books/progress/${bookId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
percentage: currentProgress,
|
||||
epub_cfi: currentLocation,
|
||||
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);
|
||||
}
|
||||
await rendition.display(chapter.href);
|
||||
} catch (error) {
|
||||
console.error('Error navigating to chapter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
// Initial setup
|
||||
async function loadBook() {
|
||||
loadError = null;
|
||||
isReaderVisible = false;
|
||||
|
||||
try {
|
||||
updateDimensions();
|
||||
|
||||
try {
|
||||
// Fetch the EPUB file
|
||||
const response = await fetch(bookUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const response = await fetch(bookUrl);
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
locations = await book.locations.generate(1600);
|
||||
// Save locations to localStorage
|
||||
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,
|
||||
spread: 'auto',
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
await setUserBookProgress();
|
||||
|
||||
hasNextPage = !rendition!.location.atEnd;
|
||||
hasPrevPage = !rendition!.location.atStart;
|
||||
});
|
||||
|
||||
chapters = await getChapters(book);
|
||||
|
||||
let initialLocationCfi =
|
||||
currentLocation || book.locations.cfiFromPercentage(currentProgress);
|
||||
|
||||
if (initialLocationCfi) {
|
||||
await rendition.display(initialLocationCfi);
|
||||
} else {
|
||||
await rendition.display();
|
||||
}
|
||||
|
||||
isMounted = true;
|
||||
isReaderVisible = true;
|
||||
} catch (error) {
|
||||
console.error('Error loading EPUB', error);
|
||||
// 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();
|
||||
|
||||
book = new Book();
|
||||
await book.open(arrayBuffer, 'binary');
|
||||
|
||||
// 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 {
|
||||
const locations = await book.locations.generate(1600);
|
||||
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
|
||||
}
|
||||
|
||||
await book.ready;
|
||||
|
||||
rendition = book.renderTo('epub-viewer', {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
spread: 'auto',
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
flow: 'paginated'
|
||||
});
|
||||
|
||||
rendition.on('keydown', async (e: any) => {
|
||||
if ((e.keyCode || e.which) === 37) await prevPage();
|
||||
if ((e.keyCode || e.which) === 39) await nextPage();
|
||||
});
|
||||
|
||||
rendition.on('locationChanged', async (location: DisplayedLocation) => {
|
||||
if (!location?.start) return;
|
||||
|
||||
const cfi = rendition!.currentLocation().start.cfi;
|
||||
currentLocation = cfi;
|
||||
currentProgress = book?.locations.percentageFromCfi(cfi) ?? 0;
|
||||
|
||||
await setUserBookProgress();
|
||||
|
||||
hasNextPage = !rendition!.location.atEnd;
|
||||
hasPrevPage = !rendition!.location.atStart;
|
||||
});
|
||||
|
||||
chapters = await getChapters(book);
|
||||
|
||||
const startAt = currentLocation || book.locations.cfiFromPercentage(currentProgress);
|
||||
await (startAt ? rendition.display(startAt) : rendition.display());
|
||||
|
||||
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) {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
book.destroy();
|
||||
}
|
||||
if (!browser) return;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
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)}
|
||||
onclick={() => navigateToChapter(subchapter)}
|
||||
>
|
||||
<span class="block truncate" title={subchapter.label}
|
||||
>{subchapter.label}</span
|
||||
>
|
||||
<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">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<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}
|
||||
|
||||
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
|
||||
<div class="flex">
|
||||
<Sidebar.Trigger />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
|
||||
<ChevronLeft
|
||||
onclick={prevPage}
|
||||
class={hasPrevPage
|
||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
||||
: 'text-muted'}
|
||||
/>
|
||||
<div
|
||||
id="epub-viewer"
|
||||
bind:this={epubViewer}
|
||||
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
|
||||
></div>
|
||||
<ChevronRight
|
||||
onclick={nextPage}
|
||||
class={hasNextPage
|
||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
||||
: 'text-muted'}
|
||||
/>
|
||||
{#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}
|
||||
|
||||
<!-- 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}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft class="size-5" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
id="epub-viewer"
|
||||
bind:this={epubViewer}
|
||||
class="epub-content mx-4 rounded-lg border bg-card p-8 shadow-sm"
|
||||
></div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-10 shrink-0"
|
||||
disabled={!hasNextPage}
|
||||
onclick={nextPage}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight class="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</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: '';
|
||||
}
|
||||
}
|
||||
|
||||
+16
-13
@@ -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">
|
||||
<EpubReader
|
||||
{bookUrl}
|
||||
{bookId}
|
||||
initialProgress={data.bookProgress?.progress}
|
||||
initialEpubLoc={data.bookProgress?.epub_loc}
|
||||
/>
|
||||
</div>
|
||||
<svelte:head>
|
||||
<title>{data.title} — Chitai</title>
|
||||
</svelte:head>
|
||||
|
||||
<EpubReader
|
||||
{bookUrl}
|
||||
{bookId}
|
||||
title={data.title}
|
||||
initialProgress={data.initialProgress}
|
||||
initialEpubLoc={data.initialEpubLoc}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
export async function load({ fetch, params, parent }) {
|
||||
try {
|
||||
const response = await fetch(`/api/books/${params.bookId}`);
|
||||
const result = await response.json();
|
||||
let bookProgress = result.progress;
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
return {
|
||||
bookProgress
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching book: ', error);
|
||||
return {
|
||||
status: error.status || 500,
|
||||
error: error.message
|
||||
};
|
||||
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}`);
|
||||
|
||||
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 {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user