Files
windwiderstand/src/routes/+layout.svelte
T
Peter Meier 57f4b226f9 feat(footer,org): Footer-Redesign + OrganisationCard auf InfoCard-Basis
Footer:
- Newsletter 2-spaltig (Titel/Desc links, Formular rechts), Desc als
  Translation-Key newsletter_inline_desc
- Link-Spalten-Überschriften als kleine Label-Kappen, Links ohne Unterstrich
- Bottom-Bar: Logo + Site-Name (data.siteName) links, Copyright/Lizenz rechts
- Site-Name-Default "Windwiderstand Thüringen" (constants.ts)

OrganisationCard:
- Neue Komponente auf InfoCard-Basis; InfoCard um href/media/badge/footer-Slots
  erweitert
- OrganisationsBlock: 4 divergierende Kartenpfade → 1 OrganisationCard
- Story + CTA-Button (mitmachen) size md + Icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:59:36 +02:00

428 lines
16 KiB
Svelte

<script lang="ts">
import '../app.css';
import '$lib/iconify-offline';
import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte';
import HeaderOverlay from '$lib/components/HeaderOverlay.svelte';
import Footer from '$lib/components/Footer.svelte';
import TopBanner from '$lib/components/TopBanner.svelte';
import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte';
import DeadlineBannerBlock from '$lib/components/blocks/DeadlineBannerBlock.svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import ContentModal from '$lib/components/ContentModal.svelte';
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
import type { LayoutData } from './$types';
import { ensureTransformedImage } from '$lib/rusty-image';
import { DEFAULT_SOCIAL_IMAGE_URL, SOCIAL_IMAGE_DIMENSIONS } from '$lib/constants';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
/**
* Embed-Routen (z.B. /widget/...) liegen in der `(embed)`-Route-Group und
* sollen ohne Site-Chrome (Header, Footer, TopBanner, Breadcrumbs, Skip-Link,
* Preview-Banner) gerendert werden — auch SEO-Tags ausser noindex sind irrelevant,
* weil das Widget per `<iframe>` eingebettet wird.
*/
const isEmbed = $derived(($page.route.id ?? '').startsWith('/(embed)'));
// Nur EIN Balken über der Navi: aktive Frist hat Vorrang, sonst NEU-Banner.
const deadlineActive = $derived.by(() => {
const b = data.deadlineBanner as { mode?: string; date?: string; items?: unknown[] } | null;
if (!b) return false;
if (b.mode === 'auto') return (b.items ?? []).length > 0;
if (!b.date) return false;
const d = new Date(b.date);
if (Number.isNaN(d.getTime())) return false;
const today = new Date(); today.setHours(0, 0, 0, 0);
const t = new Date(d); t.setHours(0, 0, 0, 0);
return t.getTime() >= today.getTime();
});
// Live-preview reload bridge: when the page was loaded with
// `?preview_draft=...`, accept `rustycms:reload` postMessage events from the
// admin iframe parent and re-run the SvelteKit `load()` so the iframe
// reflects the latest in-memory draft. We re-run instead of `location.reload()`
// to keep scroll position and avoid the full document re-mount cost.
onMount(() => {
if (typeof window === 'undefined') return;
const isPreviewDraft = window.location.search.includes('preview_draft=');
if (!isPreviewDraft) return;
const allowed = (import.meta.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').toString().trim();
const handler = (event: MessageEvent) => {
if (allowed && event.origin !== allowed) return;
const data = event.data;
if (!data) return;
if (data === 'rustycms:reload' || (typeof data === 'object' && data.type === 'rustycms:reload')) {
void invalidateAll();
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
});
// Per-page SEO data from $page.data
const pageData = $derived($page.data as {
seoTitle?: string;
seoDescription?: string;
socialImage?: string;
ogType?: string;
ogImageAlt?: string;
articlePublishedTime?: string | null;
articleModifiedTime?: string | null;
articleAuthor?: string | null;
articleTags?: string[];
twitterLabel1?: string;
twitterData1?: string;
robots?: string;
keywords?: string;
preview?: boolean;
previewDraft?: boolean;
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
breadcrumbItems?: { href?: string; label: string }[];
topBanner?: {
banner: unknown;
resolvedImages: string[];
headline?: string;
subheadline?: string;
responsive?: {
sources: { type: string; srcset: string; media?: string }[];
fallback: string;
width: number;
height: number;
lqip?: string;
} | null;
focalCss?: string | null;
} | null;
});
const baseUrl = $derived(data.siteBaseUrl ?? '');
const canonical = $derived(`${baseUrl}${$page.url.pathname}`);
function applyTemplate(tpl: string | null | undefined, value: string): string {
if (!tpl) return value;
return tpl.replace(/\$1/g, value);
}
const rawSeoTitle = $derived(pageData?.seoTitle ?? data.siteName);
const rawSeoDescription = $derived(pageData?.seoDescription ?? '');
const seoTitle = $derived(applyTemplate(data.seoTitleTemplate, rawSeoTitle));
const seoDescription = $derived(applyTemplate(data.seoDescriptionTemplate, rawSeoDescription));
const socialImageUrl = $derived.by(() => {
const raw = pageData?.socialImage ?? data.logoSocialImage ?? data.logoUrl ?? DEFAULT_SOCIAL_IMAGE_URL;
if (!raw) return '';
if (raw.startsWith('//')) return `https:${raw}`;
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
if (raw.startsWith('/')) return `${baseUrl}${raw}`;
return raw;
});
const breadcrumbItems = $derived(pageData?.breadcrumbItems ?? []);
const faviconUrl = $derived(data.logoUrl ?? '/favicon.svg');
const faviconType = $derived(
faviconUrl.split('?')[0].toLowerCase().endsWith('.svg') ? 'image/svg+xml' : 'image/png'
);
const robotsContent = $derived.by(() => {
const base = (pageData?.robots ?? 'index, follow').trim();
if (/noindex/i.test(base)) return base;
return `${base}, max-image-preview:large, max-snippet:-1`;
});
const topBannerData = $derived(pageData?.topBanner ?? null);
// Ein Preload pro Breakpoint-Variante (media-Attribut), Format: WebP.
// AVIF wäre kleiner, aber ein type-Mismatch (Browser ohne AVIF) macht den
// Preload wirkungslos — WebP greift überall. Browser laden nur den Preload,
// dessen media-Query matcht.
const bannerPreloads = $derived.by(() => {
const r = topBannerData?.responsive;
if (!r) return [];
type Src = { type: string; srcset: string; media?: string };
const sources = r.sources as Src[];
const byMedia = new Map<string, Src>();
for (const s of sources) {
const key = s.media ?? '';
const existing = byMedia.get(key);
if (!existing || (existing.type !== 'image/webp' && s.type === 'image/webp')) {
if (s.type === 'image/webp' || !existing) byMedia.set(key, s);
}
}
return [...byMedia.values()].map((s) => ({
href: r.fallback,
srcset: s.srcset,
type: s.type,
media: s.media,
}));
});
function escapeLdJson(s: string): string {
return s.replace(/<\/script/gi, '<\\/script');
}
const jsonLdScript = $derived(
`<script type="application/ld+json">${escapeLdJson(JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: data.siteName,
url: baseUrl,
inLanguage: 'de-DE',
publisher: { '@type': 'Organization', name: data.siteName, ...(baseUrl ? { '@id': `${baseUrl}#org` } : {}) },
}))}<\/script>`,
);
const organizationLdScript = $derived.by(() => {
const sameAs = (data.socialLinks ?? [])
.map((l) => l.href)
.filter((h) => /^https?:\/\//.test(h));
const org: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'Organization',
...(baseUrl ? { '@id': `${baseUrl}#org` } : {}),
name: data.siteName,
url: baseUrl,
...(data.logoSocialImage ? { logo: data.logoSocialImage } : {}),
...(sameAs.length ? { sameAs } : {}),
};
return `<script type="application/ld+json">${escapeLdJson(JSON.stringify(org))}<\/script>`;
});
const pageJsonLdScripts = $derived.by(() => {
const raw = pageData?.jsonLd;
if (!raw) return '';
const items = Array.isArray(raw) ? raw : [raw];
return items
.map(
(o) =>
`<script type="application/ld+json">${escapeLdJson(JSON.stringify(o))}<\/script>`,
)
.join('');
});
const breadcrumbJsonLdScript = $derived.by(() => {
const items = pageData?.breadcrumbItems ?? [];
if (items.length < 2) return '';
const ld = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, i) => ({
'@type': 'ListItem',
position: i + 1,
name: item.label,
...(item.href
? {
item: item.href.startsWith('http')
? item.href
: `${baseUrl}${item.href}`,
}
: {}),
})),
};
return `<script type="application/ld+json">${escapeLdJson(JSON.stringify(ld))}<\/script>`;
});
const formbricksAppUrl = String(import.meta.env.PUBLIC_FORMBRICKS_APP_URL ?? '').replace(/\/$/, '');
const formbricksEnvId = String(import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID ?? '').trim();
const formbricksScript =
formbricksAppUrl && formbricksEnvId
? `<script>(function(){var appUrl=${JSON.stringify(formbricksAppUrl)};var environmentId=${JSON.stringify(formbricksEnvId)};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>
<svelte:head>
{#if isEmbed}
<!-- Embed-Modus: minimaler Head, kein OG/JSON-LD/Analytics. Page setzt
eigenen <title> und robots-meta. -->
<link rel="icon" type={faviconType} href={faviconUrl} />
{:else}
<meta name="theme-color" content="#ffffff" />
<meta name="robots" content={robotsContent} />
{#each bannerPreloads as p (p.media ?? p.type)}
<link
rel="preload"
as="image"
href={p.href}
imagesrcset={p.srcset}
imagesizes="100vw"
type={p.type}
media={p.media}
fetchpriority="high"
/>
{/each}
<link rel="icon" type={faviconType} href={faviconUrl} />
<link rel="apple-touch-icon" href={faviconUrl} />
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/posts/rss.xml" />
<link rel="sitemap" href="/sitemap.xml" />
<title>{seoTitle}</title>
{#if seoDescription}
<meta name="description" content={seoDescription} />
{/if}
{#if pageData?.keywords}
<meta name="keywords" content={pageData.keywords} />
{/if}
<link rel="canonical" href={canonical} />
<!-- Open Graph -->
<meta property="og:type" content={pageData?.ogType ?? "website"} />
<meta property="og:url" content={canonical} />
<meta property="og:title" content={seoTitle} />
{#if seoDescription}
<meta property="og:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:width" content={String(SOCIAL_IMAGE_DIMENSIONS.width)} />
<meta property="og:image:height" content={String(SOCIAL_IMAGE_DIMENSIONS.height)} />
{#if pageData?.ogImageAlt}
<meta property="og:image:alt" content={pageData.ogImageAlt} />
{/if}
{/if}
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content={data.siteName} />
{#if pageData?.articlePublishedTime}
<meta property="article:published_time" content={pageData.articlePublishedTime} />
{/if}
{#if pageData?.articleModifiedTime}
<meta property="article:modified_time" content={pageData.articleModifiedTime} />
{/if}
{#if pageData?.articleAuthor}
<meta property="article:author" content={pageData.articleAuthor} />
{/if}
{#each pageData?.articleTags ?? [] as tag}
<meta property="article:tag" content={tag} />
{/each}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={canonical} />
<meta name="twitter:title" content={seoTitle} />
{#if seoDescription}
<meta name="twitter:description" content={seoDescription} />
{/if}
{#if socialImageUrl}
<meta name="twitter:image" content={socialImageUrl} />
{#if pageData?.ogImageAlt}
<meta name="twitter:image:alt" content={pageData.ogImageAlt} />
{/if}
{/if}
{#if pageData?.twitterLabel1 && pageData?.twitterData1}
<meta name="twitter:label1" content={pageData.twitterLabel1} />
<meta name="twitter:data1" content={pageData.twitterData1} />
{/if}
<!-- JSON-LD WebSite + Organization -->
{@html jsonLdScript}
{@html organizationLdScript}
{#if breadcrumbJsonLdScript}
{@html breadcrumbJsonLdScript}
{/if}
{#if pageJsonLdScripts}
{@html pageJsonLdScripts}
{/if}
{#if import.meta.env.PUBLIC_UMAMI_SCRIPT_URL && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
<script
defer
src={import.meta.env.PUBLIC_UMAMI_SCRIPT_URL}
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}
{#if data.analyticsPm86SiteId}
<script
defer
src="https://analytics.pm86.de/api/script.js"
data-site-id={data.analyticsPm86SiteId}
></script>
{/if}
{#if formbricksScript}
{@html formbricksScript}
{/if}
{/if}
</svelte:head>
<TranslationProvider translations={data.translations}>
{#if isEmbed}
{@render children()}
{:else}
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:bg-white focus:px-3 focus:py-2 focus:shadow focus:outline focus:outline-2 focus:outline-wald-600 focus:rounded-xs"
>Zum Inhalt springen</a>
<div class="flex min-h-screen flex-col text-stein-900">
{#if deadlineActive && data.deadlineBanner}
<DeadlineBannerBlock block={data.deadlineBanner} />
{:else if data.announcementBanner}
<AnnouncementBanner banner={data.announcementBanner} />
{/if}
{#if pageData?.preview || pageData?.previewDraft}
<div
class="sticky top-0 z-[90] w-full bg-erde-500 text-black shadow-sm"
role="status"
aria-live="polite"
>
<div class="container-custom flex items-center justify-between gap-3 py-1.5 text-sm">
<span class="font-semibold">
{pageData?.previewDraft ? 'Live-Preview · ungespeicherter Draft' : 'Preview-Modus · Draft sichtbar (nicht indexiert)'}
</span>
<a
href={$page.url.pathname}
class="underline hover:no-underline"
rel="nofollow"
>
Preview verlassen
</a>
</div>
</div>
{/if}
<div class="print:hidden">
<Header
links={data.headerLinks}
socialLinks={data.socialLinks}
logoUrl={data.logoUrl}
logoSvgHtml={data.logoSvgHtml}
translations={data.translations}
/>
<HeaderOverlay />
</div>
{#if topBannerData}
<div class="print:hidden">
<TopBanner
banner={topBannerData.banner as import('$lib/cms').FullwidthBannerEntry | null}
resolvedImages={topBannerData.resolvedImages}
responsive={topBannerData.responsive ?? null}
focalCss={topBannerData.focalCss ?? null}
headline={topBannerData.headline}
subheadline={topBannerData.subheadline}
/>
</div>
{/if}
<main id="main-content" tabindex="-1" class="flex-1 min-h-[60em] print:min-h-0">
<div class="container-custom pb-12 print:pb-0">
{#if breadcrumbItems.length > 0}
<div class="print:hidden">
<Breadcrumbs items={breadcrumbItems} />
</div>
{/if}
<!-- Konsolidierter Top-Abstand vorm ersten Content-Element (alle
Seitentypen), zusätzlich zum Breadcrumb-py. -->
<div class="pt-4 md:pt-6">
{@render children()}
</div>
</div>
</main>
<div class="print:hidden">
<Footer
footer={data.footerData}
translations={data.translations}
logoSvgHtml={data.logoSvgHtml}
logoUrl={data.logoUrl}
siteName={data.siteName}
/>
</div>
</div>
<ContentModal
modals={data.modals}
pathname={$page.url.pathname}
translations={data.translations}
/>
{/if}
</TranslationProvider>