feat(03-04): ImageGallery and ImageLightbox components
- ImageGallery renders horizontal scrolling thumbnails - Edit mode shows delete buttons on images - ImageLightbox provides fullscreen image viewer - Keyboard navigation (Escape, arrows) in lightbox - Click outside image or X button closes lightbox - Image counter shown when multiple images
This commit is contained in:
89
src/lib/components/ImageGallery.svelte
Normal file
89
src/lib/components/ImageGallery.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
|
||||
interface ImageData {
|
||||
id: string;
|
||||
ext: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
images: ImageData[];
|
||||
entryId: string;
|
||||
editMode?: boolean;
|
||||
onDelete?: (imageId: string) => void;
|
||||
}
|
||||
|
||||
let { images, entryId, editMode = false, onDelete }: Props = $props();
|
||||
|
||||
// Lightbox state
|
||||
let lightboxOpen = $state(false);
|
||||
let lightboxIndex = $state(0);
|
||||
|
||||
function openLightbox(index: number) {
|
||||
lightboxIndex = index;
|
||||
lightboxOpen = true;
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxOpen = false;
|
||||
}
|
||||
|
||||
function handleDelete(imageId: string) {
|
||||
onDelete?.(imageId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto py-2 -mx-2 px-2 scrollbar-thin">
|
||||
{#each images as image, i}
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openLightbox(i)}
|
||||
class="block rounded-lg overflow-hidden focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<img
|
||||
src="/api/images/{image.id}/thumbnail"
|
||||
alt={image.filename}
|
||||
class="w-20 h-20 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if editMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(image.id)}
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center shadow-md hover:bg-red-600 transition-colors"
|
||||
aria-label="Delete image"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if lightboxOpen}
|
||||
<ImageLightbox {images} startIndex={lightboxIndex} onClose={closeLightbox} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
132
src/lib/components/ImageLightbox.svelte
Normal file
132
src/lib/components/ImageLightbox.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
interface ImageData {
|
||||
id: string;
|
||||
ext: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
images: ImageData[];
|
||||
startIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { images, startIndex, onClose }: Props = $props();
|
||||
|
||||
let currentIndex = $state(startIndex);
|
||||
|
||||
const currentImage = $derived(images[currentIndex]);
|
||||
const hasMultiple = $derived(images.length > 1);
|
||||
|
||||
function goToNext() {
|
||||
if (currentIndex < images.length - 1) {
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image lightbox"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="absolute top-4 right-4 w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors z-10"
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Navigation: Previous -->
|
||||
{#if hasMultiple && currentIndex > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToPrevious}
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors z-10"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation: Next -->
|
||||
{#if hasMultiple && currentIndex < images.length - 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={goToNext}
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors z-10"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Main image -->
|
||||
{#if currentImage}
|
||||
<img
|
||||
src="/api/images/{currentImage.id}"
|
||||
alt={currentImage.filename}
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Image counter -->
|
||||
{#if hasMultiple}
|
||||
<div
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/50 text-white rounded-full text-sm"
|
||||
>
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user