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
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
label,
open = false,
id = undefined,
class: cls = "",
children,
}: {
label: string;
open?: boolean;
id?: string;
class?: string;
children: Snippet;
} = $props();
</script>
<details {id} {open} class="pt-4 border-t border-zinc-200 group {cls}">
<summary class="flex cursor-pointer list-none items-center gap-2 text-sm font-medium select-none">
<Icon icon="mdi:chevron-right" class="size-4 shrink-0 transition-transform group-open:rotate-90" />
{label}
</summary>
<div class="mt-4">
{@render children()}
</div>
</details>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
color,
class: className = "",
}: { children?: Snippet; color?: string; class?: string } = $props();
function colorClasses(c?: string): string {
switch (c) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
default: return "bg-stein-100 text-stein-600 border border-stein-200";
}
}
</script>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {colorClasses(color)} {className}">
{@render children?.()}
</span>
+301
View File
@@ -0,0 +1,301 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import PostCard from "./PostCard.svelte";
import Tag from "./Tag.svelte";
import Pagination from "./Pagination.svelte";
import type { PostEntry } from "$lib/cms";
import { postHref } from "$lib/blog-utils";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface TagEntry {
_slug?: string;
name: string;
icon?: string;
color?: string;
}
type SearchIndexEntry = {
slug: string;
headline: string;
excerpt: string;
subheadline: string;
tags: string[];
image: string | null;
created: string | null;
isEvent: boolean;
eventDate: string | null;
};
type EventPost = PostEntry & { eventDate?: string; isEvent?: boolean };
let {
posts = [],
tags = [],
activeTag = null,
currentPage = 1,
totalPages = 1,
totalPosts = 0,
basePath = "/posts",
translations = {},
upcomingEvents = [],
searchIndex = null,
}: {
posts: PostEntry[];
tags: TagEntry[];
activeTag: string | null;
currentPage: number;
totalPages: number;
totalPosts?: number;
basePath?: string;
translations?: Translations | null;
upcomingEvents?: EventPost[];
searchIndex?: SearchIndexEntry[] | null;
} = $props();
let query = $state("");
let year = $state<string>("");
const normalizedQuery = $derived(query.trim().toLowerCase());
const isFiltering = $derived(normalizedQuery.length >= 2 || year !== "");
function yearOf(entry: SearchIndexEntry): string {
const iso = entry.created ?? "";
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : String(d.getFullYear());
}
const availableYears = $derived.by<string[]>(() => {
if (!searchIndex) return [];
const set = new Set<string>();
for (const p of searchIndex) {
const y = yearOf(p);
if (y) set.add(y);
}
return Array.from(set).sort((a, b) => b.localeCompare(a));
});
const filteredResults = $derived.by<SearchIndexEntry[]>(() => {
if (!isFiltering || !searchIndex) return [];
const q = normalizedQuery;
return searchIndex
.filter((p) => {
if (year && yearOf(p) !== year) return false;
if (q) {
const hay = `${p.headline} ${p.excerpt} ${p.subheadline} ${p.tags.join(" ")}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
})
.slice(0, 80);
});
function searchResultHref(slug: string): string {
const norm = (slug ?? "").replace(/^\//, "").trim();
return norm ? `/post/${encodeURIComponent(norm)}` : "/post";
}
function formatEventDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return basePath;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
}
const totalCount = $derived(totalPosts);
</script>
<div class="blog-overview">
{#if searchIndex}
<div class="mb-4 flex flex-wrap items-center gap-2">
<label class="relative block w-full sm:max-w-sm">
<span class="sr-only">{t(T.blog_search_label)}</span>
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:magnify" class="h-4 w-4" />
</span>
<input
type="search"
bind:value={query}
placeholder={t(T.blog_search_placeholder)}
class="w-full rounded-sm border border-zinc-300 bg-white py-2 pr-9 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
class="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
aria-label={t(T.blog_search_clear)}
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
{#if availableYears.length > 0}
<label class="relative inline-flex items-center">
<span class="sr-only">{t(T.blog_year_label)}</span>
<span class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:calendar" class="h-4 w-4" />
</span>
<select
bind:value={year}
class="appearance-none rounded-sm border border-zinc-300 bg-white py-2 pr-7 pl-8 text-sm focus:border-zinc-500 focus:outline-none"
>
<option value="">{t(T.blog_year_all)}</option>
{#each availableYears as y}
<option value={y}>{y}</option>
{/each}
</select>
<span class="pointer-events-none absolute top-1/2 right-1.5 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:chevron-down" class="h-4 w-4" />
</span>
</label>
{/if}
</div>
{/if}
{#if !isFiltering && upcomingEvents.length > 0}
<section class="mb-6 rounded-sm border border-zinc-200 bg-zinc-50 p-4">
<h2 class="mb-3 flex items-center gap-1.5 text-sm font-semibold tracking-wide text-zinc-700 uppercase">
<Icon icon="mdi:calendar-clock" class="h-4 w-4" />
{t(T.blog_upcoming_events)}
</h2>
<ul class="space-y-2">
{#each upcomingEvents as ev}
<li>
<a href={postHref(ev)} class="group flex items-baseline gap-3 no-underline hover:underline">
<time class="shrink-0 text-xs font-medium text-zinc-600 tabular-nums">
{formatEventDate(ev.eventDate)}
</time>
<span class="text-sm text-zinc-900 group-hover:text-zinc-950">{ev.headline}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if isFiltering}
<div class="mb-3 flex flex-wrap items-center gap-2 text-xs text-zinc-600">
<span>
{t(T.blog_results_count, { count: filteredResults.length })}
{#if query}{t(T.blog_results_for, { query })}{/if}
{#if year}{t(T.blog_results_in_year, { year })}{/if}
</span>
<button
type="button"
onclick={() => { query = ""; year = ""; }}
class="text-zinc-500 underline hover:text-zinc-900"
>{t(T.blog_filter_reset)}</button>
</div>
{#if filteredResults.length > 0}
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredResults as hit}
<a href={searchResultHref(hit.slug)} class="flex flex-col overflow-hidden border border-gray-200 bg-white text-slate-950 no-underline transition-all">
{#if hit.image}
<div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
<img src={hit.image} alt={hit.headline} class="h-full w-full object-cover" loading="lazy" />
</div>
{/if}
<div class="flex flex-1 flex-col p-4">
<h3 class="mb-1 text-base font-semibold">{hit.headline}</h3>
{#if hit.excerpt}
<p class="text-sm text-zinc-700 line-clamp-3">{hit.excerpt}</p>
{/if}
</div>
</a>
{/each}
</div>
{:else}
<div class="rounded-sm border border-zinc-200 bg-white p-6 text-center text-sm text-zinc-600">
{t(T.blog_no_results)}
</div>
{/if}
{:else}
{#if tags.length > 0}
<div class="mb-2">
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
<Tag
label={t(T.blog_tag_all)}
href={tagHref(null)}
variant={activeTag ? "blue" : "green"}
active={!activeTag}
/>
{#each tags as tag}
{@const slug = tag._slug ?? ""}
<Tag
label={tag.name}
href={tagHref(slug)}
variant={activeTag === slug ? "inactive" : "green"}
active={activeTag === slug}
icon={tag.icon?.trim() || null}
customColor={activeTag === slug ? null : tag.color?.trim() || null}
/>
{/each}
</div>
</div>
{/if}
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post}
<PostCard
post={post}
href={postHref(post)}
tagBase={`${basePath}/tag`}
/>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2">
<Pagination
{currentPage}
{totalPages}
{basePath}
activeTag={activeTag}
/>
<div class="grow"></div>
<div class="bg-white rounded-md font-thin">
<div class="inline-flex text-xs p-2">
{#if totalCount > 0}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
</div>
</div>
<div class="mt-4 text-xs text-zinc-600">
<a href="/posts/rss.xml" class="inline-flex items-center gap-1 hover:underline">
<Icon icon="mdi:rss" class="h-3.5 w-3.5" />
{t(T.blog_rss_subscribe)}
</a>
</div>
{/if}
</div>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
href,
target,
rel,
class: className = "",
}: {
children?: Snippet;
href?: string;
target?: string;
rel?: string;
class?: string;
} = $props();
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm min-w-0 overflow-hidden";
const interactive = $derived(
href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "",
);
</script>
{#if href}
<a {href} {target} {rel} class="{base} {interactive} {className}">
{@render children?.()}
</a>
{:else}
<div class="{base} {className}">
{@render children?.()}
</div>
{/if}
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { getCmsImageUrl } from '$lib/rusty-image';
interface Props {
/** Asset-Dateiname (z.B. "hero.jpg") oder volle URL. */
src: string;
width?: number;
height?: number;
/** Qualität 1100 (JPEG/WebP). */
quality?: number;
format?: 'jpeg' | 'png' | 'webp' | 'avif';
fit?: 'fill' | 'contain' | 'cover';
/** Seitenverhältnis, z.B. "16:9" oder "1:1". */
ar?: string;
alt?: string;
class?: string;
loading?: 'lazy' | 'eager';
}
let {
src,
width,
height,
quality,
format,
fit,
ar,
alt = '',
class: className = '',
loading = 'lazy',
}: Props = $props();
const resolvedSrc = $derived(
getCmsImageUrl(src, { width, height, quality, format, fit, ar }),
);
</script>
<img
src={resolvedSrc}
{alt}
width={width}
height={height}
class={className}
{loading}
/>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import MarkdownBlock from "./blocks/MarkdownBlock.svelte";
import HeadlineBlock from "./blocks/HeadlineBlock.svelte";
import HtmlBlock from "./blocks/HtmlBlock.svelte";
import IframeBlock from "./blocks/IframeBlock.svelte";
import ImageBlock from "./blocks/ImageBlock.svelte";
import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte";
import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte";
import QuoteBlock from "./blocks/QuoteBlock.svelte";
import LinkListBlock from "./blocks/LinkListBlock.svelte";
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
import type { BlockLayout } from "$lib/block-layout";
import type { Translations } from "$lib/translations";
import type {
RowContentLayout,
ResolvedBlock,
MarkdownBlockData,
HeadlineBlockData,
HtmlBlockData,
IframeBlockData,
ImageBlockData,
ImageGalleryBlockData,
YoutubeVideoBlockData,
QuoteBlockData,
LinkListBlockData,
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
} from "$lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
function isResolvedBlock(item: unknown): item is ResolvedBlock {
return typeof item === "object" && item !== null;
}
function blockType(item: ResolvedBlock): string {
return (item._type as string) ?? "unknown";
}
function justifyToClass(j?: string): string {
switch (j) {
case "end": return "justify-end";
case "center": return "justify-center";
case "between": return "justify-between";
case "around": return "justify-around";
case "evenly": return "justify-evenly";
default: return "justify-start";
}
}
function alignToClass(a?: string): string {
switch (a) {
case "end": return "items-end";
case "center": return "items-center";
case "baseline": return "items-baseline";
case "stretch": return "items-stretch";
default: return "items-start";
}
}
const rows = $derived([
{
content: layout.row1Content ?? [],
justify: justifyToClass(layout.row1JustifyContent),
align: alignToClass(layout.row1AlignItems),
},
{
content: layout.row2Content ?? [],
justify: justifyToClass(layout.row2JustifyContent),
align: alignToClass(layout.row2AlignItems),
},
{
content: layout.row3Content ?? [],
justify: justifyToClass(layout.row3JustifyContent),
align: alignToClass(layout.row3AlignItems),
},
]);
</script>
<div class="content my-6 {className}">
{#each rows as row}
{#if row.content.length > 0}
<div
class="content-row grid grid-cols-12 gap-4 {row.justify} {row.align}"
role="region"
data-cf-type="grid-row"
>
{#each row.content as item}
{#if isResolvedBlock(item)}
{#snippet blockContent()}
{#if blockType(item) === "markdown"}
<MarkdownBlock block={item as MarkdownBlockData} />
{:else if blockType(item) === "headline"}
<HeadlineBlock block={item as HeadlineBlockData} />
{:else if blockType(item) === "html"}
<HtmlBlock block={item as HtmlBlockData} />
{:else if blockType(item) === "iframe"}
<IframeBlock block={item as IframeBlockData} translations={translations} />
{:else if blockType(item) === "image"}
<ImageBlock block={item as ImageBlockData} />
{:else if blockType(item) === "image_gallery"}
<ImageGalleryBlock block={item as ImageGalleryBlockData} />
{:else if blockType(item) === "youtube_video"}
<YoutubeVideoBlock block={item as YoutubeVideoBlockData} />
{:else if blockType(item) === "quote"}
<QuoteBlock block={item as QuoteBlockData} />
{:else if blockType(item) === "link_list"}
<LinkListBlock block={item as LinkListBlockData} />
{:else if blockType(item) === "post_overview"}
<PostOverviewBlock block={item as PostOverviewBlockData} />
{:else if blockType(item) === "searchable_text"}
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
{:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
{:else if blockType(item) === "organisations"}
<OrganisationsBlock block={item as OrganisationsBlockData} />
{:else if blockType(item) === "opnform"}
<OpnFormBlock block={item as OpnFormBlockData} />
{:else}
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Unbekannter Block: <code>{blockType(item)}</code>
</div>
{/if}
{/snippet}
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
{@const rowBlockType = blockType(item)}
<div class="col-span-12">
<div
class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote'
? 'bg-wald-50'
: '[background:var(--color-container-breakout)]'}"
>
<div class="container-custom py-8">
{@render blockContent()}
</div>
</div>
</div>
{:else}
{@render blockContent()}
{/if}
{/if}
{/each}
</div>
{/if}
{/each}
</div>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let {
eventDate = null,
locationText = null,
locationHref = null,
}: {
eventDate?: string | null;
locationText?: string | null;
locationHref?: string | null;
} = $props();
function formatEventDate(dateStr: string): string | null {
try {
return new Date(dateStr).toLocaleDateString("de-DE", {
weekday: "short", day: "2-digit", month: "short",
year: "numeric", hour: "2-digit", minute: "2-digit",
});
} catch { return null; }
}
const dateLabel = $derived(eventDate ? formatEventDate(eventDate) : null);
</script>
{#if dateLabel || locationText}
<div class="flex flex-wrap gap-2">
{#if dateLabel}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-wald-500 px-3 py-1 text-[.65rem] font-semibold text-white shadow-sm">
<Icon icon="mdi:calendar" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{dateLabel}
</span>
{/if}
{#if locationText}
{#if locationHref}
<a href={locationHref} target={locationHref.startsWith('#') ? undefined : '_blank'} rel={locationHref.startsWith('#') ? undefined : 'noopener noreferrer'} class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50! px-3 py-1 text-[.65rem] shadow-sm hover:bg-neutral-700 transition-colors no-underline!">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText}
</a>
{:else}
<span class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-neutral-800 text-neutral-50 px-3 py-1 text-[.65rem] font-medium shadow-sm">
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0 opacity-90" aria-hidden="true" />
{locationText}
</span>
{/if}
{/if}
</div>
{/if}
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import Accordion from "./Accordion.svelte";
import { t, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let {
embedUrl = null,
linkUrl = null,
locationText = null,
label = "Karte",
translations = null,
}: {
embedUrl?: string | null;
linkUrl?: string | null;
locationText?: string | null;
label?: string;
translations?: Translations | null;
} = $props();
let overlayVisible = $state(true);
</script>
<Accordion id="map-section" {label} open class="mt-12">
<div class="overflow-hidden rounded-lg border border-wald-200 shadow-sm">
{#if embedUrl}
<div>
<div class="relative">
<iframe
src={embedUrl}
width="600"
height="300"
class="block w-full border-0"
loading="lazy"
title={locationText ? `Karte: ${locationText}` : "Veranstaltungsort"}
></iframe>
{#if overlayVisible}
<div
role="button"
tabindex="0"
class="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-[1px] cursor-pointer select-none"
aria-label="Karte aktivieren"
onclick={() => { overlayVisible = false; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') overlayVisible = false; }}
>
<div class="flex items-center gap-2 rounded-full bg-white/90 px-4 py-2 text-xs font-medium text-zinc-700 shadow">
<Icon icon="mdi:cursor-default-click" class="size-4 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_activate)}
</div>
</div>
{/if}
</div>
{#if linkUrl}
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 bg-wald-50 text-wald-700 text-[.65rem] font-medium hover:bg-wald-100 transition-colors border-t border-wald-200"
>
<Icon icon="mdi:open-in-new" class="size-3.5 shrink-0" aria-hidden="true" />
{t(translations, T.post_map_open_osm)}
</a>
{/if}
</div>
{:else if linkUrl}
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 px-3 py-2 bg-wald-50 text-wald-700 text-xs font-medium hover:bg-wald-100 transition-colors"
>
<Icon icon="mdi:map-marker" class="size-4 shrink-0" aria-hidden="true" />
{locationText ?? t(translations, T.post_map_open_osm)}
</a>
{/if}
</div>
</Accordion>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import ContentRows from "./ContentRows.svelte";
import type { RowContentLayout } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface FooterEntry extends RowContentLayout {
id?: string;
}
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
const justifyClass = $derived(
footer?.row1JustifyContent === "end"
? "justify-end"
: footer?.row1JustifyContent === "between"
? "justify-between"
: footer?.row1JustifyContent === "around"
? "justify-around"
: footer?.row1JustifyContent === "evenly"
? "justify-evenly"
: footer?.row1JustifyContent === "start"
? "justify-start"
: "justify-center",
);
const hasRowContent = $derived(
!!footer &&
((footer.row1Content?.length ?? 0) > 0 ||
(footer.row2Content?.length ?? 0) > 0 ||
(footer.row3Content?.length ?? 0) > 0),
);
</script>
{#if footer}
<footer
class="mt-auto bg-zinc-950 text-white py-6"
data-footer-id={footer.id}
>
<div class="container-custom py-6">
{#if hasRowContent}
<div class="content-footer text-xs">
<ContentRows layout={footer} translations={translations} />
</div>
{:else}
<p class="text-sm text-white/80 {justifyClass} flex">
{t(T.footer_copyright) !== T.footer_copyright
? t(T.footer_copyright)
: `© ${new Date().getFullYear()} Windwiderstand`}
</p>
{/if}
</div>
</footer>
{/if}
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import '$lib/iconify-offline';
import { slide } from "svelte/transition";
import Icon from "@iconify/svelte";
import Search from "./Search.svelte";
import { overlayOpen, overlayClose } from "$lib/overlay-store";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
interface NavLink {
href: string;
label: string;
}
interface SocialLink {
href: string;
icon: string;
label?: string;
}
let {
links = [],
socialLinks = [],
logoUrl = null,
logoSvgHtml = null,
translations = {},
}: {
links?: NavLink[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
logoSvgHtml?: string | null;
translations?: Translations | null;
} = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
let menuOpen = $state(false);
let searchOpen = $state(false);
function toggleMenu() {
if (menuOpen) {
menuOpen = false;
} else {
searchOpen = false;
menuOpen = true;
}
}
function closeMenu() {
menuOpen = false;
}
function closeOverlays() {
menuOpen = false;
searchOpen = false;
}
$effect(() => {
if (searchOpen) menuOpen = false;
});
$effect(() => {
if (menuOpen) searchOpen = false;
});
$effect(() => {
if (typeof window === "undefined") return;
const open = menuOpen || searchOpen;
overlayOpen.set(open);
overlayClose.set(open ? closeOverlays : null);
});
$effect(() => {
if (typeof window === "undefined") return;
const open = menuOpen || searchOpen;
if (open) window.scrollTo({ top: 0, behavior: "smooth" });
document.documentElement.style.overflow = open ? "hidden" : "";
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
};
});
$effect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMenu();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
</script>
<header
id="horizontal-navigation"
class="sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-linear-to-br from-stein-50 from-0% via-wald-100/90 via-42% to-himmel-100 to-100%"
>
<div class="flex items-center justify-between container-custom py-2">
<a
href="/"
class="logo flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
>
{#if logoSvgHtml}
<span class="logo-svg h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10" aria-hidden="true">{@html logoSvgHtml}</span>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-10 w-auto object-contain" />
{:else}
<span>{t(T.site_name_fallback)}</span>
{/if}
</a>
<!-- Desktop: horizontale Nav (ab lg) -->
<nav
class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="hover:animate-pulse transition-all text-xs tracking-wide"
>
{link.label}
</a>
{/each}
<div><span class="opacity-20">|</span></div>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center"
aria-label={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} aria-hidden="true" />
</a>
{/each}
<button
type="button"
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else}
<Icon icon="mdi:magnify" aria-hidden="true" />
{/if}
</button>
</nav>
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="mdi:close"
class="text-2xl text-error!"
aria-hidden="true"
/>
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
aria-controls="mobile-nav"
onclick={toggleMenu}
>
<span class="sr-only"
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</span
>
{#if menuOpen}
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
{:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
{/if}
</button>
</div>
</div>
<Search bind:open={searchOpen} />
<!-- Mobile: Dropdown-Menü (unter lg) -->
{#if menuOpen}
<div
id="mobile-nav"
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
role="dialog"
aria-label="Navigation"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<nav
class="container-custom py-4 flex flex-col gap-1"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
onclick={closeMenu}
>
{link.label}
</a>
{/each}
{#if socialLinks.length > 0}
<div
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
aria-label={social.label || t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon
icon={social.icon}
class="text-[1.5rem]"
aria-hidden="true"
/>
</a>
{/each}
</div>
{/if}
</nav>
</div>
{/if}
</header>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { get } from "svelte/store";
import { overlayOpen, overlayClose } from "$lib/overlay-store";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
function handleClick() {
get(overlayClose)?.();
}
</script>
{#if $overlayOpen}
<button
type="button"
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
aria-label={t(T.header_overlay_close)}
onclick={handleClick}
></button>
{/if}
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import '$lib/iconify-offline';
import Icon from "@iconify/svelte";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
let {
currentPage = 1,
totalPages = 1,
basePath = "/posts",
activeTag = null,
}: {
currentPage: number;
totalPages: number;
basePath?: string;
activeTag?: string | null;
} = $props();
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return basePath;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
}
function pageHref(page: number): string {
if (page <= 1) return tagHref(activeTag ?? null);
const tagPart = activeTag
? `/tag/${encodeURIComponent(activeTag)}`
: "";
return `${basePath}${tagPart}/page/${page}`;
}
const showPagination = $derived(totalPages > 1);
const hasPrev = $derived(currentPage > 1);
const hasNext = $derived(currentPage < totalPages);
const prevPage = $derived(currentPage - 1);
const nextPage = $derived(currentPage + 1);
const pageNumbers = $derived.by(() => {
const max = 5;
let start = Math.max(1, currentPage - Math.floor(max / 2));
const end = Math.min(totalPages, start + max - 1);
if (end - start + 1 < max) start = Math.max(1, end - max + 1);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
});
const paginationLinkClass =
" bg-linear-to-b from-stein-50 to-stein-100 rounded-full h-6 w-6 mx-[2px] flex justify-center items-center text-stein-950 no-underline text-xs font-medium border border-stein-200 shadow-md";
</script>
{#if showPagination}
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
</a>
{/if}
{#each pageNumbers as num}
<a
href={pageHref(num)}
class="{paginationLinkClass} {num === currentPage ? 'opacity-50 shadow-none' : ''}"
aria-current={num === currentPage ? 'page' : undefined}
>
{num}
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
</a>
{/if}
</div>
</div>
{/if}
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PostEntry } from "$lib/cms";
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
type PostWithImage = PostEntry & { _resolvedImageUrl?: string };
let {
post,
href,
tagBase = "/posts/tag",
}: {
post: PostWithImage;
href: string;
tagBase?: string;
} = $props();
const resolvedImg = $derived(post._resolvedImageUrl);
type PostWithEvent = PostWithImage & {
isEvent?: boolean;
eventDate?: string;
eventLocation?: { text?: string };
};
const p = post as PostWithEvent;
const eventDate = $derived(p.isEvent && p.eventDate ? p.eventDate : null);
const locationText = $derived(p.eventLocation?.text ?? null);
</script>
<a
href={href}
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white"
>
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
{#if resolvedImg}
<img
src={resolvedImg}
alt={post.headline ?? ""}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="size-12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
{/if}
{#if eventDate}
<div class="absolute bottom-2 left-2 right-2">
<EventBadges {eventDate} {locationText} />
</div>
{/if}
</div>
<div class="flex-1 flex flex-col justify-start p-4">
<PostMeta
date={post.created ?? null}
tags={post.postTag ?? []}
{tagBase}
/>
<div>
<h5 class="line-clamp-2 font-medium leading-tight mb-1">
{post.headline ?? post.linkName ?? post.slug ?? post._slug}
</h5>
{#if post.subheadline}
<div class="text-xs line-clamp-2 mb-1">
{post.subheadline}
</div>
{/if}
{#if post.excerpt}
<p class="text-xs font-light line-clamp-3">
{post.excerpt}
</p>
{/if}
</div>
</div>
</a>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { formatPostDate } from "$lib/blog-utils";
import Tag from "./Tag.svelte";
import Tags from "./Tags.svelte";
type TagItem = string | { name?: string; _slug?: string };
type TagVariant = "white" | "green" | "blue";
let {
date = null,
tags = [],
tagVariant = "white",
tagBase = null,
}: {
date?: string | null;
tags?: TagItem[];
tagVariant?: TagVariant;
tagBase?: string | null;
} = $props();
</script>
<div class="mb-1 min-w-0 flex flex-nowrap gap-1 items-center overflow-x-auto overflow-y-hidden p-1 pb-4">
{#if date}
<Tag label={formatPostDate(date)} variant="date" />
{/if}
<div class="flex flex-nowrap gap-1 items-center shrink-0">
<Tags tags={tags} variant={tagVariant} tagBase={tagBase} />
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/**
* Dünner img-Wrapper: `src` ist typischerweise von getCmsImageUrl / ensureTransformedImage (/cms-images?…).
*/
interface Props {
/** Aufgelöster Bildpfad (z. B. ensureTransformedImage). */
src: string;
width?: number;
height?: number;
alt?: string;
class?: string;
loading?: "lazy" | "eager";
}
let { src, width, height, alt = "", class: className = "", loading = "lazy" }: Props = $props();
</script>
<img
{src}
{alt}
{width}
{height}
class={className}
{loading}
/>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { tick } from "svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
type SearchHit = {
type: "page" | "post";
slug: string;
href: string;
title: string;
subtitle: string;
excerpt: string;
image: string | null;
created: string | null;
isEvent?: boolean;
eventDate?: string | null;
};
let { open = $bindable(false) } = $props();
let query = $state("");
let allHits = $state<SearchHit[] | null>(null);
let loading = $state(false);
let loadError = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
const normalizedQuery = $derived(query.trim().toLowerCase());
const results = $derived.by<SearchHit[]>(() => {
if (!allHits || normalizedQuery.length < 2) return [];
const q = normalizedQuery;
return allHits
.filter((h) => {
const hay = `${h.title} ${h.subtitle} ${h.excerpt} ${h.slug}`.toLowerCase();
return hay.includes(q);
})
.slice(0, 40);
});
async function loadIndex() {
if (allHits || loading) return;
loading = true;
loadError = null;
try {
const res = await fetch("/api/search-index");
if (!res.ok) throw new Error("search index fetch failed");
const data = (await res.json()) as { hits: SearchHit[] };
allHits = data.hits ?? [];
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
allHits = [];
} finally {
loading = false;
}
}
function closeSearch() {
open = false;
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" });
}
$effect(() => {
if (!open) return;
void loadIndex();
tick().then(() => inputEl?.focus());
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSearch();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
</script>
{#if open}
<div
id="search-panel"
class="border-t border-white/30 bg-white/95 backdrop-blur overflow-hidden"
role="region"
aria-label="Suche"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<div class="container-custom py-4 overflow-auto max-h-[calc(100vh-120px)]">
<label class="relative block max-w-xl mx-auto">
<span class="sr-only">Suche</span>
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-zinc-500">
<Icon icon="mdi:magnify" class="h-5 w-5" />
</span>
<input
bind:this={inputEl}
type="search"
bind:value={query}
placeholder="Seiten und Beiträge durchsuchen…"
class="w-full rounded-sm border border-zinc-300 bg-white py-3 pl-10 pr-10 text-sm focus:border-zinc-500 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={() => (query = "")}
aria-label="Eingabe leeren"
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
>
<Icon icon="mdi:close" class="h-4 w-4" />
</button>
{/if}
</label>
<div class="max-w-xl mx-auto mt-4 text-sm">
{#if loading}
<p class="text-zinc-500 text-center py-2">Lade Suchindex…</p>
{:else if loadError}
<p class="text-red-600 text-center py-2">Suche nicht verfügbar: {loadError}</p>
{:else if normalizedQuery.length < 2}
<p class="text-zinc-500 text-center py-2">Mindestens 2 Zeichen eingeben.</p>
{:else if results.length === 0}
<p class="text-zinc-500 text-center py-2">Keine Treffer.</p>
{:else}
<ul class="divide-y divide-zinc-200">
{#each results as hit}
<li>
<a
href={hit.href}
onclick={closeSearch}
class="flex items-start gap-3 py-3 hover:bg-zinc-50 rounded-sm px-2 -mx-2 no-underline"
>
{#if hit.image}
<img
src={hit.image}
alt=""
class="w-20 h-12 object-cover shrink-0 rounded-sm border border-zinc-200"
loading="lazy"
/>
{:else}
<span class="w-20 h-12 shrink-0 bg-zinc-100 rounded-sm border border-zinc-200 flex items-center justify-center text-zinc-400">
<Icon icon={hit.type === "post" ? "mdi:file-document-outline" : "mdi:file-outline"} class="h-5 w-5" />
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-[.65rem] uppercase tracking-wide text-zinc-500">
<span>{hit.type === "post" ? "Beitrag" : "Seite"}</span>
{#if hit.isEvent && hit.eventDate}
<span class="text-wald-700">{formatDate(hit.eventDate)}</span>
{:else if hit.created}
<span>{formatDate(hit.created)}</span>
{/if}
</div>
<div class="font-medium text-zinc-900 truncate">{hit.title}</div>
{#if hit.excerpt}
<div class="text-xs text-zinc-600 line-clamp-2">{hit.excerpt}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/if}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
type Variant = "white" | "green" | "blue" | "inactive" | "date";
let {
label,
variant = "white",
href = null,
active = false,
onclick = null,
icon = null,
customColor = null,
}: {
label: string;
variant?: Variant;
href?: string | null;
active?: boolean;
onclick?: (() => void) | null;
icon?: string | null;
customColor?: string | null;
} = $props();
function isLightBackground(cssColor: string): boolean {
const s = cssColor.trim();
const hex6 = /^#([0-9a-f]{6})$/i.exec(s);
const hex3 = /^#([0-9a-f]{3})$/i.exec(s);
let r = 200;
let g = 200;
let b = 200;
if (hex6) {
const n = parseInt(hex6[1], 16);
r = (n >> 16) & 255;
g = (n >> 8) & 255;
b = n & 255;
} else if (hex3) {
const h = hex3[1];
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (s.startsWith("rgb")) {
const m = s.match(/\d+/g);
if (m && m.length >= 3) {
r = Number(m[0]);
g = Number(m[1]);
b = Number(m[2]);
}
} else {
return true;
}
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55;
}
const baseClass =
"inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0";
const variantClass = $derived.by(() => {
if (customColor?.trim()) {
return "";
}
if (variant === "white")
return "bg-stein-100 text-stein-800";
if (variant === "green") return "bg-wald-100 text-wald-800";
if (variant === "blue") return "bg-sky-100/90 text-sky-900";
if (variant === "inactive") return "bg-stein-200 text-stein-500";
if (variant === "date") return "bg-wald-50 text-wald-800";
return "bg-stein-200 text-stein-500";
});
const pillStyle = $derived.by(() => {
const c = customColor?.trim();
if (!c) return "";
const light = isLightBackground(c);
const fg = light ? "#111827" : "#fafafa";
return `background-color: ${c}; color: ${fg};`;
});
const interactiveClass = $derived.by(() =>
href || onclick ? "hover:brightness-[0.97] active:brightness-[0.94]" : "",
);
const classes = $derived.by(
() =>
`${baseClass} ${variantClass} ${interactiveClass} ${active ? "pointer-events-none opacity-80" : ""}`.trim(),
);
</script>
{#if href}
<a
href={href}
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
onclick={onclick}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</button>
{:else}
<span class={classes} style={pillStyle || undefined}>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</span>
{/if}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import Tag from "./Tag.svelte";
type TagItem =
| string
| { name?: string; _slug?: string; icon?: string; color?: string };
type Variant = "white" | "green" | "blue";
let {
tags = [],
variant = "white",
tagBase = null,
}: {
tags?: TagItem[];
variant?: Variant;
tagBase?: string | null;
} = $props();
function displayName(t: TagItem): string {
if (typeof t === "string") return t;
return (
(t as { name?: string; _slug?: string })?.name ??
(t as { _slug?: string })?._slug ??
""
);
}
function slug(t: TagItem): string {
if (typeof t === "string") return t;
return (
(t as { _slug?: string })?._slug ?? (t as { name?: string })?.name ?? ""
);
}
function tagIcon(t: TagItem): string | null {
if (typeof t === "string") return null;
const i = (t as { icon?: string }).icon?.trim();
return i || null;
}
function tagColor(t: TagItem): string | null {
if (typeof t === "string") return null;
const c = (t as { color?: string }).color?.trim();
return c || null;
}
</script>
{#each (tags ?? []) as t}
{@const name = displayName(t)}
{#if name}
<Tag
label={name}
variant={variant}
href={tagBase ? `${tagBase}/${slug(t)}` : null}
icon={tagIcon(t)}
customColor={tagColor(t)}
/>
{/if}
{/each}
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
/**
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown).
*/
import { marked } from "marked";
import type { FullwidthBannerEntry } from "$lib/cms";
marked.setOptions({ gfm: true });
interface Props {
banner: FullwidthBannerEntry | null;
resolvedImages?: string[];
/** Headline der Page (Markdown). */
headline?: string;
/** Subheadline der Page (Markdown). */
subheadline?: string;
}
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
const headlineHtml = $derived(
headline?.trim() ? (marked.parseInline(headline) as string) : "",
);
const subheadlineHtml = $derived(
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
);
const hasImage = $derived(resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim()));
const hasText = $derived(banner?.text != null && banner.text !== "");
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
const showBanner = $derived(hasImage || hasText || hasPageTitle);
</script>
{#if showBanner}
<div class="w-full">
{#if hasImage}
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
<img
src={resolvedImages[0]}
alt={banner?.headline ?? headline ?? ""}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
loading="eager"
fetchpriority="high"
/>
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
<div class="container-custom mx-auto px-6 relative">
{#if headlineHtml}
<h1 class="bg-red-400/70 inline-block py-1 px-2 -ml-2 backdrop-blur">{@html headlineHtml}</h1>
{/if}
<div></div>
{#if subheadlineHtml}
<h2 class="bg-white/50 inline-block py-1 px-2 -ml-2 backdrop-blur font-light! text-xl!">{@html subheadlineHtml}</h2>
{/if}
</div>
</div>
{/if}
</div>
{:else if hasPageTitle}
<div class="border-b border-zinc-200 container mx-auto px-6 py-4">
{#if headlineHtml}
<h1 class="text-2xl font-bold text-zinc-900">{@html headlineHtml}</h1>
{/if}
{#if subheadlineHtml}
<h2 class="text-lg text-zinc-600 mt-1">{@html subheadlineHtml}</h2>
{/if}
</div>
{/if}
{#if hasText}
<div class="w-full bg-green-700 text-white text-center py-2 px-4">
<div class="container mx-auto">
<div class="text-sm font-medium">{banner!.text}</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
import { setContext } from "svelte";
import type { Snippet } from "svelte";
import type { Translations, TranslationReplacements } from "$lib/translations";
import { T_CONTEXT_KEY, replacePlaceholders } from "$lib/translations";
let {
translations = {},
children,
}: {
translations?: Translations;
children?: Snippet;
} = $props();
const t = (key: string, replacements?: TranslationReplacements): string => {
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
};
setContext(T_CONTEXT_KEY, t);
</script>
{#if children}
{@render children()}
{/if}
@@ -0,0 +1,349 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type {
CalendarBlockData,
CalendarItemData,
} from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
type EventCardItem = CalendarItemData & {
date: Date | null;
timeStr: string;
};
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const items = $derived.by(() => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => ({
...x,
date: parseDate(x.terminZeit),
timeStr: formatTime(x.terminZeit),
}))
.filter((x) => x.date)
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
});
function parseDate(iso: string): Date | null {
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
function getEventLinkHref(link: unknown): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
const o = link as { url?: string; _slug?: string };
if (typeof o.url === "string" && o.url) return o.url;
if (typeof o._slug === "string" && o._slug)
return `/post/${encodeURIComponent(o._slug)}`;
}
return "";
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const eventsByDay = $derived.by(() => {
const map = new Map<string, typeof items>();
for (const item of items) {
if (!item.date) continue;
const key = dayKey(item.date);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return map;
});
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
const year = $derived(currentMonth.getFullYear());
const month = $derived(currentMonth.getMonth());
const monthLabel = $derived(
currentMonth.toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
);
const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const count = last.getDate();
const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last };
});
const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null);
const days = Array.from(
{ length: count },
(_, i) => new Date(year, month, i + 1),
);
return [...empty, ...days];
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
/** Nächste 3 Termine ab heute zur Laufzeit gefiltert. */
const nextUpcomingEvents = $derived.by(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
return items
.filter((item) => item.date && item.date.getTime() >= todayMs)
.slice(0, 3);
});
function prevMonth() {
currentMonth = new Date(year, month - 1);
selectedDay = null;
}
function nextMonth() {
currentMonth = new Date(year, month + 1);
selectedDay = null;
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
const todayKey = $derived(dayKey(new Date()));
const weekdays = $derived([
t(T.calendar_weekday_mo),
t(T.calendar_weekday_di),
t(T.calendar_weekday_mi),
t(T.calendar_weekday_do),
t(T.calendar_weekday_fr),
t(T.calendar_weekday_sa),
t(T.calendar_weekday_so),
]);
</script>
<div
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
data-block-type="calendar"
data-block-slug={block._slug}
>
{#snippet eventCard(item: EventCardItem)}
{@const eventHref = getEventLinkHref(item.link)}
<li class="bg-himmel-100 p-2">
<div class="flex gap-1">
{#if item.date}
<div class="text-xs text-stein-500 mb-0.5">
{item.date.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
})}
</div>
{#if item.timeStr}
<span class="text-xs text-stein-600">/</span>
{/if}
{/if}
{#if item.timeStr}
<div class="text-xs text-stein-600">
{item.timeStr}
{t(T.calendar_time_suffix)}
</div>
{/if}
</div>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
>
{item.title}
</a>
{:else}
<div class="text-base font-medium text-stein-900">{item.title}</div>
{/if}
{#if item.description}
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
{item.description}
</p>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
{:else if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
</li>
{/snippet}
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
{block.title}
</h3>
{/if}
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
<!-- Links: Kalender -->
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
<div class="flex items-center justify-between gap-2 mb-4">
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
</button>
<span
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
{#each weekdays as w}
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
{/each}
{#each calendarDays as d}
{#if d === null}
<div class="aspect-square" aria-hidden="true"></div>
{:else}
{@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const events = eventsByDay.get(key) ?? []}
{@const hasEvents = events.length > 0}
{@const isSelected = selectedDay === key}
{@const isFuture = key >= todayKey}
{#if hasEvents}
<button
type="button"
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
? 'text-himmel-100 hover:bg-himmel-700'
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
? 'ring-2 ring-himmel-400 bg-himmel-600'
: ''}"
onclick={() => selectDay(key)}
>
<span class="text-xs">{d.getDate()}</span>
<span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
aria-hidden="true"
>
{events.length}
</span>
</button>
{:else}
<div
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
? 'text-himmel-100'
: 'text-himmel-100 opacity-50'}"
>
<span class="text-xs">{d.getDate()}</span>
</div>
{/if}
{/if}
{/each}
</div>
</div>
<!-- Rechts: Events -->
<div class="calendar-events-wrapper min-h-0">
<div
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
>
{#if items.length === 0}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
{:else if selectedDay}
<p class="text-xs font-medium text-stein-500 mb-2">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
})}
</p>
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
{#each selectedDayEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else if nextUpcomingEvents.length > 0}
<p class="text-xs uppercase font-medium text-stein-500">
{t(T.calendar_next_events)}
</p>
<ul
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
>
{#each nextUpcomingEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
{/if}
</div>
</div>
</div>
</div>
@@ -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>
@@ -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>
@@ -0,0 +1,67 @@
<script lang="ts">
import '$lib/iconify-offline';
import Icon from "@iconify/svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { IframeBlockData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen. */
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={t(T.iframe_activate_aria)}
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">{t(T.iframe_missing)}</p>
{/if}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { ImageBlockData } from "$lib/block-types";
import CmsImage from "$lib/components/CmsImage.svelte";
let { block }: { block: ImageBlockData } = $props();
/** Rohe Bild-Quelle für /cms-images (URL, //-URL oder Asset-Dateiname/Slug). */
function getRawSrc(b: ImageBlockData): string | null {
const img = b.img ?? b.image;
if (typeof img === "string") {
const s = img.trim();
if (!s) return null;
return s.startsWith("//") ? `https:${s}` : s;
}
if (img && typeof img === "object") {
const src =
(img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
if (typeof src === "string" && src)
return src.startsWith("//") ? `https:${src}` : src;
}
return null;
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const imageSource = $derived(block.image ?? block.img);
const rawSrc = $derived(getRawSrc(block));
$effect(() => {
if (!rawSrc && block._slug) {
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
hasImage: !!block.image,
hasImg: !!block.img,
imageType: typeof block.image,
imgType: typeof block.img,
imgSrc:
block.img && typeof block.img === "object"
? (block.img as { src?: string }).src
: undefined,
imageSrc:
block.image && typeof block.image === "object"
? (block.image as { src?: string }).src
: undefined,
});
}
});
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 rawSrc}
<figure class="max-w-[400px]">
<CmsImage
src={rawSrc}
width={800}
format="webp"
quality={85}
fit="contain"
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,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>
@@ -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,36 @@
<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.resolvedContent ??
(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,99 @@
<script lang="ts">
import { onMount } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OpnFormBlockData } from "$lib/block-types";
let { block }: { block: OpnFormBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const formId = $derived((block.formId ?? "").trim());
const baseUrl = $derived(
(typeof block.baseUrl === "string" && block.baseUrl.trim()
? block.baseUrl.trim()
: "https://opnform.pm86.de"
).replace(/\/$/, ""),
);
const iframeSrc = $derived(formId ? `${baseUrl}/forms/${formId}` : "");
const iframeTitle = $derived(block.iframeTitle?.trim() || "Kontaktformular");
let iframeEl = $state<HTMLIFrameElement | null>(null);
onMount(() => {
if (!formId || !iframeEl) return;
const el = iframeEl;
el.style.height = "600px";
el.style.transition = "height 0.25s ease";
el.setAttribute("scrolling", "no");
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
function handleMessage(event: MessageEvent) {
if (event.source !== el.contentWindow) return;
const d = event.data;
let h: number | undefined;
if (typeof d === "number") {
h = d;
} else if (d && typeof d === "object") {
h = d.height ?? d.frameHeight ?? d.iFrameHeight;
} else if (typeof d === "string" && d.startsWith("[iFrameSizer]")) {
const parts = d.split(":");
h = parts.length >= 2 ? parseFloat(parts[1]) : undefined;
}
if (typeof h === "number" && h > 50) {
el.style.height = `${Math.ceil(h)}px`;
}
}
window.addEventListener("message", handleMessage);
function runInit() {
const w = window as Window & {
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
};
w.initEmbed?.(formId, { autoResize: true });
}
const existing = document.querySelector<HTMLScriptElement>(
`script[data-opnform-embed-js="${baseUrl}"]`,
);
const w = window as Window & { initEmbed?: unknown };
if (existing) {
if (typeof w.initEmbed === "function") {
queueMicrotask(runInit);
} else {
existing.addEventListener("load", runInit, { once: true });
}
} else {
const s = document.createElement("script");
s.type = "text/javascript";
s.async = true;
s.src = scriptUrl;
s.dataset.opnformEmbedJs = baseUrl;
s.onload = () => runInit();
document.body.appendChild(s);
}
return () => {
window.removeEventListener("message", handleMessage);
};
});
</script>
<div
class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4"
data-block-type="opnform"
data-block-slug={block._slug}
>
{#if iframeSrc}
<iframe
bind:this={iframeEl}
style="border: none; width: 100%;"
id={formId}
title={iframeTitle}
src={iframeSrc}
></iframe>
{:else}
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
{/if}
</div>
@@ -0,0 +1,231 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "$lib/block-types";
import { absoluteUrlFromCmsImageField, getCmsImageUrl } from "$lib/rusty-image";
import Card from "$lib/components/Card.svelte";
import Badge from "$lib/components/Badge.svelte";
marked.setOptions({ gfm: true });
/** Nach resolveContentImages: resolvedLogoSrc; sonst /cms-images Proxy. */
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
const resolved = o.resolvedLogoSrc?.trim();
if (resolved) return resolved;
const url = absoluteUrlFromCmsImageField(o.logo);
if (!url) return undefined;
return getCmsImageUrl(url, { width: 256, fit: 'contain', format: 'webp', quality: 85 });
}
function organisationLogoAlt(o: OrganisationEntry): string {
if (o.logo && typeof o.logo === "object" && o.logo.description) {
return o.logo.description;
}
return o.name ?? "";
}
let { block }: { block: OrganisationsBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
const compact = $derived(block.presentation === "compact");
const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived(
block.addOrganisationMarkdown
? (marked.parse(block.addOrganisationMarkdown) as string)
: ""
);
const addOrganisationCtaLink = $derived.by(() => {
const raw = block.addOrganisationLink;
if (!raw || typeof raw !== "object") return undefined;
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return undefined;
const linkName =
typeof raw.linkName === "string" && raw.linkName.trim() !== ""
? raw.linkName.trim()
: url;
return { url, linkName, newTab: Boolean(raw.newTab) };
});
const showAddOrganisationCard = $derived(
Boolean(
block.addOrganisationTitle?.trim() ||
block.addOrganisationMarkdown?.trim() ||
addOrganisationCtaLink
)
);
const addOrganisationCardClass = $derived(
`${compact ? "border-dashed border-wald-300 bg-wald-50! p-3! gap-1.5!" : "border-dashed border-wald-300 bg-wald-50!"}${addOrganisationCtaLink ? " cursor-pointer" : ""}`,
);
</script>
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline}
<h3 class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}>{block.headline}</h3>
{/if}
<div
class={compact
? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3"
: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoSrc = organisationLogoSrc(o)}
{#if compact}
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
<div
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg
class="h-7 w-7 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
<div class="min-w-0 flex-1 flex flex-col gap-0.5">
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
{/if}
</div>
</Card>
{:else}
<Card href={linkUrl}>
<div
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-1"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg
class="h-10 w-10 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<div class="absolute top-3 right-3">
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="min-w-0 text-sm font-semibold leading-snug text-stein-900 truncate">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-sm text-stein-600">{o.description}</p>
{/if}
</Card>
{/if}
{/if}
{/each}
{#if showAddOrganisationCard}
<Card
href={addOrganisationCtaLink?.url}
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class={addOrganisationCardClass}
>
{#if block.addOrganisationTitle}
<h5
class={compact
? "text-sm font-semibold text-wald-800 leading-snug"
: "text-base font-semibold text-wald-800 leading-snug"}
>
{block.addOrganisationTitle}
</h5>
{/if}
{#if ctaHtml}
<div class={compact ? "markdown text-xs text-wald-700" : "markdown text-sm text-wald-700"}>
{@html ctaHtml}
</div>
{/if}
{#if addOrganisationCtaLink}
<p
class={compact
? "mb-0 mt-1 text-xs font-medium text-wald-800"
: "mb-0 mt-2 text-sm font-medium text-wald-800"}
>
{addOrganisationCtaLink.linkName}
</p>
{/if}
</Card>
{/if}
</div>
</div>
@@ -0,0 +1,59 @@
<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";
import { useTranslate, T } from "$lib/translations";
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
const t = useTranslate();
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}
{#if posts.length > 0}
<div class="mt-3 text-xs px-2">
<a href="/posts/" class="inline-flex items-center gap-1 text-zinc-700 hover:text-zinc-900 hover:underline !no-underline">
{t(T.post_overview_all)}
<span aria-hidden="true"></span>
</a>
</div>
{/if}
</div>
@@ -0,0 +1,18 @@
<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,324 @@
<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";
import Tooltip from "$lib/ui/Tooltip.svelte";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
let {
block,
class: className = "",
translations = {},
}: {
block: SearchableTextBlockData;
class?: string;
translations?: Translations | null;
} = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const helpTooltipText = $derived(t(T.searchable_text_help));
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");
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 {
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 */
}
}
}
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-wald-200 text-wald-900 rounded px-0.5";
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>`,
);
}
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-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 text-stein-900 mb-2">
{block.title}
</h2>
{/if}
{#if descriptionHtml}
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
{@html descriptionHtml}
</div>
{/if}
</div>
<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 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={t(T.searchable_text_help_aria)}
>
<Icon
icon="mdi:help-circle-outline"
class="size-5"
aria-hidden="true"
/>
</button>
</Tooltip>
<input
type="search"
placeholder={t(T.searchable_text_placeholder)}
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={t(T.searchable_text_search_aria)}
/>
{#if searchQuery}
<button
type="button"
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={t(T.searchable_text_clear_search)}
>
</button>
{/if}
<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">
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
<Tag
label={t(T.searchable_text_tag_all)}
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-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>{t(T.searchable_text_no_results)}</span>
{:else}
<span>
{visibleFragments.length === fragments.length
? t(T.searchable_text_count_all, { count: visibleFragments.length })
: t(T.searchable_text_count_filtered, {
visible: visibleFragments.length,
total: fragments.length,
})}
</span>
{/if}
</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 || t(T.searchable_text_untitled),
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">
<Tags tags={fragment.tags} variant="white" />
</span>
{/if}
</summary>
<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={t(T.searchable_text_copy_aria)}
>
{#if copiedIndex === i}
<Icon
icon="mdi:check"
class="size-4 text-wald-600"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copied)}</span>
{:else}
<Icon
icon="mdi:content-copy"
class="size-4"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copy_button)}</span>
{/if}
</button>
</div>
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
</div>
</details>
{/each}
</div>
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { YoutubeVideoBlockData } from "$lib/block-types";
import { useTranslate } from "$lib/translations";
import { T } from "$lib/translations";
const t = useTranslate();
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-stein-100">
<iframe
title={block.title ?? t(T.youtube_title_fallback)}
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}
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
{/if}
{:else}
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
{/if}
</div>