fix: surface reader load failures instead of spinning forever

The PDF reader pointed an iframe at the vendored pdf.js viewer and had no
way to report a failure: the viewer renders its own errors inside the
frame, so a missing or unreadable file showed an empty grey panel. Probe
the file with a request first and render an error card with a retry when
that fails, matching the chrome the EPUB reader uses.

Add the missing +page.ts for the PDF route so the tab gets the book title
rather than a bare "Reader".

Drop chapter-sidebar.svelte and its import. It rendered a list of chapters
whose buttons had no click handler, and read/+layout@.svelte imported it
without ever rendering it. The reader's own sidebar replaces it.
This commit is contained in:
2026-08-11 21:47:03 -04:00
parent 49f94f9ee1
commit ac5a5c75aa
4 changed files with 99 additions and 59 deletions
@@ -1,27 +0,0 @@
<script>
import * as Sidebar from '$lib/components/ui/sidebar/index';
let { chapters } = $props();
</script>
<Sidebar.Root>
<Sidebar.Header />
<Sidebar.Content>
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<span>{chapter.label}</span>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.Group />
</Sidebar.Content>
<Sidebar.Footer />
</Sidebar.Root>
@@ -1,7 +1,4 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import ChapterSidebar from '$lib/components/reader/chapter-sidebar.svelte';
let { children } = $props();
</script>
@@ -1,42 +1,99 @@
<script lang="ts">
import { page } from '$app/state';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index';
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
import { RotateCcw, TriangleAlert } from '@lucide/svelte';
let fileId = page.params.fileId;
let bookId = page.params.bookId;
let { data } = $props();
// Make sure this endpoint returns the complete PDF file
let url = `${page.url.origin}/api/books/download/${bookId}/${fileId}`;
const fileId = page.params.fileId;
const bookId = page.params.bookId;
const fileUrl = `${page.url.origin}/api/books/download/${bookId}/${fileId}`;
let iframeEl = $state<HTMLIFrameElement>();
let loading = $state(true);
let loadError = $state<string | null>(null);
let attempt = $state(0);
async function load() {
loading = true;
loadError = null;
try {
// Ask for the file before handing it to pdf.js. The viewer runs in an
// iframe and reports its own failures inside that frame, so without
// this probe a missing file just showed an empty grey panel.
const response = await fetch(fileUrl, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`The server returned ${response.status} for this file.`);
}
if (iframeEl) {
iframeEl.src = `/pdfjs/web/viewer.html?file=${encodeURIComponent(fileUrl)}`;
}
} catch (error) {
console.error('Error loading PDF', error);
loadError = error instanceof Error ? error.message : 'The file could not be opened.';
loading = false;
}
}
$effect(() => {
if (iframeEl && url) {
// Use relative path to viewer.html with the PDF file URL as a parameter
iframeEl.src = `/pdfjs/web/viewer.html?file=${encodeURIComponent(url)}`;
}
attempt;
if (iframeEl) load();
});
</script>
<div class="pdf-container">
<iframe bind:this={iframeEl} title="PDF Viewer" class="pdf-iframe"></iframe>
<svelte:head>
<title>{data?.title ? `${data.title} Chitai` : 'Reader — Chitai'}</title>
</svelte:head>
<main class="flex h-screen w-full flex-col overflow-hidden bg-background">
<!-- Same chrome as the EPUB reader, so leaving works the same way in both -->
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<a
href="/book/{bookId}"
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
title={data?.title}
>
{data?.title || 'Reader'}
</a>
<ThemeToggle />
</header>
<div class="relative flex-1">
{#if loadError}
<div class="absolute inset-0 flex 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 file wouldn't open</h2>
<p class="text-sm text-muted-foreground">{loadError}</p>
<div class="mt-2 flex gap-2">
<Button onclick={() => attempt++}>
<RotateCcw class="size-4" />
Try again
</Button>
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
Back to book
</a>
</div>
</div>
</div>
{:else if loading}
<div class="absolute inset-0 flex items-center justify-center gap-3">
<Spinner />
<span class="text-sm text-muted-foreground">Opening…</span>
</div>
{/if}
<style>
/* Make the container take up the full viewport height */
.pdf-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
}
/* Make the iframe fill its container */
.pdf-iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
<iframe
bind:this={iframeEl}
title="PDF viewer"
class="h-full w-full border-0 {loading || loadError ? 'invisible' : ''}"
onload={() => (loading = false)}
></iframe>
</div>
</main>
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
export async function load({ fetch, params }) {
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();
return { title: book.title };
}