Enhance project structure and functionality by adding new components and integrations. Updated .gitignore to exclude history and transformed images. Integrated Svelte, sitemap, and icon support in astro.config.mjs. Added multiple new components including BlogOverview, ContentRows, Footer, Header, and various block components for improved content management. Updated package.json with new dependencies and modified scripts for build and development processes.

This commit is contained in:
Peter Meier
2026-02-22 12:14:43 +01:00
parent 054b5719e5
commit bb6ec20d45
52 changed files with 12536 additions and 179 deletions
@@ -0,0 +1,25 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { HeadlineBlockData } from "../../lib/block-types";
let { block }: { block: HeadlineBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const alignClass = $derived(
block.align === "center"
? "text-center"
: block.align === "right"
? "text-right"
: "text-left",
);
const Tag = $derived((block.tag || "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
</script>
<div class={layoutClasses} data-block-type="headline" data-block-slug={block._slug}>
<svelte:element
this={Tag}
class={alignClass}
>
{block.text ?? ""}
</svelte:element>
</div>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { HtmlBlockData } from "../../lib/block-types";
let { block }: { block: HtmlBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
</script>
<div
class="{layoutClasses} content-html"
data-block-type="html"
data-block-slug={block._slug}
>
{@html block.html ?? ""}
</div>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { IframeBlockData } from "../../lib/block-types";
let { block }: { block: IframeBlockData } = $props();
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
let overlayActive = $state(true);
/** Nimmt entweder eine reine URL oder Iframe-HTML und liefert die src-URL. */
function getIframeSrc(raw: string | undefined): string {
if (typeof raw !== "string" || !raw.trim()) return "";
const s = raw.trim();
if (s.startsWith("http") || s.startsWith("//")) return s;
const match = s.match(/<iframe[^>]+src\s*=\s*["']([^"']+)["']/i);
return match ? match[1].trim() : "";
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const src = $derived(getIframeSrc(block.iframe));
function activateMap() {
overlayActive = false;
}
</script>
<div class={layoutClasses} data-block-type="iframe" data-block-slug={block._slug}>
{#if src}
<div class="relative aspect-video w-full overflow-hidden rounded-sm bg-zinc-100">
<iframe
title={block.content ?? "Embed"}
src={src}
class="h-full w-full border-0"
loading="lazy"
allowfullscreen
></iframe>
{#if overlayActive}
<div
role="button"
tabindex="0"
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
aria-label="Klicken zum Aktivieren der Karte"
onclick={activateMap}
onkeydown={(e) => e.key === "Enter" && activateMap()}
>
<Icon
icon="mdi:cursor-default-click"
width="2rem"
height="2rem"
class="block text-white"
aria-hidden="true"
/>
</div>
{/if}
</div>
{:else}
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
{/if}
</div>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { ImageBlockData } from "../../lib/block-types";
let { block }: { block: ImageBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const imageSource = $derived(block.image ?? block.img);
const imageUrl = $derived(
block.resolvedImageSrc ??
(typeof imageSource === "string"
? imageSource.startsWith("http")
? imageSource
: null
: imageSource && typeof imageSource === "object"
? "src" in imageSource && typeof imageSource.src === "string"
? imageSource.src
: imageSource.file?.url ?? null
: null),
);
const title = $derived(
typeof imageSource === "object" && imageSource
? ("description" in imageSource && imageSource.description
? imageSource.description
: "title" in imageSource
? (imageSource as { title?: string }).title
: undefined)
: undefined,
);
</script>
<div class={layoutClasses} data-block-type="image" data-block-slug={block._slug}>
{#if imageUrl}
<figure class="max-w-[400px]">
<img
src={imageUrl}
alt={block.caption ?? title ?? ""}
class="w-full rounded-sm"
loading="lazy"
/>
{#if block.caption}
<figcaption class="mt-1 text-sm text-zinc-600">{block.caption}</figcaption>
{/if}
</figure>
{:else}
<p class="text-sm text-zinc-500">Bild nicht verfügbar.</p>
{/if}
</div>
@@ -0,0 +1,125 @@
<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";
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) => ({ img, url: imageUrl(img) }))
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
);
let currentIndex = $state(0);
function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
return null;
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
}
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
}
</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-zinc-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"
loading="eager"
/>
{#if withUrl[0].img.description}
<figcaption class="mt-1 text-sm text-zinc-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">
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
class="m-0 absolute inset-0"
transition:fade={{ duration: 200 }}
>
<img
src={current.url}
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-sm text-white bg-black/50 rounded-b-sm"
>
{current.img.description}
</figcaption>
{/if}
</figure>
{/key}
</div>
<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"
aria-label="Vorheriges Bild"
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-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"
aria-label="Nächstes Bild"
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="Galerieposition">
{#each withUrl as _, i}
<button
type="button"
role="tab"
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"}"
onclick={() => (currentIndex = i)}
></button>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { LinkListBlockData } from "../../lib/block-types";
let { block }: { block: LinkListBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
const links = $derived(block.links ?? []);
</script>
<div class={layoutClasses} data-block-type="link_list" data-block-slug={block._slug}>
{#if block.headline}
<h3 class="mb-2 font-semibold text-zinc-900">{block.headline}</h3>
{/if}
<ul class="list-disc space-y-1 pl-5">
{#each links as link}
{#if typeof link === "object" && link !== null && "url" in link}
<li>
<a
href={(link as { url?: string }).url ?? "#"}
class="underline text-green-700 hover:text-green-800"
target={(link as { newTab?: boolean }).newTab ? "_blank" : undefined}
rel={(link as { newTab?: boolean }).newTab ? "noopener noreferrer" : undefined}
>
{(link as { linkName?: string }).linkName ?? (link as { url?: string }).url}
</a>
</li>
{:else}
<li>
<span class="text-zinc-500">{typeof link === "string" ? link : "Link"}</span>
</li>
{/if}
{/each}
</ul>
</div>
@@ -0,0 +1,35 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { MarkdownBlockData } from "../../lib/block-types";
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
marked.setOptions({ gfm: true });
const html = $derived(
block.content && typeof block.content === "string"
? (marked.parse(block.content) as string)
: "",
);
const alignClass = $derived(
block.alignment === "center"
? "text-center"
: block.alignment === "right"
? "text-right"
: "text-left",
);
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
</script>
<div
class={layoutClasses}
data-block-type="markdown"
data-markdown-id={block.name}
data-block-slug={block._slug}
>
<div class="markdown max-w-none {alignClass} {className}">
{@html html}
</div>
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import { marked } from "marked";
import PostCard from "../PostCard.svelte";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { PostOverviewBlockData } from "../../lib/block-types";
import type { PostEntry } from "../../lib/cms";
import { postHref } from "../../lib/blog-utils";
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
marked.setOptions({ gfm: true });
const textHtml = $derived(
block.text && typeof block.text === "string"
? (marked.parse(block.text) as string)
: "",
);
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]);
const design = $derived(block.design ?? "cards");
const tagBase = "/posts/tag";
</script>
<div
class="post-overview mb-4 {layoutClasses} {className}"
data-block-type="post_overview"
data-block-slug={block._slug}
>
{#if block.headline}
<h3 class="mb-2">{block.headline}</h3>
{/if}
{#if textHtml}
<div class="mb-2 markdown max-w-none prose prose-zinc">{@html textHtml}</div>
{/if}
{#if design === "list" && posts.length > 0}
<div class="flex flex-col gap-4">
{#each posts as post}
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
{/each}
</div>
{:else if design === "cards" && posts.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each posts as post}
<PostCard post={post} href={postHref(post)} tagBase={tagBase} />
{/each}
</div>
{/if}
</div>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { QuoteBlockData } from "../../lib/block-types";
let { block }: { block: QuoteBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const variantClass = $derived(block.variant === "right" ? "text-right" : "text-left");
</script>
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
<blockquote class="border-l-4 border-green-700 pl-4 {variantClass}">
<p class="text-lg italic text-zinc-700">"{block.quote ?? ""}"</p>
{#if block.author}
<cite class="mt-1 block not-italic text-sm text-zinc-500">{block.author}</cite>
{/if}
</blockquote>
</div>
@@ -0,0 +1,207 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type {
SearchableTextBlockData,
SearchableTextFragment,
} from "../../lib/block-types";
import "../../lib/iconify-offline";
import Icon from "@iconify/svelte";
import Tag from "../Tag.svelte";
import Tags from "../Tags.svelte";
let { block, class: className = "" }: { block: SearchableTextBlockData; class?: string } = $props();
marked.setOptions({ gfm: true });
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const fragments = $derived.by(() => {
const raw = block.textFragments ?? [];
return raw
.filter((f): f is SearchableTextFragment => typeof f === "object" && f !== null && ("title" in f || "text" in f))
.map((f) => {
const tagsRaw = f.tags ?? [];
const tags = tagsRaw
.map((t) => (typeof t === "string" ? t : (t as { name?: string })?.name ?? ""))
.filter(Boolean);
return {
title: (f.title ?? "").trim(),
text: (f.text ?? "").trim(),
tags,
textHtml: (f.text ?? "").trim()
? (marked.parse((f.text ?? "").trim()) as string)
: "",
};
});
});
const allTags = $derived(
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) => a.localeCompare(b)),
);
let searchQuery = $state("");
let selectedTag = $state("all");
/** Wird im Template aufgerufen, damit Reaktivität auf searchQuery/selectedTag sicher greift. */
function getVisibleFragments() {
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
const tag = selectedTag;
return fragments.filter((f) => {
const tagMatch = tag === "all" || f.tags.includes(tag);
if (!tagMatch) return false;
if (q === "") return true;
const titleNorm = (f.title ?? "").toLowerCase().replace(/\s+/g, " ");
const textNorm = (f.text ?? "")
.toLowerCase()
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ");
return titleNorm.includes(q) || textNorm.includes(q);
});
}
const visibleFragments = $derived(getVisibleFragments());
const HIGHLIGHT_CLASS = "bg-amber-200 rounded px-0.5";
/** Suchbegriff in Plain-Text hervorheben (HTML-escaped, Treffer in <mark>). */
function highlightInText(text: string, term: string): string {
if (!term.trim()) return escapeHtml(text);
const escaped = escapeRegex(term);
const re = new RegExp(escaped, "gi");
return escapeHtml(text).replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
}
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
function highlightInHtml(html: string, term: string): string {
if (!term.trim()) return html;
const escaped = escapeRegex(term);
const re = new RegExp(escaped, "gi");
const parts = html.split(/(<[^>]+>)/g);
return parts
.map((part) => {
if (part.startsWith("<")) return part;
return part.replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
})
.join("");
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const descriptionHtml = $derived(
block.description && typeof block.description === "string"
? (marked.parse(block.description) as string)
: "",
);
</script>
<div
class="searchable-text flex flex-col gap-0 border border-gray-200 bg-slate-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>
{/if}
{#if descriptionHtml}
<div class="markdown text-zinc-600 max-w-none prose prose-zinc prose-sm bg-slate-50">{@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">
<div class="flex flex-col gap-2">
<div class="relative flex items-center">
<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"
bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label="Suche in Titeln und Inhalten"
/>
{#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"
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" />
</div>
{#if allTags.length > 0}
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
<div class="flex gap-2 flex-nowrap min-w-max md:flex-wrap md:w-auto">
<Tag
label="Alle"
variant={selectedTag === "all" ? "green" : "white"}
active={selectedTag === "all"}
onclick={() => (selectedTag = "all")}
/>
{#each allTags as tag}
<Tag
label={tag}
variant={selectedTag === tag ? "green" : "white"}
active={selectedTag === tag}
onclick={() => (selectedTag = tag)}
/>
{/each}
</div>
</div>
{/if}
</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">
{#if visibleFragments.length === 0}
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
{:else}
<span>
{visibleFragments.length === fragments.length
? `${visibleFragments.length} Einträge`
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
</span>
{/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>
{#if fragment.tags.length > 0}
<span class="flex flex-wrap gap-1 justify-end">
<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">
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
{/each}
</div>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { YoutubeVideoBlockData } from "../../lib/block-types";
let { block }: { block: YoutubeVideoBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const embedUrl = $derived(
block.youtubeId
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
: null,
);
</script>
<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">
<iframe
title={block.title ?? "YouTube-Video"}
src={embedUrl}
class="h-full w-full border-0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
{#if block.description}
<p class="mt-1 text-sm text-zinc-600">{block.description}</p>
{/if}
{:else}
<p class="text-sm text-zinc-500">YouTube-Video-ID fehlt.</p>
{/if}
</div>