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[];
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
|
||||
|
||||
@@ -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[] } {
|
||||
|
||||
@@ -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 ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user