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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user