feat(calendar): compact variant for next-events teaser
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:
@@ -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"). */
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user