Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

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:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
@@ -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>