Files
windwiderstand/src/lib/components/blocks/ImageGalleryBlock.svelte
T
Peter Meier 03e84c1f28
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 2m9s
feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Storybook (SvelteKit-Framework, Tailwind v4, MSW, Translation-Decorator):
Stories für Atoms/Molecules/Organisms + alle CMS-Blocks.

Button-Atom: 5 .btn-*-Varianten (primary/secondary/tertiary/outline/warning),
class-Merge-Prop, href→<a>, Loading-Spinner neben Label, rounded-md/font-medium.
Alle rohen .btn-*- und Hand-Style-Buttons app-weit aufs Atom migriert.

SectionHeader-Molecule (kicker/number/title/subtitle/action) + Integration in
7 CMS-Blocks (post_overview, organisations, organisations_map, searchable_text,
image_gallery, youtube_video_gallery, files) via neuem section_header-Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:35:57 +02:00

381 lines
13 KiB
Svelte

<script lang="ts">
import { marked } from "$lib/markdown-safe";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
import RustyImage from "$lib/components/RustyImage.svelte";
import { extractCmsImageField } from "$lib/rusty-image";
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
/** 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";
}
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const descriptionHtml = $derived(
block.description && typeof block.description === "string"
? (marked.parse(block.description) as string)
: "",
);
const images = $derived(
(block.images ?? []).filter((i): i is ImageGalleryImage => i != null && typeof i === "object"),
);
const withUrl = $derived(
images
.map((img) => {
const field = extractCmsImageField(img);
return field?.url
? { img, url: field.url, focal: field.focal ?? null }
: null;
})
.filter(
(
x,
): x is {
img: ImageGalleryImage;
url: string;
focal: { x: number; y: number } | null;
} => x != null,
),
);
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;
}
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
}
const SWIPE_THRESHOLD = 50;
let startX = $state(0);
let isDragging = $state(false);
let dragDelta = $state(0);
function onPointerDown(e: PointerEvent) {
if (withUrl.length <= 1) return;
startX = e.clientX;
isDragging = true;
dragDelta = 0;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
dragDelta = e.clientX - startX;
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
}
function onPointerUp() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
function onTouchStart(e: TouchEvent) {
if (withUrl.length <= 1) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
function onTouchMove(e: TouchEvent) {
if (!isDragging || !e.touches[0]) return;
const delta = e.touches[0].clientX - startX;
dragDelta = delta;
if (Math.abs(delta) > 10) e.preventDefault();
}
function onTouchEnd() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
</script>
<svelte:window onkeydown={onModalKey} />
<div class={layoutClasses} data-block="ImageGallery" data-block-type="image_gallery" data-block-slug={block._slug}>
{#if descriptionHtml}
<div class="markdown max-w-none prose prose-zinc mb-4">
{@html descriptionHtml}
</div>
{/if}
{#if withUrl.length === 0}
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if block.variant === "grid"}
{#if block.title}
<div class="mb-3">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.title}
tag="h3"
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<ul class="not-prose grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{#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-xs bg-wald-100 p-2 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-wald-500"
onclick={() => openModal(i)}
aria-label={label || t(T.image_gallery_open_aria)}
>
<RustyImage
src={item.url}
focal={item.focal}
width={400}
aspect="1/1"
fit="contain"
quality={80}
alt={label}
class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
loading={i < 4 ? "eager" : "lazy"}
/>
{#if label}
<span
class="absolute inset-x-0 bottom-0 max-h-full overflow-y-auto px-2 py-1.5 text-left text-[10px] leading-tight text-white bg-gradient-to-t from-black/95 via-black/80 to-black/30 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus:opacity-100 line-clamp-2 group-hover:line-clamp-none group-focus:line-clamp-none"
>
{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 || t(T.image_gallery_modal_aria)}
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"
>
<img
src={cur.url}
alt={curLabel}
class="max-h-[80vh] max-w-full rounded-xs 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="no-underline inline-flex h-9 w-9 items-center justify-center rounded-full text-white/80 hover:text-white hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/40"
title={t(T.image_gallery_download)}
aria-label={t(T.image_gallery_download)}
>
<Icon icon="lucide:download" class="text-xl" aria-hidden="true" />
</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="lucide: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="lucide: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={t(T.image_gallery_close)}
>
<Icon icon="lucide:x" class="text-lg" aria-hidden="true" />
</button>
</div>
</div>
{/if}
{:else if withUrl.length === 1}
<figure class="m-0">
<RustyImage
src={withUrl[0].url}
focal={withUrl[0].focal}
width={1280}
aspect="16/9"
fit="cover"
quality={80}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-xs object-cover aspect-video"
loading="eager"
/>
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
{/if}
</figure>
{:else}
<div class="group relative">
<div
class="relative overflow-hidden rounded-xs bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
role="region"
aria-label={t(T.image_gallery_swipe_aria)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchmove={onTouchMove}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
>
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
class="m-0 absolute inset-0"
transition:fade={{ duration: 200 }}
>
<RustyImage
src={current.url}
focal={current.focal}
width={1280}
aspect="16/9"
fit="cover"
quality={80}
alt={current.img.description ?? ""}
class="w-full h-full object-cover"
loading={currentIndex === 0 ? "eager" : "lazy"}
/>
{#if current.img.description}
<figcaption
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
>
{current.img.description}
</figcaption>
{/if}
</figure>
{/key}
</div>
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_prev_aria)}
onclick={prev}
>
<Icon icon="lucide:chevron-left" class="text-2xl" aria-hidden="true" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_next_aria)}
onclick={next}
>
<Icon icon="lucide:chevron-right" class="text-2xl" aria-hidden="true" />
</button>
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
{#each withUrl as _, i}
<button
type="button"
role="tab"
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
aria-selected={i === currentIndex}
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
? 'bg-wald-600'
: 'bg-stein-300 hover:bg-stein-400'}"
onclick={() => (currentIndex = i)}
></button>
{/each}
</div>
</div>
{/if}
</div>