Add Formbricks configuration to environment files and update package dependencies
Deploy to Firebase Hosting / deploy (push) Failing after 1m26s

- Introduced new environment variables for Formbricks in .env.backup and .env.example.
- Updated package.json and yarn.lock to include React and related types for improved functionality.
- Added new favicon images and manifest for better branding and user experience.
- Implemented new Svelte components for enhanced content management, including Badge, Card, OrganisationsBlock, and OpnFormBlock.
- Updated ContentRows component to support new block types.
This commit is contained in:
Peter Meier
2026-04-05 20:50:23 +02:00
parent b40579ee0b
commit f5f6beeabe
66 changed files with 2788 additions and 280 deletions
+4
View File
@@ -0,0 +1,4 @@
PUBLIC_CMS_URL=http://localhost:3000
PUBLIC_FORMBRICKS_APP_URL=https://forms.pm86.de
PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cmnlgrjle0008mr01k018oaiz
+4
View File
@@ -13,4 +13,8 @@ PUBLIC_CMS_URL=http://localhost:3000
# PUBLIC_UMAMI_SCRIPT_URL=https://eu.umami.is/script.js
# PUBLIC_UMAMI_WEBSITE_ID=your-website-id
# Formbricks Website/App Surveys (beide setzen → SDK lädt, vgl. https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#html )
# PUBLIC_FORMBRICKS_APP_URL=https://forms.pm86.de
# PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
# Firebase: Projekt in .firebaserc anpassen oder: firebase use <project-id>
+10
View File
@@ -18,6 +18,14 @@ on:
description: "PUBLIC_SITE_URL"
required: false
default: "https://windwiderstand.de"
public_formbricks_app_url:
description: "PUBLIC_FORMBRICKS_APP_URL (leer = kein Formbricks)"
required: false
default: "https://forms.pm86.de"
public_formbricks_environment_id:
description: "PUBLIC_FORMBRICKS_ENVIRONMENT_ID"
required: false
default: "cmnlgrjle0008mr01k018oaiz"
jobs:
deploy:
@@ -41,6 +49,8 @@ jobs:
env:
PUBLIC_CMS_URL: ${{ inputs.public_cms_url }}
PUBLIC_SITE_URL: ${{ inputs.public_site_url }}
PUBLIC_FORMBRICKS_APP_URL: ${{ inputs.public_formbricks_app_url || 'https://forms.pm86.de' }}
PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ inputs.public_formbricks_environment_id || 'cmnlgrjle0008mr01k018oaiz' }}
run: yarn build
- name: Deploy to Firebase
+5
View File
@@ -27,6 +27,9 @@
"astro": "^6.1.1",
"json5": "^2.2.3",
"marked": "^17.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-photo-album": "^3.5.1",
"svelte": "^5.51.2",
"tailwindcss": "^4.1.18"
},
@@ -35,6 +38,8 @@
"@storybook/svelte": "10",
"@storybook/svelte-vite": "10",
"@types/node": "^22.10.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro-icon": "^1.1.5",
"firebase": "^11.0.1",
"openapi-typescript": "^7.4.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/favicons/ms-icon-70x70.png" />
<square150x150logo src="/favicons/ms-icon-150x150.png" />
<square310x310logo src="/favicons/ms-icon-310x310.png" />
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+46
View File
@@ -0,0 +1,46 @@
{
"name": "Windwiderstand",
"short_name": "Windwiderstand",
"start_url": "/",
"display": "browser",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/favicons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75"
},
{
"src": "/favicons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0"
},
{
"src": "/favicons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5"
},
{
"src": "/favicons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0"
},
{
"src": "/favicons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0"
},
{
"src": "/favicons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
href,
class: className = "",
}: { children?: Snippet; href?: string; class?: string } = $props();
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm";
const interactive = href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "";
</script>
{#if href}
<a {href} class="{base} {interactive} {className}">
{@render children?.()}
</a>
{:else}
<div class="{base} {className}">
{@render children?.()}
</div>
{/if}
+8
View File
@@ -11,6 +11,8 @@
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";
@@ -29,6 +31,8 @@
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
} from "../lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
@@ -116,6 +120,10 @@
<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>
+112 -30
View File
@@ -7,9 +7,20 @@
import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations";
interface NavLink {
href: string;
label: string;
type NavChild = { href: string; label: string };
/** Flacher Link oder Navgroup (nur Label + Unterlinks, optional eigener Parent-href). */
type NavItem =
| NavChild
| { label: string; href?: string; children: NavChild[] };
function isNavGroup(
item: NavItem,
): item is { label: string; href?: string; children: NavChild[] } {
return (
"children" in item &&
Array.isArray(item.children) &&
item.children.length > 0
);
}
interface SocialLink {
@@ -22,11 +33,14 @@
links = [],
socialLinks = [],
logoUrl = null,
logoSvgHtml = null,
translations = {},
}: {
links?: NavLink[];
links?: NavItem[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
/** Inline-SVG-Markup (z. B. aus Layout per fetch); hat Vorrang vor logoUrl. */
logoSvgHtml?: string | null;
translations?: Translations | null;
} = $props();
@@ -89,15 +103,20 @@
<header
id="horizontal-navigation"
class="sticky top-0 z-20 backdrop-blur bg-stein-900/95 shadow-2xl"
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 text-stein-0 font-bold text-lg tracking-wide hover:text-wald-100 transition-colors relative"
class="logo logo--color-cycle flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
>
{#if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-12 w-auto object-contain" />
{#if logoSvgHtml && logoSvgHtml.trim() !== ""}
<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}
@@ -108,21 +127,55 @@
class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="text-stein-100 hover:text-wald-100 hover:animate-pulse transition-all text-xs tracking-wide"
>
{link.label}
</a>
{#each links as item}
{#if isNavGroup(item)}
<details class="relative group">
<summary
class="cursor-pointer list-none text-xs tracking-wide hover:animate-pulse transition-all [&::-webkit-details-marker]:hidden"
>
{item.label}
</summary>
<ul
class="absolute left-0 top-full z-30 mt-1 min-w-48 rounded-md border border-stein-200/80 bg-linear-to-br from-stein-50 to-himmel-50 py-1 shadow-lg"
>
{#if item.href}
<li>
<a
href={item.href}
class="block px-3 py-2 text-xs tracking-wide hover:bg-stein-200/50"
>
{item.label}
</a>
</li>
{/if}
{#each item.children as child}
<li>
<a
href={child.href}
class="block px-3 py-2 text-xs tracking-wide hover:bg-stein-200/50"
>
{child.label}
</a>
</li>
{/each}
</ul>
</details>
{:else}
<a
href={item.href}
class=" hover:animate-pulse transition-all text-xs tracking-wide"
>
{item.label}
</a>
{/if}
{/each}
<div><span class="text-stein-100">|</span></div>
<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 text-stein-100 hover:text-wald-100 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
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" />
@@ -130,7 +183,7 @@
{/each}
<button
type="button"
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
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)}
@@ -141,7 +194,7 @@
{#if searchOpen}
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else}
<Icon icon="mdi:magnify" class="text-wald-400!" aria-hidden="true" />
<Icon icon="mdi:magnify" aria-hidden="true" />
{/if}
</button>
</nav>
@@ -150,7 +203,7 @@
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
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)}
@@ -167,14 +220,14 @@
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl text-wald-400!"
class="text-2xl"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
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}
@@ -207,14 +260,42 @@
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 links as item}
{#if isNavGroup(item)}
<span
class="block py-2 px-2 text-xs font-medium uppercase tracking-wide text-stein-400"
>
{item.label}
</span>
<div class="ml-2 flex flex-col border-l border-stein-0/20 pl-3">
{#if item.href}
<a
href={item.href}
class="block py-2 px-2 text-sm text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
onclick={closeMenu}
>
{item.label}
</a>
{/if}
{#each item.children as child}
<a
href={child.href}
class="block py-2 px-2 text-sm text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
onclick={closeMenu}
>
{child.label}
</a>
{/each}
</div>
{:else}
<a
href={item.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}
>
{item.label}
</a>
{/if}
{/each}
{#if socialLinks.length > 0}
<div
@@ -242,3 +323,4 @@
</div>
{/if}
</header>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { OrganisationEntry } from "../lib/block-types";
let { org }: { org: OrganisationEntry } = $props();
const link = $derived(typeof org.link === "object" ? org.link : null);
const badge = $derived(typeof org.badge === "object" ? org.badge : null);
function badgeClasses(color?: string): string {
switch (color) {
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>
<div class="flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2">
<h5 class="text-base font-semibold text-stein-900 leading-snug">{org.name ?? ""}</h5>
{#if badge?.label}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {badgeClasses(badge.color)}">
{badge.label}
</span>
{/if}
</div>
{#if org.description}
<p class="text-sm text-stein-600">{org.description}</p>
{/if}
{#if link?.url}
<a
href={link.url}
class="no-underline mt-auto inline-flex items-center gap-1 text-sm font-medium text-himmel-600 hover:text-himmel-700 transition-colors"
>
{link.linkName ?? link.url}
<span aria-hidden="true"></span>
</a>
{/if}
</div>
+100 -126
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MasonryPhotoAlbum, type Photo } from "react-photo-album";
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 "react-photo-album/masonry.css";
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
@@ -28,7 +30,14 @@
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
);
let currentIndex = $state(0);
/** Fallback, solange keine echten Pixelmaße bekannt sind (CMS liefert keine width/height). */
const DEFAULT_W = 1600;
const DEFAULT_H = 900;
let galleryPhotos = $state<Photo[]>([]);
let albumHost: HTMLDivElement | undefined = $state();
let reactRoot: Root | null = null;
function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
@@ -36,68 +45,98 @@
return null;
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
function metaToPhotos(
meta: { img: ImageGalleryImage; url: string }[],
dims: { w: number; h: number }[],
): Photo[] {
return meta.map(({ img, url }, i) => ({
src: url,
width: Math.max(1, dims[i]?.w ?? DEFAULT_W),
height: Math.max(1, dims[i]?.h ?? DEFAULT_H),
alt: img.description ?? "",
title: img.description ?? undefined,
}));
}
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
function loadImageSize(url: string): Promise<{ w: number; h: number }> {
return new Promise((resolve) => {
const im = new Image();
im.onload = () =>
resolve({
w: im.naturalWidth || DEFAULT_W,
h: im.naturalHeight || DEFAULT_H,
});
im.onerror = () => resolve({ w: DEFAULT_W, h: DEFAULT_H });
im.src = url;
});
}
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();
$effect(() => {
const meta = withUrl;
if (meta.length < 2) {
galleryPhotos = [];
return;
}
isDragging = false;
dragDelta = 0;
startX = 0;
const fallback = meta.map(() => ({ w: DEFAULT_W, h: DEFAULT_H }));
galleryPhotos = metaToPhotos(meta, fallback);
if (typeof window === "undefined") return;
let cancelled = false;
void Promise.all(meta.map(({ url }) => loadImageSize(url))).then((dims) => {
if (cancelled) return;
galleryPhotos = metaToPhotos(meta, dims);
});
return () => {
cancelled = true;
};
});
function renderAlbum() {
if (!reactRoot || galleryPhotos.length < 2) return;
reactRoot.render(
createElement(MasonryPhotoAlbum, {
photos: galleryPhotos,
defaultContainerWidth: 1180,
columns: (containerWidth: number) => {
if (containerWidth < 480) return 2;
if (containerWidth < 800) return 3;
return 4;
},
spacing: 8,
padding: 0,
sizes: {
size: "min(1180px, 100vw - 2rem)",
sizes: [{ viewport: "(max-width: 1280px)", size: "calc(100vw - 2rem)" }],
},
}),
);
}
function onTouchStart(e: TouchEvent) {
if (withUrl.length <= 1) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
onMount(() => {
if (withUrl.length < 2) return;
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();
}
let cancelled = false;
void tick().then(() => {
if (cancelled || !albumHost) return;
reactRoot = createRoot(albumHost);
renderAlbum();
});
function onTouchEnd() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
return () => {
cancelled = true;
reactRoot?.unmount();
reactRoot = null;
};
});
$effect(() => {
galleryPhotos;
if (withUrl.length < 2 || !reactRoot) return;
renderAlbum();
});
</script>
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
@@ -122,74 +161,9 @@
{/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 }}
>
<img
src={current.url}
alt={current.img.description ?? ""}
class="w-full h-full object-cover"
loading={currentIndex === 0 ? "eager" : "lazy"}
/>
{#if current.img.description}
<figcaption
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-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>
<div
bind:this={albumHost}
class="react-photo-album-host w-full min-h-48 [&_.react-photo-album]:max-w-none"
></div>
{/if}
</div>
+81
View File
@@ -0,0 +1,81 @@
<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",
);
/**
* Wie im OpnForm-Embed: initEmbed erst wenn iframe.min.js fertig geladen ist
* (entspricht <script … onload="initEmbed('…', { autoResize: true })">).
* Zusätzlich min-height, weil das Widget oft ~150px inline setzt, bevor autoResize greift.
*/
const iframeStyle =
"border: none; width: 100%; min-height: min(88vh, 48rem) !important;";
onMount(() => {
if (!formId) return;
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
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 });
}
return;
}
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);
});
</script>
<div
class="{layoutClasses} content-opnform w-full min-w-0 overflow-visible -mx-6"
data-block-type="opnform"
data-block-slug={block._slug}
>
{#if iframeSrc}
<iframe
style={iframeStyle}
id={formId}
title={iframeTitle}
src={iframeSrc}
></iframe>
{:else}
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
{/if}
</div>
@@ -0,0 +1,200 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "../../lib/block-types";
import { absoluteUrlFromCmsImageField } from "../../lib/cms-media-url";
import Card from "../Card.svelte";
import Badge from "../Badge.svelte";
marked.setOptions({ gfm: true });
/** Nach `resolveContentImages`: `resolvedLogoSrc`; sonst gleiche URL-Auflösung wie Image-Blöcke. */
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
const resolved = o.resolvedLogoSrc?.trim();
if (resolved) return resolved;
return absoluteUrlFromCmsImageField(o.logo) ?? undefined;
}
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)
: ""
);
</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">
<div class="flex flex-wrap items-center gap-2">
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<Badge color={o.badge.color}>{o.badge.label}</Badge>
{/if}
</div>
{#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="text-base font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-sm text-stein-600">{o.description}</p>
{/if}
</Card>
{/if}
{/if}
{/each}
{#if block.addOrganisationTitle || block.addOrganisationMarkdown}
<Card
class={compact
? "border-dashed border-wald-300 bg-wald-50 p-3! gap-1.5!"
: "border-dashed border-wald-300 bg-wald-50"}
>
{#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}
</Card>
{/if}
</div>
</div>
+4
View File
@@ -4,6 +4,10 @@ interface ImportMetaEnv {
readonly PUBLIC_CMS_URL: string;
readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
/** Formbricks-Instanz (ohne trailing slash), z. B. https://forms.pm86.de */
readonly PUBLIC_FORMBRICKS_APP_URL?: string;
/** Formbricks Environment ID (Setup Checklist) */
readonly PUBLIC_FORMBRICKS_ENVIRONMENT_ID?: string;
}
interface ImportMeta {
+231 -34
View File
@@ -81,12 +81,12 @@ const resolvedBreadcrumbItems =
: (item.label ?? ""),
})) ?? [];
// Header: Navigation laden und Links bauen
interface NavLink {
href: string;
label: string;
}
let headerLinks: NavLink[] = [];
// Header: Navigation (flache Links + navgroup-Untermenüs, vgl. angelverein-vachdorf.de Layout.astro)
type HeaderNavChild = { href: string; label: string };
type HeaderNavItem =
| HeaderNavChild
| { label: string; href?: string; children: HeaderNavChild[] };
let headerNavItems: HeaderNavItem[] = [];
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
function normalizeSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").trim();
@@ -96,6 +96,49 @@ function getSlugFromEntry(entry: NavLinkEntry): string {
const e = entry as { _slug?: string; slug?: string };
return e.slug ?? e._slug ?? "";
}
function normalizeNavHref(url: string): string {
return url.startsWith("/") || url.startsWith("http")
? url
: `/${url.replace(/^\//, "")}`;
}
/** CMS-Kanon-Slug ohne führenden Slash (z. B. bi/vachdorf) → URL-Pfad. */
function pageSlugToPath(canonicalSlug: string): string {
const s = (canonicalSlug ?? "").trim();
if (!s || s === "home") return "/";
return (
"/" +
s.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
.join("/")
);
}
function resolveNavEntryToChild(
entry: NavLinkEntry,
slugToLabel: Map<string, string>,
slugToPageHref: Map<string, string>,
slugToPostHref: Map<string, string>,
): HeaderNavChild | null {
const asObj =
typeof entry === "object" && entry !== null
? (entry as { url?: string; linkName?: string; name?: string })
: null;
if (asObj?.url && String(asObj.url).trim() !== "") {
const href = normalizeNavHref(String(asObj.url).trim());
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? "";
return { href, label };
}
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || "home";
const label = slugToLabel.get(slug) ?? slug;
const postHref = slugToPostHref.get(slug);
const pageSlug = slugToPageHref.get(slug) ?? slug;
const hrefSlug = normalizeSlug(pageSlug) || "home";
const href =
postHref ??
(hrefSlug === "home" ? "/" : pageSlugToPath(hrefSlug));
return { href, label };
}
try {
const nav = await getNavigationByKey(NavigationKeys.header, {
locale: "de",
@@ -138,29 +181,51 @@ try {
}
}
for (const entry of rawLinks) {
const asObj =
const asRecord =
typeof entry === "object" && entry !== null
? (entry as { url?: string; linkName?: string; name?: string })
? (entry as Record<string, unknown>)
: null;
if (asObj?.url) {
const href =
asObj.url.startsWith("/") || asObj.url.startsWith("http")
? asObj.url
: `/${asObj.url.replace(/^\//, "")}`;
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? "";
headerLinks.push({ href, label });
const rawLinksInner = asRecord?.links;
const isNavGroup =
asRecord &&
typeof asRecord.label === "string" &&
Array.isArray(rawLinksInner) &&
rawLinksInner.length > 0 &&
typeof rawLinksInner[0] === "object" &&
rawLinksInner[0] !== null;
if (isNavGroup) {
const children: HeaderNavChild[] = [];
for (const sub of rawLinksInner as unknown[]) {
const child = resolveNavEntryToChild(
sub as NavLinkEntry,
slugToLabel,
slugToPageHref,
slugToPostHref,
);
if (child) children.push(child);
}
if (children.length === 0) continue;
const urlRaw = asRecord.url;
const parentHref =
typeof urlRaw === "string" && urlRaw.trim() !== ""
? normalizeNavHref(urlRaw.trim())
: undefined;
headerNavItems.push({
label: asRecord.label as string,
...(parentHref ? { href: parentHref } : {}),
children,
});
continue;
}
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || "home";
const label = slugToLabel.get(slug) ?? slug;
const postHref = slugToPostHref.get(slug);
const pageSlug = slugToPageHref.get(slug) ?? slug;
const hrefSlug = normalizeSlug(pageSlug) || "home";
const href =
postHref ??
(hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`);
headerLinks.push({ href, label });
const flat = resolveNavEntryToChild(
entry,
slugToLabel,
slugToPageHref,
slugToPostHref,
);
if (flat) headerNavItems.push(flat);
}
} catch {
// CMS nicht erreichbar
@@ -215,6 +280,8 @@ try {
// Logo und SEO-Templates aus page_config (wie windwiderstand).
let logoUrl: string | null = null;
/** Bei SVG: serverseitig geladener Markup für Inline-Render (currentColor statt img-Tag). */
let logoSvgHtml: string | null = null;
let pageConfig: Awaited<ReturnType<typeof getPageConfigBySlug>> | null = null;
try {
// Zuerst page-config-default (häufiger Slug), dann default vermeidet doppelten 404
@@ -244,11 +311,26 @@ try {
typeof logoFileUrl === "string" && logoFileUrl.startsWith("//")
? `https:${logoFileUrl}`
: logoFileUrl;
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: "contain",
format: "webp",
});
const isSvg = u.split("?")[0].toLowerCase().endsWith(".svg");
if (isSvg) {
// SVGs können nicht transformiert werden — direkt als URL nutzen
logoUrl = u.startsWith("/") ? `${import.meta.env.PUBLIC_CMS_URL}${u}` : u;
try {
const res = await fetch(logoUrl);
if (res.ok) {
const text = (await res.text()).trim();
if (text.includes("<svg")) logoSvgHtml = text;
}
} catch {
/* z. B. Build ohne erreichbares CMS → Fallback img mit logoUrl */
}
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: "contain",
format: "webp",
});
}
}
} catch {
/* Logo optional */
@@ -376,11 +458,89 @@ const cmsFromCache = getCmsFromCache();
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#2d7a45" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicons/favicon-16x16.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/favicons/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/favicons/android-icon-192x192.png"
/>
<link
rel="apple-touch-icon"
sizes="57x57"
href="/favicons/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/favicons/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/favicons/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/favicons/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/favicons/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/favicons/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/favicons/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/favicons/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicons/apple-icon-180x180.png"
/>
<link rel="manifest" href="/favicons/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/favicons/ms-icon-144x144.png"
/>
<meta
name="msapplication-config"
content="/favicons/browserconfig.xml"
/>
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#2d7a45" />
<meta name="generator" content={Astro.generator} />
<title>{seoTitle}</title>
{seoDescription && <meta name="description" content={seoDescription} />}
@@ -441,6 +601,42 @@ const cmsFromCache = getCmsFromCache();
/>
)
}
{
import.meta.env.PUBLIC_FORMBRICKS_APP_URL &&
import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID && (
<script
is:inline
define:vars={{
formbricksAppUrl: import.meta.env.PUBLIC_FORMBRICKS_APP_URL,
formbricksEnvironmentId:
import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
}}
>
(function () {
var appUrl = String(formbricksAppUrl || "").replace(/\/$/, "");
var environmentId = String(formbricksEnvironmentId || "").trim();
if (!appUrl || !environmentId) return;
var t = document.createElement("script");
t.type = "text/javascript";
t.async = true;
t.src = appUrl + "/js/formbricks.umd.cjs";
var e = document.getElementsByTagName("script")[0];
if (e && e.parentNode) e.parentNode.insertBefore(t, e);
setTimeout(function () {
if (
window.formbricks &&
typeof window.formbricks.setup === "function"
) {
window.formbricks.setup({
environmentId: environmentId,
appUrl: appUrl,
});
}
}, 500);
})();
</script>
)
}
<script
src="https://analytics.pm86.de/api/script.js"
data-site-id="ad2858cf2db1"
@@ -465,9 +661,10 @@ const cmsFromCache = getCmsFromCache();
</script>
<TranslationProvider translations={translations}>
<Header
links={headerLinks}
links={headerNavItems}
socialLinks={socialLinks}
logoUrl={logoUrl}
logoSvgHtml={logoSvgHtml}
translations={translations}
client:load
/>
+39 -1
View File
@@ -57,6 +57,17 @@ export interface HtmlBlockData {
layout?: BlockLayout;
}
/** OpnForm embed (_type: "opnform"). */
export interface OpnFormBlockData {
_type?: "opnform";
_slug?: string;
id?: string;
formId?: string;
baseUrl?: string;
iframeTitle?: string;
layout?: BlockLayout;
}
/** Iframe (_type: "iframe"). */
export interface IframeBlockData {
_type?: "iframe";
@@ -105,7 +116,7 @@ export interface ImageGalleryImage {
src?: string;
description?: string;
file?: { url?: string };
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (16:9). */
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (max. Breite, Ratio erhalten). */
resolvedSrc?: string;
}
@@ -195,6 +206,33 @@ export interface SearchableTextBlockData {
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: lokaler Pfad (public/dist) 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;
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
/**
* CMS-Bild-URLs für Browser und Node (ohne fs/crypto).
* Wird von rusty-image und Svelte-Blöcken genutzt — eine Quelle für Loopback→Production und relative Pfade.
*/
export function getCmsBaseUrl(): string {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
).replace(/\/$/, "");
return base;
}
function isLoopbackAssetHost(hostname: string): boolean {
const h = hostname.toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "::1" ||
h === "[::1]" ||
h === "0.0.0.0"
);
}
/**
* Relative CMS-Pfade (`/api/assets/…`) absolut machen; in gespeicherten Einträgen oft
* `http://127.0.0.1:3000/api/assets/…` → `PUBLIC_CMS_URL` + Pfad (Production-Build).
*/
export function normalizeMediaUrl(url: string): string {
const u = url.trim();
if (!u) return u;
if (u.startsWith("//")) return `https:${u}`;
if (u.startsWith("http://") || u.startsWith("https://")) {
try {
const parsed = new URL(u);
if (
isLoopbackAssetHost(parsed.hostname) &&
parsed.pathname.includes("/api/assets/")
) {
const base = getCmsBaseUrl();
return `${base}${parsed.pathname}${parsed.search}`;
}
} catch {
/* ignore */
}
return u;
}
if (u.startsWith("/")) return `${getCmsBaseUrl()}${u}`;
return u;
}
/**
* Wie Image-Block / Galerie / Organisation-Logo: `string` oder Objekt mit `src` / `file.url`.
* Liefert eine für `ensureTransformedImage` nutzbare URL (nach normalizeMediaUrl).
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
if (typeof field === "string") {
const s = field.trim();
if (!s) return null;
return normalizeMediaUrl(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 normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
}
return null;
}
+33 -11
View File
@@ -85,24 +85,40 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
}
/**
* Alle Page-Einträge (GET /api/content/page). Gecacht pro Prozess.
* Alle Page-Einträge (GET /api/content/page). Lädt alle Seiten (paginiert), damit
* getStaticPaths ([slug]) auch jenseits der API-Standard-Seitengröße (50) vollständig ist.
*/
export async function getPages(): Promise<PageEntry[]> {
const key = "getPages";
export async function getPages(options?: { locale?: string }): Promise<PageEntry[]> {
const locale = options?.locale ?? "de";
const key = `getPages:${locale}`;
if (!skipListCache) {
const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const res = await (await getCachedFetch())(`${base}/api/content/page`);
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
);
const perPage = 200;
const all: PageEntry[] = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = new URL(`${base}/api/content/page`);
url.searchParams.set("_locale", locale);
url.searchParams.set("_page", String(page));
url.searchParams.set("_per_page", String(perPage));
const res = await (await getCachedFetch())(url.toString());
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;
const items = data.items ?? [];
all.push(...items);
totalPages = Math.max(1, data.total_pages ?? 1);
page += 1;
}
const data = (await res.json()) as PageListResponse;
return data.items ?? [];
return all;
})();
if (!skipListCache) listCache.set(key, p);
return p;
@@ -216,8 +232,14 @@ export async function getPageBySlug(
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
// Dateiname im Content-Dir ist meist page-{urlSlug} (z. B. page-bi-vachdorf), nicht bi-vachdorf.
if (res.status === 404 && !slug.startsWith("page-")) {
res = await trySlug(`page-${slug}`);
}
if (res.status === 404) {
const items = await getPages();
const items = await getPages({
locale: options?.locale,
});
const norm = normalizePageSlug(slug);
const match = items.find(
(p) =>
+139 -62
View File
@@ -7,6 +7,14 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
absoluteUrlFromCmsImageField,
getCmsBaseUrl,
normalizeMediaUrl,
} from "./cms-media-url";
/** Für ältere Importe von `rusty-image`; neue Nutzung: `./cms-media-url`. */
export { normalizeMediaUrl };
/** Transform-Parameter wie von der RustyCMS-API unterstützt. */
export interface RustyImageTransformParams {
@@ -27,27 +35,6 @@ const SUBDIR = "images/transformed";
/** Persistenter Cache für Builds überlebt "astro build" (dist wird geleert). */
const TRANSFORM_CACHE_DIR = ".cache/transformed-images";
function getCmsBaseUrl(): string {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
).replace(/\/$/, "");
return base;
}
/**
* Relative CMS-Pfade (z. B. `/api/assets/…`) für Fetch/Transform absolut machen.
* RustyCMS liefert oft relative URLs; `/api/transform` braucht eine absolute URL.
*/
export function normalizeMediaUrl(url: string): string {
const u = url.trim();
if (!u) return u;
if (u.startsWith("//")) return `https:${u}`;
if (u.startsWith("http://") || u.startsWith("https://")) return u;
if (u.startsWith("/")) return `${getCmsBaseUrl()}${u}`;
return u;
}
/** Stabile Dateiendung aus format. */
function formatToExt(format: string): string {
switch (format.toLowerCase()) {
@@ -176,20 +163,6 @@ export async function ensureTransformedImage(
/** Ein Eintrag aus einem Bild-Array: URL-String oder aufgelöstes img-Objekt (src). */
type ImageArrayItem = string | { src?: string };
function toImageUrl(item: ImageArrayItem): string | null {
if (typeof item === "string") {
const s = item.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (item && typeof item === "object" && typeof item.src === "string") {
const s = item.src.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
return null;
}
/**
* Löst ein Array von Bild-URLs/Referenzen über die Transform-API auf.
* Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
@@ -201,7 +174,7 @@ export async function resolveImageUrls(
): Promise<string[]> {
const result: string[] = [];
for (const item of items) {
const u = toImageUrl(item);
const u = absoluteUrlFromCmsImageField(item);
if (!u) continue;
try {
result.push(await ensureTransformedImage(u, params));
@@ -217,34 +190,14 @@ function getImageBlockUrl(block: {
image?: string | { src?: string; file?: { url?: string }; title?: string };
img?: string | { src?: string; file?: { url?: string }; title?: string };
}): string | null {
const source = block.image ?? block.img;
if (typeof source === "string") {
const s = source.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (source && typeof source === "object") {
const u =
"src" in source && typeof source.src === "string" ? source.src : source.file?.url;
if (typeof u !== "string" || !u.trim()) return null;
return normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
}
return null;
return absoluteUrlFromCmsImageField(block.image ?? block.img);
}
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageUrl(
img: { src?: string; file?: { url?: string } },
): string | null {
if (typeof img.src === "string" && img.src.trim()) {
const s = img.src;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (img.file?.url) {
const s = img.file.url;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
return null;
return absoluteUrlFromCmsImageField(img);
}
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
@@ -308,6 +261,99 @@ export async function processMarkdownHtmlImages(
return out;
}
const RAW_SUBDIR = "images/raw";
const RAW_CACHE_DIR = ".cache/raw-assets";
/**
* Lädt ein CMS-Asset direkt herunter (ohne Transform) und legt es lokal ab.
* Gibt den lokalen öffentlichen Pfad zurück (z.B. /images/raw/abc123.pdf).
*/
export async function downloadAsset(url: string): Promise<string> {
const absoluteUrl = url.trim();
if (!absoluteUrl.startsWith("http")) return url;
// _environment und _locale aus URL entfernen für sauberen Hash und Dateinamen
let cleanUrl = absoluteUrl;
try {
const u = new URL(absoluteUrl);
u.searchParams.delete("_environment");
u.searchParams.delete("_locale");
cleanUrl = u.toString();
} catch { /* ignore */ }
const hash = createHash("sha256").update(cleanUrl).digest("hex").slice(0, 24);
const rawExt = cleanUrl.split("?")[0].split(".").pop()?.toLowerCase() ?? "bin";
const filename = `${hash}.${rawExt}`;
const projectRoot = typeof process !== "undefined" && process.cwd ? process.cwd() : undefined;
if (!projectRoot) return url;
const isBuild = typeof import.meta !== "undefined" && (import.meta as { env?: { PROD?: boolean } }).env?.PROD === true;
const baseDir = isBuild ? path.join(projectRoot, "dist") : path.join(projectRoot, "public");
const dir = path.join(baseDir, RAW_SUBDIR);
const filePath = path.join(dir, filename);
const publicPath = `/${RAW_SUBDIR}/${filename}`;
const cacheDir = path.join(projectRoot, RAW_CACHE_DIR);
const cacheFilePath = path.join(cacheDir, filename);
if (isBuild) {
try {
await fs.access(cacheFilePath);
await fs.mkdir(dir, { recursive: true });
await fs.copyFile(cacheFilePath, filePath);
return publicPath;
} catch { /* nicht im Cache */ }
} else {
try {
await fs.access(filePath);
return publicPath;
} catch { /* Datei existiert nicht */ }
}
console.log("[rusty-image] GET (raw)", absoluteUrl);
const res = await fetch(absoluteUrl);
if (!res.ok) throw new Error(`downloadAsset: fetch failed ${res.status} for ${absoluteUrl}`);
const buffer = Buffer.from(await res.arrayBuffer());
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, buffer);
if (isBuild) {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(cacheFilePath, buffer);
}
return publicPath;
}
const A_HREF_REGEX = /<a(\s[^>]*?)href\s*=\s*["']([^"']+)["']([^>]*)>/gi;
/**
* Findet alle <a href="..."> die auf CMS-Assets zeigen (/api/assets/…),
* lädt die Datei lokal herunter und ersetzt die href mit dem lokalen Pfad.
*/
export async function downloadCmsAssetLinks(html: string): Promise<string> {
const matches = [...html.matchAll(A_HREF_REGEX)];
const replacements: { index: number; length: number; newTag: string }[] = [];
for (const m of matches) {
const rawHref = m[2];
const absolute = normalizeMediaUrl(rawHref);
if (!absolute.startsWith("http") || !absolute.includes("/api/assets/")) continue;
try {
const localPath = await downloadAsset(absolute);
const newTag = m[0].replace(rawHref, localPath);
replacements.push({ index: m.index ?? 0, length: m[0].length, newTag });
} catch (e) {
console.warn("[rusty-image] asset link download failed for", rawHref, e);
}
}
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.newTag + out.slice(r.index + r.length);
}
return out;
}
/**
* Geht alle Content-Rows durch: Image-Blöcke → resolvedImageSrc;
* Markdown-Blöcke → resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
@@ -328,11 +374,13 @@ export async function resolveContentImages(
layout.row3Content,
].filter((r): r is unknown[] => Array.isArray(r));
/** Galerie: 1280×720 (16:9), WebP, Qualität 80 kleinere Dateien. */
/**
* Galerie: max. Breite 1280, Seitenverhältnis erhalten (contain + nur w).
* Feste 16:9-Crops würden alle Bilder gleich groß machen — schlecht für Masonry/Album-Layout.
*/
const galleryParams: RustyImageTransformParams = {
width: 1280,
height: 720,
fit: "cover",
fit: "contain",
format: "webp",
quality: 80,
...transformParams,
@@ -356,7 +404,7 @@ export async function resolveContentImages(
if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) {
try {
const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html);
block.resolvedContent = await downloadCmsAssetLinks(await processMarkdownHtmlImages(html));
} catch (e) {
console.warn("[rusty-image] markdown block resolve failed", e);
}
@@ -392,6 +440,35 @@ export async function resolveContentImages(
console.warn("[rusty-image] gallery resolve failed for", url, e);
}
}
continue;
}
if (block._type === "organisations" && Array.isArray(block.organisations)) {
const orgLogoParams: RustyImageTransformParams = {
width: 256,
fit: "contain",
format: "webp",
quality: 85,
...transformParams,
};
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) continue;
try {
o.resolvedLogoSrc = await ensureTransformedImage(url, orgLogoParams);
} catch (e) {
console.warn(
"[rusty-image] organisation logo resolve failed for",
url,
e,
);
}
}
continue;
}
}
}
@@ -1,10 +1,17 @@
---
/**
* Alle CMS-Pages: URL-Pfad = slug aus dem CMS (ein oder mehrere Segmente, z. B. about, bi/vachdorf).
* Keine fest verdrahteten Fallbacks — getStaticPaths kommt nur aus getPages().
*/
import Layout from "../layouts/Layout.astro";
import ContentRows from "../components/ContentRows.svelte";
import { getPageSlugs, getPageBySlug } from "../lib/cms";
import type { PageEntry } from "../lib/cms";
import { getPages, getPageBySlug } from "../lib/cms";
import { resolveContentImages } from "../lib/rusty-image";
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks } from "../lib/blog-utils";
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from "../lib/blog-utils";
import { PAGE_RESOLVE } from "../lib/constants";
import { T } from "../lib/translations";
@@ -12,24 +19,26 @@ export const prerender = true;
export async function getStaticPaths() {
try {
const pageSlugs = await getPageSlugs();
const pages = await getPages({ locale: "de" });
const seen = new Set<string>();
const paths: { params: { slug: string } }[] = [];
for (const s of pageSlugs ?? []) {
const slug = (s ?? "").trim();
if (!slug || seen.has(slug)) continue;
seen.add(slug);
paths.push({ params: { slug } });
for (const p of pages) {
// Inline: Astro extrahiert getStaticPaths — keine lokalen Helfer aus dem Frontmatter.
const path = (p.slug ?? p._slug ?? "").replace(/^\//, "").trim();
if (!path) continue;
if (seen.has(path)) continue;
seen.add(path);
paths.push({ params: { slug: path } });
}
return paths;
} catch (e) {
console.error("getStaticPaths ([slug]):", e);
console.error("getStaticPaths ([...slug]):", e);
return [];
}
}
const { slug } = Astro.params as { slug: string };
const page = await getPageBySlug(slug, {
const slugPath = (Astro.params.slug ?? "").trim();
const page = await getPageBySlug(slugPath, {
locale: "de",
resolve: PAGE_RESOLVE,
});
@@ -46,7 +55,7 @@ const translations = Astro.locals.translations ?? {};
---
<Layout
title={page.seoTitle ?? page.headline ?? page.linkName ?? slug}
title={page.seoTitle ?? page.headline ?? page.linkName ?? slugPath}
description={page.seoDescription ?? page.subheadline}
headline={page.headline ?? page.linkName ?? page.slug ?? page._slug}
subheadline={page.subheadline}
@@ -54,7 +63,7 @@ const translations = Astro.locals.translations ?? {};
showBannerInLayout={!!page.topFullwidthBanner}
breadcrumbItems={[
{ href: "/", translationKey: T.nav_start },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slugPath },
]}
>
<article>
+6 -1
View File
@@ -345,7 +345,12 @@ main ol li {
.markdown ul,
.markdown ol {
@apply mt-0 mb-0;
@apply mt-0 mb-4;
}
.markdown li ul,
.markdown li ol {
@apply mb-0;
}
/* Markdown-Bilder: RustyImage-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */
+40 -1
View File
@@ -1695,6 +1695,18 @@
dependencies:
undici-types "~7.16.0"
"@types/react-dom@^19.2.3":
version "19.2.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c"
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==
"@types/react@^19.2.14":
version "19.2.14"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad"
integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==
dependencies:
csstype "^3.2.2"
"@types/sax@^1.2.1":
version "1.2.7"
resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d"
@@ -1834,7 +1846,7 @@ astro-icon@^1.1.5:
"@iconify/types" "^2.0.0"
"@iconify/utils" "^2.1.30"
astro@6.1.1:
astro@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/astro/-/astro-6.1.1.tgz#7dfb61ad2b28ffbf7b8e2384152ea399560a7672"
integrity sha512-vq8sHpu1JsY1fWAunn+tdKNbVDmLQNiVdyuGsVT2csgITdFGXXVAyEXFWc1DzkMN0ehElPeiHnqItyQOJK+GqA==
@@ -2153,6 +2165,11 @@ csso@^5.0.5:
dependencies:
css-tree "~2.2.0"
csstype@^3.2.2:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
debug@4, debug@^4.0.0, debug@^4.1.1, debug@^4.4.0:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
@@ -3753,6 +3770,23 @@ radix3@^1.1.2:
resolved "https://registry.yarnpkg.com/radix3/-/radix3-1.1.2.tgz#fd27d2af3896c6bf4bcdfab6427c69c2afc69ec0"
integrity sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==
react-dom@^19.2.4:
version "19.2.4"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
dependencies:
scheduler "^0.27.0"
react-photo-album@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/react-photo-album/-/react-photo-album-3.5.1.tgz#3a6ec40bd87568a3f6fa04fa3693024535bb9234"
integrity sha512-g1uFG+loxdIb8eIOGnrh/71Ao80+XWV+NLfluY9qDDTbS76m+X1K6dpfNtsx1xKr9aw5N1ornkFTWpfyqOoD5Q==
react@^19.2.4:
version "19.2.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
readdirp@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91"
@@ -3991,6 +4025,11 @@ sax@^1.5.0:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
scule@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/scule/-/scule-1.3.0.tgz#6efbd22fd0bb801bdcc585c89266a7d2daa8fbd3"