1500 lines
63 KiB
Svelte
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>
|