03e84c1f28
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>
381 lines
13 KiB
Svelte
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>
|