feat(gallery): grid variant with hover labels + download modal
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:
@@ -104,6 +104,10 @@ export interface ImageGalleryImage {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Optionaler Hover-Titel (image-Type: alt/caption/title). */
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
title?: string;
|
||||||
file?: { url?: string };
|
file?: { url?: string };
|
||||||
/** Nach resolveContentImages: Proxy-URL zum transformierten Bild (16:9). */
|
/** Nach resolveContentImages: Proxy-URL zum transformierten Bild (16:9). */
|
||||||
resolvedSrc?: string;
|
resolvedSrc?: string;
|
||||||
@@ -115,6 +119,10 @@ export interface ImageGalleryBlockData {
|
|||||||
_slug?: string;
|
_slug?: string;
|
||||||
/** Optionaler Einleitungstext (Markdown). */
|
/** Optionaler Einleitungstext (Markdown). */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Optional: oberhalb des Galerie-Modals als Header. */
|
||||||
|
title?: string;
|
||||||
|
/** "standard" = Carousel (Default), "grid" = Thumbnail-Grid mit Modal. */
|
||||||
|
variant?: "standard" | "grid";
|
||||||
images?: ImageGalleryImage[];
|
images?: ImageGalleryImage[];
|
||||||
layout?: BlockLayout;
|
layout?: BlockLayout;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,22 @@
|
|||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
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 });
|
marked.setOptions({ gfm: true });
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
@@ -45,6 +61,36 @@
|
|||||||
|
|
||||||
let currentIndex = $state(0);
|
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() {
|
function prev() {
|
||||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||||
}
|
}
|
||||||
@@ -109,6 +155,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onModalKey} />
|
||||||
|
|
||||||
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
||||||
{#if descriptionHtml}
|
{#if descriptionHtml}
|
||||||
<div class="markdown max-w-none prose prose-zinc mb-4">
|
<div class="markdown max-w-none prose prose-zinc mb-4">
|
||||||
@@ -118,6 +166,110 @@
|
|||||||
|
|
||||||
{#if withUrl.length === 0}
|
{#if withUrl.length === 0}
|
||||||
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
|
<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}
|
{:else if withUrl.length === 1}
|
||||||
<figure class="m-0">
|
<figure class="m-0">
|
||||||
<RustyImage
|
<RustyImage
|
||||||
|
|||||||
Reference in New Issue
Block a user