[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:
gamer147
2025-12-08 18:30:00 -05:00
parent c9d93a4e55
commit 81e4e88ad4
48 changed files with 3298 additions and 329 deletions

View File

@@ -1,25 +1,375 @@
<script lang="ts" module>
import type { NovelQuery, NovelStatus, Language } from '$lib/graphql/__generated__/graphql';
export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number];
const statusColors: Record<NovelStatus, string> = {
IN_PROGRESS: 'bg-green-500 text-white',
COMPLETED: 'bg-blue-500 text-white',
HIATUS: 'bg-amber-500 text-white',
ABANDONED: 'bg-gray-500 text-white',
UNKNOWN: 'bg-gray-500 text-white'
};
const statusLabels: Record<NovelStatus, string> = {
IN_PROGRESS: 'Ongoing',
COMPLETED: 'Complete',
HIATUS: 'Hiatus',
ABANDONED: 'Dropped',
UNKNOWN: 'Unknown'
};
const languageLabels: Record<Language, string> = {
EN: 'English',
KR: 'Korean',
JA: 'Japanese',
CH: 'Chinese'
};
</script>
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { NovelDocument } from '$lib/graphql/__generated__/graphql';
import { Card, CardContent, CardHeader } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '$lib/components/ui/tabs';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider
} from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize';
// Direct imports for faster builds
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ExternalLink from '@lucide/svelte/icons/external-link';
import BookOpen from '@lucide/svelte/icons/book-open';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
interface Props {
novelId?: string;
}
let { novelId }: Props = $props();
let novel: NovelNode | null = $state(null);
let fetching = $state(true);
let error: string | null = $state(null);
let descriptionExpanded = $state(false);
const DESCRIPTION_PREVIEW_LENGTH = 300;
// Derived values
const coverSrc = $derived(novel?.coverImage?.newPath ?? novel?.coverImage?.originalPath);
const status = $derived(novel?.rawStatus ?? 'UNKNOWN');
const statusColor = $derived(statusColors[status]);
const statusLabel = $derived(statusLabels[status]);
const language = $derived(novel?.rawLanguage ?? 'EN');
const languageLabel = $derived(languageLabels[language]);
const lastUpdated = $derived(novel?.lastUpdatedTime ? new Date(novel.lastUpdatedTime) : null);
const relativeTime = $derived(lastUpdated ? formatRelativeTime(lastUpdated) : null);
const absoluteTime = $derived(lastUpdated ? formatAbsoluteTime(lastUpdated) : null);
const description = $derived(novel?.description ?? '');
const descriptionHtml = $derived(sanitizeHtml(description));
const isDescriptionLong = $derived(description.length > DESCRIPTION_PREVIEW_LENGTH);
const truncatedDescriptionHtml = $derived(
isDescriptionLong && !descriptionExpanded
? sanitizeHtml(description.slice(0, DESCRIPTION_PREVIEW_LENGTH) + '...')
: descriptionHtml
);
const sortedChapters = $derived(
[...(novel?.chapters ?? [])].sort((a, b) => a.order - b.order)
);
const chapterCount = $derived(novel?.chapters?.length ?? 0);
async function fetchNovel() {
if (!novelId) {
error = 'No novel ID provided';
fetching = false;
return;
}
fetching = true;
error = null;
try {
const result = await client
.query(NovelDocument, { id: parseInt(novelId, 10) })
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
const nodes = result.data?.novels?.nodes;
if (nodes && nodes.length > 0) {
novel = nodes[0];
} else {
error = 'Novel not found';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
onMount(() => {
fetchNovel();
});
</script>
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Novel Details</CardTitle>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">
{#if novelId}
Viewing novel ID: <code class="rounded bg-muted px-1 py-0.5">{novelId}</code>
{/if}
</p>
<p class="mt-2 text-sm text-muted-foreground">
Detail view coming soon. Select a novel to explore chapters and metadata once implemented.
</p>
</CardContent>
</Card>
<div class="space-y-6">
<!-- Back Navigation -->
<Button variant="ghost" href="/novels" class="gap-2 -ml-2">
<ArrowLeft class="h-4 w-4" />
Back to Novels
</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 novel"
></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 === 'Novel not found' ? 'Novel Not Found' : 'Error Loading Novel'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchNovel} class="mt-4">
Try Again
</Button>
</div>
</CardContent>
</Card>
{/if}
<!-- Novel Content -->
{#if novel && !fetching}
<!-- Header Section (Metadata + Tags + Description) -->
<Card class="shadow-md shadow-primary/10 overflow-hidden">
<CardContent class="p-0">
<!-- Cover Image + Metadata + Tags -->
<div class="flex flex-col sm:flex-row gap-6 p-6 pb-4">
<!-- Cover Image -->
<div class="shrink-0 sm:w-40">
{#if coverSrc}
<div class="aspect-[3/4] w-full overflow-hidden rounded-lg bg-muted/50">
<img
src={coverSrc}
alt={novel.name}
class="h-full w-full object-cover"
/>
</div>
{:else}
<div class="aspect-[3/4] w-full rounded-lg bg-muted/50 flex items-center justify-center">
<BookOpen class="h-12 w-12 text-muted-foreground/50" />
</div>
{/if}
</div>
<!-- Metadata + Tags -->
<div class="flex-1 space-y-3">
<div>
<h1 class="text-2xl font-bold leading-tight">{novel.name}</h1>
{#if novel.author}
<p class="text-muted-foreground mt-1">
by
{#if novel.author.externalUrl}
<a
href={novel.author.externalUrl}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline inline-flex items-center gap-1"
>
{novel.author.name}
<ExternalLink class="h-3 w-3" />
</a>
{:else}
<span>{novel.author.name}</span>
{/if}
</p>
{/if}
</div>
<!-- Badges -->
<div class="flex flex-wrap gap-2">
<Badge class={statusColor}>{statusLabel}</Badge>
<Badge variant="outline">{languageLabel}</Badge>
</div>
<!-- Stats (inline) -->
<div class="text-sm text-muted-foreground flex flex-wrap gap-x-4 gap-y-1">
{#if novel.source}
<span>
Source:
<a
href={novel.url}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline inline-flex items-center gap-1"
>
{novel.source.name}
<ExternalLink class="h-3 w-3" />
</a>
</span>
{/if}
{#if relativeTime && absoluteTime}
<span>
Updated:
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="cursor-default hover:text-foreground transition-colors">
<time datetime={lastUpdated?.toISOString()}>{relativeTime}</time>
</TooltipTrigger>
<TooltipContent>{absoluteTime}</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
{/if}
<span>{chapterCount} chapters</span>
</div>
<!-- Tags -->
{#if novel.tags && novel.tags.length > 0}
<div class="flex flex-wrap gap-1.5 pt-1">
{#each novel.tags as tag (tag.key)}
<Badge
variant="secondary"
href="/novels?tags={tag.key}"
class="cursor-pointer hover:bg-secondary/80 transition-colors text-xs"
>
{tag.displayName}
</Badge>
{/each}
</div>
{/if}
</div>
</div>
<!-- Description (full width below) -->
{#if description}
<div class="border-t px-6 py-4">
<div class="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
{@html truncatedDescriptionHtml}
</div>
{#if isDescriptionLong}
<Button
variant="ghost"
size="sm"
onclick={() => (descriptionExpanded = !descriptionExpanded)}
class="mt-2 gap-1 -ml-2"
>
{#if descriptionExpanded}
<ChevronUp class="h-4 w-4" />
Show less
{:else}
<ChevronDown class="h-4 w-4" />
Show more
{/if}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
<!-- Tabbed Content -->
<Card>
<Tabs value="chapters" class="w-full">
<CardHeader class="pb-0">
<TabsList class="grid w-full grid-cols-3 bg-muted/50 p-1 rounded-lg">
<TabsTrigger
value="chapters"
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
>
Chapters
</TabsTrigger>
<TabsTrigger
value="comments"
disabled
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Comments
</TabsTrigger>
<TabsTrigger
value="recommendations"
disabled
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Recommendations
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent class="pt-4">
<TabsContent value="chapters" class="mt-0">
{#if sortedChapters.length === 0}
<p class="text-muted-foreground text-sm py-4 text-center">
No chapters available yet.
</p>
{:else}
<div class="max-h-96 overflow-y-auto -mx-2">
{#each sortedChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order}
</span>
<span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name}
</span>
</div>
{#if chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
{/each}
</div>
{/if}
</TabsContent>
<TabsContent value="comments" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Comments coming soon.
</p>
</TabsContent>
<TabsContent value="recommendations" class="mt-0">
<p class="text-muted-foreground text-sm py-8 text-center">
Recommendations coming soon.
</p>
</TabsContent>
</CardContent>
</Tabs>
</Card>
{/if}
</div>