Files
windwiderstand/src/lib/components/blocks/CalendarBlock.svelte
T
Peter Meier 2c61cc03b2
Deploy / verify (push) Successful in 1m10s
Deploy / deploy (push) Successful in 1m47s
style(kalender): Termin-melden-Button Gelb als inline-style
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:44:53 +02:00

1500 lines
63 KiB
Svelte

<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";
import SocialImageModal from "$lib/components/SocialImageModal.svelte";
import QrModal from "$lib/components/QrModal.svelte";
import EventDateBox from "./EventDateBox.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);
let socialModal = $state<{ slug: string; title: string } | null>(null);
function openSocial(item: EventCardItem) {
if (!item._slug) return;
socialModal = { slug: item._slug, title: item.title || "Termin" };
}
function closeSocial() {
socialModal = null;
}
// QR-Code zum direkten Ausgeben/Scannen (Aushang, Infostand). Nutzt die
// canonical /kalender/termin/<slug>-URL (hat OG-Tags), Fallback = Hash-
// Anchor im Kalender wenn kein Slug.
let qrModal = $state<{ value: string; label: string } | null>(null);
function openQr(item: EventCardItem) {
const url = item._slug
? `${window.location.origin}/kalender/termin/${encodeURIComponent(item._slug)}/`
: `${window.location.origin}${window.location.pathname}#${eventId(item)}`;
qrModal = { value: url, label: item.title || "Termin" };
}
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" });
}
// Direkt-Link für Social-Bild-Modal: ?social=<slug>
// Beispiel: /kalender/?social=calendar-item-060626-...
const params = new URLSearchParams(window.location.search);
const socialSlug = params.get("social");
if (socialSlug) {
const item = items.find((it) => it._slug === socialSlug);
if (item) {
openSocial(item);
}
}
});
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-Kachel: Wochentag/Tag/Monat (Einzel) bzw.
Wochentag- und Tag-Range (mehrtägig). -->
<EventDateBox
start={item.start}
end={item.end}
multiDay={!!item.isMultiDay}
/>
<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="lucide:clock"
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="lucide:map-pin"
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="lucide: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: 240,
height: 240,
fit: "contain",
})}
{@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 flex max-w-24 max-h-24 shrink-0 items-center justify-center overflow-hidden hover:opacity-90 transition-opacity"
aria-label="Bild vergrößern"
>
<img
src={thumbSrc}
alt={imageAlt}
class="max-w-24 max-h-24 object-contain"
loading="lazy"
/>
<span
class="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors"
>
<Icon
icon="lucide:zoom-in"
class="size-5 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow"
aria-hidden="true"
/>
</span>
</button>
{/if}
{/if}
<!-- Aktionen: Primary-Reihe (Info/Karte/Anhang) + kompakte Action-Toolbar -->
<div class="pt-2 space-y-2">
{#if item._slug || eventHref || locText || item.attachment?.src}
<div class="flex flex-wrap gap-2">
{#if item._slug}
<a
href={`/kalender/termin/${encodeURIComponent(item._slug)}/`}
class="btn-tertiary btn-sm"
>
Details
<Icon
icon="lucide:arrow-right"
class="size-3.5"
aria-hidden="true"
/>
</a>
{/if}
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="btn-tertiary btn-sm"
>
{t(T.calendar_more_info)}
<Icon
icon="lucide:external-link"
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="lucide:map-pin"
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="lucide:file-down"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span class="whitespace-nowrap">{item.attachment.label || "Anhang"}</span>
</a>
{/if}
</div>
{/if}
<!-- Kompakte Action-Toolbar: gruppiert (Kalender · Teilen · Anzeigen) mit Separatoren.
Auf Mobile bleiben Label+Buttons pro Gruppe via inline-flex zusammen,
umgebrochen wird nur zwischen Gruppen. -->
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-stein-200 bg-stein-50 p-1">
<!-- Gruppe: Kalender -->
<div class="inline-flex items-center gap-1">
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Kalender</span>
<button
type="button"
onclick={() => downloadICS(toICS(item), fileSlug(title))}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title={t(T.calendar_download_ics)}
aria-label={t(T.calendar_download_ics)}
>
<Icon icon="lucide:download" class="size-3.5" />
ICS
</button>
<a
href={googleCalUrl(toICS(item))}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 no-underline hover:bg-white"
title={t(T.calendar_add_to_google)}
>
<Icon icon="mdi:google" class="size-3.5" />
Google
</a>
</div>
{#if feedHost}
{@const shareKey = item._slug ?? String(item.start.getTime())}
{@const copied = copyToastKey === shareKey}
{@const shared = shareToastKey === shareKey}
<!-- Force-Break vor Teilen-Gruppe auf Mobile -->
<div class="w-full sm:hidden" aria-hidden="true"></div>
<!-- Gruppe: Teilen -->
<div class="inline-flex items-center gap-1 sm:border-l sm:border-stein-300 sm:pl-2">
<span class="px-1.5 text-[10px] uppercase tracking-wide text-stein-400">Teilen</span>
<button
type="button"
onclick={() => copyEventLink(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title={t(T.post_action_copy)}
aria-label={t(T.post_action_copy)}
>
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5" />
{copied ? t(T.post_action_copied) : "Link"}
</button>
<button
type="button"
onclick={() => shareEvent(item, title, locText)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title={t(T.calendar_share_aria)}
aria-label={t(T.calendar_share_aria)}
>
<Icon icon={shared ? "lucide:check" : "lucide:share-2"} class="size-3.5" />
{shared ? t(T.calendar_share_copied) : t(T.calendar_share)}
</button>
{#if item._slug}
<button
type="button"
onclick={() => openSocial(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title="Social-Bild Vorschau"
aria-label="Social-Bild Vorschau"
>
<Icon icon="lucide:image" class="size-3.5" />
Bild
</button>
{/if}
<button
type="button"
onclick={() => openQr(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-700 hover:bg-white"
title="QR-Code anzeigen & herunterladen"
aria-label="QR-Code anzeigen & herunterladen"
>
<Icon icon="lucide:qr-code" class="size-3.5" />
QR
</button>
</div>
{/if}
<!-- Gruppe: Anzeigen (rechts auf Desktop, eigene Zeile auf Mobile wenn nötig) -->
<div class="inline-flex items-center gap-1 sm:ml-auto">
<button
type="button"
onclick={() => jumpToEventInGrid(item)}
class="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-stein-500 hover:bg-white"
title={t(T.calendar_jump_to_month)}
aria-label={t(T.calendar_jump_to_month)}
>
<Icon icon="lucide:calendar-search" class="size-3.5" />
Im Grid
</button>
</div>
</div>
</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"
>
<a
href="/termin-melden"
class="btn-sm inline-flex items-center gap-1.5 min-h-[36px] rounded-md border px-3 font-semibold no-underline"
style="background:#fde047;border-color:#eab308;color:#713f12"
>
<Icon icon="lucide:plus" class="size-3.5 shrink-0" aria-hidden="true" />
Termin melden
</a>
{#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="lucide: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="lucide: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="lucide: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="lucide: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="lucide: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="lucide:x"
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="lucide: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="lucide: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}
{#if socialModal}
<SocialImageModal
open={true}
title={socialModal.title}
slug={socialModal.slug}
endpointBase="/api/social/calendar"
downloadPrefix="termin"
onclose={closeSocial}
/>
{/if}
{#if qrModal}
<QrModal
value={qrModal.value}
label={qrModal.label}
sublabel="Termin-Link"
onclose={() => (qrModal = 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. */
/* iOS Safari zeigt trotz list-style:none noch den Disclosure-Marker
(dunkler Punkt oben-links der Kachel) → explizit ausblenden. */
.calendar-block summary {
list-style: none;
}
.calendar-block summary::-webkit-details-marker {
display: none;
}
.calendar-block .event-item {
position: relative;
list-style: none;
border-left: 4px solid transparent;
background: var(--color-stein-0, #fff);
transition: background-color 150ms ease;
}
/* iOS/WebKit zeigt sonst den <li>-Disc-Marker (dunkler Punkt links der
Kachel) — list-none am <ul> greift wegen globaler Typo-Regel nicht. */
.calendar-block .event-item::marker {
content: "";
}
.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>