598 lines
17 KiB
Svelte
598 lines
17 KiB
Svelte
<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 { onMount } from 'svelte';
|
|
import { client } from '$lib/graphql/client';
|
|
import { NovelDocument, ImportNovelDocument } from '$lib/graphql/__generated__/graphql';
|
|
import { isAuthenticated } from '$lib/auth/authStore';
|
|
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';
|
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
|
import X from '@lucide/svelte/icons/x';
|
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
|
|
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);
|
|
let refreshing = $state(false);
|
|
let refreshError: string | null = $state(null);
|
|
let refreshSuccess = $state(false);
|
|
|
|
// Image viewer state
|
|
type GalleryImage = {
|
|
src: string;
|
|
alt: string;
|
|
chapterId?: number;
|
|
chapterOrder?: number;
|
|
chapterName?: string;
|
|
isCover: boolean;
|
|
};
|
|
let viewerOpen = $state(false);
|
|
let viewerIndex = $state(0);
|
|
|
|
const DESCRIPTION_PREVIEW_LENGTH = 300;
|
|
|
|
// Derived values
|
|
const coverSrc = $derived(novel?.coverImage?.newPath);
|
|
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);
|
|
|
|
const canRefresh = $derived(() => {
|
|
if (status === 'COMPLETED') return false;
|
|
if (!lastUpdated) return true;
|
|
const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
|
|
return lastUpdated.getTime() < sixHoursAgo;
|
|
});
|
|
|
|
// Gallery images - cover + chapter images
|
|
const galleryImages = $derived.by(() => {
|
|
const images: GalleryImage[] = [];
|
|
|
|
// Add cover image first
|
|
if (coverSrc && novel) {
|
|
images.push({ src: coverSrc, alt: `${novel.name} cover`, isCover: true });
|
|
}
|
|
|
|
// Add chapter images
|
|
for (const chapter of sortedChapters) {
|
|
for (const img of chapter.images ?? []) {
|
|
if (img.newPath) {
|
|
images.push({
|
|
src: img.newPath,
|
|
alt: `Image from ${chapter.name}`,
|
|
chapterId: chapter.id,
|
|
chapterOrder: chapter.order,
|
|
chapterName: chapter.name,
|
|
isCover: false
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return images;
|
|
});
|
|
|
|
const currentImage = $derived(galleryImages[viewerIndex]);
|
|
|
|
// Image viewer functions
|
|
function openImageViewer(index: number) {
|
|
viewerIndex = index;
|
|
viewerOpen = true;
|
|
}
|
|
|
|
function closeViewer() {
|
|
viewerOpen = false;
|
|
}
|
|
|
|
function nextImage() {
|
|
if (galleryImages.length > 0) {
|
|
viewerIndex = (viewerIndex + 1) % galleryImages.length;
|
|
}
|
|
}
|
|
|
|
function prevImage() {
|
|
if (galleryImages.length > 0) {
|
|
viewerIndex = (viewerIndex - 1 + galleryImages.length) % galleryImages.length;
|
|
}
|
|
}
|
|
|
|
function handleViewerKeydown(e: KeyboardEvent) {
|
|
if (!viewerOpen) return;
|
|
if (e.key === 'Escape') closeViewer();
|
|
if (e.key === 'ArrowRight') nextImage();
|
|
if (e.key === 'ArrowLeft') prevImage();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async function refreshNovel() {
|
|
if (!novel?.url) return;
|
|
|
|
refreshing = true;
|
|
refreshError = null;
|
|
refreshSuccess = false;
|
|
|
|
try {
|
|
const result = await client
|
|
.mutation(ImportNovelDocument, { input: { novelUrl: novel.url } })
|
|
.toPromise();
|
|
|
|
if (result.error) {
|
|
refreshError = result.error.message;
|
|
} else {
|
|
refreshSuccess = true;
|
|
setTimeout(() => (refreshSuccess = false), 2000);
|
|
}
|
|
} catch (e) {
|
|
refreshError = e instanceof Error ? e.message : 'Failed to refresh';
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
fetchNovel();
|
|
});
|
|
</script>
|
|
|
|
<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
|
|
<a
|
|
href="/novels?author={encodeURIComponent(novel.author.name)}"
|
|
class="text-primary hover:underline"
|
|
>
|
|
{novel.author.name}
|
|
</a>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Badges -->
|
|
<div class="flex flex-wrap gap-2 items-center">
|
|
<Badge class={statusColor}>{statusLabel}</Badge>
|
|
<Badge variant="outline">{languageLabel}</Badge>
|
|
{#if $isAuthenticated}
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={refreshNovel}
|
|
disabled={refreshing || !canRefresh()}
|
|
class="gap-1.5 h-6 text-xs"
|
|
>
|
|
<RefreshCw class="h-3 w-3 {refreshing ? 'animate-spin' : ''}" />
|
|
{refreshing ? 'Refreshing...' : 'Refresh'}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
{#if !canRefresh()}
|
|
<TooltipContent>
|
|
{status === 'COMPLETED' ? 'Cannot refresh completed novels' : 'Updated less than 6 hours ago'}
|
|
</TooltipContent>
|
|
{/if}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{/if}
|
|
{#if refreshSuccess}
|
|
<Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30">
|
|
Refresh queued
|
|
</Badge>
|
|
{/if}
|
|
{#if refreshError}
|
|
<Badge variant="outline" class="bg-destructive/10 text-destructive border-destructive/30">
|
|
{refreshError}
|
|
</Badge>
|
|
{/if}
|
|
</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="gallery"
|
|
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
|
|
>
|
|
Gallery
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="bookmarks"
|
|
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"
|
|
>
|
|
Bookmarks
|
|
</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="gallery" class="mt-0">
|
|
{#if galleryImages.length === 0}
|
|
<p class="text-muted-foreground text-sm py-4 text-center">
|
|
No images available.
|
|
</p>
|
|
{:else}
|
|
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
|
{#each galleryImages as image, index (image.src)}
|
|
<button
|
|
type="button"
|
|
onclick={() => openImageViewer(index)}
|
|
class="relative aspect-square overflow-hidden rounded-md bg-muted/50 hover:ring-2 ring-primary transition-all"
|
|
>
|
|
<img src={image.src} alt={image.alt} class="h-full w-full object-cover" />
|
|
{#if image.isCover}
|
|
<Badge class="absolute top-1 left-1 text-xs">Cover</Badge>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="bookmarks" class="mt-0">
|
|
<p class="text-muted-foreground text-sm py-8 text-center">
|
|
Bookmarks coming soon.
|
|
</p>
|
|
</TabsContent>
|
|
</CardContent>
|
|
</Tabs>
|
|
</Card>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Image Viewer Modal -->
|
|
{#if viewerOpen && currentImage}
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
|
onclick={closeViewer}
|
|
onkeydown={handleViewerKeydown}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
>
|
|
<!-- Close button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="absolute top-4 right-4 text-white hover:bg-white/10"
|
|
onclick={closeViewer}
|
|
>
|
|
<X class="h-6 w-6" />
|
|
</Button>
|
|
|
|
<!-- Navigation arrows -->
|
|
{#if galleryImages.length > 1}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="absolute left-4 text-white hover:bg-white/10 h-12 w-12"
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); prevImage(); }}
|
|
>
|
|
<ChevronLeft class="h-8 w-8" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:bg-white/10 h-12 w-12"
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); nextImage(); }}
|
|
>
|
|
<ChevronRight class="h-8 w-8" />
|
|
</Button>
|
|
{/if}
|
|
|
|
<!-- Image container -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="flex flex-col items-center max-w-[90vw] max-h-[90vh]" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
|
<img
|
|
src={currentImage.src}
|
|
alt={currentImage.alt}
|
|
class="max-w-full max-h-[80vh] object-contain rounded-lg"
|
|
/>
|
|
|
|
<!-- Chapter link (if not cover) -->
|
|
{#if !currentImage.isCover && currentImage.chapterOrder}
|
|
<a
|
|
href="/novels/{novelId}/chapters/{currentImage.chapterOrder}"
|
|
class="text-white/80 hover:text-white text-sm inline-flex items-center gap-1 mt-3"
|
|
>
|
|
From: Ch. {currentImage.chapterOrder} - {currentImage.chapterName}
|
|
<ExternalLink class="h-3 w-3" />
|
|
</a>
|
|
{/if}
|
|
|
|
<!-- Image counter -->
|
|
<div class="text-white/60 text-sm mt-2">
|
|
{viewerIndex + 1} / {galleryImages.length}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|