[FA-6] Volumes work probably?
This commit is contained in:
@@ -6,22 +6,31 @@
|
||||
|
||||
interface Props {
|
||||
novelId: string;
|
||||
prevChapterVolumeId: number | null | undefined;
|
||||
prevChapterOrder: number | null | undefined;
|
||||
nextChapterVolumeId: number | null | undefined;
|
||||
nextChapterOrder: number | null | undefined;
|
||||
showKeyboardHints?: boolean;
|
||||
}
|
||||
|
||||
let { novelId, prevChapterOrder, nextChapterOrder, showKeyboardHints = true }: Props = $props();
|
||||
let {
|
||||
novelId,
|
||||
prevChapterVolumeId,
|
||||
prevChapterOrder,
|
||||
nextChapterVolumeId,
|
||||
nextChapterOrder,
|
||||
showKeyboardHints = true
|
||||
}: Props = $props();
|
||||
|
||||
const hasPrev = $derived(prevChapterOrder != null);
|
||||
const hasNext = $derived(nextChapterOrder != null);
|
||||
const hasPrev = $derived(prevChapterOrder != null && prevChapterVolumeId != null);
|
||||
const hasNext = $derived(nextChapterOrder != null && nextChapterVolumeId != null);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
href={hasPrev ? `/novels/${novelId}/chapters/${prevChapterOrder}` : undefined}
|
||||
href={hasPrev ? `/novels/${novelId}/volumes/${prevChapterVolumeId}/chapters/${prevChapterOrder}` : undefined}
|
||||
disabled={!hasPrev}
|
||||
class="gap-2"
|
||||
>
|
||||
@@ -36,7 +45,7 @@
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
href={hasNext ? `/novels/${novelId}/chapters/${nextChapterOrder}` : undefined}
|
||||
href={hasNext ? `/novels/${novelId}/volumes/${nextChapterVolumeId}/chapters/${nextChapterOrder}` : undefined}
|
||||
disabled={!hasNext}
|
||||
class="gap-2"
|
||||
>
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
|
||||
interface Props {
|
||||
novelId?: string;
|
||||
volumeId?: string;
|
||||
chapterNumber?: string;
|
||||
}
|
||||
|
||||
let { novelId, chapterNumber }: Props = $props();
|
||||
let { novelId, volumeId, chapterNumber }: Props = $props();
|
||||
|
||||
// State
|
||||
let chapter: ChapterData | null = $state(null);
|
||||
@@ -42,16 +43,16 @@
|
||||
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}`;
|
||||
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null && chapter?.prevChapterVolumeId != null) {
|
||||
window.location.href = `/novels/${novelId}/volumes/${chapter.prevChapterVolumeId}/chapters/${chapter.prevChapterOrder}`;
|
||||
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null && chapter?.nextChapterVolumeId != null) {
|
||||
window.location.href = `/novels/${novelId}/volumes/${chapter.nextChapterVolumeId}/chapters/${chapter.nextChapterOrder}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChapter() {
|
||||
if (!novelId || !chapterNumber) {
|
||||
error = 'Missing novel ID or chapter number';
|
||||
if (!novelId || !volumeId || !chapterNumber) {
|
||||
error = 'Missing novel ID, volume ID, or chapter number';
|
||||
fetching = false;
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +64,7 @@
|
||||
const result = await client
|
||||
.query(GetChapterDocument, {
|
||||
novelId: parseInt(novelId, 10),
|
||||
volumeId: parseInt(volumeId, 10),
|
||||
chapterOrder: parseInt(chapterNumber, 10)
|
||||
})
|
||||
.toPromise();
|
||||
@@ -137,7 +139,9 @@
|
||||
<!-- Navigation (top) -->
|
||||
<ChapterNavigation
|
||||
novelId={novelId ?? ''}
|
||||
prevChapterVolumeId={chapter.prevChapterVolumeId}
|
||||
prevChapterOrder={chapter.prevChapterOrder}
|
||||
nextChapterVolumeId={chapter.nextChapterVolumeId}
|
||||
nextChapterOrder={chapter.nextChapterOrder}
|
||||
/>
|
||||
|
||||
@@ -169,7 +173,9 @@
|
||||
<!-- Navigation (bottom) -->
|
||||
<ChapterNavigation
|
||||
novelId={novelId ?? ''}
|
||||
prevChapterVolumeId={chapter.prevChapterVolumeId}
|
||||
prevChapterOrder={chapter.prevChapterOrder}
|
||||
nextChapterVolumeId={chapter.nextChapterVolumeId}
|
||||
nextChapterOrder={chapter.nextChapterOrder}
|
||||
showKeyboardHints={false}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
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 {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent
|
||||
} from '$lib/components/ui/accordion';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -81,6 +87,7 @@
|
||||
type GalleryImage = {
|
||||
src: string;
|
||||
alt: string;
|
||||
volumeId?: number;
|
||||
chapterId?: number;
|
||||
chapterOrder?: number;
|
||||
chapterName?: string;
|
||||
@@ -114,11 +121,16 @@
|
||||
: descriptionHtml
|
||||
);
|
||||
|
||||
const sortedChapters = $derived(
|
||||
[...(novel?.chapters ?? [])].sort((a, b) => a.order - b.order)
|
||||
// Volume-aware chapter organization
|
||||
const sortedVolumes = $derived(
|
||||
[...(novel?.volumes ?? [])].sort((a, b) => a.order - b.order)
|
||||
);
|
||||
|
||||
const chapterCount = $derived(novel?.chapters?.length ?? 0);
|
||||
const isSingleVolume = $derived(sortedVolumes.length === 1);
|
||||
|
||||
const chapterCount = $derived(
|
||||
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
|
||||
);
|
||||
|
||||
// Filter out system tags for display, check for NSFW
|
||||
const displayTags = $derived(novel?.tags?.filter((tag) => tag.tagType !== TagType.System) ?? []);
|
||||
@@ -139,18 +151,22 @@
|
||||
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
|
||||
});
|
||||
// Add chapter images (loop through volumes to preserve volumeId)
|
||||
for (const volume of sortedVolumes) {
|
||||
const volumeChapters = [...volume.chapters].sort((a, b) => a.order - b.order);
|
||||
for (const chapter of volumeChapters) {
|
||||
for (const img of chapter.images ?? []) {
|
||||
if (img.newPath) {
|
||||
images.push({
|
||||
src: img.newPath,
|
||||
alt: `Image from ${chapter.name}`,
|
||||
volumeId: volume.id,
|
||||
chapterId: chapter.id,
|
||||
chapterOrder: chapter.order,
|
||||
chapterName: chapter.name,
|
||||
isCover: false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,16 +538,18 @@
|
||||
|
||||
<CardContent class="pt-4">
|
||||
<TabsContent value="chapters" class="mt-0">
|
||||
{#if sortedChapters.length === 0}
|
||||
{#if chapterCount === 0}
|
||||
<p class="text-muted-foreground text-sm py-4 text-center">
|
||||
No chapters available yet.
|
||||
</p>
|
||||
{:else}
|
||||
{:else if isSingleVolume}
|
||||
<!-- Single volume: flat chapter list -->
|
||||
{@const singleVolumeChapters = [...(sortedVolumes[0]?.chapters ?? [])].sort((a, b) => a.order - b.order)}
|
||||
<div class="max-h-96 overflow-y-auto -mx-2">
|
||||
{#each sortedChapters as chapter (chapter.id)}
|
||||
{#each singleVolumeChapters as chapter (chapter.id)}
|
||||
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
|
||||
<a
|
||||
href="/novels/{novelId}/chapters/{chapter.order}"
|
||||
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.id}/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">
|
||||
@@ -550,6 +568,50 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Multiple volumes: accordion display -->
|
||||
<div class="max-h-96 overflow-y-auto -mx-2">
|
||||
<Accordion type="single">
|
||||
{#each sortedVolumes as volume (volume.id)}
|
||||
{@const volumeChapters = [...volume.chapters].sort((a, b) => a.order - b.order)}
|
||||
<AccordionItem value="volume-{volume.id}">
|
||||
<AccordionTrigger class="px-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium">{volume.name}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({volumeChapters.length} chapters)
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="space-y-0.5">
|
||||
{#each volumeChapters as chapter (chapter.id)}
|
||||
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
|
||||
<a
|
||||
href="/novels/{novelId}/volumes/{volume.id}/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>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{/each}
|
||||
</Accordion>
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
|
||||
@@ -645,9 +707,9 @@
|
||||
/>
|
||||
|
||||
<!-- Chapter link (if not cover) -->
|
||||
{#if !currentImage.isCover && currentImage.chapterOrder}
|
||||
{#if !currentImage.isCover && currentImage.volumeId && currentImage.chapterOrder}
|
||||
<a
|
||||
href="/novels/{novelId}/chapters/{currentImage.chapterOrder}"
|
||||
href="/novels/{novelId}/volumes/{currentImage.volumeId}/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}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof AccordionPrimitive.Content> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
class={cn('overflow-hidden text-sm transition-all', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="pb-4 pt-0">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof AccordionPrimitive.Item> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item class={cn('border-b', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</AccordionPrimitive.Item>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof AccordionPrimitive.Trigger> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
class={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import Item from './accordion-item.svelte';
|
||||
import Trigger from './accordion-trigger.svelte';
|
||||
import Content from './accordion-content.svelte';
|
||||
|
||||
const Root = AccordionPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
Trigger,
|
||||
Content,
|
||||
//
|
||||
Root as Accordion,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
Content as AccordionContent
|
||||
};
|
||||
Reference in New Issue
Block a user