5a1a49a97b
calendar-Schema extends section_header + title-Feld. CalendarBlockData + kicker/number/action. CalendarCompact reicht kicker/number an SectionHeader. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
5.2 KiB
Svelte
138 lines
5.2 KiB
Svelte
<script lang="ts">
|
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
|
import SectionHeader from "$lib/components/SectionHeader.svelte";
|
|
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: "long",
|
|
});
|
|
}
|
|
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" });
|
|
}
|
|
|
|
/** Titel + optional nachgestellte "(…)"-Klammer separat (Klammer gedimmt wie im Design). */
|
|
function splitTitle(raw: string): { main: string; paren: string } {
|
|
const m = raw.match(/^(.*?)\s*(\([^()]*\))\s*$/);
|
|
if (m) return { main: m[1], paren: m[2] };
|
|
return { main: raw, paren: "" };
|
|
}
|
|
|
|
function locationText(it: CalendarItemData): string {
|
|
if (!it.location) return "";
|
|
return typeof it.location === "string" ? it.location : ((it.location as { text?: string })?.text ?? "");
|
|
}
|
|
|
|
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="not-prose {layoutClasses} w-full min-w-0"
|
|
data-block="CalendarCompact"
|
|
data-block-type="calendar"
|
|
data-block-variant="compact"
|
|
data-block-slug={block._slug}
|
|
>
|
|
<div class="mb-4">
|
|
<SectionHeader
|
|
number={block.number}
|
|
kicker={block.kicker}
|
|
title={block.title ?? t(T.calendar_next_events)}
|
|
tag="h3"
|
|
actionLabel={linkHref() ? t(T.calendar_all_upcoming) : undefined}
|
|
actionHref={linkHref() ?? undefined}
|
|
/>
|
|
</div>
|
|
|
|
{#if upcoming.length === 0}
|
|
<p class="rounded-lg border border-stein-200 px-3 py-6 text-center text-sm text-stein-500">
|
|
{t(T.calendar_no_future_events)}
|
|
</p>
|
|
{:else}
|
|
<ul class="divide-y divide-stein-100 overflow-hidden rounded-lg border border-stein-200 bg-white">
|
|
{#each upcoming as { it, start } (it._slug ?? it.title)}
|
|
{@const timeStr = fmtTime(start)}
|
|
{@const loc = locationText(it)}
|
|
{@const title = splitTitle(it.title ?? t(T.calendar_untitled))}
|
|
{@const base = linkHref()}
|
|
{@const itemHref = base && it._slug ? `${base}#event-${it._slug}` : null}
|
|
<li>
|
|
<svelte:element
|
|
this={itemHref ? "a" : "div"}
|
|
href={itemHref ?? undefined}
|
|
class="flex items-start gap-3 px-4 py-3 no-underline {itemHref ? 'transition-colors hover:bg-stein-50' : ''}"
|
|
>
|
|
<div class="flex w-12 shrink-0 flex-col items-center rounded-sm bg-stein-800 px-1 py-1.5 text-white">
|
|
<span class="text-[0.6rem] font-semibold uppercase leading-none tracking-wide">
|
|
{start.toLocaleDateString("de-DE", { month: "short" }).replace(".", "")}
|
|
</span>
|
|
<span class="mt-0.5 text-lg font-bold tabular-nums leading-none">
|
|
{start.getDate()}
|
|
</span>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-semibold leading-snug text-stein-900">
|
|
{title.main}{#if title.paren}{" "}<span class="font-normal text-stein-400">{title.paren}</span>{/if}
|
|
</p>
|
|
<p class="mt-1 flex items-center gap-1.5 text-xs text-stein-500">
|
|
<Icon icon="lucide:clock" class="size-3.5 shrink-0" aria-hidden="true" />
|
|
<span class="min-w-0 truncate">
|
|
{fmtDate(start)}{timeStr ? ` · ${timeStr}` : ""}{loc ? ` · ${loc}` : ""}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</svelte:element>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|