177 lines
4.9 KiB
Svelte
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>
|