Update environment configuration, enhance component functionality, and improve project structure. Added Umami analytics configuration to .env.example and updated astro.config.mjs to use the new site URL. Introduced pagination component in BlogOverview.svelte, refactored image handling in ImageGalleryBlock.svelte, and improved SearchableTextBlock.svelte with tooltip support. Updated package.json with new scripts for Firebase deployment and added new dependencies for Storybook and Firebase. Enhanced styling and layout across various components for better user experience.

This commit is contained in:
Peter Meier
2026-02-27 14:13:38 +01:00
parent bb6ec20d45
commit 1f5454d04e
68 changed files with 3668 additions and 265 deletions
+78 -11
View File
@@ -21,7 +21,7 @@
);
const withUrl = $derived(
images
.map((img) => ({ img, url: imageUrl(img) }))
.map((img) => ({ img, url: img.resolvedSrc ?? imageUrl(img) }))
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
);
@@ -40,6 +40,61 @@
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}>
@@ -50,22 +105,34 @@
{/if}
{#if withUrl.length === 0}
<p class="text-sm text-zinc-500">Keine Bilder in der Galerie.</p>
<p class="text-sm text-stein-500">Keine Bilder in der Galerie.</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<img
src={withUrl[0].url}
alt={withUrl[0].img.description ?? ""}
class="w-full rounded-sm object-cover aspect-4/3"
class="w-full rounded-sm object-cover aspect-video"
loading="eager"
/>
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-zinc-600">{withUrl[0].img.description}</figcaption>
<figcaption class="mt-1 text-sm text-stein-600">{withUrl[0].img.description}</figcaption>
{/if}
</figure>
{:else}
<div class="relative">
<div class="relative overflow-hidden rounded-sm bg-zinc-100 aspect-4/3">
<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="Galerie: wischen zum Wechseln"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchmove={onTouchMove}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
>
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
@@ -80,7 +147,7 @@
/>
{#if current.img.description}
<figcaption
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-sm text-white bg-black/50 rounded-b-sm"
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>
@@ -91,7 +158,7 @@
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
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="Vorheriges Bild"
onclick={prev}
>
@@ -99,7 +166,7 @@
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-green-500"
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="Nächstes Bild"
onclick={next}
>
@@ -114,8 +181,8 @@
aria-label="Bild {i + 1}"
aria-selected={i === currentIndex}
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
? "bg-green-600"
: "bg-zinc-300 hover:bg-zinc-400"}"
? "bg-wald-600"
: "bg-stein-300 hover:bg-stein-400"}"
onclick={() => (currentIndex = i)}
></button>
{/each}
@@ -9,8 +9,22 @@
import Icon from "@iconify/svelte";
import Tag from "../Tag.svelte";
import Tags from "../Tags.svelte";
import Tooltip from "../../lib/ui/Tooltip.svelte";
let { block, class: className = "" }: { block: SearchableTextBlockData; class?: string } = $props();
const DEFAULT_HELP_TOOLTIP =
"Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren“ könnt ihr den Text in die Zwischenablage übernehmen (z.B. für Stellungnahmen).";
let {
block,
class: className = "",
helpTooltip,
}: {
block: SearchableTextBlockData;
class?: string;
helpTooltip?: string;
} = $props();
const helpTooltipText = $derived(helpTooltip ?? DEFAULT_HELP_TOOLTIP);
marked.setOptions({ gfm: true });
@@ -42,6 +56,33 @@
let searchQuery = $state("");
let selectedTag = $state("all");
let copiedIndex = $state<number | null>(null);
async function copyFragmentContent(fragment: { title: string; text: string }, index: number) {
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(plain);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
// Fallback für ältere Browser
try {
const ta = document.createElement("textarea");
ta.value = plain;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 2000);
} catch {
/* ignore */
}
}
}
/** Wird im Template aufgerufen, damit Reaktivität auf searchQuery/selectedTag sicher greift. */
function getVisibleFragments() {
@@ -62,7 +103,7 @@
const visibleFragments = $derived(getVisibleFragments());
const HIGHLIGHT_CLASS = "bg-amber-200 rounded px-0.5";
const HIGHLIGHT_CLASS = "bg-wald-200 text-wald-900 rounded px-0.5";
/** Suchbegriff in Plain-Text hervorheben (HTML-escaped, Treffer in <mark>). */
function highlightInText(text: string, term: string): string {
@@ -106,35 +147,35 @@
</script>
<div
class="searchable-text flex flex-col gap-0 border border-gray-200 bg-slate-50 {layoutClasses} {className}"
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
data-block-type="searchable_text"
data-block-slug={block._slug}
>
<div class="p-4 md:p-6">
{#if block.title}
<h2 class="text-xl md:text-2xl font-bold bg-slate-50 mb-2">{block.title}</h2>
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">{block.title}</h2>
{/if}
{#if descriptionHtml}
<div class="markdown text-zinc-600 max-w-none prose prose-zinc prose-sm bg-slate-50">{@html descriptionHtml}</div>
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">{@html descriptionHtml}</div>
{/if}
</div>
<div class="px-4 md:px-6">
<div class="inline-flex bg-amber-100 border border-amber-200/60 text-xs p-3 text-amber-800 items-start gap-2 mb-4 rounded">
<span class="shrink-0" aria-hidden="true"></span>
<span>
Sie können nach Stichwörtern in Titeln und Inhalten suchen. Über die Schlagwörter darunter lassen sich die Einträge eingrenzen.
</span>
</div>
</div>
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-slate-300 bg-white/90 backdrop-blur-sm md:static md:bg-slate-200 md:backdrop-blur-none shadow-md md:shadow-none">
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none">
<div class="flex flex-col gap-2">
<div class="relative flex items-center">
<div class="relative flex items-center gap-2">
<Tooltip content={helpTooltipText} placement="top">
<button
type="button"
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
aria-label="Hilfe zur Suche anzeigen"
>
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
</button>
</Tooltip>
<input
type="search"
placeholder="In Titeln und Inhalten suchen…"
class="w-full text-sm px-4 py-2 pr-10 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-transparent bg-white"
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label="Suche in Titeln und Inhalten"
@@ -142,14 +183,14 @@
{#if searchQuery}
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-gray-200 text-gray-500"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Suche leeren"
>
</button>
{/if}
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none size-5" aria-hidden="true" />
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5" aria-hidden="true" />
</div>
{#if allTags.length > 0}
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
@@ -174,7 +215,7 @@
</div>
</header>
<div class="text-xs text-slate-600 px-4 md:px-6 py-2 bg-slate-200 border-b border-slate-300" aria-live="polite">
<div class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200" aria-live="polite">
{#if visibleFragments.length === 0}
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
{:else}
@@ -186,19 +227,37 @@
{/if}
</div>
<div class="flex flex-col gap-0 bg-slate-200">
{#each visibleFragments as fragment}
<details class="group border-b border-slate-300 last:border-b-0">
<summary class="cursor-pointer list-none px-4 md:px-6 py-3 bg-slate-100 hover:bg-slate-50 text-sm font-medium flex items-start gap-2">
<div class="grow">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
<div class="flex flex-col gap-0 bg-stein-100">
{#each visibleFragments as fragment, i}
<details class="group border-b border-stein-200 last:border-b-0">
<summary class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors">
<div class="flex items-start gap-2 min-w-0">
<div class="grow min-w-0">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
<Icon icon="mdi:chevron-down" class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform" aria-hidden="true" />
</div>
{#if fragment.tags.length > 0}
<span class="flex flex-wrap gap-1 justify-end">
<span class="flex flex-wrap gap-1">
<Tags tags={fragment.tags} variant="white" />
</span>
{/if}
<Icon icon="mdi:chevron-down" class="shrink-0 text-slate-400 group-open:rotate-180 transition-transform" aria-hidden="true" />
</summary>
<div class="px-4 md:px-6 py-4 bg-white markdown prose prose-zinc prose-sm max-w-none">
<div class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs">
<div class="flex justify-end mb-3 -mt-1">
<button
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
onclick={(e) => { e.preventDefault(); copyFragmentContent(fragment, i); }}
aria-label="Inhalt in Zwischenablage kopieren"
>
{#if copiedIndex === i}
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
<span>Kopiert!</span>
{:else}
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
<span>Inhalt kopieren</span>
{/if}
</button>
</div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
@@ -14,7 +14,7 @@
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
{#if block.youtubeId}
<div class="aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
<iframe
title={block.title ?? "YouTube-Video"}
src={embedUrl}
@@ -25,9 +25,9 @@
></iframe>
</div>
{#if block.description}
<p class="mt-1 text-sm text-zinc-600">{block.description}</p>
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
{/if}
{:else}
<p class="text-sm text-zinc-500">YouTube-Video-ID fehlt.</p>
<div class="text-sm text-stein-500">YouTube-Video-ID fehlt.</div>
{/if}
</div>