Files
FictionArchive/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte
gamer147 e70c39ea75
All checks were successful
CI / build-backend (pull_request) Successful in 1m45s
CI / build-frontend (pull_request) Successful in 39s
[FA-misc] Refresh button, UI mostly gold
2025-12-09 09:11:39 -05:00

177 lines
4.9 KiB
Svelte

<script lang="ts" module>
import type { GetChapterQuery } from '$lib/graphql/__generated__/graphql';
export type ChapterData = NonNullable<GetChapterQuery['chapter']>;
</script>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { client } from '$lib/graphql/client';
import { GetChapterDocument } from '$lib/graphql/__generated__/graphql';
import { Card, CardContent } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ChapterNavigation from './ChapterNavigation.svelte';
import ChapterProgressBar from './ChapterProgressBar.svelte';
import { sanitizeChapterHtml } from '$lib/utils/sanitizeChapter';
interface Props {
novelId?: string;
chapterNumber?: string;
}
let { novelId, chapterNumber }: Props = $props();
// State
let chapter: ChapterData | null = $state(null);
let fetching = $state(true);
let error: string | null = $state(null);
let scrollProgress = $state(0);
// Derived values
const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : '');
function handleScroll() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
scrollProgress = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0;
}
function handleKeydown(event: KeyboardEvent) {
// Don't trigger if user is typing in an input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return;
}
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.prevChapterOrder}`;
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.nextChapterOrder}`;
}
}
async function fetchChapter() {
if (!novelId || !chapterNumber) {
error = 'Missing novel ID or chapter number';
fetching = false;
return;
}
fetching = true;
error = null;
try {
const result = await client
.query(GetChapterDocument, {
novelId: parseInt(novelId, 10),
chapterOrder: parseInt(chapterNumber, 10)
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.chapter) {
chapter = result.data.chapter;
} else {
error = 'Chapter not found';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
onMount(() => {
fetchChapter();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('keydown', handleKeydown);
}
});
</script>
<ChapterProgressBar progress={scrollProgress} />
<div class="space-y-6 pt-2">
<!-- Loading State -->
{#if fetching}
<Card>
<CardContent>
<div class="flex items-center justify-center py-12">
<div
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading chapter"
></div>
</div>
</CardContent>
</Card>
{/if}
<!-- Error State -->
{#if error && !fetching}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent class="py-8">
<div class="text-center">
<p class="text-destructive text-lg font-medium">
{error === 'Chapter not found' ? 'Chapter Not Found' : 'Error Loading Chapter'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchChapter} class="mt-4"> Try Again </Button>
</div>
</CardContent>
</Card>
{/if}
<!-- Chapter Content -->
{#if chapter && !fetching}
<!-- Navigation (top) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterOrder={chapter.nextChapterOrder}
/>
<!-- Chapter Header -->
<Card>
<CardContent class="py-6">
<div class="space-y-2 text-center">
<p class="text-muted-foreground text-sm">
{chapter.novelName}
</p>
<h1 class="text-2xl font-bold">Chapter {chapter.order}: {chapter.name}</h1>
</div>
</CardContent>
</Card>
<!-- Chapter Body -->
<Card>
<CardContent class="px-6 py-8 md:px-12">
<article
class="prose prose-lg dark:prose-invert mx-auto max-w-none whitespace-pre-line
prose-p:text-foreground prose-p:mb-4 prose-p:leading-relaxed
prose-headings:text-foreground
first:prose-p:mt-0 last:prose-p:mb-0"
>
{@html sanitizedBody}
</article>
</CardContent>
</Card>
<!-- Navigation (bottom) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterOrder={chapter.nextChapterOrder}
showKeyboardHints={false}
/>
{/if}
</div>