[FA-misc] Gallery
This commit is contained in:
@@ -51,6 +51,9 @@
|
||||
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;
|
||||
@@ -66,10 +69,22 @@
|
||||
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 ?? novel?.coverImage?.originalPath);
|
||||
const coverSrc = $derived(novel?.coverImage?.newPath);
|
||||
const status = $derived(novel?.rawStatus ?? 'UNKNOWN');
|
||||
const statusColor = $derived(statusColors[status]);
|
||||
const statusLabel = $derived(statusLabels[status]);
|
||||
@@ -102,6 +117,65 @@
|
||||
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';
|
||||
@@ -371,18 +445,17 @@
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Comments
|
||||
Gallery
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="recommendations"
|
||||
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"
|
||||
>
|
||||
Recommendations
|
||||
Bookmarks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
@@ -420,15 +493,32 @@
|
||||
{/if}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments" class="mt-0">
|
||||
<p class="text-muted-foreground text-sm py-8 text-center">
|
||||
Comments coming soon.
|
||||
</p>
|
||||
<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="recommendations" class="mt-0">
|
||||
<TabsContent value="bookmarks" class="mt-0">
|
||||
<p class="text-muted-foreground text-sm py-8 text-center">
|
||||
Recommendations coming soon.
|
||||
Bookmarks coming soon.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
@@ -436,3 +526,72 @@
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user