From 62d25202a6ebff04d9e21543911a164bb3a5255b Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sun, 19 Apr 2026 11:02:50 +0200 Subject: [PATCH] feat(deadline-banner): auto mode pulls all calendar_items globally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-mode deadline_banner now resolves to the next future event across all calendar_items in the CMS instead of requiring a manually-curated items[] pool. Server load hooks (+page.server.ts for /, /[...slug], /post/[slug]) call resolveDeadlineBannerBlocks which fetches the global calendar_item list once and attaches it as items[] when mode is "auto" and items[] is not already resolved. Component logic unchanged — it still picks the soonest future entry. Also: - cms.ts: getCalendarItems() bulk list fetcher - PostOverviewBlock.svelte: design defaults to "cards" when value is empty string (not just null/undefined) so existing entries with design: "" render Co-Authored-By: Claude Opus 4.7 --- src/lib/block-types.ts | 24 ++++ src/lib/blog-utils.ts | 55 +++++++ src/lib/cms.ts | 17 +++ src/lib/components/ContentRows.svelte | 4 + .../blocks/DeadlineBannerBlock.svelte | 135 ++++++++++++++++++ .../blocks/PostOverviewBlock.svelte | 2 +- src/routes/+page.server.ts | 2 + src/routes/[...slug]/+page.server.ts | 2 + src/routes/post/[slug]/+page.server.ts | 2 + 9 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/blocks/DeadlineBannerBlock.svelte diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index c7a894d..93b75ce 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -191,6 +191,30 @@ export interface PostOverviewBlockData { postsResolved?: unknown[]; } +/** Deadline-Banner (_type: "deadline_banner"). Schmaler Balken unter Hero. */ +export interface DeadlineBannerBlockData { + _type?: "deadline_banner"; + _slug?: string; + mode?: "manual" | "auto"; + label?: string; + text?: string; + date?: string; + items?: Array< + | string + | { + _slug?: string; + title?: string; + terminZeit?: string; + description?: string; + link?: string | { url?: string; linkName?: string; newTab?: boolean; _slug?: string }; + } + >; + link?: string | { url?: string; linkName?: string; newTab?: boolean; _slug?: string }; + variant?: "accent" | "urgent" | "info"; + showCountdown?: boolean; + layout?: BlockLayout; +} + /** Tag-Referenz (API liefert oft { _slug, _type, name }). */ export type SearchableTextTagRef = string | { _slug?: string; name?: string }; diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index 896e302..9d92819 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -6,6 +6,7 @@ import { getTextFragmentBySlug, getCalendarBySlug, getCalendarItemBySlug, + getCalendarItems, } from "./cms"; import type { RowContentLayout } from "./block-types"; import type { CalendarItemData } from "./block-types"; @@ -528,6 +529,60 @@ export async function resolveCalendarBlocks( } } +function isDeadlineBannerBlock( + item: unknown, +): item is { + _type?: string; + mode?: string; + items?: unknown[]; +} { + return ( + typeof item === "object" && + item !== null && + (item as { _type?: string })._type === "deadline_banner" + ); +} + +/** + * Löst deadline_banner-Blöcke im auto-Modus auf: Lädt alle calendar_items + * global und setzt sie als Pool in block.items, sofern dort noch keine + * aufgelösten Einträge liegen. Die Komponente wählt daraus den nächsten + * zukünftigen Termin. + */ +export async function resolveDeadlineBannerBlocks( + layout: RowContentLayout, +): Promise { + const rows = [ + layout.row1Content ?? [], + layout.row2Content ?? [], + layout.row3Content ?? [], + ]; + let pool: CalendarItemData[] | null = null; + const loadPool = async (): Promise => { + if (pool === null) { + const items = await getCalendarItems({ locale: "de" }); + pool = items as unknown as CalendarItemData[]; + } + return pool; + }; + for (const content of rows) { + if (!Array.isArray(content)) continue; + for (const item of content) { + if (!isDeadlineBannerBlock(item)) continue; + if (item.mode !== "auto") continue; + const existing = item.items ?? []; + const first = existing[0]; + const alreadyResolved = + typeof first === "object" && + first !== null && + "terminZeit" in first && + "title" in first; + if (alreadyResolved) continue; + (item as { items?: CalendarItemData[] }).items = await loadPool(); + } + } +} + function isSearchableTextBlock( item: unknown, ): item is { _type?: string; textFragments?: unknown[] } { diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 949e81b..fd35fb9 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -666,3 +666,20 @@ export async function getCalendarItemBySlug( return (await res.json()) as CalendarItemEntry; }); } + +/** Alle calendar_item-Einträge (GET /api/content/calendar_item). */ +export async function getCalendarItems( + options?: { locale?: string }, +): Promise { + const key = `calendar_item:list:${options?.locale ?? ""}`; + return cached("calendar_item", key, async () => { + const base = getBaseUrl(); + const url = new URL(`${base}/api/content/calendar_item`); + url.searchParams.set("_per_page", "1000"); + if (options?.locale) url.searchParams.set("_locale", options.locale); + const res = await fetch(url.toString()); + if (!res.ok) return []; + const data = (await res.json()) as { items?: CalendarItemEntry[] }; + return data.items ?? []; + }); +} diff --git a/src/lib/components/ContentRows.svelte b/src/lib/components/ContentRows.svelte index 1fb34f2..4df5b54 100644 --- a/src/lib/components/ContentRows.svelte +++ b/src/lib/components/ContentRows.svelte @@ -14,6 +14,7 @@ import CalendarBlock from "./blocks/CalendarBlock.svelte"; import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte"; import OpnFormBlock from "./blocks/OpnFormBlock.svelte"; + import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte"; import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout"; import type { BlockLayout } from "$lib/block-layout"; import type { Translations } from "$lib/translations"; @@ -35,6 +36,7 @@ CalendarBlockData, OrganisationsBlockData, OpnFormBlockData, + DeadlineBannerBlockData, } from "$lib/block-types"; let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props(); @@ -128,6 +130,8 @@ {:else if blockType(item) === "opnform"} + {:else if blockType(item) === "deadline_banner"} + {:else}
Unbekannter Block: {blockType(item)} diff --git a/src/lib/components/blocks/DeadlineBannerBlock.svelte b/src/lib/components/blocks/DeadlineBannerBlock.svelte new file mode 100644 index 0000000..94635ae --- /dev/null +++ b/src/lib/components/blocks/DeadlineBannerBlock.svelte @@ -0,0 +1,135 @@ + + +{#if hasContent} +
+
+
+
+
+
+{/if} diff --git a/src/lib/components/blocks/PostOverviewBlock.svelte b/src/lib/components/blocks/PostOverviewBlock.svelte index a07e57e..675e786 100644 --- a/src/lib/components/blocks/PostOverviewBlock.svelte +++ b/src/lib/components/blocks/PostOverviewBlock.svelte @@ -18,7 +18,7 @@ ); const layoutClasses = $derived(getBlockLayoutClasses(block.layout)); const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]); - const design = $derived(block.design ?? "cards"); + const design = $derived(block.design || "cards"); const tagBase = "/posts/tag"; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 9b90a4b..697e6d4 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -10,6 +10,7 @@ import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks, + resolveDeadlineBannerBlocks, } from '$lib/blog-utils'; import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants'; @@ -45,6 +46,7 @@ export const load: PageServerLoad = async ({ locals, fetch }) => { const tagsMap = await resolvePostOverviewBlocks(page); await resolveSearchableTextBlocks(page, tagsMap); await resolveCalendarBlocks(page); + await resolveDeadlineBannerBlocks(page); } } catch { // CMS nicht erreichbar diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index ffcda39..a0e7ee8 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -10,6 +10,7 @@ import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks, + resolveDeadlineBannerBlocks, } from '$lib/blog-utils'; import { PAGE_RESOLVE } from '$lib/constants'; import { error } from '@sveltejs/kit'; @@ -39,6 +40,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => { const tagsMap = await resolvePostOverviewBlocks(page); await resolveSearchableTextBlocks(page, tagsMap); await resolveCalendarBlocks(page); + await resolveDeadlineBannerBlocks(page); // Resolve top banner if present let topBannerResolvedImages: string[] = []; diff --git a/src/routes/post/[slug]/+page.server.ts b/src/routes/post/[slug]/+page.server.ts index 771adc8..c722c3b 100644 --- a/src/routes/post/[slug]/+page.server.ts +++ b/src/routes/post/[slug]/+page.server.ts @@ -9,6 +9,7 @@ import { resolvePostTagsInPost, resolvePostOverviewBlocks, resolveSearchableTextBlocks, + resolveDeadlineBannerBlocks, getPostImageField, formatPostDate, } from '$lib/blog-utils'; @@ -35,6 +36,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { await resolveContentImages(post); const tagsMap = await resolvePostOverviewBlocks(post); await resolveSearchableTextBlocks(post, tagsMap); + await resolveDeadlineBannerBlocks(post); resolvePostTagsInPost(post, tagsMap); const postImageField = getPostImageField(post.postImage);