feat(deadline-banner): auto mode pulls all calendar_items globally
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:
@@ -191,6 +191,30 @@ export interface PostOverviewBlockData {
|
|||||||
postsResolved?: unknown[];
|
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 }). */
|
/** Tag-Referenz (API liefert oft { _slug, _type, name }). */
|
||||||
export type SearchableTextTagRef = string | { _slug?: string; name?: string };
|
export type SearchableTextTagRef = string | { _slug?: string; name?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getTextFragmentBySlug,
|
getTextFragmentBySlug,
|
||||||
getCalendarBySlug,
|
getCalendarBySlug,
|
||||||
getCalendarItemBySlug,
|
getCalendarItemBySlug,
|
||||||
|
getCalendarItems,
|
||||||
} from "./cms";
|
} from "./cms";
|
||||||
import type { RowContentLayout } from "./block-types";
|
import type { RowContentLayout } from "./block-types";
|
||||||
import type { CalendarItemData } 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(
|
function isSearchableTextBlock(
|
||||||
item: unknown,
|
item: unknown,
|
||||||
): item is { _type?: string; textFragments?: unknown[] } {
|
): item is { _type?: string; textFragments?: unknown[] } {
|
||||||
|
|||||||
@@ -666,3 +666,20 @@ export async function getCalendarItemBySlug(
|
|||||||
return (await res.json()) as CalendarItemEntry;
|
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 ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
import CalendarBlock from "./blocks/CalendarBlock.svelte";
|
||||||
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
|
||||||
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
|
||||||
|
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
|
||||||
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
import { isBlockLayoutBreakout, getSpaceBottomClass } from "$lib/block-layout";
|
||||||
import type { BlockLayout } from "$lib/block-layout";
|
import type { BlockLayout } from "$lib/block-layout";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
CalendarBlockData,
|
CalendarBlockData,
|
||||||
OrganisationsBlockData,
|
OrganisationsBlockData,
|
||||||
OpnFormBlockData,
|
OpnFormBlockData,
|
||||||
|
DeadlineBannerBlockData,
|
||||||
} from "$lib/block-types";
|
} from "$lib/block-types";
|
||||||
|
|
||||||
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
|
||||||
@@ -128,6 +130,8 @@
|
|||||||
<OrganisationsBlock block={item as OrganisationsBlockData} />
|
<OrganisationsBlock block={item as OrganisationsBlockData} />
|
||||||
{:else if blockType(item) === "opnform"}
|
{:else if blockType(item) === "opnform"}
|
||||||
<OpnFormBlock block={item as OpnFormBlockData} />
|
<OpnFormBlock block={item as OpnFormBlockData} />
|
||||||
|
{:else if blockType(item) === "deadline_banner"}
|
||||||
|
<DeadlineBannerBlock block={item as DeadlineBannerBlockData} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
<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>
|
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 layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const posts = $derived((block.postsResolved ?? []) as (PostEntry & { _resolvedImageUrl?: string })[]);
|
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";
|
const tagBase = "/posts/tag";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
resolveCalendarBlocks,
|
resolveCalendarBlocks,
|
||||||
|
resolveDeadlineBannerBlocks,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
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);
|
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||||
await resolveSearchableTextBlocks(page, tagsMap);
|
await resolveSearchableTextBlocks(page, tagsMap);
|
||||||
await resolveCalendarBlocks(page);
|
await resolveCalendarBlocks(page);
|
||||||
|
await resolveDeadlineBannerBlocks(page);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// CMS nicht erreichbar
|
// CMS nicht erreichbar
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
resolveCalendarBlocks,
|
resolveCalendarBlocks,
|
||||||
|
resolveDeadlineBannerBlocks,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { PAGE_RESOLVE } from '$lib/constants';
|
import { PAGE_RESOLVE } from '$lib/constants';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
@@ -39,6 +40,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch }) => {
|
|||||||
const tagsMap = await resolvePostOverviewBlocks(page);
|
const tagsMap = await resolvePostOverviewBlocks(page);
|
||||||
await resolveSearchableTextBlocks(page, tagsMap);
|
await resolveSearchableTextBlocks(page, tagsMap);
|
||||||
await resolveCalendarBlocks(page);
|
await resolveCalendarBlocks(page);
|
||||||
|
await resolveDeadlineBannerBlocks(page);
|
||||||
|
|
||||||
// Resolve top banner if present
|
// Resolve top banner if present
|
||||||
let topBannerResolvedImages: string[] = [];
|
let topBannerResolvedImages: string[] = [];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resolvePostTagsInPost,
|
resolvePostTagsInPost,
|
||||||
resolvePostOverviewBlocks,
|
resolvePostOverviewBlocks,
|
||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
|
resolveDeadlineBannerBlocks,
|
||||||
getPostImageField,
|
getPostImageField,
|
||||||
formatPostDate,
|
formatPostDate,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
@@ -35,6 +36,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => {
|
|||||||
await resolveContentImages(post);
|
await resolveContentImages(post);
|
||||||
const tagsMap = await resolvePostOverviewBlocks(post);
|
const tagsMap = await resolvePostOverviewBlocks(post);
|
||||||
await resolveSearchableTextBlocks(post, tagsMap);
|
await resolveSearchableTextBlocks(post, tagsMap);
|
||||||
|
await resolveDeadlineBannerBlocks(post);
|
||||||
resolvePostTagsInPost(post, tagsMap);
|
resolvePostTagsInPost(post, tagsMap);
|
||||||
|
|
||||||
const postImageField = getPostImageField(post.postImage);
|
const postImageField = getPostImageField(post.postImage);
|
||||||
|
|||||||
Reference in New Issue
Block a user