f0d9fecfb8
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>
1479 lines
61 KiB
Svelte
1479 lines
61 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";
|
||
|
||
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>
|