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
+163
View File
@@ -0,0 +1,163 @@
/**
* Layout für Content-Blöcke (z. B. Markdown): Grid-Spalten (112) und Abstand.
* Entspricht component_layout im CMS (desktop/tablet/mobile = Breite in 12tel, spaceBottom = rem).
*/
export type BlockLayout = {
/** Breite auf Desktop (112), z. B. "8" = 8/12. */
desktop?:
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
/** Breite auf Tablet (112). */
tablet?:
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
/** Breite auf Mobile (112), Default 12. */
mobile?:
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "10"
| "11"
| "12";
/** Abstand nach unten (rem): 0, 0.5, 1, 1.5, 2. */
spaceBottom?: 0 | 0.5 | 1 | 1.5 | 2;
/** true = Block fullwidth aus dem Grid ausbrechen (viewport-breit). */
breakout?: boolean;
};
const COL_SPAN: Record<string, string> = {
"1": "col-span-1",
"2": "col-span-2",
"3": "col-span-3",
"4": "col-span-4",
"5": "col-span-5",
"6": "col-span-6",
"7": "col-span-7",
"8": "col-span-8",
"9": "col-span-9",
"10": "col-span-10",
"11": "col-span-11",
"12": "col-span-12",
};
const MD_COL_SPAN: Record<string, string> = {
"1": "md:col-span-1",
"2": "md:col-span-2",
"3": "md:col-span-3",
"4": "md:col-span-4",
"5": "md:col-span-5",
"6": "md:col-span-6",
"7": "md:col-span-7",
"8": "md:col-span-8",
"9": "md:col-span-9",
"10": "md:col-span-10",
"11": "md:col-span-11",
"12": "md:col-span-12",
};
const LG_COL_SPAN: Record<string, string> = {
"1": "lg:col-span-1",
"2": "lg:col-span-2",
"3": "lg:col-span-3",
"4": "lg:col-span-4",
"5": "lg:col-span-5",
"6": "lg:col-span-6",
"7": "lg:col-span-7",
"8": "lg:col-span-8",
"9": "lg:col-span-9",
"10": "lg:col-span-10",
"11": "lg:col-span-11",
"12": "lg:col-span-12",
};
const SPACE_BOTTOM: Record<number, string> = {
0: "mb-0",
0.5: "mb-2", // 0.5rem
1: "mb-4", // 1rem
1.5: "mb-6", // 1.5rem
2: "mb-8", // 2rem
};
/**
* Gibt Tailwind-Klassen für das Block-Layout zurück (12-Spalten-Grid + Abstand).
* Parent muss grid grid-cols-12 haben.
*/
/**
* Gibt Tailwind-Klassen für den Block-Inhalt zurück.
* Bei breakout: true nur w-full + Abstand (Spalten/Wrapper kommen von außen).
*/
export function getBlockLayoutClasses(
layout: BlockLayout | undefined | null,
): string {
if (!layout || typeof layout !== "object") {
return "col-span-12 mb-4";
}
const breakout = layout.breakout === true;
const sb =
typeof layout.spaceBottom === "number"
? layout.spaceBottom
: Number(layout.spaceBottom);
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
if (breakout) {
return "w-full";
}
const mobile = String(layout.mobile ?? "12");
const tablet = layout.tablet != null ? String(layout.tablet) : undefined;
const desktop = layout.desktop != null ? String(layout.desktop) : undefined;
const parts: string[] = [
COL_SPAN[mobile] ?? "col-span-12",
tablet ? (MD_COL_SPAN[tablet] ?? "") : "",
desktop ? (LG_COL_SPAN[desktop] ?? "") : "",
spaceClass,
];
return parts.filter(Boolean).join(" ");
}
/** Tailwind-Klasse für spaceBottom (z. B. am Breakout-Wrapper). */
export function getSpaceBottomClass(
layout: BlockLayout | undefined | null,
): string {
if (!layout || typeof layout !== "object") return "mb-4";
const sb =
typeof layout.spaceBottom === "number"
? layout.spaceBottom
: Number(layout.spaceBottom);
return SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
}
/** Prüft, ob das Layout breakout (fullwidth) haben soll. */
export function isBlockLayoutBreakout(
layout: BlockLayout | undefined | null,
): boolean {
return !!(layout && typeof layout === "object" && layout.breakout === true);
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
* Wird von SvelteKit-Seiten und Svelte-Komponenten genutzt.
* Kalender-Typen basieren auf der OpenAPI-Spec (cms-api.generated).
*/
import type { BlockLayout } from "./block-layout";
import type { components } from "./cms-api.generated";
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
export type ResolvedBlock = {
_type?: string;
_slug?: string;
[key: string]: unknown;
};
/** Gemeinsames Row-Layout (page, post, footer erben content_layout). */
export interface RowContentLayout {
row1Content?: unknown[];
row1JustifyContent?: string;
row1AlignItems?: string;
row2Content?: unknown[];
row2JustifyContent?: string;
row2AlignItems?: string;
row3Content?: unknown[];
row3JustifyContent?: string;
row3AlignItems?: string;
}
/** Resolved Markdown-Block vom CMS (_type: "markdown"). resolvedContent = HTML mit transformierten Bildern (von resolveContentImages). */
export interface MarkdownBlockData {
_type?: "markdown";
_slug?: string;
name?: string;
content?: string;
/** Gesetzt von resolveContentImages: HTML (Bilder transformiert, in Links eingewickelt). */
resolvedContent?: string;
alignment?: "left" | "center" | "right";
layout?: BlockLayout;
}
/** Headline (_type: "headline"). */
export interface HeadlineBlockData {
_type?: "headline";
_slug?: string;
tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
text?: string;
align?: "left" | "center" | "right";
layout?: BlockLayout;
}
/** HTML (_type: "html"). */
export interface HtmlBlockData {
_type?: "html";
_slug?: string;
html?: string;
layout?: BlockLayout;
}
/** Iframe (_type: "iframe"). */
export interface IframeBlockData {
_type?: "iframe";
_slug?: string;
iframe?: string;
content?: string;
layout?: BlockLayout;
}
/** Image (_type: "image"). image oder img: Slug, URL-String oder aufgelöstes Objekt (src/description oder file.url). */
export interface ImageBlockData {
_type?: "image";
_slug?: string;
image?:
| string
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
title?: string;
};
/** Alternative zu image (manche CMS-Responses liefern img). */
img?:
| string
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
title?: string;
};
/** Nach resolveContentImages: Proxy-URL zum transformierten Bild. */
resolvedImageSrc?: string;
caption?: string;
aspectRatio?: number;
layout?: BlockLayout;
}
/** Eintrag in einer image_gallery (img mit src/description). */
export interface ImageGalleryImage {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
/** Nach resolveContentImages: Proxy-URL zum transformierten Bild (16:9). */
resolvedSrc?: string;
}
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
export interface ImageGalleryBlockData {
_type?: "image_gallery";
_slug?: string;
/** Optionaler Einleitungstext (Markdown). */
description?: string;
images?: ImageGalleryImage[];
layout?: BlockLayout;
}
/** YouTube-Video (_type: "youtube_video"). */
export interface YoutubeVideoBlockData {
_type?: "youtube_video";
_slug?: string;
youtubeId?: string;
title?: string;
description?: string;
params?: string;
layout?: BlockLayout;
}
/** Zitat (_type: "quote"). */
export interface QuoteBlockData {
_type?: "quote";
_slug?: string;
quote?: string;
author?: string;
variant?: "left" | "right";
layout?: BlockLayout;
}
/** Link-Liste (_type: "link_list"). links können Slugs oder aufgelöste link-Objekte sein. */
export interface LinkListBlockData {
_type?: "link_list";
_slug?: string;
headline?: string;
links?: Array<string | { url?: string; linkName?: string; newTab?: boolean }>;
layout?: BlockLayout;
}
/** Post-Übersicht (_type: "post_overview"). Nach resolvePostOverviewBlocks: postsResolved gesetzt. */
export interface PostOverviewBlockData {
_type?: "post_overview";
_slug?: string;
headline?: string;
/** Einleitungstext (Markdown). */
text?: string;
/** true = alle Posts laden; false = posts (Slugs/Objekte) aus dem Block nutzen. */
allPosts?: boolean;
/** Nur wenn allPosts false: feste Post-Liste (Slugs oder aufgelöste Einträge). */
posts?: unknown[];
/** Tag-Slugs, nach denen gefiltert wird (nur bei allPosts true). */
filterByTag?: string[];
/** Max. Anzahl Einträge. */
numberItems?: number;
/** "list" | "cards". */
design?: "list" | "cards";
layout?: BlockLayout;
/** Nach resolvePostOverviewBlocks: geladene/gefilterte Posts (PostEntry[]). */
postsResolved?: unknown[];
}
/** Tag-Referenz (API liefert oft { _slug, _type, name }). */
export type SearchableTextTagRef = string | { _slug?: string; name?: string };
/** Ein Text-Fragment (aus text_fragment), für SearchableTextBlock. */
export interface SearchableTextFragment {
_slug?: string;
title?: string;
text?: string;
tags?: SearchableTextTagRef[];
}
/** Durchsuchbarer Text (_type: "searchable_text"). textFragments können Slugs oder aufgelöste Objekte sein. */
export interface SearchableTextBlockData {
_type?: "searchable_text";
_slug?: string;
/** Titel der Suchkomponente. */
title?: string;
/** Einleitung (Markdown). */
description?: string;
/** Slugs oder aufgelöste text_fragment-Einträge. */
textFragments?: (string | SearchableTextFragment)[];
layout?: BlockLayout;
}
/** OpnForm embed (_type: "opnform"). */
export interface OpnFormBlockData {
_type?: "opnform";
_slug?: string;
id?: string;
formId?: string;
baseUrl?: string;
iframeTitle?: string;
layout?: BlockLayout;
}
/** Einzelne Organisation (aufgelöst aus organisation-Collection). */
export interface OrganisationEntry {
_type?: "organisation";
_slug?: string;
name?: string;
logo?: string | { src?: string; description?: string };
/** Nach resolveContentImages: Proxy-URL zum Logo (WebP). */
resolvedLogoSrc?: string;
description?: string;
link?: string | { url?: string; linkName?: string; newTab?: boolean };
badge?: string | { label?: string; color?: string };
}
/** Organisations-Block (_type: "organisations"). */
export interface OrganisationsBlockData {
_type?: "organisations";
_slug?: string;
headline?: string;
description?: string;
/** default = Karten-Grid; compact = engere Karten, kleinere Typo, Logo links */
presentation?: "default" | "compact";
addOrganisationTitle?: string;
addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
addOrganisationLink?: string | { url?: string; linkName?: string; newTab?: boolean };
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit<
components["schemas"]["calendar"],
"items"
> & {
_type?: "calendar";
/** Optionaler Titel über dem Kalender. */
title?: string;
/** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
};
+582
View File
@@ -0,0 +1,582 @@
import type { PostEntry } from "./cms";
import {
getPosts,
getPostBySlug,
getTags,
getTextFragmentBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
} from "./cms";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types";
import { ensureTransformedImage } from "./rusty-image";
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
export type TagMeta = {
name: string;
icon?: string;
color?: string;
};
/** Slug → TagMeta aus der Tag-API. Für Auflösung von post.postTag. */
export async function getTagsMap(): Promise<Map<string, TagMeta>> {
const tags = await getTags();
const m = new Map<string, TagMeta>();
for (const t of tags) {
const slug =
(t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? "";
if (!slug) continue;
const name = (t as { name?: string }).name?.trim() ?? slug;
const icon = (t as { icon?: string }).icon?.trim();
const color = (t as { color?: string }).color?.trim();
m.set(slug, {
name,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
});
}
return m;
}
export type ResolvedPostTag = {
_slug: string;
name: string;
icon?: string;
color?: string;
};
/** Setzt post.postTag auf { _slug, name, icon?, color? }[]; Metadaten aus slugToMeta bzw. bereits aufgelösten Refs. */
export function resolvePostTagsInPost(
post: PostEntry,
slugToMeta: Map<string, TagMeta>,
): void {
const raw = post.postTag ?? [];
if (!Array.isArray(raw)) return;
(post as { postTag?: ResolvedPostTag[] }).postTag = raw.map((t) => {
if (typeof t === "string") {
const meta = slugToMeta.get(t);
return {
_slug: t,
name: meta?.name ?? t,
...(meta?.icon ? { icon: meta.icon } : {}),
...(meta?.color ? { color: meta.color } : {}),
};
}
const o = t as {
_slug?: string;
name?: string;
icon?: string;
color?: string;
};
const slug = o._slug ?? o?.name ?? "";
const meta = slug ? slugToMeta.get(slug) : undefined;
const icon = (o.icon ?? meta?.icon)?.trim();
const color = (o.color ?? meta?.color)?.trim();
return {
_slug: slug,
name: o?.name ?? meta?.name ?? slug,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
};
});
}
const POSTS_PER_PAGE = 10;
export function getPostsPerPage(): number {
return POSTS_PER_PAGE;
}
/** Filtert Posts mit hideFromListing: true aus Übersichten heraus. */
export function filterHiddenPosts(posts: PostEntry[]): PostEntry[] {
return posts.filter(
(p) => !(p as PostEntry & { hideFromListing?: boolean }).hideFromListing,
);
}
/** Sortiert Posts nach Datum (neueste zuerst). */
export function sortPostsByDate(posts: PostEntry[]): PostEntry[] {
return [...posts].sort((a, b) => {
const da = a.created ?? "";
const db = b.created ?? "";
if (!da) return 1;
if (!db) return -1;
return new Date(db).getTime() - new Date(da).getTime();
});
}
/** Filtert Posts nach Tag-Slug (post.postTag enthält den Slug). */
export function filterPostsByTag(
posts: PostEntry[],
tagSlug: string | null,
): PostEntry[] {
if (!tagSlug) return posts;
return posts.filter((p) => {
const tags = p.postTag ?? [];
return tags.some((t) => {
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
return slug === tagSlug;
});
});
}
/** URL aus post.postImage (RustyCMS img mit src, oder legacy file.url). */
export function getPostImageUrl(
postImage: PostEntry["postImage"],
): string | null {
if (!postImage || typeof postImage === "string") return null;
const obj = postImage as {
src?: string;
file?: { url?: string };
fields?: { file?: { url?: string } };
};
const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url;
return typeof url === "string"
? url.startsWith("//")
? `https:${url}`
: url
: null;
}
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
export function filterPostsByTagSlugs(
posts: PostEntry[],
tagSlugs: string[],
): PostEntry[] {
if (!tagSlugs?.length) return posts;
return posts.filter((p) => {
const tags = p.postTag ?? [];
return tags.some((t) => {
const slug = typeof t === "string" ? t : (t as { _slug?: string })?._slug;
return tagSlugs.includes(slug ?? "");
});
});
}
export function getTotalPages(count: number, perPage: number): number {
return Math.max(1, Math.ceil(count / perPage));
}
export function paginate<T>(items: T[], page: number, perPage: number): T[] {
const start = (page - 1) * perPage;
return items.slice(start, start + perPage);
}
export type PostSearchIndexEntry = {
slug: string;
headline: string;
excerpt: string;
subheadline: string;
tags: string[];
image: string | null;
created: string | null;
isEvent: boolean;
eventDate: string | null;
};
type PostWithEventIndex = PostEntry & {
isEvent?: boolean;
eventDate?: string;
_resolvedImageUrl?: string;
};
export function buildPostSearchIndex(posts: PostEntry[]): PostSearchIndexEntry[] {
return posts.map((p) => ({
slug: p.slug ?? p._slug ?? "",
headline: p.headline ?? "",
excerpt: p.excerpt ?? "",
subheadline: p.subheadline ?? "",
tags: (p.postTag ?? [])
.map((t) =>
typeof t === "string"
? t
: (t as { name?: string; _slug?: string }).name ??
(t as { _slug?: string })._slug ??
"",
)
.filter(Boolean),
image: (p as PostWithEventIndex)._resolvedImageUrl ?? null,
created: p.created ?? null,
isEvent: Boolean((p as PostWithEventIndex).isEvent),
eventDate: (p as PostWithEventIndex).eventDate ?? null,
}));
}
export type LoadPostsResult = {
posts: PostEntry[];
tags: Awaited<ReturnType<typeof getTags>>;
activeTag: string | null;
currentPage: number;
totalPages: number;
totalPosts: number;
upcomingEvents: PostEntry[];
searchIndex: PostSearchIndexEntry[];
tagName: string | null;
cmsError: string | null;
};
/** Gemeinsame Ladefunktion für /posts, /posts/page/[n], /posts/tag/[t], /posts/tag/[t]/page/[n]. */
export async function loadPostsList(opts: {
tagSlug?: string | null;
page?: number;
}): Promise<LoadPostsResult> {
const tagSlug = opts.tagSlug ?? null;
const requestedPage = Math.max(1, opts.page ?? 1);
const perPage = getPostsPerPage();
let posts: PostEntry[] = [];
let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null;
try {
[posts, tags] = await Promise.all([
getPosts({ resolve: ["postImage"] }),
getTags(),
]);
posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
});
(post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch {
/* image optional */
}
}
}
} catch (e) {
cmsError = e instanceof Error ? e.message : String(e);
}
const filtered = filterPostsByTag(posts, tagSlug);
const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(requestedPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
const tagName = tagSlug
? (tags.find((t) => ((t as { _slug?: string })._slug ?? "") === tagSlug)?.name ?? tagSlug)
: null;
const upcomingEvents = selectUpcomingEvents(posts);
const searchIndex = buildPostSearchIndex(posts);
return {
posts: pagePosts,
tags,
activeTag: tagSlug,
currentPage: pageNum,
totalPages,
totalPosts: filtered.length,
upcomingEvents,
searchIndex,
tagName,
cmsError,
};
}
export function selectUpcomingEvents(posts: PostEntry[], max = 4): PostEntry[] {
const now = Date.now();
return (posts as PostWithEventIndex[])
.filter(
(p) => p.isEvent && p.eventDate && new Date(p.eventDate).getTime() >= now,
)
.sort(
(a, b) =>
new Date(a.eventDate ?? 0).getTime() -
new Date(b.eventDate ?? 0).getTime(),
)
.slice(0, max) as PostEntry[];
}
const POST_BASE = "/post";
/** Post-URL: slug ist die URL; führender Slash entfernt. */
export function postHref(post: PostEntry): string {
const slug = post.slug ?? post._slug ?? "";
const norm = (slug ?? "").replace(/^\//, "").trim();
return norm ? `${POST_BASE}/${encodeURIComponent(norm)}` : POST_BASE;
}
export function formatPostDate(value: string | undefined): string {
if (!value) return "";
try {
const d = new Date(value);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
/** Prüft, ob ein Block ein Post-Overview-Block ist (mit _type "post_overview"). */
function isPostOverviewBlock(item: unknown): item is {
_type?: string;
allPosts?: boolean;
posts?: unknown[];
filterByTag?: unknown[];
numberItems?: number;
postsResolved?: unknown[];
} {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "post_overview"
);
}
/**
* Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved.
* Nutzt die globale Tag-API (getTagsMap()) für Slug → TagMeta.
* Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug]+page.server.ts).
*/
export async function resolvePostOverviewBlocks(
layout: RowContentLayout | null | undefined,
): Promise<Map<string, TagMeta>> {
const tagsMap = await getTagsMap();
if (!layout) return tagsMap;
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
let allPosts: PostEntry[] | null = null;
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isPostOverviewBlock(item)) continue;
if (item.allPosts) {
if (allPosts === null)
allPosts = await getPosts({
_sort: "created",
_order: "desc",
resolve: "all",
});
let list = filterHiddenPosts(sortPostsByDate(allPosts));
const rawFilter = item.filterByTag ?? [];
const tagSlugs = Array.isArray(rawFilter)
? rawFilter
.map((t) =>
typeof t === "string"
? t
: ((t as { _slug?: string })?._slug ?? ""),
)
.filter(Boolean)
: [];
if (tagSlugs.length) list = filterPostsByTagSlugs(list, tagSlugs);
const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999;
item.postsResolved = list.slice(0, limit);
} else if (Array.isArray(item.posts) && item.posts.length > 0) {
const first = item.posts[0];
const isResolved =
typeof first === "object" &&
first !== null &&
"_slug" in first &&
("headline" in first || "linkName" in first);
if (isResolved) {
item.postsResolved = item.posts as PostEntry[];
} else {
const slugs = item.posts
.map((p) =>
typeof p === "string"
? p
: ((p as { _slug?: string })?._slug ?? ""),
)
.filter(Boolean);
const limit =
typeof item.numberItems === "number" ? item.numberItems : 9999;
const resolved: PostEntry[] = [];
for (const slug of slugs.slice(0, limit)) {
const post = await getPostBySlug(slug, {
locale: "de",
resolve: ["all"],
});
if (post) resolved.push(post);
}
item.postsResolved = resolved;
}
}
if (Array.isArray(item.postsResolved)) {
const posts = item.postsResolved as PostEntry[];
if (posts.length > 0) {
for (const post of posts) resolvePostTagsInPost(post, tagsMap);
}
for (const post of item.postsResolved as PostEntry[]) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
});
(
post as PostEntry & { _resolvedImageUrl?: string }
)._resolvedImageUrl = url;
} catch {
// Bild optional
}
}
}
}
}
}
return tagsMap;
}
function isCalendarBlock(
item: unknown,
): item is { _type?: string; _slug?: string; items?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "calendar"
);
}
/**
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
*/
export async function resolveCalendarBlocks(
layout: RowContentLayout,
): Promise<void> {
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isCalendarBlock(item)) continue;
const slug = item._slug;
if (!slug) continue;
const rawItems = item.items ?? [];
const first = rawItems[0];
const alreadyResolved =
typeof first === "object" &&
first !== null &&
"terminZeit" in first &&
"title" in first;
if (alreadyResolved) continue;
const calendar = await getCalendarBySlug(slug, {
locale: "de",
resolve: "items",
});
const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = [];
for (const x of raw) {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
resolved.push(x as CalendarItemData);
} else {
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
}
(item as { items?: CalendarItemData[] }).items = resolved;
}
}
}
function isSearchableTextBlock(
item: unknown,
): item is { _type?: string; textFragments?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "searchable_text"
);
}
/** Prüft, ob ein Fragment bereits aufgelöst ist (hat title oder text). */
function isResolvedFragment(f: unknown): boolean {
if (typeof f !== "object" || f === null) return false;
const o = f as { title?: string; text?: string };
return "title" in o || "text" in o;
}
/**
* Normalisiert tags eines Fragments zu Anzeigenamen (string[]).
* Nutzt slugToMeta für Slug → Anzeigename; Objekte mit name/_slug werden unterstützt.
*/
function resolveFragmentTags(
tagsRaw: unknown[] | undefined,
slugToMeta: Map<string, TagMeta>,
): string[] {
if (!Array.isArray(tagsRaw)) return [];
return tagsRaw
.map((t) => {
if (typeof t === "string") return slugToMeta.get(t)?.name ?? t;
const o = t as { _slug?: string; name?: string };
const slug = o._slug ?? "";
return (o.name ?? slugToMeta.get(slug)?.name ?? slug).trim();
})
.filter(Boolean);
}
/**
* Lädt für alle searchable_text-Blöcke im Layout die textFragments nach
* (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und
* löst Tag-Slugs/Refs in Anzeigenamen auf.
* @param slugToMeta Optionale Map Slug → TagMeta (z. B. von resolvePostOverviewBlocks); sonst getTagsMap().
*/
export async function resolveSearchableTextBlocks(
layout: RowContentLayout | null | undefined,
slugToMeta?: Map<string, TagMeta>,
): Promise<void> {
if (!layout) return;
const tagsMap = slugToMeta ?? (await getTagsMap());
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isSearchableTextBlock(item)) continue;
const raw = item.textFragments ?? [];
if (!Array.isArray(raw) || raw.length === 0) continue;
const needsResolve = raw.some((f) => !isResolvedFragment(f));
if (!needsResolve) continue;
const resolved: unknown[] = [];
for (const f of raw) {
const slug =
typeof f === "string" ? f : ((f as { _slug?: string })?._slug ?? "");
if (!slug) {
if (isResolvedFragment(f)) resolved.push(f);
continue;
}
const frag = await getTextFragmentBySlug(slug, { locale: "de" });
if (frag) {
const fragObj = frag as { tags?: unknown[] };
const tagNames = resolveFragmentTags(fragObj.tags, tagsMap);
resolved.push({ ...fragObj, tags: tagNames });
}
}
(item as { textFragments?: unknown[] }).textFragments = resolved;
}
}
}
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
/**
* RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
* Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
*
* Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests).
* Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec.
* Nach Schema-Änderungen im CMS: `npm run generate:api-types` (RustyCMS muss laufen).
*/
import type { components, operations } from "./cms-api.generated";
import { env } from "$env/dynamic/public";
const getBaseUrl = (): string => {
const url = env.PUBLIC_CMS_URL || import.meta.env.PUBLIC_CMS_URL || "http://localhost:3000";
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
};
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
export type PageEntry = components["schemas"]["page"];
/** Antwort von GET /api/content/page (aus OpenAPI-Spec generiert). */
export type PageListResponse =
operations["listPage"]["responses"][200]["content"]["application/json"];
/** Page-Config (Site-Einstellungen, wie in windwiderstand). Optional: homePage für Startseiten-Slug. */
export type PageConfigEntry = components["schemas"]["page_config"] & {
/** Referenz auf die Page für die Startseite (Slug oder { _slug }). */
homePage?: string | { _slug?: string };
};
export type PageConfigListResponse =
operations["listPageConfig"]["responses"][200]["content"]["application/json"];
export type OpenApiSpec = {
openapi: string;
info: { title: string; version: string };
paths: Record<string, unknown>;
components?: { schemas?: Record<string, unknown> };
};
let openApiCache: OpenApiSpec | null = null;
/**
* TTL-Cache pro Collection (ms). Webhooks purgen bei Mutation → hohe TTL unkritisch.
* Override via PUBLIC_CMS_CACHE_TTL_MS_<COLLECTION> falls nötig.
*/
const CACHE_TTL: Record<string, number> = {
page: 5 * 60_000,
page_config: 10 * 60_000,
navigation: 10 * 60_000,
footer: 10 * 60_000,
post: 60_000,
tag: 5 * 60_000,
text_fragment: 5 * 60_000,
fullwidth_banner: 5 * 60_000,
calendar: 60_000,
calendar_item: 60_000,
translation: 5 * 60_000,
translation_bundle: 5 * 60_000,
openapi: 10 * 60_000,
default: 60_000,
};
type CacheEntry<T> = { value: T; expires: number };
const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
function ttlFor(collection: string): number {
return CACHE_TTL[collection] ?? CACHE_TTL.default;
}
/**
* Cached fetch mit TTL + in-flight dedup. Key-Format: "<collection>:<op>:<params>".
* Bei Fehler wird nichts gecacht.
*/
async function cached<T>(
collection: string,
key: string,
fn: () => Promise<T>,
): Promise<T> {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T;
const pending = inflight.get(key) as Promise<T> | undefined;
if (pending) return pending;
const promise = (async () => {
try {
const value = await fn();
cache.set(key, { value, expires: Date.now() + ttlFor(collection) });
return value;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
return promise;
}
/** Purge Cache für eine Collection (z.B. nach Webhook). */
export function invalidateCollection(collection: string): number {
let n = 0;
for (const k of cache.keys()) {
if (k.startsWith(`${collection}:`)) {
cache.delete(k);
n++;
}
}
return n;
}
/** Purge kompletter Cache. */
export function invalidateAll(): void {
cache.clear();
openApiCache = null;
}
/** Stats/Debug. */
export function cacheStats(): { size: number; keys: string[] } {
return { size: cache.size, keys: [...cache.keys()] };
}
/**
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
* Wird gecacht (einmal pro Request/Build).
*/
export async function fetchOpenApi(): Promise<OpenApiSpec> {
if (openApiCache) return openApiCache;
const base = getBaseUrl();
const res = await fetch(`${base}/api-docs/openapi.json`);
if (!res.ok) {
throw new Error(
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
);
}
const spec = (await res.json()) as OpenApiSpec;
openApiCache = spec;
return spec;
}
/**
* Alle Page-Einträge (GET /api/content/page). TTL-gecacht.
*/
export async function getPages(): Promise<PageEntry[]> {
return cached("page", "page:list:", async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/page`);
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as PageListResponse;
return data.items ?? [];
});
}
/**
* Alle page_config-Einträge (GET /api/content/page_config).
*/
export async function getPageConfigs(options?: {
locale?: string;
per_page?: number;
}): Promise<PageConfigEntry[]> {
const locale = options?.locale ?? "";
const per = options?.per_page ?? 50;
const key = `page_config:list:${locale}:${per}`;
return cached("page_config", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/page_config`);
if (locale) url.searchParams.set("_locale", locale);
url.searchParams.set("_per_page", String(per));
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as PageConfigListResponse;
return (data.items ?? []) as PageConfigEntry[];
});
}
/**
* Page-Config anhand Slug (z. B. "default"). Gecacht pro Request (Key: slug + options).
*/
export async function getPageConfigBySlug(
slug: string,
options?: { locale?: string; resolve?: string },
): Promise<PageConfigEntry | null> {
const opts = options ?? {};
const key = `page_config:slug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
return cached("page_config", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/page_config/${encodeURIComponent(slug)}`,
);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get page_config failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as PageConfigEntry;
});
}
/**
* Startseiten-Slug aus page_config.
*/
export async function getHomePageSlugFromConfig(options?: {
locale?: string;
}): Promise<string | null> {
const config =
(await getPageConfigBySlug("page-config-default", options)) ??
(await getPageConfigBySlug("default", options)) ??
(await getPageConfigs({ locale: options?.locale, per_page: 1 }))[0] ??
null;
if (!config?.homePage) return null;
const raw = config.homePage;
if (typeof raw === "string") return raw.trim() || null;
const slug = (raw as { _slug?: string })?._slug?.trim();
return slug ?? null;
}
/** Optionen für Content-Get (z. B. _resolve, _locale). */
export type ContentGetOptions = {
locale?: string;
/** Felder, deren Referenzen aufgelöst werden (z. B. ["row1Content", "row2Content", "row3Content"]). */
resolve?: string[];
};
/** Führenden Slash entfernen (CMS kann "slug": "/about" liefern). */
function normalizePageSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").trim();
}
/**
* Ein Page-Eintrag nach Slug (GET /api/content/page/:slug).
*/
export async function getPageBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<PageEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `page:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("page", key, async () => {
const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
return fetch(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
if (res.status === 404) {
const items = await getPages();
const norm = normalizePageSlug(slug);
const match = items.find(
(p) =>
normalizePageSlug(p.slug) === norm ||
normalizePageSlug(p._slug) === norm ||
p._slug === slug ||
p.slug === slug,
);
if (match?._slug) {
res = await trySlug(match._slug);
}
}
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get page failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as PageEntry;
});
}
/** Für Page-URLs wird `slug` bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
export async function getPageSlugs(): Promise<string[]> {
const items = await getPages();
return items.map((p) => normalizePageSlug(p.slug ?? p._slug)).filter(Boolean);
}
/** Navigation (Single Source of Truth: RustyCMS OpenAPI). */
export type NavigationEntry = components["schemas"]["navigation"];
/** Ein Eintrag aus navigation.links (Referenz { _slug, _type } oder Slug-String). */
export type NavLinkEntry = NavigationEntry["links"] extends (infer L)[]
? L
: never;
/** Antwort von GET /api/content/navigation (paginiert). */
export type NavigationListResponse =
operations["listNavigation"]["responses"][200]["content"]["application/json"];
/**
* Alle Navigation-Einträge (GET /api/content/navigation). Gecacht pro Request.
*/
export async function getNavigations(options?: {
locale?: string;
page?: number;
per_page?: number;
resolve?: string;
}): Promise<{
items: NavigationEntry[];
page: number;
per_page: number;
total: number;
total_pages: number;
}> {
const opts = options ?? {};
const key = `navigation:list:${opts.locale ?? ""}:${opts.page ?? 1}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
return cached("navigation", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/navigation`);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
url.searchParams.set("_page", String(opts.page ?? 1));
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as NavigationListResponse;
return {
items: data.items ?? [],
page: data.page ?? 1,
per_page: data.per_page ?? 50,
total: data.total ?? 0,
total_pages: data.total_pages ?? 1,
};
});
}
/** Keys für Navigation. */
export const NavigationKeys = {
header: "navigation-header",
socialMedia: "navigation-social-media",
footer: "navigation-footer",
} as const;
/**
* Navigation anhand Key/Slug aus der Liste holen.
*/
export async function getNavigationByKey(
key: string,
options?: { locale?: string; resolve?: string },
): Promise<NavigationEntry | null> {
const { items } = await getNavigations({
locale: options?.locale ?? "de",
per_page: 50,
resolve: options?.resolve,
});
const entry = items.find(
(n) =>
(n as { _slug?: string; internal?: string })._slug === key ||
(n as { _slug?: string; internal?: string }).internal === key,
);
return entry ?? null;
}
/**
* Navigation anhand des Slugs holen.
*/
export async function getNavigationBySlug(
slug: string,
options?: { locale?: string },
): Promise<NavigationEntry | null> {
const byKey = await getNavigationByKey(slug, options);
if (byKey) return byKey;
const key = `navigation:slug:${slug}:${options?.locale ?? ""}`;
return cached("navigation", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as NavigationEntry;
});
}
/** Footer (Single Source of Truth: RustyCMS OpenAPI). */
export type FooterEntry = components["schemas"]["footer"];
/**
* Footer anhand des Slugs holen (z. B. "default").
*/
export async function getFooterBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<FooterEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `footer:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("footer", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/footer/${encodeURIComponent(slug)}`);
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
url.searchParams.set("_resolve", options.resolve.join(","));
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get footer failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as FooterEntry;
});
}
/** Post-Eintrag (Single Source of Truth: RustyCMS OpenAPI). */
export type PostEntry = components["schemas"]["post"];
/** Normalisiert Post-Slug für URL (führenden Slash entfernen). */
function normalizePostSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").trim();
}
/**
* Post nach Slug (GET /api/content/post/:slug).
*/
export async function getPostBySlug(
slug: string,
options?: ContentGetOptions,
): Promise<PostEntry | null> {
const resolveKey = options?.resolve?.join(",") ?? "";
const key = `post:slug:${slug}:${options?.locale ?? ""}:${resolveKey}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
return fetch(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
if (res.status === 404) {
const items = await getPosts();
const norm = normalizePostSlug(slug);
const match = items.find(
(p) =>
normalizePostSlug(p.slug) === norm ||
normalizePostSlug(p._slug) === norm ||
p._slug === slug ||
p.slug === slug,
);
if (match?._slug) {
res = await trySlug(match._slug);
}
}
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get post failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as PostEntry;
});
}
/** Für Post-URLs wird slug bevorzugt (nicht _slug). Führender Slash wird ignoriert. */
export async function getPostSlugs(): Promise<string[]> {
const items = await getPosts();
return items.map((p) => normalizePostSlug(p.slug ?? p._slug)).filter(Boolean);
}
/** Optionen für getPosts (Query-Parameter _sort, _order, _resolve). */
export interface GetPostsOptions {
_sort?: string;
_order?: "asc" | "desc";
/** Referenzen auflösen, z. B. "all" oder ["postImage", "postTag"] */
resolve?: string | string[];
}
/** Alle Post-Einträge (GET /api/content/post). Gecacht pro Request. */
export async function getPosts(
options?: GetPostsOptions,
): Promise<PostEntry[]> {
const opts = options ?? {};
const resolveVal = Array.isArray(opts.resolve)
? opts.resolve.join(",")
: (opts.resolve ?? "");
const key = `post:list:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
return cached("post", key, async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
url.searchParams.set("_per_page", "1000");
if (opts._sort) url.searchParams.set("_sort", opts._sort);
if (opts._order) url.searchParams.set("_order", opts._order);
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
const res = await fetch(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
return data.items ?? [];
});
}
/** Tag-Eintrag (z. B. für Blog-Filter). */
export type TagEntry = components["schemas"]["tag"];
/** Alle Tags (GET /api/content/tag). TTL-gecacht. */
export async function getTags(): Promise<TagEntry[]> {
return cached("tag", "tag:list:", async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/tag`);
if (!res.ok) return [];
const data = (await res.json()) as { items?: TagEntry[] };
return data.items ?? [];
});
}
/** Text-Fragment (z. B. für searchable_text). */
export type TextFragmentEntry = components["schemas"]["text_fragment"];
/** Text-Fragment anhand Slug (GET /api/content/text_fragment/:slug). */
export async function getTextFragmentBySlug(
slug: string,
options?: { locale?: string },
): Promise<TextFragmentEntry | null> {
const key = `text_fragment:slug:${slug}:${options?.locale ?? ""}`;
return cached("text_fragment", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get text_fragment failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as TextFragmentEntry;
});
}
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"];
/** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */
export async function getFullwidthBannerBySlug(
slug: string,
options?: { locale?: string },
): Promise<FullwidthBannerEntry | null> {
const key = `fullwidth_banner:slug:${slug}:${options?.locale ?? ""}`;
return cached("fullwidth_banner", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get fullwidth_banner failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as FullwidthBannerEntry;
});
}
/** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */
export type TranslationEntry = { _slug?: string; text: string };
/** Translation anhand Slug (GET /api/content/translation/:slug). */
export async function getTranslationBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationEntry | null> {
const key = `translation:slug:${slug}:${options?.locale ?? ""}`;
return cached("translation", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as TranslationEntry;
});
}
/** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */
export type TranslationBundleEntry = {
_slug?: string;
strings: Record<string, string>;
};
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
*/
export async function getTranslationBundleBySlug(
slug: string,
options?: { locale?: string },
): Promise<TranslationBundleEntry | null> {
const key = `translation_bundle:slug:${slug}:${options?.locale ?? ""}`;
return cached("translation_bundle", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
);
url.searchParams.set("_resolve", "all");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as TranslationBundleEntry;
});
}
/** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"];
/** Kalender-Item (calendar_item) aus OpenAPI-Spec. */
export type CalendarItemEntry = components["schemas"]["calendar_item"];
/** Kalender anhand Slug (GET /api/content/calendar/:slug). */
export async function getCalendarBySlug(
slug: string,
options?: { locale?: string; resolve?: string },
): Promise<CalendarEntry | null> {
const key = `calendar:slug:${slug}:${options?.locale ?? ""}:${options?.resolve ?? ""}`;
return cached("calendar", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarEntry;
});
}
/** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */
export async function getCalendarItemBySlug(
slug: string,
options?: { locale?: string },
): Promise<CalendarItemEntry | null> {
const key = `calendar_item:slug:${slug}:${options?.locale ?? ""}`;
return cached("calendar_item", key, async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarItemEntry;
});
}
+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>
+38
View File
@@ -0,0 +1,38 @@
/**
* Zentrale Konstanten für die Anwendung.
* URLs, CMS-Resolve-Arrays und Fallback-Werte.
*/
/** Platzhalter-Bild für og:image / twitter:image, wenn weder Page/Post-Bild noch Logo gesetzt ist. */
export const DEFAULT_SOCIAL_IMAGE_URL =
"https://images.ctfassets.net/xjxq6v7l1pfe/43yZHpF9OCnrBlEWHJSZMK/1ef20b265ea1e4e55317812ee5ae6b4c/simple-green-tree-illustrated-watercolor-with-gentle-shading-great-rural-scenery-farm-backdrops-naturethemed-designs-landsca.jpg";
/** Parameter für og:image / twitter:image (über /cms-images + Disk-Cache). */
export const SOCIAL_IMAGE_TRANSFORM = {
width: 200,
height: 200,
fit: "cover" as const,
format: "webp" as const,
};
/** CMS Content-Rows für resolve (Footer, Pages, Posts). */
export const ROW_RESOLVE: string[] = [
"row1Content",
"row2Content",
"row3Content",
];
/** Resolve für Page-Requests: "all" = alle Referenzen inkl. verschachtelt (z. B. image_gallery.images). */
export const PAGE_RESOLVE = ["all"];
/** Resolve für Post-Requests: "all" damit auch img in row1Content/Image-Blöcken aufgelöst wird (src), sonst nur Referenz. */
export const POST_RESOLVE = ["all"];
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
export const SITE_NAME = "Windwiderstand";
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
export const TRANSLATION_BUNDLE_SLUG = "app";
+12
View File
@@ -0,0 +1,12 @@
/**
* Iconify Offline: MDI-Collection für Server und Client registrieren.
* Muss an zwei Stellen importiert werden (Side-Effect-Import), damit
* <Icon icon="mdi:…" /> überall gleich rendert (kein Hydration-Mismatch):
* 1. Layout.astro (--- Block) → läuft beim SSR
* 2. Header.svelte (oder andere frühe client:load-Komponente) → läuft im Browser
* Danach können alle Svelte-Komponenten Icon ohne API nutzen.
*/
import { addCollection } from "@iconify/svelte";
import { icons as mdiIcons } from "@iconify-json/mdi";
addCollection(mdiIcons);
+211
View File
@@ -0,0 +1,211 @@
/**
* Persistenter Disk-Cache für CMS-Bilder.
* Bilder werden einmal vom CMS geholt, auf Disk gespeichert und danach
* direkt von dort ausgeliefert kein erneuter CMS-Request nötig.
*
* Zwei Ebenen: In-Memory (LRU) für häufig angefragte Bilder,
* Disk für Persistenz über Neustarts hinweg.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import { readFile, readdir, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const CACHE_DIR = process.env.IMAGE_CACHE_DIR?.trim() || '.cache/images';
const MAX_MEMORY_ENTRIES = Number(process.env.IMAGE_CACHE_MAX_MEMORY) || 200;
export interface CachedImage {
buffer: Buffer;
contentType: string;
}
export interface ImageTransformParams {
w?: number;
h?: number;
q?: number;
format?: string;
fit?: string;
ar?: string;
}
const memoryCache = new Map<string, CachedImage>();
let resolvedDir: string | null = null;
function ensureCacheDir(): string {
if (resolvedDir) return resolvedDir;
const dir = join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
resolvedDir = dir;
return dir;
}
/**
* Deterministischer Cache-Key aus src + allen Transform-Parametern.
* Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge.
*/
export function buildCacheKey(src: string, params: ImageTransformParams): string {
const parts = [src];
if (params.w != null) parts.push(`w=${params.w}`);
if (params.h != null) parts.push(`h=${params.h}`);
if (params.q != null) parts.push(`q=${params.q}`);
if (params.format) parts.push(`f=${params.format}`);
if (params.fit) parts.push(`fit=${params.fit}`);
if (params.ar) parts.push(`ar=${params.ar}`);
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
}
function extForContentType(ct: string): string {
if (ct.includes('webp')) return 'webp';
if (ct.includes('avif')) return 'avif';
if (ct.includes('png')) return 'png';
if (ct.includes('svg')) return 'svg';
if (ct.includes('gif')) return 'gif';
return 'jpg';
}
/** Content-Type für HTTP-Header aus Dateiendung (eine Datei pro Cache-Eintrag). */
export function contentTypeFromExtension(ext: string): string {
switch (ext.toLowerCase()) {
case 'webp':
return 'image/webp';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'avif':
return 'image/avif';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
default:
return 'image/jpeg';
}
}
const IMAGE_FILE_EXT = new Set(['webp', 'avif', 'png', 'jpg', 'jpeg', 'gif', 'svg']);
function rememberInMemory(key: string, cached: CachedImage): CachedImage {
if (memoryCache.size >= MAX_MEMORY_ENTRIES) {
const oldest = memoryCache.keys().next().value;
if (oldest !== undefined) memoryCache.delete(oldest);
}
memoryCache.set(key, cached);
return cached;
}
/**
* Liest einen Cache-Eintrag: eine Datei `{key}.{webp|jpg|…}` (ein I/O).
* Fallback: altes Paar `.meta` + `.bin`. Optional `params.format`, um die Endung vorab zu probieren.
*/
export async function getCachedImage(
key: string,
params?: ImageTransformParams,
): Promise<CachedImage | null> {
const mem = memoryCache.get(key);
if (mem) return mem;
const dir = ensureCacheDir();
const tryFile = async (ext: string): Promise<CachedImage | null> => {
try {
const buf = await readFile(join(dir, `${key}.${ext}`));
const cached: CachedImage = {
buffer: buf,
contentType: contentTypeFromExtension(ext),
};
return rememberInMemory(key, cached);
} catch {
return null;
}
};
if (params?.format) {
const ext = resolveExtension(params);
const hit = await tryFile(ext);
if (hit) return hit;
if (ext === 'jpg') {
const hitJpeg = await tryFile('jpeg');
if (hitJpeg) return hitJpeg;
}
}
try {
const files = await readdir(dir);
const prefix = `${key}.`;
const imageName = files.find((f) => {
if (!f.startsWith(prefix)) return false;
const rest = f.slice(prefix.length).toLowerCase();
if (rest === 'meta' || rest === 'bin') return false;
return IMAGE_FILE_EXT.has(rest);
});
if (imageName) {
try {
const buf = await readFile(join(dir, imageName));
const ext = imageName.slice(prefix.length);
return rememberInMemory(key, {
buffer: buf,
contentType: contentTypeFromExtension(ext),
});
} catch {
// weiter zu Legacy
}
}
} catch {
// ignore
}
const metaPath = join(dir, `${key}.meta`);
const dataPath = join(dir, `${key}.bin`);
try {
const [contentType, buf] = await Promise.all([
readFile(metaPath, 'utf8'),
readFile(dataPath),
]);
return rememberInMemory(key, { buffer: buf, contentType });
} catch {
return null;
}
}
export async function cacheImage(
key: string,
buffer: ArrayBuffer | Buffer,
contentType: string,
): Promise<CachedImage> {
const dir = ensureCacheDir();
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const ext = extForContentType(contentType);
const cached: CachedImage = { buffer: buf, contentType };
rememberInMemory(key, cached);
const base = join(dir, key);
try {
const files = await readdir(dir);
const prefix = `${key}.`;
for (const f of files) {
if (!f.startsWith(prefix)) continue;
await unlink(join(dir, f)).catch(() => {});
}
} catch {
await unlink(`${base}.meta`).catch(() => {});
await unlink(`${base}.bin`).catch(() => {});
}
await writeFile(join(dir, `${key}.${ext}`), buf);
return cached;
}
/**
* Dateiendung aus den Transform-Parametern oder Content-Type ableiten.
*/
export function resolveExtension(params: ImageTransformParams, contentType?: string): string {
if (params.format) return params.format === 'jpeg' ? 'jpg' : params.format;
if (contentType) return extForContentType(contentType);
return 'jpg';
}
+7
View File
@@ -0,0 +1,7 @@
import { writable } from "svelte/store";
/** true = Backdrop über Content anzeigen (Menü oder Suche offen). */
export const overlayOpen = writable(false);
/** Callback zum Schließen (setzt im Header menuOpen/searchOpen auf false). */
export const overlayClose = writable<(() => void) | null>(null);
+203
View File
@@ -0,0 +1,203 @@
/**
* Bild-URLs für SvelteKit: /cms-images mit persistentem Server-Disk-Cache.
* Der Endpoint holt bei Bedarf vom CMS /api/transform und legt Bytes unter IMAGE_CACHE_DIR ab.
*/
/**
* Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL.
* Für Browser-seitigen Code (kein Node.js/fs nötig).
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
if (typeof field === 'string') {
const s = field.trim();
if (!s) return null;
return s.startsWith('//') ? `https:${s}` : s;
}
if (field && typeof field === 'object') {
const o = field as { src?: string; file?: { url?: string } };
const u =
typeof o.src === 'string' && o.src.trim()
? o.src
: typeof o.file?.url === 'string' && o.file.url.trim()
? o.file.url
: undefined;
if (!u) return null;
return u.startsWith('//') ? `https:${u}` : u;
}
return null;
}
export interface RustyImageTransformParams {
width?: number;
height?: number;
ar?: string;
fit?: 'fill' | 'contain' | 'cover';
format?: 'jpeg' | 'png' | 'webp' | 'avif';
quality?: number;
}
/**
* Lokale Bild-URL: /cms-images?src=…&w=…&q=…
* `src` = volle Bild-URL oder CMS-Asset-Dateiname (wird serverseitig zu /api/assets/… aufgelöst).
*/
export function getCmsImageUrl(src: string, params: RustyImageTransformParams = {}): string {
const searchParams = new URLSearchParams();
searchParams.set('src', src);
if (params.width != null) searchParams.set('w', String(params.width));
if (params.height != null) searchParams.set('h', String(params.height));
if (params.ar != null) searchParams.set('ar', params.ar);
if (params.fit != null) searchParams.set('fit', params.fit);
if (params.format != null) searchParams.set('format', params.format);
if (params.quality != null) searchParams.set('q', String(params.quality));
return `/cms-images?${searchParams.toString()}`;
}
/**
* @deprecated Bevorzuge getCmsImageUrl — gleiche Signatur, liefert /cms-images (Disk-Cache).
*/
export function getTransformUrl(url: string, params: RustyImageTransformParams = {}): string {
return getCmsImageUrl(url, params);
}
/** Alias für Kompatibilität (z. B. rustyastro / Layout). */
export async function ensureTransformedImage(url: string, params: RustyImageTransformParams = {}): Promise<string> {
return getCmsImageUrl(url, params);
}
type ImageArrayItem = string | { src?: string };
function toImageUrl(item: ImageArrayItem): string | null {
if (typeof item === 'string') return item.startsWith('//') ? `https:${item}` : item;
if (item && typeof item === 'object' && typeof item.src === 'string')
return item.src.startsWith('//') ? `https:${item.src}` : item.src;
return null;
}
export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise<string[]> {
return items
.map(toImageUrl)
.filter((u): u is string => u !== null && u.startsWith('http'))
.map((u) => getCmsImageUrl(u, params));
}
export interface RowContentLayoutLike {
row1Content?: unknown[];
row2Content?: unknown[];
row3Content?: unknown[];
}
const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = {
width: 1200,
fit: 'contain',
format: 'webp',
quality: 85,
};
const IMG_SRC_REGEX = /<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
export async function processMarkdownHtmlImages(html: string, _baseUrl?: string, transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS): Promise<string> {
const matches = [...html.matchAll(IMG_SRC_REGEX)];
const replacements: { index: number; length: number; newBlock: string }[] = [];
for (const m of matches) {
const rawSrc = m[2];
const url = rawSrc.startsWith('//') ? `https:${rawSrc}` : rawSrc;
if (!url.startsWith('http')) continue;
const transformedPath = getCmsImageUrl(url, transformParams);
const href = transformedPath;
const newImg = m[0].replace(rawSrc, transformedPath);
const newBlock = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock });
}
if (replacements.length === 0) return html;
replacements.sort((a, b) => b.index - a.index);
let out = html;
for (const r of replacements) {
out = out.slice(0, r.index) + r.newBlock + out.slice(r.index + r.length);
}
return out;
}
export async function resolveContentImages(layout: RowContentLayoutLike, transformParamsOrOptions: RustyImageTransformParams | { transformParams?: RustyImageTransformParams; baseUrl?: string } = {}): Promise<void> {
const options = transformParamsOrOptions && 'baseUrl' in transformParamsOrOptions
? (transformParamsOrOptions as { transformParams?: RustyImageTransformParams; baseUrl?: string })
: { transformParams: transformParamsOrOptions as RustyImageTransformParams };
const transformParams = options.transformParams ?? {};
const rows = [layout.row1Content, layout.row2Content, layout.row3Content].filter((r): r is unknown[] => Array.isArray(r));
const galleryParams: RustyImageTransformParams = {
width: 1280,
fit: 'contain',
format: 'webp',
quality: 80,
...transformParams,
};
const orgLogoParams: RustyImageTransformParams = {
width: 256,
fit: 'contain',
format: 'webp',
quality: 85,
...transformParams,
};
const { marked } = await import('marked');
marked.setOptions({ gfm: true });
for (const content of rows) {
for (const item of content) {
if (typeof item !== 'object' || item === null) continue;
const block = item as {
_type?: string;
content?: string;
image?: unknown;
img?: unknown;
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
organisations?: Array<unknown>;
resolvedImageSrc?: string;
resolvedContent?: string;
};
if (block._type === 'markdown' && typeof block.content === 'string' && block.content.trim()) {
const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html, undefined, {
...MARKDOWN_IMAGE_PARAMS,
...transformParams,
});
continue;
}
if (block._type === 'image') {
const raw = block.image ?? block.img;
const url = absoluteUrlFromCmsImageField(raw);
if (url) {
block.resolvedImageSrc = getCmsImageUrl(url, transformParams);
}
continue;
}
if (block._type === 'image_gallery' && Array.isArray(block.images)) {
for (const img of block.images) {
if (!img || typeof img !== 'object') continue;
const url = absoluteUrlFromCmsImageField(img);
if (url) {
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(url, galleryParams);
}
}
continue;
}
if (block._type === 'organisations' && Array.isArray(block.organisations)) {
for (const org of block.organisations) {
if (!org || typeof org !== 'object') continue;
const o = org as { logo?: unknown; resolvedLogoSrc?: string };
const url = absoluteUrlFromCmsImageField(o.logo);
if (url) {
o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams);
}
}
continue;
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Server-seitiger Cache für /api/transform (Bilder vom CMS).
* Reduziert Aufrufe zum CMS; gleiche Parameter = gleiche Antwort (immutable).
*
* - In-Memory: LRU-artig, begrenzt auf TRANSFORM_CACHE_MAX_ENTRIES (Default 100).
* - Optional Datei-Cache: TRANSFORM_CACHE_DIR setzen (z. B. .cache/transform).
*/
const MAX_ENTRIES = Number(process.env.TRANSFORM_CACHE_MAX_ENTRIES) || 100;
const CACHE_DIR = process.env.TRANSFORM_CACHE_DIR?.trim() || '';
interface CachedImage {
buffer: ArrayBuffer;
contentType: string;
}
const memoryCache = new Map<string, CachedImage>();
/** Cache-Key aus Query-String (eindeutig pro URL + Parameter). */
function cacheKey(params: string): string {
return params;
}
/** In-Memory: ältesten Eintrag entfernen (Map behält Einfüge-Reihenfolge). */
function evictOldest(): void {
const first = memoryCache.keys().next();
if (first.value !== undefined) memoryCache.delete(first.value);
}
/** Prüft, ob Datei-Cache verfügbar ist (nur Server mit fs). */
async function getCacheDir(): Promise<string | null> {
if (!CACHE_DIR) return null;
try {
const { existsSync, mkdirSync } = await import('node:fs');
const { join } = await import('node:path');
const dir = join(process.cwd(), CACHE_DIR);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
/** Sicheren Dateinamen aus Key erzeugen (Hash). */
function fileKey(key: string): string {
let h = 0;
for (let i = 0; i < key.length; i++)
h = (Math.imul(31, h) + key.charCodeAt(i)) | 0;
return Math.abs(h).toString(36) + key.length.toString(36);
}
/** Aus Datei-Cache lesen. */
async function getFromFile(key: string): Promise<CachedImage | null> {
const dir = await getCacheDir();
if (!dir) return null;
try {
const { readFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
const metaPath = join(dir, `${base}.meta`);
const dataPath = join(dir, `${base}.bin`);
const [ct, buf] = await Promise.all([
readFile(metaPath, 'utf8').catch(() => null),
readFile(dataPath).catch(() => null),
]);
if (ct && buf)
return {
buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
contentType: ct,
};
} catch {
// ignore
}
return null;
}
/** In Datei-Cache schreiben. */
async function setInFile(key: string, value: CachedImage): Promise<void> {
const dir = await getCacheDir();
if (!dir) return;
try {
const { writeFile } = await import('node:fs/promises');
const { join } = await import('node:path');
const base = fileKey(key);
await Promise.all([
writeFile(join(dir, `${base}.meta`), value.contentType),
writeFile(join(dir, `${base}.bin`), Buffer.from(value.buffer)),
]);
} catch {
// ignore
}
}
/**
* Transform-Ergebnis aus Cache holen (Memory zuerst, dann Datei).
*/
export async function getCachedTransform(params: string): Promise<CachedImage | null> {
const key = cacheKey(params);
const mem = memoryCache.get(key);
if (mem) return mem;
const file = await getFromFile(key);
if (file) {
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, file);
return file;
}
return null;
}
/**
* Transform-Ergebnis cachen (Memory + optional Datei).
*/
export async function setCachedTransform(
params: string,
buffer: ArrayBuffer,
contentType: string,
): Promise<void> {
const key = cacheKey(params);
const value: CachedImage = { buffer, contentType };
if (memoryCache.size >= MAX_ENTRIES) evictOldest();
memoryCache.set(key, value);
await setInFile(key, value);
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Zentrale Übersetzungen (i18n-ähnlich).
* Quelle: CMS translation_bundle (API, hooks.server.ts setzt event.locals.translations).
* Wird in +layout.server.ts geladen und über TranslationProvider/Svelte-Context bereitgestellt.
*/
import { getContext } from "svelte";
export type Translations = Record<string, string>;
/** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */
export const T_CONTEXT_KEY = Symbol("translations.t");
/** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */
export type TranslationReplacements = Record<string, string | number>;
/** t(key) oder t(key, { placeholder: value }) mit optionalen Ersetzungen für {{placeholder}}. */
export type TFunction = (
key: string,
replacements?: TranslationReplacements,
) => string;
/**
* Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}.
*/
export function replacePlaceholders(
text: string,
replacements?: TranslationReplacements | null,
): string {
if (!replacements || typeof text !== "string") return text;
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
const val = replacements[name];
return val !== undefined && val !== null ? String(val) : `{{${name}}}`;
});
}
/**
* t-Funktion aus Svelte-Context holen.
* Zuverlässig innerhalb des TranslationProvider-Baums.
*/
export function useTranslate(): TFunction {
const fromContext = getContext<TFunction | undefined>(T_CONTEXT_KEY);
if (fromContext) return fromContext;
return (key: string, replacements?: TranslationReplacements): string => {
return replacePlaceholders(key, replacements);
};
}
/** Bekannte Übersetzungs-Keys nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */
const TRANSLATION_KEYS = [
"searchable_text_help",
"searchable_text_help_aria",
"searchable_text_placeholder",
"searchable_text_search_aria",
"searchable_text_clear_search",
"searchable_text_tag_all",
"searchable_text_no_results",
"searchable_text_count_all",
"searchable_text_count_filtered",
"searchable_text_copy_aria",
"searchable_text_copy_button",
"searchable_text_copied",
"searchable_text_untitled",
"footer_copyright",
"nav_start",
"nav_posts",
"nav_home",
"header_search_open",
"header_search_close",
"header_menu_open",
"header_menu_close",
"header_nav_aria",
"header_overlay_close",
"header_social_aria",
"site_name_fallback",
"pagination_prev",
"pagination_next",
"blog_tag_all",
"blog_filter_aria",
"blog_count",
"page_404_title",
"page_404_text",
"page_404_back",
"posts_title",
"posts_subtitle",
"posts_tag_title",
"posts_tag_subtitle",
"posts_cms_error",
"iframe_activate_aria",
"iframe_missing",
"image_gallery_swipe_aria",
"image_gallery_prev_aria",
"image_gallery_next_aria",
"image_gallery_position_aria",
"image_gallery_slide_aria",
"image_gallery_empty",
"youtube_missing",
"youtube_title_fallback",
"calendar_prev_month",
"calendar_next_month",
"calendar_weekday_mo",
"calendar_weekday_di",
"calendar_weekday_mi",
"calendar_weekday_do",
"calendar_weekday_fr",
"calendar_weekday_sa",
"calendar_weekday_so",
"calendar_more_info",
"calendar_no_events",
"calendar_time_suffix",
"calendar_select_day",
"calendar_next_events",
"calendar_show_more",
"calendar_show_less",
"post_overview_all",
"blog_search_label",
"blog_search_placeholder",
"blog_search_clear",
"blog_year_label",
"blog_year_all",
"blog_upcoming_events",
"blog_results_count",
"blog_results_for",
"blog_results_in_year",
"blog_filter_reset",
"blog_no_results",
"blog_rss_subscribe",
"post_map",
"post_map_activate",
"post_map_open_osm",
"post_comments",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
/** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */
export const T: { [K in TranslationKey]: K } = Object.fromEntries(
TRANSLATION_KEYS.map((k) => [k, k]),
) as { [K in TranslationKey]: K };
/**
* Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}.
* Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind).
*/
export function t(
translations: Translations | null | undefined,
key: string,
replacements?: TranslationReplacements,
): string {
if (!key) return key;
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
}
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Variant = 'default' | 'primary' | 'accent' | 'inactive' | 'date';
let {
label = '',
variant = 'default',
href = null as string | null,
active = false,
onclick = null as (() => void) | null,
} = $props();
const baseClass =
'inline-flex shrink-0 whitespace-nowrap rounded-full py-1 px-2.5 text-[0.6875rem] font-medium transition-all ring-1 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1';
const variantClasses = $derived(
{
default: 'bg-stein-0 text-stein-800 ring-stein-200 hover:bg-stein-50',
primary: 'bg-wald-500 text-white ring-wald-600/30 hover:bg-wald-600',
accent: 'bg-himmel-400 text-white ring-himmel-500/30 hover:bg-himmel-500',
inactive: 'bg-stein-100 text-stein-500 ring-stein-200',
date: 'bg-wald-600 text-white ring-wald-700/40',
}[variant]
);
const shadowClass = $derived(
href || onclick
? variant === 'date'
? 'shadow-[0_0_6px_rgba(0,0,0,0.25)]'
: 'shadow-[0_2px_6px_rgba(0,0,0,0.12)]'
: ''
);
const classes = $derived(
`${baseClass} ${variantClasses} ${shadowClass} ${active ? 'pointer-events-none ring-2' : ''}`
);
</script>
{#if href}
<a
href={href}
class={classes}
aria-current={active ? 'true' : undefined}
>
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
aria-current={active ? 'true' : undefined}
onclick={onclick}
>
{label}
</button>
{:else}
<span class={classes}>{label}</span>
{/if}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
/** @type {{ href?: string; label: string }[]} */
let { items = [] } = $props();
</script>
<nav aria-label="Breadcrumb" class="overflow-x-auto">
<ol class="list-none flex flex-nowrap items-center gap-x-1 text-xs py-4 pl-0 m-0!">
{#each items as item, i}
<li class="flex items-center gap-x-1.5 shrink-0 whitespace-nowrap">
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="inline-flex font-medium no-underline items-center gap-1.5 text-stein-500 hover:text-wald-600 transition-colors duration-150 rounded px-1 -mx-1 py-0.5 -my-0.5"
aria-label={i === 0 ? item.label : undefined}
>
{#if i === 0}
<Icon icon="mdi:home" class="size-4 shrink-0" aria-hidden="true" />
{:else}
<span>{item.label}</span>
{/if}
</a>
{:else}
<span
class="font-medium text-stein-800"
aria-current={i === items.length - 1 ? 'page' : undefined}
>
{item.label}
</span>
{/if}
{#if i < items.length - 1}
<span aria-hidden="true" class="shrink-0 text-stein-300">
<Icon icon="mdi:chevron-right" class="size-3.5" />
</span>
{/if}
</li>
{/each}
</ol>
</nav>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
} = $props();
const base =
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else}
<slot>{label}</slot>
{/if}
</button>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
name = '',
value = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="checkbox"
bind:checked
{disabled}
{name}
{value}
class="h-5 w-5 rounded border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/** Gleicher name für alle Radios in einer Gruppe; bind:group vom Parent hält den gewählten value. */
let {
name = '',
value = '',
/** Gebundener Wert der Gruppe entspricht dem value des ausgewählten Radios. */
group = $bindable(''),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="radio"
{name}
{value}
bind:group={group}
{disabled}
class="h-5 w-5 rounded-full border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
/** @type {{ value: string; label: string }[]} */
let {
options = [],
value = $bindable(''),
label = '',
placeholder = 'Bitte wählen',
disabled = false,
} = $props();
const selectId = $derived(`select-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<label
for={selectId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
</label>
{/if}
<div class="relative">
<select
id={selectId}
bind:value
{disabled}
class="h-12 w-full appearance-none rounded-lg border border-stein-300 bg-white px-4 py-3 pr-10 text-base text-stein-800 outline-none hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100 disabled:bg-stein-100 disabled:opacity-60"
>
<option value="" disabled>{placeholder}</option>
{#each options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span
class="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stein-500"
aria-hidden="true"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* @type {{ label: string; links: { href: string; label: string; active?: boolean }[] }[]}
*/
let { groups = [] } = $props();
</script>
<nav aria-label="Seitennavigation" class="w-60 bg-stein-50 p-4">
{#each groups as group, i}
<div class="{i > 0 ? 'mt-6' : ''}">
<p
class="mb-2 text-xs font-medium uppercase tracking-wider text-stein-500"
>
{group.label}
</p>
<ul class="space-y-0">
{#each group.links as link}
<li>
<a
href={link.href}
class="block rounded-lg px-3 py-2 text-sm {link.active
? 'bg-wald-50 font-medium text-wald-500'
: 'text-stein-700 hover:bg-wald-50'}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
{/each}
</nav>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let {
min = 0,
max = 100,
value = $bindable(50),
label = '',
disabled = false,
} = $props();
const inputId = $derived(`slider-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<div class="mb-1.5 flex items-center justify-between">
<label for={inputId} class="text-sm font-medium text-stein-700">
{label}
</label>
<span class="text-sm font-semibold text-stein-700">{value}</span>
</div>
{/if}
<input
type="range"
id={inputId}
{min}
{max}
bind:value
{disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
class="h-5 w-5 accent-wald-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-wald-500 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-stein-200"
/>
<div class="mt-1 flex justify-between text-xs text-stein-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/** @type {{ id: string; label: string }[]} */
let {
tabs = [],
selectedId = $bindable(''),
labelledBy = '',
} = $props();
$effect(() => {
if (tabs.length && !selectedId) selectedId = tabs[0].id;
});
function selectTab(id: string) {
selectedId = id;
}
function handleKeydown(e: KeyboardEvent) {
const idx = tabs.findIndex((t) => t.id === selectedId);
if (e.key === 'ArrowRight' && idx < tabs.length - 1) {
e.preventDefault();
selectedId = tabs[idx + 1].id;
} else if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault();
selectedId = tabs[idx - 1].id;
}
}
</script>
<div
role="tablist"
aria-label={labelledBy || 'Tabs'}
class="flex h-12 border-b border-stein-200"
onkeydown={handleKeydown}
>
{#each tabs as tab}
<button
type="button"
role="tab"
aria-selected={selectedId === tab.id}
aria-controls="panel-{tab.id}"
id="tab-{tab.id}"
class="border-b-2 px-4 text-sm font-medium transition-colors {selectedId === tab.id
? 'border-wald-500 text-wald-600 font-semibold'
: 'border-transparent text-stein-500 hover:text-stein-700'}"
tabindex={selectedId === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<input
{type}
{name}
{placeholder}
{required}
{disabled}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
rows = 4,
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<textarea
{name}
{placeholder}
{required}
{disabled}
{rows}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center gap-2 {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<span
class="relative inline-block h-6 w-11 shrink-0 rounded-full transition-all duration-200 {checked ? 'bg-wald-500' : 'bg-stein-300'}"
role="switch"
aria-checked={checked}
>
<input
type="checkbox"
class="sr-only"
bind:checked
{disabled}
onkeydown={(e) => e.key === ' ' && (checked = !checked)}
/>
<span
class="absolute top-0.5 inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 {checked ? 'left-[22px] translate-x-0.5' : 'left-0.5'}"
/>
</span>
{#if label}
<span class="text-base text-stein-700">{label}</span>
{/if}
</label>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
content,
placement: preferredPlacement = "top",
children,
}: { content: string; placement?: "top" | "bottom"; children?: Snippet } = $props();
const TOOLTIP_GAP = 8;
const TOOLTIP_MAX_WIDTH_PX = 352; // 22rem
let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left
function updatePosition() {
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top;
const spaceBelow = vh - rect.bottom;
placement =
preferredPlacement === "top"
? spaceAbove >= spaceBelow
? "top"
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max(
TOOLTIP_GAP,
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
}
function handleShow() {
updatePosition();
showTooltip = true;
}
function handleHide() {
showTooltip = false;
leftPx = null;
}
const placementClasses = $derived(
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2",
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
);
</script>
<div
bind:this={wrapperEl}
role="group"
class="relative inline-flex focus-within:outline-none"
onmouseenter={handleShow}
onmouseleave={handleHide}
onfocusin={handleShow}
onfocusout={handleHide}
>
{@render children?.()}
{#if showTooltip}
<div
role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-md shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle}
>
{content}
</div>
{/if}
</div>
+16
View File
@@ -0,0 +1,16 @@
/**
* Windwiderstand UI components
*/
export { default as TabBar } from './TabBar.svelte';
export { default as SidebarNav } from './SidebarNav.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as Button } from './Button.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as Radio } from './Radio.svelte';
export { default as Slider } from './Slider.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Tooltip } from './Tooltip.svelte';