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">
|
<script lang="ts">
|
||||||
// TODO: Add type hints to the rest of this file
|
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
|
|
||||||
import { Book, type Rendition } from 'epubjs';
|
import { Book, type Rendition } from 'epubjs';
|
||||||
|
import type { DisplayedLocation } from 'epubjs/types/rendition';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
import '../../../app.css';
|
import '../../../app.css';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
import * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible/index';
|
import * as Collapsible from '$lib/components/ui/collapsible/index';
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
|
||||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
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 epubViewer = $state<HTMLElement>();
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
let isReaderVisible = $state(false);
|
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 hasNextPage = $state(true);
|
||||||
let hasPrevPage = $state(false);
|
let hasPrevPage = $state(false);
|
||||||
|
|
||||||
let book: Book | undefined = $state();
|
let book: Book | undefined = $state();
|
||||||
let rendition = $state<Rendition>();
|
let rendition = $state<Rendition>();
|
||||||
let chapters = $state([]);
|
let chapters = $state<any[]>([]);
|
||||||
|
|
||||||
let isMounted = $state(false);
|
let currentLocation = $state(untrack(() => initialEpubLoc));
|
||||||
|
let currentProgress = $state(untrack(() => initialProgress));
|
||||||
let currentLocation = $state(initialEpubLoc);
|
|
||||||
let currentProgress = $state(initialProgress);
|
|
||||||
|
|
||||||
let isSidebarOpen = $state(false);
|
let isSidebarOpen = $state(false);
|
||||||
let debounceTimeout = $state<NodeJS.Timeout>();
|
let debounceTimeout = $state<NodeJS.Timeout>();
|
||||||
|
|
||||||
// Function to update dimensions
|
const percent = $derived(Math.round((currentProgress ?? 0) * 100));
|
||||||
function updateDimensions() {
|
|
||||||
if (epubViewer) {
|
|
||||||
// Get parent element dimensions
|
|
||||||
const parent = epubViewer.parentElement;
|
|
||||||
|
|
||||||
containerWidth = parent?.clientWidth! * 0.9 - 240;
|
function updateDimensions() {
|
||||||
containerHeight = window.innerHeight * 0.8;
|
if (!epubViewer) return;
|
||||||
}
|
const parent = epubViewer.parentElement;
|
||||||
|
containerWidth = parent?.clientWidth! * 0.9 - 240;
|
||||||
|
containerHeight = window.innerHeight * 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window resize
|
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
updateDimensions();
|
updateDimensions();
|
||||||
if (rendition) {
|
rendition?.resize(containerWidth, containerHeight);
|
||||||
rendition.resize(containerWidth, containerHeight);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleKeydown(e: KeyboardEvent) {
|
async function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft') await prevPage();
|
||||||
await prevPage();
|
else if (e.key === 'ArrowRight') await nextPage();
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
await nextPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nextPage() {
|
async function nextPage() {
|
||||||
if (hasNextPage) await rendition!.next();
|
if (hasNextPage) await rendition?.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prevPage() {
|
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() {
|
async function setUserBookProgress() {
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
|
|
||||||
debounceTimeout = setTimeout(async () => {
|
debounceTimeout = setTimeout(async () => {
|
||||||
await fetch(`/api/books/progress/${bookId}`, {
|
try {
|
||||||
method: 'POST',
|
const response = await fetch(`/api/books/progress/${bookId}`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
percentage: currentProgress,
|
body: JSON.stringify({
|
||||||
epub_cfi: currentLocation,
|
percentage: currentProgress,
|
||||||
completed: currentProgress === 1
|
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);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChapters = async (book: Book) => {
|
const getChapters = async (book: Book) => {
|
||||||
await book.ready;
|
await book.ready;
|
||||||
// Get spine items (basic chapter structure)
|
|
||||||
const spineItems = book.spine.items.map((item, index) => ({
|
const spineItems = book.spine.items.map((item, index) => ({
|
||||||
id: item.idref,
|
id: item.idref,
|
||||||
href: item.href,
|
href: item.href,
|
||||||
index: index,
|
index,
|
||||||
label: item.label || `Chapter ${index + 1}`,
|
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;
|
const toc = await book.loaded.navigation;
|
||||||
|
|
||||||
// Combine spine items with TOC information
|
return toc.toc.map((chapter) => {
|
||||||
const chapters = toc.toc.map((chapter) => {
|
|
||||||
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
|
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
|
||||||
return {
|
return {
|
||||||
...spineItem,
|
...spineItem,
|
||||||
label: chapter.label || spineItem?.label,
|
label: chapter.label || spineItem?.label,
|
||||||
subitems: chapter.subitems,
|
subitems: chapter.subitems ?? [],
|
||||||
href: chapter.href,
|
href: chapter.href,
|
||||||
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
|
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return chapters;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function navigateToChapter(chapter: any) {
|
async function navigateToChapter(chapter: any) {
|
||||||
try {
|
try {
|
||||||
if (!rendition || !book) return;
|
if (!rendition || !book || !chapter.href) return;
|
||||||
|
|
||||||
// Ensure book is ready
|
|
||||||
await book.ready;
|
await book.ready;
|
||||||
|
await rendition.display(chapter.href);
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error('Error navigating to chapter:', error);
|
console.error('Error navigating to chapter:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadBook() {
|
||||||
if (browser) {
|
loadError = null;
|
||||||
// Initial setup
|
isReaderVisible = false;
|
||||||
|
|
||||||
|
try {
|
||||||
updateDimensions();
|
updateDimensions();
|
||||||
|
|
||||||
try {
|
const response = await fetch(bookUrl);
|
||||||
// Fetch the EPUB file
|
|
||||||
const response = await fetch(bookUrl);
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
// Create book from array buffer
|
// fetch only rejects on network failure — a 404 or 500 still resolves,
|
||||||
book = new Book();
|
// and arrayBuffer() happily returns the error page. Without this check
|
||||||
await book.open(arrayBuffer, 'binary');
|
// the failure only surfaced as an epub parse error, caught below and
|
||||||
|
// logged, leaving the spinner running forever.
|
||||||
// Generate locations if they do not exist in localStorage
|
if (!response.ok) {
|
||||||
let existingLocations = localStorage.getItem(`${bookId}-locations`);
|
throw new Error(`The server returned ${response.status} for this file.`);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onDestroy(() => {
|
||||||
if (browser) {
|
if (!browser) return;
|
||||||
window.removeEventListener('resize', handleResize);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={handleKeydown} />
|
<svelte:document onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<Sidebar.Provider bind:open={isSidebarOpen}>
|
<Sidebar.Provider bind:open={isSidebarOpen}>
|
||||||
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
|
<Sidebar.Root class={isReaderVisible ? '' : 'hidden'}>
|
||||||
<Sidebar.Header />
|
<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.Content>
|
||||||
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
|
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each chapters as chapter}
|
{#each chapters as chapter (chapter.href)}
|
||||||
{#if chapter.subitems.length > 0}
|
{#if chapter.subitems.length > 0}
|
||||||
<Collapsible.Root class="group/collapsible">
|
<Collapsible.Root class="group/collapsible">
|
||||||
<div class="flex w-full items-center gap-1">
|
<div class="flex w-full items-center gap-1">
|
||||||
<Sidebar.MenuItem class="min-w-0 flex-1">
|
<Sidebar.MenuItem class="min-w-0 flex-1">
|
||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
|
||||||
class="w-full"
|
|
||||||
onclick={async () => await navigateToChapter(chapter)}
|
|
||||||
>
|
|
||||||
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
<Collapsible.Trigger class="flex-shrink-0 p-2">
|
<Collapsible.Trigger class="flex-shrink-0 p-2">
|
||||||
<ChevronDown
|
<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>
|
</Collapsible.Trigger>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<Sidebar.MenuSub>
|
<Sidebar.MenuSub>
|
||||||
{#each chapter.subitems as subchapter}
|
{#each chapter.subitems as subchapter (subchapter.href)}
|
||||||
<Sidebar.MenuSubItem class="min-w-0">
|
<Sidebar.MenuSubItem class="min-w-0">
|
||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onclick={async () => await navigateToChapter(subchapter)}
|
onclick={() => navigateToChapter(subchapter)}
|
||||||
>
|
>
|
||||||
<span class="block truncate" title={subchapter.label}
|
<span class="block truncate" title={subchapter.label}>
|
||||||
>{subchapter.label}</span
|
{subchapter.label}
|
||||||
>
|
</span>
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuSubItem>
|
</Sidebar.MenuSubItem>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -275,10 +314,7 @@
|
|||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<Sidebar.MenuItem class="min-w-0">
|
<Sidebar.MenuItem class="min-w-0">
|
||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton class="w-full" onclick={() => navigateToChapter(chapter)}>
|
||||||
class="w-full"
|
|
||||||
onclick={async () => await navigateToChapter(chapter)}
|
|
||||||
>
|
|
||||||
<span class="block truncate">{chapter.label}</span>
|
<span class="block truncate">{chapter.label}</span>
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
@@ -286,68 +322,119 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Sidebar.Menu>
|
</Sidebar.Menu>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
<Sidebar.Group />
|
|
||||||
</Sidebar.Content>
|
</Sidebar.Content>
|
||||||
<Sidebar.Footer />
|
|
||||||
</Sidebar.Root>
|
</Sidebar.Root>
|
||||||
<main class="flex w-full overflow-hidden">
|
|
||||||
{#if browser}
|
<main class="flex h-screen w-full flex-col overflow-hidden bg-background">
|
||||||
{#if !isReaderVisible}
|
<!-- Reader chrome: somewhere to go back to, what you are reading, how far in -->
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<Spinner />
|
<Tooltip.Provider>
|
||||||
</div>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
|
<ThemeToggle />
|
||||||
<div class="flex">
|
</header>
|
||||||
<Sidebar.Trigger />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
|
{#if loadError}
|
||||||
<ChevronLeft
|
<!-- Previously this state was a console.error and a spinner that never stopped -->
|
||||||
onclick={prevPage}
|
<div class="flex flex-1 items-center justify-center p-6">
|
||||||
class={hasPrevPage
|
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
<TriangleAlert class="size-8 text-destructive" />
|
||||||
: 'text-muted'}
|
<h2 class="font-serif text-lg">This book wouldn't open</h2>
|
||||||
/>
|
<p class="text-sm text-muted-foreground">{loadError}</p>
|
||||||
<div
|
<div class="mt-2 flex gap-2">
|
||||||
id="epub-viewer"
|
<Button onclick={loadBook}>
|
||||||
bind:this={epubViewer}
|
<RotateCcw class="size-4" />
|
||||||
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
|
Try again
|
||||||
></div>
|
</Button>
|
||||||
<ChevronRight
|
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
|
||||||
onclick={nextPage}
|
Back to book
|
||||||
class={hasNextPage
|
</a>
|
||||||
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
|
</div>
|
||||||
: 'text-muted'}
|
|
||||||
/>
|
|
||||||
</div>
|
</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}
|
{/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>
|
</main>
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.epub-content {
|
.epub-content {
|
||||||
position: relative;
|
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) {
|
@media (min-width: 1209px) {
|
||||||
.epub-content:after {
|
.epub-content::after {
|
||||||
/* Calculate position based on content width */
|
|
||||||
--separator-position: calc(var(--content-width, 100%) / 2);
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
border-right: 1px #000 solid;
|
border-right: 1px solid var(--border);
|
||||||
height: 90%;
|
height: 90%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
top: 5%;
|
top: 5%;
|
||||||
opacity: 0.15;
|
|
||||||
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
|
|
||||||
content: '';
|
content: '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -1,21 +1,24 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import EpubReader from '$lib/components/reader/epub-reader.svelte';
|
import EpubReader from '$lib/components/reader/epub-reader.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let fileId = page.params.fileId;
|
const fileId = page.params.fileId!;
|
||||||
let bookId = page.params.bookId;
|
const bookId = page.params.bookId!;
|
||||||
|
|
||||||
// Make sure this endpoint returns the complete EPUB file
|
// Streams the file through the proxy so the browser fetches it directly
|
||||||
let bookUrl = `/api/books/download/${bookId}/${fileId}`;
|
const bookUrl = `/api/books/download/${bookId}/${fileId}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen w-screen">
|
<svelte:head>
|
||||||
<EpubReader
|
<title>{data.title} — Chitai</title>
|
||||||
{bookUrl}
|
</svelte:head>
|
||||||
{bookId}
|
|
||||||
initialProgress={data.bookProgress?.progress}
|
<EpubReader
|
||||||
initialEpubLoc={data.bookProgress?.epub_loc}
|
{bookUrl}
|
||||||
/>
|
{bookId}
|
||||||
</div>
|
title={data.title}
|
||||||
|
initialProgress={data.initialProgress}
|
||||||
|
initialEpubLoc={data.initialEpubLoc}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
export async function load({ fetch, params, parent }) {
|
import { error } from '@sveltejs/kit';
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/books/${params.bookId}`);
|
|
||||||
const result = await response.json();
|
|
||||||
let bookProgress = result.progress;
|
|
||||||
|
|
||||||
return {
|
export async function load({ fetch, params }) {
|
||||||
bookProgress
|
// Fail loudly. The previous version caught everything and returned
|
||||||
};
|
// {status, error}, which nothing consumed — so a missing book produced a
|
||||||
} catch (error) {
|
// reader that sat on its spinner forever.
|
||||||
console.error('Error fetching book: ', error);
|
const response = await fetch(`/api/books/${params.bookId}`);
|
||||||
return {
|
|
||||||
status: error.status || 500,
|
if (!response.ok) {
|
||||||
error: error.message
|
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