Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<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 CmsImage from "$lib/components/CmsImage.svelte";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
|
||||
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 url = imageUrl(img);
|
||||
return url != null ? { img, url } : null;
|
||||
})
|
||||
.filter((x): x is { img: ImageGalleryImage; url: string } => x != null),
|
||||
);
|
||||
|
||||
let currentIndex = $state(0);
|
||||
|
||||
function imageUrl(img: ImageGalleryImage): string | null {
|
||||
if (typeof img.src === "string") {
|
||||
const s = img.src.trim();
|
||||
if (!s) return null;
|
||||
return s.startsWith("//") ? `https:${s}` : s;
|
||||
}
|
||||
if (img.file?.url) {
|
||||
const u = img.file.url;
|
||||
return u.startsWith("//") ? `https:${u}` : u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 withUrl.length === 1}
|
||||
<figure class="m-0">
|
||||
<CmsImage
|
||||
src={withUrl[0].url}
|
||||
width={1280}
|
||||
height={720}
|
||||
fit="cover"
|
||||
format="webp"
|
||||
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 }}
|
||||
>
|
||||
<CmsImage
|
||||
src={current.url}
|
||||
width={1280}
|
||||
height={720}
|
||||
fit="cover"
|
||||
format="webp"
|
||||
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>
|
||||
Reference in New Issue
Block a user