Files
FictionArchive/fictionarchive-web-astro/src/lib/components/NovelCard.svelte
gamer147 c60aaf2bdb
Some checks failed
CI / build-backend (pull_request) Successful in 1m49s
CI / build-frontend (pull_request) Failing after 16s
[FA-misc] Should be good
2025-12-01 07:26:38 -05:00

120 lines
3.9 KiB
Svelte

<script lang="ts" module>
import type { NovelsQuery, NovelStatus } from '$lib/graphql/__generated__/graphql';
export type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
export interface NovelCardProps {
novel: NovelNode;
}
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'
};
</script>
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider
} from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize';
let { novel }: NovelCardProps = $props();
function pickText(novelText?: NovelNode['name'] | NovelNode['description']) {
const texts = novelText?.texts ?? [];
const english = texts.find((t) => t.language === 'EN');
return (english ?? texts[0])?.text ?? 'No description available.';
}
const title = $derived(pickText(novel.name));
const descriptionRaw = $derived(pickText(novel.description));
const descriptionHtml = $derived(sanitizeHtml(descriptionRaw));
const coverSrc = $derived(novel.coverImage?.newPath ?? novel.coverImage?.originalPath);
const latestChapter = $derived(
novel.chapters?.slice().sort((a, b) => b.order - a.order)[0] ?? null
);
const chapterDisplay = $derived(latestChapter ? `Ch. ${latestChapter.order}` : null);
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 status = $derived(novel.rawStatus ?? 'UNKNOWN');
const statusColor = $derived(statusColors[status]);
const statusLabel = $derived(statusLabels[status]);
</script>
<a
href="/novels/{novel.id}"
class="block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg"
>
<Card class="overflow-hidden border shadow-sm transition-shadow hover:shadow-md h-full pt-0 gap-0">
<div class="relative">
{#if coverSrc}
<div class="aspect-[3/4] w-full overflow-hidden bg-muted/50">
<img src={coverSrc} alt={title} class="h-full w-full object-cover" loading="lazy" />
</div>
{:else}
<div class="aspect-[3/4] w-full bg-muted/50"></div>
{/if}
<Badge
class="absolute top-2 right-2 {statusColor} shadow-sm"
aria-label="Status: {statusLabel}"
>
{statusLabel}
</Badge>
</div>
<CardHeader class="space-y-2 pt-4">
<CardTitle class="line-clamp-2 text-lg leading-tight" title={title}>
{title}
</CardTitle>
</CardHeader>
<CardContent class="pt-0 pb-4 space-y-3">
<div class="line-clamp-3 text-sm text-muted-foreground" title={descriptionRaw}>
{@html descriptionHtml}
</div>
{#if chapterDisplay || relativeTime}
<div class="flex items-center gap-1 text-xs text-muted-foreground/80">
{#if chapterDisplay}
<span>{chapterDisplay}</span>
{/if}
{#if chapterDisplay && relativeTime}
<span aria-hidden="true">·</span>
{/if}
{#if relativeTime && absoluteTime}
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="cursor-default hover:text-foreground transition-colors">
<time datetime={lastUpdated?.toISOString()}>{relativeTime}</time>
</TooltipTrigger>
<TooltipContent>
{absoluteTime}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/if}
</div>
{/if}
</CardContent>
</Card>
</a>