[FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<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';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
|
||||
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">
|
||||
<!-- Back Navigation -->
|
||||
<Button variant="ghost" href="/novels/{novelId}" class="-ml-2 gap-2">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to Novel
|
||||
</Button>
|
||||
|
||||
<!-- 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>
|
||||
Reference in New Issue
Block a user