feat(calendar): compact variant for next-events teaser
Deploy / verify (push) Successful in 1m8s
Deploy / deploy (push) Successful in 1m13s

Calendar block now supports variant: "default" | "compact".
- default = full calendar (unchanged)
- compact = next N events (default 3) + link to a target page

New CalendarCompactBlock.svelte: small card with month/day badge,
title, date/time/location line, "Alle kommenden Termine →" footer
link resolved from the schema's linkTo reference. Filters to future
events (12h tolerance for running ones).

BlockRenderer dispatches by block.variant inside the calendar case.
Schema (cms_content/_types/core/calendar.json5) adds variant + linkTo
+ limit fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-22 08:49:08 +02:00
parent 055f001174
commit b7bcdd76ee
3 changed files with 130 additions and 1 deletions
+6
View File
@@ -420,6 +420,12 @@ export type CalendarBlockData = Omit<
/** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
/** Variante: "default" voller Kalender, "compact" Anteaser. */
variant?: "default" | "compact";
/** Compact: Ziel-Page-Referenz hinter dem "Alle Termine"-Link. */
linkTo?: string | { _slug?: string;[key: string]: unknown };
/** Compact: Anzahl Termine (default 3). */
limit?: number;
};
/** Internal component block (_type: "internal_component"). */
+6 -1
View File
@@ -14,6 +14,7 @@
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import CalendarCompactBlock from "./blocks/CalendarCompactBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import InternalComponentBlock from "./blocks/InternalComponentBlock.svelte";
@@ -82,7 +83,11 @@
{:else if type === "searchable_text"}
<SearchableTextBlock block={block as SearchableTextBlockData} translations={translations} />
{:else if type === "calendar"}
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
{#if (block as CalendarBlockData).variant === "compact"}
<CalendarCompactBlock block={block as CalendarBlockData} translations={translations} />
{:else}
<CalendarBlock block={block as CalendarBlockData} translations={translations} />
{/if}
{:else if type === "organisations"}
<OrganisationsBlock block={block as OrganisationsBlockData} />
{:else if type === "opnform"}
@@ -0,0 +1,118 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
let {
block,
translations = {},
}: { block: CalendarBlockData; translations?: Translations | null } = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const limit = $derived(typeof block.limit === "number" && block.limit > 0 ? block.limit : 3);
/** Compact-Variante zeigt nur aufgelöste Items (Strings = unresolved Slugs → skip). */
const allItems = $derived(
(block.items ?? []).filter(
(x): x is CalendarItemData => typeof x === "object" && x !== null && "terminZeit" in x,
),
);
/** Nur zukünftige Termine, aufsteigend sortiert, auf Limit gekürzt. */
const upcoming = $derived(
allItems
.map((it) => ({ it, start: it.terminZeit ? new Date(it.terminZeit) : null }))
.filter((x): x is { it: CalendarItemData; start: Date } => {
if (!x.start || isNaN(x.start.getTime())) return false;
return x.start.getTime() >= Date.now() - 12 * 3600 * 1000; // 12h Toleranz
})
.sort((a, b) => a.start.getTime() - b.start.getTime())
.slice(0, limit),
);
function fmtDate(d: Date): string {
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "short",
});
}
function fmtTime(d: Date): string {
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
const linkHref = $derived(() => {
const lt = block.linkTo;
if (!lt) return null;
const slug = typeof lt === "string" ? lt : (lt._slug ?? "");
if (!slug) return null;
const clean = slug.replace(/^\/+|\/+$/g, "");
return clean ? `/${clean}/` : "/";
});
</script>
<div
class="{layoutClasses} w-full min-w-0 card-surface overflow-hidden rounded-lg border border-stein-200 bg-white"
data-block="CalendarCompact"
data-block-type="calendar"
data-block-variant="compact"
data-block-slug={block._slug}
>
<header class="flex items-center gap-2 border-b border-stein-100 px-4 py-3 sm:px-5">
<Icon icon="mdi:calendar-month-outline" class="size-5 text-wald-600" aria-hidden="true" />
<h2 class="text-base font-semibold text-stein-800">
{block.title ?? t(T.calendar_next_events)}
</h2>
</header>
{#if upcoming.length === 0}
<p class="px-4 py-6 text-center text-sm text-stein-500 sm:px-5">
{t(T.calendar_no_future_events)}
</p>
{:else}
<ul class="divide-y divide-stein-100">
{#each upcoming as { it, start } (it._slug ?? it.title)}
{@const timeStr = fmtTime(start)}
<li class="flex items-start gap-3 px-4 py-3 sm:px-5">
<div class="flex w-14 shrink-0 flex-col items-center rounded bg-wald-50 px-1 py-1.5 text-wald-700">
<span class="text-[0.65rem] font-semibold uppercase leading-none">
{start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")}
</span>
<span class="text-lg font-bold tabular-nums leading-none">
{start.getDate()}
</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-stein-800">{it.title ?? t(T.calendar_untitled)}</p>
<p class="mt-0.5 text-xs text-stein-500">
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}
{#if it.location}
<span class="ml-1">· {typeof it.location === "string" ? it.location : (it.location as { text?: string })?.text ?? ""}</span>
{/if}
</p>
</div>
</li>
{/each}
</ul>
{/if}
{#if linkHref()}
<a
href={linkHref()}
class="flex items-center justify-center gap-1 border-t border-stein-100 bg-stein-50 px-4 py-2.5 text-sm font-medium text-wald-700 hover:bg-wald-50"
>
{t(T.calendar_all_upcoming)}
<Icon icon="mdi:arrow-right" class="size-4" aria-hidden="true" />
</a>
{/if}
</div>