From ef163fb33620dec08cb964940fb219568afb0ba3 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Fri, 8 May 2026 21:56:42 +0200 Subject: [PATCH] feat(layout): add global announcement banner above nav Adds AnnouncementBanner component fed by a new top_banner CMS entry (site-announcement). Extended top_banner schema and TS types with optional link + linkLabel fields. Rendered inline-markdown text. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/cms-api.generated.ts | 8 +++++++ src/lib/cms.ts | 25 ++++++++++++++++++++ src/lib/components/AnnouncementBanner.svelte | 24 +++++++++++++++++++ src/routes/+layout.server.ts | 9 +++++++ src/routes/+layout.svelte | 4 ++++ 5 files changed, 70 insertions(+) create mode 100644 src/lib/components/AnnouncementBanner.svelte diff --git a/src/lib/cms-api.generated.ts b/src/lib/cms-api.generated.ts index d9d806d..4503247 100644 --- a/src/lib/cms-api.generated.ts +++ b/src/lib/cms-api.generated.ts @@ -5446,6 +5446,10 @@ export interface components { id: string; /** @description Banner text (e.g. notice at top of page) */ text: string; + /** @description Optional URL the banner links to */ + link?: string; + /** @description CTA label for the link */ + linkLabel?: string; }; top_banner_input: { /** @description URL slug (used as filename) */ @@ -5454,6 +5458,10 @@ export interface components { id: string; /** @description Banner text (e.g. notice at top of page) */ text: string; + /** @description Optional URL the banner links to */ + link?: string; + /** @description CTA label for the link */ + linkLabel?: string; }; translation_bundle: { /** @description Entry identifier (filename) */ diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 40529af..87e93e0 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -747,6 +747,31 @@ export async function getTextFragmentBySlug( } /** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */ +export type TopBannerEntry = components["schemas"]["top_banner"]; + +/** Top-Banner anhand Slug (GET /api/content/top_banner/:slug). */ +export async function getTopBannerBySlug( + slug: string, + options?: { locale?: string }, +): Promise { + const key = `top_banner:slug:${slug}:${options?.locale ?? ""}`; + return cached("top_banner", key, async () => { + const base = getBaseUrl(); + const url = new URL( + `${base}/api/content/top_banner/${encodeURIComponent(slug)}`, + ); + if (options?.locale) url.searchParams.set("_locale", options.locale); + const res = await cmsFetch(url.toString()); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error( + `RustyCMS get top_banner failed: ${res.status} for slug "${slug}".`, + ); + } + return (await res.json()) as TopBannerEntry; + }); +} + export type FullwidthBannerEntry = components["schemas"]["fullwidth_banner"]; /** Fullwidth-Banner anhand Slug (GET /api/content/fullwidth_banner/:slug). */ diff --git a/src/lib/components/AnnouncementBanner.svelte b/src/lib/components/AnnouncementBanner.svelte new file mode 100644 index 0000000..ac60f4f --- /dev/null +++ b/src/lib/components/AnnouncementBanner.svelte @@ -0,0 +1,24 @@ + + +{#if banner?.text} +
+
+ {@html textHtml} + {#if banner.link} + + {banner.linkLabel ?? 'Mehr erfahren'} → + + {/if} +
+
+{/if} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 465053f..45b282e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -15,6 +15,7 @@ import type { NavigationEntry, FooterEntry, PageConfigEntry, + TopBannerEntry, } from '$lib/cms'; import { ensureTransformedImage } from '$lib/rusty-image'; import { @@ -84,6 +85,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { let bootstrapNavSocial: NavigationEntry | null = null; let bootstrapFooter: FooterEntry | null = null; let bootstrapConfig: PageConfigEntry | null = null; + let bootstrapTopBanner: TopBannerEntry | null = null; try { const batch = await batchFetch([ { @@ -114,11 +116,17 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { locale: 'de', resolve: 'logo', }, + { + id: 'topBanner', + collection: 'top_banner', + slug: 'site-announcement', + }, ]); bootstrapNavHeader = batchData(batch.results.navHeader); bootstrapNavSocial = batchData(batch.results.navSocial); bootstrapFooter = batchData(batch.results.footer); bootstrapConfig = batchData(batch.results.config); + bootstrapTopBanner = batchData(batch.results.topBanner); } catch (err) { /* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */ logWarn('layout.bootstrap', err); @@ -328,5 +336,6 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { seoDescriptionTemplate: pageConfig?.seoDescription ?? null, analyticsPm86SiteId: (pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null, + announcementBanner: bootstrapTopBanner, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 09c4b8d..b297776 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ 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 TranslationProvider from '$lib/components/TranslationProvider.svelte'; import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte'; import type { LayoutData } from './$types'; @@ -291,6 +292,9 @@ 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
+ {#if data.announcementBanner} + + {/if} {#if pageData?.preview || pageData?.previewDraft}