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_SCRIPT_URL=https://eu.umami.is/script.js
# PUBLIC_UMAMI_WEBSITE_ID=your-website-id # 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> # Firebase: Projekt in .firebaserc anpassen oder: firebase use <project-id>
+10
View File
@@ -18,6 +18,14 @@ on:
description: "PUBLIC_SITE_URL" description: "PUBLIC_SITE_URL"
required: false required: false
default: "https://windwiderstand.de" 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: jobs:
deploy: deploy:
@@ -41,6 +49,8 @@ jobs:
env: env:
PUBLIC_CMS_URL: ${{ inputs.public_cms_url }} PUBLIC_CMS_URL: ${{ inputs.public_cms_url }}
PUBLIC_SITE_URL: ${{ inputs.public_site_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 run: yarn build
- name: Deploy to Firebase - name: Deploy to Firebase
+5
View File
@@ -27,6 +27,9 @@
"astro": "^6.1.1", "astro": "^6.1.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"marked": "^17.0.2", "marked": "^17.0.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-photo-album": "^3.5.1",
"svelte": "^5.51.2", "svelte": "^5.51.2",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
@@ -35,6 +38,8 @@
"@storybook/svelte": "10", "@storybook/svelte": "10",
"@storybook/svelte-vite": "10", "@storybook/svelte-vite": "10",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"firebase": "^11.0.1", "firebase": "^11.0.1",
"openapi-typescript": "^7.4.0", "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 PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte"; import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.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 { isBlockLayoutBreakout, getSpaceBottomClass } from "../lib/block-layout";
import type { BlockLayout } from "../lib/block-layout"; import type { BlockLayout } from "../lib/block-layout";
import type { Translations } from "../lib/translations"; import type { Translations } from "../lib/translations";
@@ -29,6 +31,8 @@
PostOverviewBlockData, PostOverviewBlockData,
SearchableTextBlockData, SearchableTextBlockData,
CalendarBlockData, CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
} from "../lib/block-types"; } from "../lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props(); let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
@@ -116,6 +120,10 @@
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} /> <SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
{:else if blockType(item) === "calendar"} {:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} /> <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} {:else}
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"> <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> Unbekannter Block: <code>{blockType(item)}</code>
+112 -30
View File
@@ -7,9 +7,20 @@
import { t as tStatic, T } from "../lib/translations"; import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations"; import type { Translations } from "../lib/translations";
interface NavLink { type NavChild = { href: string; label: string };
href: string; /** Flacher Link oder Navgroup (nur Label + Unterlinks, optional eigener Parent-href). */
label: string; 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 { interface SocialLink {
@@ -22,11 +33,14 @@
links = [], links = [],
socialLinks = [], socialLinks = [],
logoUrl = null, logoUrl = null,
logoSvgHtml = null,
translations = {}, translations = {},
}: { }: {
links?: NavLink[]; links?: NavItem[];
socialLinks?: SocialLink[]; socialLinks?: SocialLink[];
logoUrl?: string | null; logoUrl?: string | null;
/** Inline-SVG-Markup (z. B. aus Layout per fetch); hat Vorrang vor logoUrl. */
logoSvgHtml?: string | null;
translations?: Translations | null; translations?: Translations | null;
} = $props(); } = $props();
@@ -89,15 +103,20 @@
<header <header
id="horizontal-navigation" 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"> <div class="flex items-center justify-between container-custom py-2">
<a <a
href="/" 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() !== ""} {#if logoSvgHtml && logoSvgHtml.trim() !== ""}
<img src={logoUrl} alt="" class="h-12 w-auto object-contain" /> <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} {:else}
<span>{t(T.site_name_fallback)}</span> <span>{t(T.site_name_fallback)}</span>
{/if} {/if}
@@ -108,21 +127,55 @@
class="hidden lg:flex items-center gap-4" class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)} aria-label={t(T.header_nav_aria)}
> >
{#each links as link} {#each links as item}
<a {#if isNavGroup(item)}
href={link.href} <details class="relative group">
class="text-stein-100 hover:text-wald-100 hover:animate-pulse transition-all text-xs tracking-wide" <summary
> class="cursor-pointer list-none text-xs tracking-wide hover:animate-pulse transition-all [&::-webkit-details-marker]:hidden"
{link.label} >
</a> {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} {/each}
<div><span class="text-stein-100">|</span></div> <div><span class="opacity-20">|</span></div>
{#each socialLinks as social} {#each socialLinks as social}
<a <a
href={social.href} href={social.href}
target="_blank" target="_blank"
rel="noopener noreferrer" 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)} aria-label={social.label || t(T.header_social_aria)}
> >
<Icon icon={social.icon} aria-hidden="true" /> <Icon icon={social.icon} aria-hidden="true" />
@@ -130,7 +183,7 @@
{/each} {/each}
<button <button
type="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 aria-label={searchOpen
? t(T.header_search_close) ? t(T.header_search_close)
: t(T.header_search_open)} : t(T.header_search_open)}
@@ -141,7 +194,7 @@
{#if searchOpen} {#if searchOpen}
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" /> <Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else} {:else}
<Icon icon="mdi:magnify" class="text-wald-400!" aria-hidden="true" /> <Icon icon="mdi:magnify" aria-hidden="true" />
{/if} {/if}
</button> </button>
</nav> </nav>
@@ -150,7 +203,7 @@
<div class="lg:hidden flex items-center gap-1"> <div class="lg:hidden flex items-center gap-1">
<button <button
type="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 aria-label={searchOpen
? t(T.header_search_close) ? t(T.header_search_close)
: t(T.header_search_open)} : t(T.header_search_open)}
@@ -167,14 +220,14 @@
{:else} {:else}
<Icon <Icon
icon="mdi:magnify" icon="mdi:magnify"
class="text-2xl text-wald-400!" class="text-2xl"
aria-hidden="true" aria-hidden="true"
/> />
{/if} {/if}
</button> </button>
<button <button
type="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-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
aria-controls="mobile-nav" aria-controls="mobile-nav"
onclick={toggleMenu} onclick={toggleMenu}
@@ -207,14 +260,42 @@
class="container-custom py-4 flex flex-col gap-1" class="container-custom py-4 flex flex-col gap-1"
aria-label={t(T.header_nav_aria)} aria-label={t(T.header_nav_aria)}
> >
{#each links as link} {#each links as item}
<a {#if isNavGroup(item)}
href={link.href} <span
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm" class="block py-2 px-2 text-xs font-medium uppercase tracking-wide text-stein-400"
onclick={closeMenu} >
> {item.label}
{link.label} </span>
</a> <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} {/each}
{#if socialLinks.length > 0} {#if socialLinks.length > 0}
<div <div
@@ -242,3 +323,4 @@
</div> </div>
{/if} {/if}
</header> </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"> <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 { marked } from "marked";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "../../lib/block-layout"; import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types"; import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
import "../../lib/iconify-offline";
import Icon from "@iconify/svelte";
import { useTranslate } from "../../lib/translations"; import { useTranslate } from "../../lib/translations";
import { T } from "../../lib/translations"; import { T } from "../../lib/translations";
import "react-photo-album/masonry.css";
const t = useTranslate(); const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props(); let { block }: { block: ImageGalleryBlockData } = $props();
@@ -28,7 +30,14 @@
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null), .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 { function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src; if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
@@ -36,68 +45,98 @@
return null; return null;
} }
function prev() { function metaToPhotos(
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length; 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() { function loadImageSize(url: string): Promise<{ w: number; h: number }> {
currentIndex = (currentIndex + 1) % withUrl.length; 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; $effect(() => {
let startX = $state(0); const meta = withUrl;
let isDragging = $state(false); if (meta.length < 2) {
let dragDelta = $state(0); galleryPhotos = [];
return;
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; const fallback = meta.map(() => ({ w: DEFAULT_W, h: DEFAULT_H }));
startX = 0; 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) { onMount(() => {
if (withUrl.length <= 1) return; if (withUrl.length < 2) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
function onTouchMove(e: TouchEvent) { let cancelled = false;
if (!isDragging || !e.touches[0]) return; void tick().then(() => {
const delta = e.touches[0].clientX - startX; if (cancelled || !albumHost) return;
dragDelta = delta; reactRoot = createRoot(albumHost);
if (Math.abs(delta) > 10) e.preventDefault(); renderAlbum();
} });
function onTouchEnd() { return () => {
if (!isDragging) return; cancelled = true;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) { reactRoot?.unmount();
if (dragDelta < 0) next(); reactRoot = null;
else prev(); };
} });
isDragging = false;
dragDelta = 0; $effect(() => {
startX = 0; galleryPhotos;
} if (withUrl.length < 2 || !reactRoot) return;
renderAlbum();
});
</script> </script>
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}> <div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
@@ -122,74 +161,9 @@
{/if} {/if}
</figure> </figure>
{:else} {:else}
<div class="group relative"> <div
<div bind:this={albumHost}
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing" class="react-photo-album-host w-full min-h-48 [&_.react-photo-album]:max-w-none"
role="region" ></div>
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>
{/if} {/if}
</div> </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_CMS_URL: string;
readonly PUBLIC_UMAMI_SCRIPT_URL?: string; readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
readonly PUBLIC_UMAMI_WEBSITE_ID?: 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 { interface ImportMeta {
+231 -34
View File
@@ -81,12 +81,12 @@ const resolvedBreadcrumbItems =
: (item.label ?? ""), : (item.label ?? ""),
})) ?? []; })) ?? [];
// Header: Navigation laden und Links bauen // Header: Navigation (flache Links + navgroup-Untermenüs, vgl. angelverein-vachdorf.de Layout.astro)
interface NavLink { type HeaderNavChild = { href: string; label: string };
href: string; type HeaderNavItem =
label: string; | HeaderNavChild
} | { label: string; href?: string; children: HeaderNavChild[] };
let headerLinks: NavLink[] = []; let headerNavItems: HeaderNavItem[] = [];
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */ /** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
function normalizeSlug(s: string | undefined): string { function normalizeSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").trim(); return (s ?? "").replace(/^\//, "").trim();
@@ -96,6 +96,49 @@ function getSlugFromEntry(entry: NavLinkEntry): string {
const e = entry as { _slug?: string; slug?: string }; const e = entry as { _slug?: string; slug?: string };
return e.slug ?? e._slug ?? ""; 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 { try {
const nav = await getNavigationByKey(NavigationKeys.header, { const nav = await getNavigationByKey(NavigationKeys.header, {
locale: "de", locale: "de",
@@ -138,29 +181,51 @@ try {
} }
} }
for (const entry of rawLinks) { for (const entry of rawLinks) {
const asObj = const asRecord =
typeof entry === "object" && entry !== null typeof entry === "object" && entry !== null
? (entry as { url?: string; linkName?: string; name?: string }) ? (entry as Record<string, unknown>)
: null; : null;
if (asObj?.url) { const rawLinksInner = asRecord?.links;
const href = const isNavGroup =
asObj.url.startsWith("/") || asObj.url.startsWith("http") asRecord &&
? asObj.url typeof asRecord.label === "string" &&
: `/${asObj.url.replace(/^\//, "")}`; Array.isArray(rawLinksInner) &&
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? ""; rawLinksInner.length > 0 &&
headerLinks.push({ href, label }); 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; continue;
} }
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || "home"; const flat = resolveNavEntryToChild(
const label = slugToLabel.get(slug) ?? slug; entry,
const postHref = slugToPostHref.get(slug); slugToLabel,
const pageSlug = slugToPageHref.get(slug) ?? slug; slugToPageHref,
const hrefSlug = normalizeSlug(pageSlug) || "home"; slugToPostHref,
const href = );
postHref ?? if (flat) headerNavItems.push(flat);
(hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`);
headerLinks.push({ href, label });
} }
} catch { } catch {
// CMS nicht erreichbar // CMS nicht erreichbar
@@ -215,6 +280,8 @@ try {
// Logo und SEO-Templates aus page_config (wie windwiderstand). // Logo und SEO-Templates aus page_config (wie windwiderstand).
let logoUrl: string | null = null; 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; let pageConfig: Awaited<ReturnType<typeof getPageConfigBySlug>> | null = null;
try { try {
// Zuerst page-config-default (häufiger Slug), dann default vermeidet doppelten 404 // Zuerst page-config-default (häufiger Slug), dann default vermeidet doppelten 404
@@ -244,11 +311,26 @@ try {
typeof logoFileUrl === "string" && logoFileUrl.startsWith("//") typeof logoFileUrl === "string" && logoFileUrl.startsWith("//")
? `https:${logoFileUrl}` ? `https:${logoFileUrl}`
: logoFileUrl; : logoFileUrl;
logoUrl = await ensureTransformedImage(u, { const isSvg = u.split("?")[0].toLowerCase().endsWith(".svg");
height: 56, if (isSvg) {
fit: "contain", // SVGs können nicht transformiert werden — direkt als URL nutzen
format: "webp", 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 { } catch {
/* Logo optional */ /* Logo optional */
@@ -376,11 +458,89 @@ const cmsFromCache = getCmsFromCache();
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <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="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" /> <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#2d7a45" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{seoTitle}</title> <title>{seoTitle}</title>
{seoDescription && <meta name="description" content={seoDescription} />} {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 <script
src="https://analytics.pm86.de/api/script.js" src="https://analytics.pm86.de/api/script.js"
data-site-id="ad2858cf2db1" data-site-id="ad2858cf2db1"
@@ -465,9 +661,10 @@ const cmsFromCache = getCmsFromCache();
</script> </script>
<TranslationProvider translations={translations}> <TranslationProvider translations={translations}>
<Header <Header
links={headerLinks} links={headerNavItems}
socialLinks={socialLinks} socialLinks={socialLinks}
logoUrl={logoUrl} logoUrl={logoUrl}
logoSvgHtml={logoSvgHtml}
translations={translations} translations={translations}
client:load client:load
/> />
+39 -1
View File
@@ -57,6 +57,17 @@ export interface HtmlBlockData {
layout?: BlockLayout; 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"). */ /** Iframe (_type: "iframe"). */
export interface IframeBlockData { export interface IframeBlockData {
_type?: "iframe"; _type?: "iframe";
@@ -105,7 +116,7 @@ export interface ImageGalleryImage {
src?: string; src?: string;
description?: string; description?: string;
file?: { url?: 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; resolvedSrc?: string;
} }
@@ -195,6 +206,33 @@ export interface SearchableTextBlockData {
layout?: BlockLayout; 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. */ /** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"]; 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[]> { export async function getPages(options?: { locale?: string }): Promise<PageEntry[]> {
const key = "getPages"; const locale = options?.locale ?? "de";
const key = `getPages:${locale}`;
if (!skipListCache) { if (!skipListCache) {
const p = listCache.get(key) as Promise<PageEntry[]> | undefined; const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
if (p) return p; if (p) return p;
} }
const p = (async () => { const p = (async () => {
const base = getBaseUrl(); const base = getBaseUrl();
const res = await (await getCachedFetch())(`${base}/api/content/page`); const perPage = 200;
if (!res.ok) { const all: PageEntry[] = [];
throw new Error( let page = 1;
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`, 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 all;
return data.items ?? [];
})(); })();
if (!skipListCache) listCache.set(key, p); if (!skipListCache) listCache.set(key, p);
return p; return p;
@@ -216,8 +232,14 @@ export async function getPageBySlug(
if (res.status === 404 && !slug.startsWith("/")) { if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug); 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) { if (res.status === 404) {
const items = await getPages(); const items = await getPages({
locale: options?.locale,
});
const norm = normalizePageSlug(slug); const norm = normalizePageSlug(slug);
const match = items.find( const match = items.find(
(p) => (p) =>
+139 -62
View File
@@ -7,6 +7,14 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; 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. */ /** Transform-Parameter wie von der RustyCMS-API unterstützt. */
export interface RustyImageTransformParams { export interface RustyImageTransformParams {
@@ -27,27 +35,6 @@ const SUBDIR = "images/transformed";
/** Persistenter Cache für Builds überlebt "astro build" (dist wird geleert). */ /** Persistenter Cache für Builds überlebt "astro build" (dist wird geleert). */
const TRANSFORM_CACHE_DIR = ".cache/transformed-images"; 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. */ /** Stabile Dateiendung aus format. */
function formatToExt(format: string): string { function formatToExt(format: string): string {
switch (format.toLowerCase()) { 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). */ /** Ein Eintrag aus einem Bild-Array: URL-String oder aufgelöstes img-Objekt (src). */
type ImageArrayItem = string | { src?: string }; 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. * 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 }). * Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
@@ -201,7 +174,7 @@ export async function resolveImageUrls(
): Promise<string[]> { ): Promise<string[]> {
const result: string[] = []; const result: string[] = [];
for (const item of items) { for (const item of items) {
const u = toImageUrl(item); const u = absoluteUrlFromCmsImageField(item);
if (!u) continue; if (!u) continue;
try { try {
result.push(await ensureTransformedImage(u, params)); result.push(await ensureTransformedImage(u, params));
@@ -217,34 +190,14 @@ function getImageBlockUrl(block: {
image?: string | { src?: string; file?: { url?: string }; title?: string }; image?: string | { src?: string; file?: { url?: string }; title?: string };
img?: string | { src?: string; file?: { url?: string }; title?: string }; img?: string | { src?: string; file?: { url?: string }; title?: string };
}): string | null { }): string | null {
const source = block.image ?? block.img; return absoluteUrlFromCmsImageField(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;
} }
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */ /** URL aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageUrl( function getGalleryImageUrl(
img: { src?: string; file?: { url?: string } }, img: { src?: string; file?: { url?: string } },
): string | null { ): string | null {
if (typeof img.src === "string" && img.src.trim()) { return absoluteUrlFromCmsImageField(img);
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;
} }
/** RowContentLayout-typ: Zeilen mit content-Arrays. */ /** RowContentLayout-typ: Zeilen mit content-Arrays. */
@@ -308,6 +261,99 @@ export async function processMarkdownHtmlImages(
return out; 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; * Geht alle Content-Rows durch: Image-Blöcke resolvedImageSrc;
* Markdown-Blöcke resolvedContent (HTML mit transformierten Bildern + Link-Wrapper). * Markdown-Blöcke resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
@@ -328,11 +374,13 @@ export async function resolveContentImages(
layout.row3Content, layout.row3Content,
].filter((r): r is unknown[] => Array.isArray(r)); ].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 = { const galleryParams: RustyImageTransformParams = {
width: 1280, width: 1280,
height: 720, fit: "contain",
fit: "cover",
format: "webp", format: "webp",
quality: 80, quality: 80,
...transformParams, ...transformParams,
@@ -356,7 +404,7 @@ export async function resolveContentImages(
if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) { if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) {
try { try {
const html = marked.parse(block.content) as string; const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html); block.resolvedContent = await downloadCmsAssetLinks(await processMarkdownHtmlImages(html));
} catch (e) { } catch (e) {
console.warn("[rusty-image] markdown block resolve failed", 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); 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 Layout from "../layouts/Layout.astro";
import ContentRows from "../components/ContentRows.svelte"; import ContentRows from "../components/ContentRows.svelte";
import { getPageSlugs, getPageBySlug } from "../lib/cms"; import { getPages, getPageBySlug } from "../lib/cms";
import type { PageEntry } from "../lib/cms";
import { resolveContentImages } from "../lib/rusty-image"; 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 { PAGE_RESOLVE } from "../lib/constants";
import { T } from "../lib/translations"; import { T } from "../lib/translations";
@@ -12,24 +19,26 @@ export const prerender = true;
export async function getStaticPaths() { export async function getStaticPaths() {
try { try {
const pageSlugs = await getPageSlugs(); const pages = await getPages({ locale: "de" });
const seen = new Set<string>(); const seen = new Set<string>();
const paths: { params: { slug: string } }[] = []; const paths: { params: { slug: string } }[] = [];
for (const s of pageSlugs ?? []) { for (const p of pages) {
const slug = (s ?? "").trim(); // Inline: Astro extrahiert getStaticPaths — keine lokalen Helfer aus dem Frontmatter.
if (!slug || seen.has(slug)) continue; const path = (p.slug ?? p._slug ?? "").replace(/^\//, "").trim();
seen.add(slug); if (!path) continue;
paths.push({ params: { slug } }); if (seen.has(path)) continue;
seen.add(path);
paths.push({ params: { slug: path } });
} }
return paths; return paths;
} catch (e) { } catch (e) {
console.error("getStaticPaths ([slug]):", e); console.error("getStaticPaths ([...slug]):", e);
return []; return [];
} }
} }
const { slug } = Astro.params as { slug: string }; const slugPath = (Astro.params.slug ?? "").trim();
const page = await getPageBySlug(slug, { const page = await getPageBySlug(slugPath, {
locale: "de", locale: "de",
resolve: PAGE_RESOLVE, resolve: PAGE_RESOLVE,
}); });
@@ -46,7 +55,7 @@ const translations = Astro.locals.translations ?? {};
--- ---
<Layout <Layout
title={page.seoTitle ?? page.headline ?? page.linkName ?? slug} title={page.seoTitle ?? page.headline ?? page.linkName ?? slugPath}
description={page.seoDescription ?? page.subheadline} description={page.seoDescription ?? page.subheadline}
headline={page.headline ?? page.linkName ?? page.slug ?? page._slug} headline={page.headline ?? page.linkName ?? page.slug ?? page._slug}
subheadline={page.subheadline} subheadline={page.subheadline}
@@ -54,7 +63,7 @@ const translations = Astro.locals.translations ?? {};
showBannerInLayout={!!page.topFullwidthBanner} showBannerInLayout={!!page.topFullwidthBanner}
breadcrumbItems={[ breadcrumbItems={[
{ href: "/", translationKey: T.nav_start }, { 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> <article>
+6 -1
View File
@@ -345,7 +345,12 @@ main ol li {
.markdown ul, .markdown ul,
.markdown ol { .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 */ /* Markdown-Bilder: RustyImage-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */
+40 -1
View File
@@ -1695,6 +1695,18 @@
dependencies: dependencies:
undici-types "~7.16.0" 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": "@types/sax@^1.2.1":
version "1.2.7" version "1.2.7"
resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.7.tgz#ba5fe7df9aa9c89b6dff7688a19023dd2963091d" 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/types" "^2.0.0"
"@iconify/utils" "^2.1.30" "@iconify/utils" "^2.1.30"
astro@6.1.1: astro@^6.1.1:
version "6.1.1" version "6.1.1"
resolved "https://registry.yarnpkg.com/astro/-/astro-6.1.1.tgz#7dfb61ad2b28ffbf7b8e2384152ea399560a7672" resolved "https://registry.yarnpkg.com/astro/-/astro-6.1.1.tgz#7dfb61ad2b28ffbf7b8e2384152ea399560a7672"
integrity sha512-vq8sHpu1JsY1fWAunn+tdKNbVDmLQNiVdyuGsVT2csgITdFGXXVAyEXFWc1DzkMN0ehElPeiHnqItyQOJK+GqA== integrity sha512-vq8sHpu1JsY1fWAunn+tdKNbVDmLQNiVdyuGsVT2csgITdFGXXVAyEXFWc1DzkMN0ehElPeiHnqItyQOJK+GqA==
@@ -2153,6 +2165,11 @@ csso@^5.0.5:
dependencies: dependencies:
css-tree "~2.2.0" 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: debug@4, debug@^4.0.0, debug@^4.1.1, debug@^4.4.0:
version "4.4.3" version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" 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" resolved "https://registry.yarnpkg.com/radix3/-/radix3-1.1.2.tgz#fd27d2af3896c6bf4bcdfab6427c69c2afc69ec0"
integrity sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA== 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: readdirp@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91" 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" resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== 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: scule@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/scule/-/scule-1.3.0.tgz#6efbd22fd0bb801bdcc585c89266a7d2daa8fbd3" resolved "https://registry.yarnpkg.com/scule/-/scule-1.3.0.tgz#6efbd22fd0bb801bdcc585c89266a7d2daa8fbd3"