Files
windwiderstand/src/lib/components/blocks/CalendarBlock.svelte
T
Peter Meier f0d9fecfb8 feat: Social-Media-Bild-Generator für Kalender-Termine
Neuer Endpoint /api/social/calendar/[slug] rendert Termin via satori +
resvg zu PNG. Formate: og (1200x630) und square (1080x1080).
Gebrandetes Layout mit Datum, Titel, Ort. Inter-Font lazy von gstatic,
10min In-Memory-Cache pro Slug+Format.

CalendarBlock: zwei Download-Buttons pro Termin (Bild quer / quadr.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:28:00 +02:00

1479 lines
61 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
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 {
downloadICS,
downloadICSFeed,
googleCalUrl,
mapsUrl,
type CalendarItemICS,
} from "$lib/calendar-ics";
import { getCmsImageUrl, extractCmsImageField } from "$lib/rusty-image";
import { marked } from "$lib/markdown-safe";
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
import ImageModal from "$lib/components/ImageModal.svelte";
type EventCardItem = CalendarItemData & {
start: Date;
end: Date | null;
timeStr: string;
isMultiDay: boolean;
spanDayKeys: string[];
};
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));
/** Wall-clock ticker for live countdown — refreshed every minute on
* the client. SSR uses Date.now() at render time; the first client
* tick after hydration takes over. */
let nowMs = $state(Date.now());
let tickTimer: ReturnType<typeof setInterval> | null = null;
/** UI state: filter chips (tag), past-events toggle, calendar view
* + selected day. */
let showPast = $state(false);
let activeTag = $state<string | null>(null);
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
function parseDate(iso: string | undefined | null): Date | null {
if (!iso) return null;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
/** Calendar-day key in the *local* timezone — avoids ISO timezone
* drift when the upstream string lacks a `Z`/offset. */
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function spanKeys(start: Date, end: Date | null): string[] {
if (!end || end.getTime() <= start.getTime()) return [dayKey(start)];
const out: string[] = [];
const cur = new Date(start);
cur.setHours(12, 0, 0, 0);
const last = new Date(end);
last.setHours(12, 0, 0, 0);
while (cur.getTime() <= last.getTime()) {
out.push(dayKey(cur));
cur.setDate(cur.getDate() + 1);
}
return out;
}
function formatTime(d: Date | null): string {
if (!d) return "";
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
}
function getEventLinkHref(link: unknown): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
const o = link as { url?: string; _slug?: string };
if (typeof o.url === "string" && o.url) return o.url;
if (typeof o._slug === "string" && o._slug)
return `/post/${encodeURIComponent(o._slug)}`;
}
return "";
}
/** Location kann string ODER {text, lat, lng}-Objekt sein
* (RustyCMS-Resolve liefert eventLocation-Struktur). */
function locationText(loc: unknown): string {
if (typeof loc === "string") return loc;
if (loc && typeof loc === "object") {
const t = (loc as { text?: unknown }).text;
if (typeof t === "string") return t;
}
return "";
}
/** Normalised, sorted item list. Filters out malformed entries. */
const items = $derived.by((): EventCardItem[] => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => {
const start = parseDate(x.terminZeit);
const end = parseDate(x.terminEnde);
if (!start) return null;
const isMultiDay = !!end && dayKey(end) !== dayKey(start);
return {
...x,
start,
end: isMultiDay ? end : null,
timeStr: formatTime(start),
isMultiDay,
spanDayKeys: spanKeys(start, isMultiDay ? end : null),
} satisfies EventCardItem;
})
.filter((x): x is EventCardItem => x !== null)
.sort((a, b) => a.start.getTime() - b.start.getTime());
});
/** Distinct tag list across all items (case-insensitive de-dup,
* display in original casing of first occurrence). */
const allTags = $derived.by((): string[] => {
const seen = new Map<string, string>();
for (const it of items) {
for (const tag of it.tags ?? []) {
const key = tag.trim().toLowerCase();
if (!key) continue;
if (!seen.has(key)) seen.set(key, tag.trim());
}
}
return [...seen.values()].sort((a, b) => a.localeCompare(b, "de"));
});
const filteredItems = $derived.by(() => {
if (!activeTag) return items;
const needle = activeTag.toLowerCase();
return items.filter((it) =>
(it.tags ?? []).some((t) => t.toLowerCase() === needle),
);
});
/** Map a day-key → events overlapping that day (multi-day events
* appear under each spanned day). */
const eventsByDay = $derived.by(() => {
const map = new Map<string, EventCardItem[]>();
for (const ev of filteredItems) {
for (const k of ev.spanDayKeys) {
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(ev);
}
}
return map;
});
const startOfTodayMs = $derived.by(() => {
const d = new Date(nowMs);
d.setHours(0, 0, 0, 0);
return d.getTime();
});
/** Future = anything whose end (or start, if no end) is today or
* later. So a multi-day event running today is still "future"
* until its last day passes. */
const futureEvents = $derived.by(() =>
filteredItems.filter((it) => {
const cutoff = (it.end ?? it.start).getTime();
return cutoff >= startOfTodayMs;
}),
);
const pastEvents = $derived.by(() =>
filteredItems
.filter((it) => {
const cutoff = (it.end ?? it.start).getTime();
return cutoff < startOfTodayMs;
})
.reverse(),
);
type EventGroup = { dayKey: string; date: Date; events: EventCardItem[] };
const futureEventsByDay = $derived.by((): EventGroup[] => {
const groups: EventGroup[] = [];
for (const ev of futureEvents) {
const key = dayKey(ev.start);
const last = groups[groups.length - 1];
if (last && last.dayKey === key) {
last.events.push(ev);
} else {
groups.push({ dayKey: key, date: ev.start, events: [ev] });
}
}
return groups;
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
const todayKey = $derived.by(() => dayKey(new Date(nowMs)));
// ── Calendar-grid derived state ──────────────────────────────────
const year = $derived(currentMonth.getFullYear());
const month = $derived(currentMonth.getMonth());
const monthLabel = $derived(
currentMonth.toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
);
const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const count = last.getDate();
const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last };
});
const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null);
const days = Array.from(
{ length: count },
(_, i) => new Date(year, month, i + 1),
);
return [...empty, ...days];
});
// ── Formatters ───────────────────────────────────────────────────
function fmtSummaryDate(d: Date): string {
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
});
}
function fmtGroupHeader(d: Date): string {
return d.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
}
function fmtRangeShort(start: Date, end: Date): string {
const sameMonth =
start.getMonth() === end.getMonth() &&
start.getFullYear() === end.getFullYear();
const fmtStart = start.toLocaleDateString("de-DE", {
day: "numeric",
...(sameMonth ? {} : { month: "short" }),
});
const fmtEnd = end.toLocaleDateString("de-DE", {
day: "numeric",
month: "short",
year:
end.getFullYear() !== start.getFullYear()
? "numeric"
: undefined,
});
return t(T.calendar_multi_day_range, { from: fmtStart, to: fmtEnd });
}
/** Relative day diff for a target date vs. current `nowMs`. */
function relativeDays(d: Date): string {
const today = new Date(nowMs);
today.setHours(0, 0, 0, 0);
const target = new Date(d);
target.setHours(0, 0, 0, 0);
const diff = Math.round(
(target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000),
);
if (diff === 0) return t(T.calendar_today);
if (diff === 1) return t(T.calendar_tomorrow);
if (diff < 0) return "";
return t(T.calendar_in_days, { n: diff });
}
/** Live status badge text — "läuft gerade", "beginnt in 45 min",
* "beginnt in 3 h" — only meaningful for events within ~24h. */
function liveStatus(ev: EventCardItem): string {
const startMs = ev.start.getTime();
const endMs = (ev.end ?? ev.start).getTime();
const dayLengthMs = ev.isMultiDay
? endMs - startMs
: 24 * 60 * 60 * 1000;
if (
nowMs >= startMs &&
nowMs <= endMs + (ev.isMultiDay ? 0 : dayLengthMs)
) {
// Only flag "running" if event has a meaningful duration window
if (
nowMs <= endMs ||
(ev.timeStr && nowMs <= startMs + 4 * 60 * 60 * 1000)
) {
return t(T.calendar_running_now);
}
}
const diffMs = startMs - nowMs;
if (diffMs <= 0 || diffMs > 24 * 60 * 60 * 1000) return "";
const minutes = Math.round(diffMs / 60000);
if (minutes < 60) {
return t(T.calendar_starts_in_minutes, { n: Math.max(1, minutes) });
}
const hours = Math.round(minutes / 60);
return t(T.calendar_starts_in, { n: hours });
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
function prevMonth() {
currentMonth = new Date(year, month - 1);
selectedDay = null;
}
function nextMonth() {
currentMonth = new Date(year, month + 1);
selectedDay = null;
}
/** "Show me this in the calendar" — used from accordion bodies for
* events that may be in a different month than the current view.
* Scrolls back up to the widget since it now sits above the list. */
function jumpToEventInGrid(ev: EventCardItem) {
currentMonth = new Date(ev.start.getFullYear(), ev.start.getMonth(), 1);
selectedDay = dayKey(ev.start);
widgetEl?.scrollIntoView({ block: "start", behavior: "smooth" });
}
/** Visual urgency-bucket for the colored left-border on each event
* card. "today" / "tomorrow" / "this-week" pull stronger accent
* colors so the eye lands on imminent dates first when scrolling
* the list. */
type Urgency = "now" | "today" | "tomorrow" | "week" | "later";
function urgencyOf(ev: EventCardItem): Urgency {
const startMs = ev.start.getTime();
const endMs = (ev.end ?? ev.start).getTime();
if (nowMs >= startMs && nowMs <= endMs + 4 * 60 * 60 * 1000)
return "now";
const today = new Date(nowMs);
today.setHours(0, 0, 0, 0);
const target = new Date(ev.start);
target.setHours(0, 0, 0, 0);
const diffDays = Math.round(
(target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000),
);
if (diffDays <= 0) return "today";
if (diffDays === 1) return "tomorrow";
if (diffDays <= 7) return "week";
return "later";
}
function toICS(ev: EventCardItem): CalendarItemICS {
return {
title: ev.title || t(T.calendar_untitled),
start: ev.start,
end: ev.end ?? null,
description: ev.description ?? undefined,
location: locationText(ev.location) || undefined,
url: getEventLinkHref(ev.link) || undefined,
};
}
function fileSlug(s: string): string {
return (
s
.toLowerCase()
.replace(/[äöüß]/g, (c) =>
c === "ä"
? "ae"
: c === "ö"
? "oe"
: c === "ü"
? "ue"
: "ss",
)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "termin"
);
}
const weekdays = $derived([
t(T.calendar_weekday_mo),
t(T.calendar_weekday_di),
t(T.calendar_weekday_mi),
t(T.calendar_weekday_do),
t(T.calendar_weekday_fr),
t(T.calendar_weekday_sa),
t(T.calendar_weekday_so),
]);
let hasHover = $state(false);
let tooltipDay = $state<string | null>(null);
let tooltipPos = $state({ x: 0, y: 0 });
function showTooltip(e: MouseEvent, key: string) {
if (!hasHover) return;
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
tooltipPos = { x: r.left + r.width / 2, y: r.top };
tooltipDay = key;
}
function hideTooltip() {
tooltipDay = null;
}
const tooltipEvents = $derived(
tooltipDay ? (eventsByDay.get(tooltipDay) ?? []) : [],
);
let feedHost = $state("");
const webcalUrl = $derived(
feedHost ? `webcal://${feedHost}/api/kalender.ics` : "#",
);
const icsUrl = $derived(feedHost ? `/api/kalender.ics` : "#");
const isApplePlatform = $derived(
feedHost
? /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent)
: false,
);
function exportAll() {
const icsItems: CalendarItemICS[] = items.map(toICS);
downloadICSFeed(icsItems, "windwiderstand-termine");
}
let shareToastKey = $state<string | null>(null);
let copyToastKey = $state<string | null>(null);
let modalImage = $state<{ src: string; alt: string } | null>(null);
let linkedSlug = $state<string | null>(null);
function eventId(item: EventCardItem): string {
if (item._slug) return `event-${item._slug}`;
const titlePart = (item.title || "event")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return `event-${titlePart}-${dayKey(item.start)}`;
}
async function shareEvent(
ev: EventCardItem,
titleStr: string,
locStr: string,
): Promise<void> {
const dateStr = ev.start.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
const timeInfo = ev.timeStr ? ` · ${ev.timeStr}` : "";
const parts = [`${titleStr}`, `${dateStr}${timeInfo}`];
if (locStr) parts.push(locStr);
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
const text = parts.join("\n");
const key = ev._slug ?? String(ev.start.getTime());
const showToast = () => {
shareToastKey = key;
setTimeout(() => {
if (shareToastKey === key) shareToastKey = null;
}, 2000);
};
if (typeof navigator.share === "function") {
try {
await navigator.share({ title: titleStr, text, url });
} catch (e) {
if ((e as DOMException).name !== "AbortError") {
await navigator.clipboard
.writeText(`${text}\n${url}`)
.catch(() => {});
showToast();
}
}
} else {
await navigator.clipboard
.writeText(`${text}\n${url}`)
.catch(() => {});
showToast();
}
}
async function copyEventLink(ev: EventCardItem): Promise<void> {
const url = `${window.location.origin}${window.location.pathname}#${eventId(ev)}`;
const key = ev._slug ?? String(ev.start.getTime());
await navigator.clipboard.writeText(url).catch(() => {});
copyToastKey = key;
setTimeout(() => {
if (copyToastKey === key) copyToastKey = null;
}, 2000);
}
let widgetEl: HTMLElement | null = $state(null);
onMount(async () => {
feedHost = window.location.host;
hasHover = window.matchMedia(
"(hover: hover) and (pointer: fine)",
).matches;
tickTimer = setInterval(() => {
nowMs = Date.now();
}, 60_000);
const hash = window.location.hash.slice(1);
if (hash.startsWith("event-")) {
linkedSlug = hash;
await tick();
document
.getElementById(hash)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
onDestroy(() => {
if (tickTimer) clearInterval(tickTimer);
});
</script>
<div
class="{layoutClasses} w-full min-w-0 calendar-block card-surface overflow-hidden"
data-block="Calendar"
data-block-type="calendar"
data-block-slug={block._slug}
>
{#snippet eventAccordion(
item: EventCardItem,
openByDefault: boolean,
isNext: boolean,
)}
{@const eventHref = getEventLinkHref(item.link)}
{@const live = liveStatus(item)}
{@const title = item.title || t(T.calendar_untitled)}
{@const urgency = urgencyOf(item)}
{@const locText = locationText(item.location)}
{@const hasBody = !!(
item.description ||
eventHref ||
locText ||
item.isMultiDay
)}
<li
id={eventId(item)}
class="event-item {isNext ? 'event-item-next' : ''}"
data-urgency={urgency}
>
<details
class="group/event"
open={openByDefault || linkedSlug === eventId(item)}
>
<summary
class="cursor-pointer list-none p-3 flex items-start gap-3 hover:bg-himmel-50/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-himmel-500"
>
<!-- Datum-Block: Wochentag/Tag/Monat in vertikalem Stack —
als Block scannbar, große Tag-Zahl als Anker. Bei Multi-
Day fällt das Block-Layout auf Range zurück. -->
{#if item.isMultiDay && item.end}
<div
class="event-date-range shrink-0 w-20 text-center leading-tight"
>
<div
class="text-[10px] uppercase tracking-wide text-stein-500"
>
{item.start.toLocaleDateString("de-DE", {
month: "short",
})}
</div>
<div
class="text-base font-bold text-stein-900 tabular-nums"
>
{item.start.getDate()}{item.end.getDate()}
</div>
<div class="text-[10px] text-stein-500">
{item.start.getFullYear() ===
item.end.getFullYear()
? item.end.toLocaleDateString("de-DE", {
month: "short",
})
: item.end.getFullYear()}
</div>
</div>
{:else}
<div
class="event-date-block shrink-0 w-14 aspect-square flex flex-col items-center justify-between leading-tight bg-himmel-800 rounded-xs py-2"
>
<div
class="text-[10px] uppercase tracking-wide text-himmel-300"
>
{item.start.toLocaleDateString("de-DE", {
weekday: "short",
})}
</div>
<div
class="text-2xl font-bold text-himmel-50 tabular-nums"
>
{item.start.getDate()}
</div>
<div
class="text-[10px] uppercase tracking-wide text-himmel-300"
>
{item.start.toLocaleDateString("de-DE", {
month: "short",
})}
</div>
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<span
class="text-sm font-semibold text-stein-900 leading-snug"
>
{title}
</span>
{#if live}
<span
class="text-[10px] uppercase tracking-wide font-semibold text-fire-50 bg-fire-700 px-1.5 py-0.5 rounded-xs shrink-0"
>
{live}
</span>
{/if}
</div>
<div
class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600"
>
{#if item.timeStr && !item.isMultiDay}
<span
class="inline-flex items-center gap-1 tabular-nums"
>
<Icon
icon="mdi:clock-outline"
class="size-3"
aria-hidden="true"
/>
{item.timeStr}
</span>
{/if}
{#if locText}
<span
class="inline-flex items-center gap-1 min-w-0"
>
<Icon
icon="mdi:map-marker-outline"
class="size-3 shrink-0"
aria-hidden="true"
/>
<span class="truncate">{locText}</span>
</span>
{/if}
{#if item.tags && item.tags.length > 0}
{#each item.tags.slice(0, 2) as tg}
<span
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
>
{tg}
</span>
{/each}
{#if item.tags.length > 2}
<span class="text-[10px] text-stein-500"
>+{item.tags.length - 2}</span
>
{/if}
{/if}
</div>
</div>
<Icon
icon="mdi:chevron-down"
class="size-4 text-stein-400 shrink-0 mt-1 transition-transform group-open/event:rotate-180"
aria-hidden="true"
/>
</summary>
<div
class="px-3 pb-3 pt-0 text-sm text-stein-700 space-y-2"
>
<div
class="text-[11px] uppercase tracking-wide text-stein-500 -mt-1"
>
{relativeDays(item.start)}
</div>
{#if item.description}
<div class="prose prose-sm prose-stein max-w-none font-light leading-relaxed mt-0 mb-2">
{@html marked.parse(item.description)}
</div>
{/if}
{#if item.tags && item.tags.length > 2}
<div class="flex flex-wrap gap-1">
{#each item.tags as tg}
<span
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
>
{tg}
</span>
{/each}
</div>
{/if}
{#if item.image}
{@const imageField = extractCmsImageField(item.image)}
{#if imageField}
{@const thumbSrc = getCmsImageUrl(imageField.url, {
width: 160,
height: 160,
fit: "cover",
})}
{@const fullSrc = getCmsImageUrl(imageField.url, {
width: 1400,
fit: "contain",
})}
{@const imageAlt =
imageField.alt ??
(typeof item.image === "object" &&
"description" in item.image
? (item.image.description ?? "")
: "")}
<button
type="button"
onclick={() =>
(modalImage = {
src: fullSrc,
alt: imageAlt,
})}
class="group relative w-24 h-24 rounded overflow-hidden border border-stein-200 shrink-0 hover:opacity-90 transition-opacity"
aria-label="Bild vergrößern"
>
<img
src={thumbSrc}
alt={imageAlt}
class="w-full h-full object-cover"
loading="lazy"
/>
<span
class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors"
>
<Icon
icon="mdi:magnify-plus"
class="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow"
aria-hidden="true"
/>
</span>
</button>
{/if}
{/if}
<div class="flex flex-wrap gap-2 pt-1 text-xs">
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-3.5"
aria-hidden="true"
/>
</a>
{/if}
{#if locText}
<a
href={mapsUrl(locText)}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_open_maps)}
>
<Icon
icon="mdi:map-marker"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_open_maps)}</span
>
</a>
{/if}
{#if item.attachment?.src}
{@const dlSrc = `/cms-files?src=${encodeURIComponent(item.attachment.src)}&dl=1`}
<a
href={dlSrc}
class="btn-outline btn-sm group"
aria-label={item.attachment.label ||
"Anhang herunterladen"}
>
<Icon
icon="mdi:file-download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{item.attachment.label ||
"Anhang herunterladen"}</span
>
</a>
{/if}
<button
type="button"
onclick={() =>
downloadICS(toICS(item), fileSlug(title))}
class="btn-outline btn-sm group"
aria-label={t(T.calendar_download_ics)}
>
<Icon
icon="mdi:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_download_ics)}</span
>
</button>
<a
href={googleCalUrl(toICS(item))}
target="_blank"
rel="noopener noreferrer"
class="btn-outline btn-sm group"
aria-label={t(T.calendar_add_to_google)}
>
<Icon
icon="mdi:google"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_add_to_google)}</span
>
</a>
{#if feedHost}
{@const shareKey =
item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
<button
type="button"
onclick={() => copyEventLink(item)}
class="btn-outline btn-sm group"
aria-label={t(T.post_action_copy)}
>
<Icon
icon={copied
? "mdi:check"
: "mdi:link-variant"}
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{copied
? t(T.post_action_copied)
: t(T.post_action_copy)}</span
>
</button>
{@const shared = shareToastKey === shareKey}
<button
type="button"
onclick={() => shareEvent(item, title, locText)}
class="btn-outline btn-sm group"
aria-label={t(T.calendar_share_aria)}
>
<Icon
icon={shared
? "mdi:check"
: "mdi:share-variant"}
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{shared
? t(T.calendar_share_copied)
: t(T.calendar_share)}</span
>
</button>
{/if}
{#if item._slug}
<a
href={`/api/social/calendar/${item._slug}?format=og`}
download={`termin-${item._slug}-og.png`}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quer 1200×630)"
title="Social-Bild Quer (1200×630)"
>
<Icon
icon="mdi:image-outline"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Bild quer</span>
</a>
<a
href={`/api/social/calendar/${item._slug}?format=square`}
download={`termin-${item._slug}-square.png`}
class="btn-outline btn-sm group"
aria-label="Social-Bild (Quadrat 1080×1080)"
title="Social-Bild Quadrat (1080×1080)"
>
<Icon
icon="mdi:crop-square"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">Bild quadr.</span>
</a>
{/if}
<button
type="button"
onclick={() => jumpToEventInGrid(item)}
class="btn-outline btn-sm group"
aria-label={t(T.calendar_jump_to_month)}
>
<Icon
icon="mdi:calendar-search"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap"
>{t(T.calendar_jump_to_month)}</span
>
</button>
</div>
</div>
</details>
</li>
{/snippet}
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
{block.title}
</h3>
{/if}
{#if allTags.length > 0}
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
mindestens ein Termin Tags hat — sonst Lärm. -->
<div
class="flex flex-wrap gap-1.5 items-center px-4 py-2 border-b border-stein-200 bg-stein-50"
>
<span class="text-[11px] uppercase tracking-wide text-stein-500">
{t(T.calendar_filter_by_tag)}
</span>
<button
type="button"
onclick={() => (activeTag = null)}
class="chip {!activeTag
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={!activeTag}
>
{t(T.calendar_all_tags)}
</button>
{#each allTags as tag}
<button
type="button"
onclick={() => (activeTag = activeTag === tag ? null : tag)}
class="chip {activeTag === tag
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
aria-pressed={activeTag === tag}
>
{tag}
</button>
{/each}
</div>
{/if}
<!-- Subscribe + Export Actions. Nur nach mount sichtbar (feedHost gesetzt).
Apple: webcal:// Link → öffnet nativ Kalender.app / iOS Kalender.
Alle anderen: direkter ICS-Download als Fallback. -->
{#if feedHost}
<div
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-stein-200 bg-himmel-100"
>
{#if isApplePlatform}
<a
href={webcalUrl}
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{:else}
<a
href={icsUrl}
download="windwiderstand-termine.ics"
aria-label={t(T.calendar_subscribe_aria)}
class="btn-primary btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:calendar-plus"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_subscribe)}
</a>
{/if}
{#if items.length > 0}
<button
type="button"
onclick={exportAll}
class="btn-outline btn-sm inline-flex items-center gap-1.5 min-h-[36px]"
>
<Icon
icon="mdi:download"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
{t(T.calendar_export_all)}
</button>
{/if}
</div>
{/if}
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
Tag-Grid. Klick auf Tag filtert Liste unten. -->
<div
bind:this={widgetEl}
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
>
<div class="w-full">
<div class="flex items-center justify-between gap-2 mb-2">
<span
class="text-xl font-semibold text-himmel-900 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<div class="flex items-center gap-0.5">
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon
icon="mdi:chevron-left"
class="size-5"
aria-hidden="true"
/>
</button>
<button
type="button"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon
icon="mdi:chevron-right"
class="size-5"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
{#each weekdays as w, i}
<div
class="py-1.5 text-xs font-bold rounded-sm {i >= 5
? 'bg-stein-100/80 text-stein-600'
: 'bg-himmel-200/50 text-himmel-800'}"
>
{w}
</div>
{/each}
{#each calendarDays as d}
{#if d === null}
<div class="h-10" aria-hidden="true"></div>
{:else}
{@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const dayEvents = eventsByDay.get(key) ?? []}
{@const hasEvents = dayEvents.length > 0}
{@const isSelected = selectedDay === key}
{@const isToday = key === todayKey}
{@const isFuture = key >= todayKey}
{@const isWeekend =
d.getDay() === 0 || d.getDay() === 6}
{#if hasEvents}
<button
type="button"
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer font-semibold
{!isFuture ? 'opacity-20 day-past' : ''}
{isCurrentMonth
? 'text-himmel-900'
: 'text-himmel-700 opacity-60 hover:opacity-80'}
{isWeekend
? 'bg-erde-100 hover:bg-erde-200'
: 'bg-himmel-200/80 hover:bg-himmel-300'}
{isSelected
? 'ring-2 ring-himmel-700 brightness-95'
: ''}
{isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)}
onmouseenter={(e) => showTooltip(e, key)}
onmouseleave={hideTooltip}
aria-pressed={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label="{d.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}, {dayEvents.length} {t(
T.calendar_event_count,
{ n: dayEvents.length },
).replace(/\d+\s*/, '')}"
>
<span class="text-xs">{d.getDate()}</span>
<span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 sm:min-w-4.5 sm:h-4.5 px-0.5 rounded-full text-stein-0 text-[9px] sm:text-[10px] font-medium leading-none {isFuture
? 'bg-wald-400'
: 'bg-error'}"
aria-hidden="true"
>
{dayEvents.length}
</span>
</button>
{:else}
<div
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default
{!isFuture ? 'opacity-20 day-past' : ''}
{isWeekend
? 'bg-stein-100/60'
: 'bg-himmel-50/50'}
{isCurrentMonth
? 'text-himmel-800'
: 'text-himmel-600 opacity-50'}
{isToday ? 'today-cell' : ''}"
aria-current={isToday ? "date" : undefined}
>
<span class="text-xs">{d.getDate()}</span>
{#if isToday}
<span class="sr-only"
>{t(T.calendar_today_marker)}</span
>
{/if}
</div>
{/if}
{/if}
{/each}
</div>
</div>
</div>
<!-- Hover-Tooltip: fixed-positioned → nicht durch overflow-hidden geclipt.
Nur auf pointer:fine Geräten (hasHover). Zeigt max 3 Events des Tages. -->
{#if hasHover && tooltipDay && tooltipEvents.length > 0}
<div
class="cal-tooltip"
style="left:{tooltipPos.x}px; top:{tooltipPos.y}px;"
aria-hidden="true"
>
{#each tooltipEvents.slice(0, 3) as ev}
<div class="cal-tooltip-row">
<span class="cal-tooltip-title"
>{ev.title || t(T.calendar_untitled)}</span
>
{#if ev.timeStr}
<span class="cal-tooltip-time">{ev.timeStr}</span>
{/if}
</div>
{/each}
{#if tooltipEvents.length > 3}
<div class="cal-tooltip-more">
+{tooltipEvents.length - 3} weitere
</div>
{/if}
</div>
{/if}
<!-- Events: voll Breite, unten. Akkordeon-Liste, in natürlichem
Page-Flow (kein internes Scrollen). Kalender-Klick filtert auf
einen Tag; ohne Filter Liste ab heute, gruppiert nach Tag. -->
<div class="calendar-events-panel bg-stein-0 border-t border-stein-200">
{#if items.length === 0}
<p class="text-sm text-stein-500 p-4">{t(T.calendar_no_events)}</p>
{:else if selectedDay}
<div
class="flex items-center justify-between gap-2 px-3 py-2 border-b border-stein-200 bg-himmel-50"
>
<div class="text-xs font-medium text-stein-700 truncate">
{new Date(selectedDay + "T12:00:00").toLocaleDateString(
"de-DE",
{
weekday: "long",
day: "numeric",
month: "long",
},
)}
</div>
<button
type="button"
onclick={() => (selectedDay = null)}
class="inline-flex items-center gap-1 text-[11px] text-himmel-700 hover:text-himmel-900 hover:underline shrink-0"
>
<Icon
icon="mdi:close"
class="size-3.5"
aria-hidden="true"
/>
{t(T.calendar_clear_filter)}
</button>
</div>
<ul class="m-0! p-0! list-none">
{#each selectedDayEvents as item}
{@render eventAccordion(item, true, false)}
{/each}
</ul>
{:else if futureEventsByDay.length > 0}
<div
class="flex items-baseline justify-between gap-2 px-3 py-2 border-b border-stein-200"
>
<h4 class="text-xs font-medium text-stein-500 m-0!">
{t(T.calendar_all_upcoming)}
</h4>
<span class="text-[11px] text-stein-500 tabular-nums">
{t(T.calendar_event_count, { n: futureEvents.length })}
</span>
</div>
<ul class="m-0! p-0! list-none">
{#each futureEventsByDay as group, gIdx}
<li class="day-group">
<div
class="px-3 py-1 bg-himmel-50/60 text-[11px] uppercase tracking-wide font-medium text-stein-600 sticky top-0 z-10 backdrop-blur-sm"
>
{fmtGroupHeader(group.date)}
<span
class="ml-2 text-stein-500 normal-case tracking-normal lowercase"
>· {relativeDays(group.date)}</span
>
</div>
<ul class="m-0! p-0! list-none">
{#each group.events as item, eIdx}
{@render eventAccordion(
item,
gIdx === 0,
gIdx === 0 && eIdx === 0,
)}
{/each}
</ul>
</li>
{/each}
</ul>
{#if pastEvents.length > 0}
<details
class="border-t border-stein-200 bg-stein-50 group/past"
>
<summary
class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5"
>
<Icon
icon="mdi:chevron-right"
class="size-3.5 transition-transform group-open/past:rotate-90"
aria-hidden="true"
/>
<span class="font-medium uppercase tracking-wide"
>{t(T.calendar_past_events)}</span
>
<span class="text-stein-500 tabular-nums"
>· {pastEvents.length}</span
>
</summary>
<ul class="m-0! p-0! list-none opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{/if}
{:else if pastEvents.length > 0}
<!-- Nur Vergangene da, keine Zukunft → past-Liste expanded
damit Panel nicht leer wirkt. -->
<details open class="bg-stein-50 group/past">
<summary
class="cursor-pointer list-none px-3 py-2 text-xs text-stein-600 hover:bg-stein-100 inline-flex items-center gap-1.5 border-b border-stein-200"
>
<Icon
icon="mdi:chevron-right"
class="size-3.5 transition-transform group-open/past:rotate-90"
aria-hidden="true"
/>
<span class="font-medium uppercase tracking-wide"
>{t(T.calendar_past_events)}</span
>
<span class="text-stein-500 tabular-nums"
>· {pastEvents.length}</span
>
</summary>
<ul class="m-0! p-0! list-none opacity-80">
{#each pastEvents as item}
{@render eventAccordion(item, false, false)}
{/each}
</ul>
</details>
{:else}
<p class="text-sm text-stein-500 p-4">
{t(T.calendar_no_future_events)}
</p>
{/if}
</div>
</div>
{#if modalImage}
<ImageModal
src={modalImage.src}
alt={modalImage.alt}
onclose={() => (modalImage = null)}
/>
{/if}
<style>
/* Heute-Markierung im Kalendergrid: dezenter Rand + leichtes Glow.
Ergänzt das Event-Badge, damit auch leere Tage als "Heute"
erkennbar sind. */
.calendar-block .today-cell {
outline: 2px solid var(--color-wald-400, #84e0a4);
outline-offset: -2px;
background-color: color-mix(
in srgb,
var(--color-wald-200, #bbf0cf) 40%,
transparent
);
}
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator
zwischen Tagesgruppen. So lassen sich Termine optisch klar
auseinanderhalten ohne komplette Kacheloptik. */
.calendar-block .event-item {
position: relative;
border-left: 4px solid transparent;
background: var(--color-stein-0, #fff);
transition: background-color 150ms ease;
}
.calendar-block .event-item + .event-item {
border-top: 1px solid var(--color-stein-200, #e0dcd5);
}
/* Zebra: jeder zweite Termin in einer Tagesgruppe leicht abgedunkelt
— sauber per :nth-of-type, ohne dass Day-Group-Header einen Slot
verbraucht (event-item ist eigene <li>-Sequenz). */
.calendar-block .event-item:nth-of-type(even) {
background: var(--color-stein-50, #f7f5f1);
}
.calendar-block .event-item:hover {
background: var(--color-himmel-50, #eef4f8);
}
/* Urgency-Akzent: 4px farbiger Strich links. Alphabetische
Wichtigkeit: now (rot stark) → today (rot) → tomorrow (warm) →
week (kühl) → later (neutral). */
.calendar-block .event-item[data-urgency="now"] {
border-left-color: var(--color-fire-700, #b91c1c);
background: var(--color-fire-50, #fef2f2);
}
.calendar-block .event-item[data-urgency="today"] {
border-left-color: var(--color-fire-500, #ef4444);
}
.calendar-block .event-item[data-urgency="tomorrow"] {
border-left-color: var(--color-erde-500, #c97a3b);
}
.calendar-block .event-item[data-urgency="week"] {
border-left-color: var(--color-himmel-500, #4798c4);
}
.calendar-block .event-item[data-urgency="later"] {
border-left-color: var(--color-stein-300, #c4bfb4);
}
/* Next-up: kräftigere linke Kante + leichter himmel-Tint, plus
ein subtiler ring damit der Eintrag beim Scroll-into-view
visuell anspringt. */
.calendar-block .event-item.event-item-next {
border-left-width: 6px;
box-shadow: inset 4px 0 0 var(--color-himmel-100, #d8e6ef);
}
/* Day-Group: dickerer Separator + etwas Luft zwischen Gruppen. */
.calendar-block .day-group + .day-group {
border-top: 4px solid var(--color-stein-100, #ede8df);
}
/* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover.
pointer-events:none damit kein mouseenter/leave Loop entsteht. */
:global(.cal-tooltip) {
position: fixed;
z-index: 9999;
transform: translateX(-50%) translateY(calc(-100% - 8px));
background: #fff;
color: var(--color-stein-900, #1c1a17);
border: 1px solid var(--color-stein-200, #e0dcd5);
border-radius: 4px;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
font-size: 11px;
line-height: 1.4;
pointer-events: none;
min-width: 130px;
max-width: 220px;
}
:global(.cal-tooltip::after) {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--color-stein-200, #e0dcd5);
}
:global(.cal-tooltip::before) {
content: "";
position: absolute;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #fff;
z-index: 1;
}
:global(.cal-tooltip-row) {
display: flex;
align-items: baseline;
gap: 4px;
padding: 1px 0;
}
:global(.cal-tooltip-row + .cal-tooltip-row) {
border-top: 1px solid var(--color-stein-100, #ede8df);
margin-top: 2px;
padding-top: 3px;
}
:global(.cal-tooltip-title) {
font-weight: 500;
color: var(--color-stein-900, #1c1a17);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
:global(.cal-tooltip-time) {
font-size: 10px;
color: var(--color-stein-500, #7a7469);
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
}
:global(.cal-tooltip-more) {
font-size: 10px;
color: var(--color-stein-500, #7a7469);
margin-top: 3px;
border-top: 1px solid var(--color-stein-100, #ede8df);
padding-top: 3px;
}
/* Print: alle <details> geöffnet, Chevrons aus, Day-Groups nicht
splitten. */
@media print {
.calendar-block details > *:not(summary) {
display: block !important;
}
.calendar-block details > summary :global(svg) {
display: none;
}
.calendar-block .day-group {
break-inside: avoid;
}
}
</style>