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"> <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(); let { children } = $props();
</script> </script>
@@ -1,42 +1,99 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 { data } = $props();
let bookId = page.params.bookId;
// Make sure this endpoint returns the complete PDF file const fileId = page.params.fileId;
let url = `${page.url.origin}/api/books/download/${bookId}/${fileId}`; const bookId = page.params.bookId;
const fileUrl = `${page.url.origin}/api/books/download/${bookId}/${fileId}`;
let iframeEl = $state<HTMLIFrameElement>(); 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(() => { $effect(() => {
if (iframeEl && url) { attempt;
// Use relative path to viewer.html with the PDF file URL as a parameter if (iframeEl) load();
iframeEl.src = `/pdfjs/web/viewer.html?file=${encodeURIComponent(url)}`;
}
}); });
</script> </script>
<div class="pdf-container"> <svelte:head>
<iframe bind:this={iframeEl} title="PDF Viewer" class="pdf-iframe"></iframe> <title>{data?.title ? `${data.title} Chitai` : 'Reader — Chitai'}</title>
</div> </svelte:head>
<style> <main class="flex h-screen w-full flex-col overflow-hidden bg-background">
/* Make the container take up the full viewport height */ <!-- Same chrome as the EPUB reader, so leaving works the same way in both -->
.pdf-container { <header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
position: fixed; <a
top: 0; href="/book/{bookId}"
left: 0; class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
right: 0; title={data?.title}
bottom: 0; >
width: 100%; {data?.title || 'Reader'}
height: 100vh; </a>
} <ThemeToggle />
</header>
/* Make the iframe fill its container */ <div class="relative flex-1">
.pdf-iframe { {#if loadError}
width: 100%; <div class="absolute inset-0 flex items-center justify-center p-6">
height: 100%; <div class="flex max-w-sm flex-col items-center gap-3 text-center">
border: none; <TriangleAlert class="size-8 text-destructive" />
} <h2 class="font-serif text-lg">This file wouldn't open</h2>
</style> <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}
<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 };
}