feat(deadline-banner): auto mode pulls all calendar_items globally
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 59s

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 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-04-19 11:02:50 +02:00
parent ffd4f599cd
commit 62d25202a6
9 changed files with 242 additions and 1 deletions
+24
View File
@@ -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 };
+55
View File
@@ -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<void> {
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
let pool: CalendarItemData[] | null = null;
const loadPool = async (): Promise<CalendarItemData[]> => {
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[] } {
+17
View File
@@ -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<CalendarItemEntry[]> {
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 ?? [];
});
}
+4
View File
@@ -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 @@
<OrganisationsBlock block={item as OrganisationsBlockData} />
{:else if blockType(item) === "opnform"}
<OpnFormBlock block={item as OpnFormBlockData} />
{:else if blockType(item) === "deadline_banner"}
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
{: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>
@@ -0,0 +1,135 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: DeadlineBannerBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const variant = $derived(block.variant ?? "accent");
const showCountdown = $derived(block.showCountdown !== false);
type ResolvedItem = {
title?: string;
terminZeit?: string;
description?: string;
link?: DeadlineBannerBlockData["link"];
};
const autoItem = $derived.by((): ResolvedItem | null => {
if (block.mode !== "auto") return null;
const now = Date.now();
const items = (block.items ?? [])
.filter(
(x): x is ResolvedItem =>
typeof x === "object" && x !== null && "terminZeit" in x,
)
.map((x) => ({ x, ts: Date.parse(x.terminZeit ?? "") }))
.filter(({ ts }) => !isNaN(ts) && ts >= now)
.sort((a, b) => a.ts - b.ts);
return items[0]?.x ?? null;
});
const text = $derived(
block.mode === "auto" ? autoItem?.title ?? "" : block.text ?? "",
);
const dateIso = $derived(
block.mode === "auto" ? autoItem?.terminZeit ?? "" : block.date ?? "",
);
const linkValue = $derived(
block.mode === "auto" ? autoItem?.link : block.link,
);
const dateObj = $derived.by(() => {
if (!dateIso) return null;
const d = new Date(dateIso);
return isNaN(d.getTime()) ? null : d;
});
const dateStr = $derived.by(() => {
if (!dateObj) return "";
return dateObj.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
});
});
const daysLeft = $derived.by(() => {
if (!dateObj) return null;
const ms = dateObj.getTime() - Date.now();
if (ms < 0) return null;
return Math.ceil(ms / (1000 * 60 * 60 * 24));
});
const countdownStr = $derived.by(() => {
if (!showCountdown || daysLeft == null) return "";
if (daysLeft === 0) return "Heute";
if (daysLeft === 1) return "Morgen";
return `Noch ${daysLeft} Tage`;
});
function getHref(link: DeadlineBannerBlockData["link"]): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
if (link.url) return link.url;
if (link._slug) return `/post/${encodeURIComponent(link._slug)}`;
}
return "";
}
const href = $derived(getHref(linkValue));
const newTab = $derived(
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
);
const variantClasses = $derived(
variant === "urgent"
? "bg-amber-100 text-amber-900 border-amber-300"
: variant === "info"
? "bg-sky-100 text-sky-900 border-sky-300"
: "bg-wald-100 text-wald-900 border-wald-300",
);
const iconName = $derived(
variant === "urgent" ? "mdi:alert-circle" : "mdi:calendar-clock",
);
const hasContent = $derived(!!(text || dateStr || countdownStr));
</script>
{#if hasContent}
<div
class="deadline-banner {layoutClasses}"
data-block-type="deadline_banner"
data-block-slug={block._slug}
>
<div class="border-y {variantClasses}">
<div class="container-custom flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-4 py-2 text-sm sm:text-base">
<Icon icon={iconName} class="size-5 shrink-0" aria-hidden="true" />
{#if block.label}
<span class="font-semibold">{block.label}:</span>
{/if}
{#if dateStr}
<span class="font-medium">{dateStr}</span>
{/if}
{#if text}
<span>{dateStr ? "·" : ""} {text}</span>
{/if}
{#if countdownStr}
<span class="rounded-full bg-white/60 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide">
{countdownStr}
</span>
{/if}
{#if href}
<a
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="ml-auto inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
Mehr
<Icon icon="mdi:arrow-right" class="size-4" aria-hidden="true" />
</a>
{/if}
</div>
</div>
</div>
{/if}
@@ -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";
</script>
+2
View File
@@ -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
+2
View File
@@ -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[] = [];
+2
View File
@@ -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);