1154be3c25
The Tailwind Typography (`prose prose-zinc`) wrapper applied higher in the page hierarchy injects `.prose ul` rules — list-style: disc, an indent, and ::marker color — at a higher specificity than single-class utilities, so list-none/p-0/m-0 silently lost. `not-prose` is the official escape hatch from the typography plugin and zaps every prose rule on the element plus its descendants. One class instead of three \!important overrides.
367 lines
13 KiB
Svelte
367 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { marked } from "marked";
|
|
import { fade } from "svelte/transition";
|
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
|
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();
|
|
|
|
/** 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));
|
|
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-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"}
|
|
<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-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
|
|
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-sm 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-sm 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="mdi: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="mdi: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>
|