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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
|
"svelte-lightbox": "^1.1.7",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -5249,6 +5250,15 @@
|
|||||||
"integrity": "sha512-Y+chXPaSx8OsPoFppUwPk8PJzgrZ7xoDJKXeiEc7JBqyKKzXer9hlf8F9O34eFuAWB4/WQEvccACvyBplESL7A==",
|
"integrity": "sha512-Y+chXPaSx8OsPoFppUwPk8PJzgrZ7xoDJKXeiEc7JBqyKKzXer9hlf8F9O34eFuAWB4/WQEvccACvyBplESL7A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/svelte-lightbox": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-lightbox/-/svelte-lightbox-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-D52LMtXbPRqgVjfe9+vYW43e+ROc2B3ci8CstfrJ2yJOi4bDkA7Wcis4MG625Hr1BDwhEZRgi5mI+TS9aYMagA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3.25.0 || ^4.0.0 || ^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svelte-persisted-store": {
|
"node_modules/svelte-persisted-store": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-persisted-store/-/svelte-persisted-store-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-persisted-store/-/svelte-persisted-store-0.12.0.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
|
"svelte-lightbox": "^1.1.7",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|||||||
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