feat(gallery): grid variant with hover labels + download modal
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s

ImageGalleryBlock now branches on `block.variant`:

- "standard" (default) → existing carousel.
- "grid" → 2/3/4-column thumbnail grid (1:1 squares, w=400/q=75
  through /cms-images). Each tile scales 5% on hover and surfaces
  the image's title/alt/caption/description as a gradient overlay
  caption that fades in on hover and keyboard focus.

Click any tile → fullscreen lightbox (z-100 overlay + 90vh image).
The lightbox carries:
- A download button using the asset's filename (extracted from the
  src URL).
- Prev/Next arrows when there's more than one image (keyboard:
  ←/→), Esc to close, click on the backdrop closes too.
- Position counter (n/total) when applicable.

Type updates in block-types.ts: ImageGalleryBlockData gains
`variant?: "standard"|"grid"` and `title?`. ImageGalleryImage gains
`alt`/`caption`/`title` so the schema's `image` field shape is
honoured for the hover label.
This commit is contained in:
Peter Meier
2026-04-25 10:32:37 +02:00
parent ecff7b0bbf
commit 6246d7c3a4
2 changed files with 160 additions and 0 deletions
@@ -13,6 +13,22 @@
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
/** Hover-Label aus image-Field — title|alt|caption|description, in dieser Reihenfolge. */
function imageLabel(img: ImageGalleryImage): string {
return (img.title ?? img.alt ?? img.caption ?? img.description ?? "").trim();
}
/** Dateiname für Download aus Asset-URL ableiten. */
function downloadFilename(url: string): string {
try {
const u = new URL(url);
const last = u.pathname.split("/").filter(Boolean).pop();
return last ? decodeURIComponent(last) : "image";
} catch {
return "image";
}
}
marked.setOptions({ gfm: true });
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
@@ -45,6 +61,36 @@
let currentIndex = $state(0);
// Grid-Modal-State: -1 = closed, 0..n-1 = open at index.
let modalIndex = $state<number>(-1);
const modalOpen = $derived(modalIndex >= 0);
function openModal(i: number) {
modalIndex = i;
}
function closeModal() {
modalIndex = -1;
}
function modalPrev() {
if (modalIndex < 0) return;
modalIndex = (modalIndex - 1 + withUrl.length) % withUrl.length;
}
function modalNext() {
if (modalIndex < 0) return;
modalIndex = (modalIndex + 1) % withUrl.length;
}
function onModalKey(e: KeyboardEvent) {
if (!modalOpen) return;
if (e.key === "Escape") {
e.preventDefault();
closeModal();
} else if (e.key === "ArrowLeft") {
modalPrev();
} else if (e.key === "ArrowRight") {
modalNext();
}
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
}
@@ -109,6 +155,8 @@
}
</script>
<svelte:window onkeydown={onModalKey} />
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
{#if descriptionHtml}
<div class="markdown max-w-none prose prose-zinc mb-4">
@@ -118,6 +166,110 @@
{#if withUrl.length === 0}
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if block.variant === "grid"}
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 list-none p-0 m-0">
{#each withUrl as item, i}
{@const label = imageLabel(item.img)}
<li class="m-0 p-0">
<button
type="button"
class="group relative block w-full aspect-square overflow-hidden rounded-sm bg-stein-100 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-wald-500"
onclick={() => openModal(i)}
aria-label={label || `Bild ${i + 1}`}
>
<RustyImage
src={item.url}
focal={item.focal}
width={400}
aspect="1/1"
fit="cover"
quality={75}
alt={label}
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading={i < 4 ? "eager" : "lazy"}
/>
{#if label}
<span
class="absolute inset-x-0 bottom-0 px-2 py-1.5 text-xs text-white bg-gradient-to-t from-black/70 via-black/40 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus:opacity-100 line-clamp-2"
>
{label}
</span>
{/if}
</button>
</li>
{/each}
</ul>
{#if modalOpen}
{@const cur = withUrl[modalIndex]}
{@const curLabel = imageLabel(cur.img)}
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/85 p-4"
role="dialog"
aria-modal="true"
aria-label={curLabel || "Bild-Vorschau"}
onclick={closeModal}
transition:fade={{ duration: 150 }}
>
<div
class="relative max-h-[90vh] max-w-[95vw] flex flex-col items-center gap-3"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<!-- eslint-disable-next-line @typescript-eslint/no-unused-expressions -->
<img
src={cur.url}
alt={curLabel}
class="max-h-[80vh] max-w-full rounded-sm bg-stein-900 object-contain shadow-2xl"
loading="eager"
/>
<div class="flex w-full items-center justify-between gap-3 text-white">
<div class="min-w-0 flex-1">
{#if curLabel}
<p class="truncate text-sm font-medium">{curLabel}</p>
{/if}
{#if withUrl.length > 1}
<p class="text-xs text-white/60">{modalIndex + 1} / {withUrl.length}</p>
{/if}
</div>
<a
href={cur.url}
download={downloadFilename(cur.url)}
class="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
>
<Icon icon="mdi:download-outline" class="text-base" aria-hidden="true" />
Download
</a>
</div>
{#if withUrl.length > 1}
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={modalPrev}
aria-label={t(T.image_gallery_prev_aria)}
>
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={modalNext}
aria-label={t(T.image_gallery_next_aria)}
>
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
</button>
{/if}
<button
type="button"
class="absolute -top-2 -right-2 inline-flex h-8 w-8 items-center justify-center rounded-full bg-white text-stein-900 shadow-md hover:bg-stein-100 focus:outline-none focus:ring-2 focus:ring-white/50"
onclick={closeModal}
aria-label="Schließen"
>
<Icon icon="mdi:close" class="text-lg" aria-hidden="true" />
</button>
</div>
</div>
{/if}
{:else if withUrl.length === 1}
<figure class="m-0">
<RustyImage