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.
@@ -0,0 +1,4 @@
|
||||
PUBLIC_CMS_URL=http://localhost:3000
|
||||
|
||||
PUBLIC_FORMBRICKS_APP_URL=https://forms.pm86.de
|
||||
PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cmnlgrjle0008mr01k018oaiz
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
{#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"
|
||||
>
|
||||
{link.label}
|
||||
{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}
|
||||
<div><span class="text-stein-100">|</span></div>
|
||||
</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="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}
|
||||
{#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={link.href}
|
||||
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}
|
||||
>
|
||||
{link.label}
|
||||
{item.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if socialLinks.length > 0}
|
||||
<div
|
||||
@@ -242,3 +323,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
$effect(() => {
|
||||
const meta = withUrl;
|
||||
if (meta.length < 2) {
|
||||
galleryPhotos = [];
|
||||
return;
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
dragDelta = e.clientX - startX;
|
||||
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
|
||||
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 onPointerUp() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
}
|
||||
onMount(() => {
|
||||
if (withUrl.length < 2) return;
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if (withUrl.length <= 1) return;
|
||||
startX = e.touches[0].clientX;
|
||||
isDragging = true;
|
||||
dragDelta = 0;
|
||||
}
|
||||
let cancelled = false;
|
||||
void tick().then(() => {
|
||||
if (cancelled || !albumHost) return;
|
||||
reactRoot = createRoot(albumHost);
|
||||
renderAlbum();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
reactRoot?.unmount();
|
||||
reactRoot = null;
|
||||
};
|
||||
});
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
}
|
||||
$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>
|
||||
bind:this={albumHost}
|
||||
class="react-photo-album-host w-full min-h-48 [&_.react-photo-album]:max-w-none"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,12 +311,27 @@ try {
|
||||
typeof logoFileUrl === "string" && logoFileUrl.startsWith("//")
|
||||
? `https:${logoFileUrl}`
|
||||
: logoFileUrl;
|
||||
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
|
||||
/>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
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;
|
||||
return data.items ?? [];
|
||||
const items = data.items ?? [];
|
||||
all.push(...items);
|
||||
totalPages = Math.max(1, data.total_pages ?? 1);
|
||||
page += 1;
|
||||
}
|
||||
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) =>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
|
||||