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:
Thomas Richter
2026-01-29 15:31:15 +01:00
parent 0188483036
commit eaf976f24b
4 changed files with 232 additions and 0 deletions

View 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>

View 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>