feat(calendar): add feed subscription, bulk export, share & Google Cal
- /api/kalender.ics SSR route: ICS feed of all calendar items (webcal-subscribable, Cache 1h) - buildICSFeed() / downloadICSFeed() for multi-event ICS export - googleCalUrl() deep-link pre-fills Google Calendar create form - Web Share API per event (navigator.share on mobile, clipboard fallback on desktop) — "Kopiert\!" toast per event for 2s after clipboard write - Actions bar (subscribe + bulk export) only rendered after onMount — Apple platforms: webcal:// link opens Calendar.app natively — All others: direct ICS download via <a download> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,72 @@ export function downloadICS(item: CalendarItemICS, filename: string): void {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds a VCALENDAR with multiple VEVENTs — for feed/bulk export. */
|
||||||
|
export function buildICSFeed(items: CalendarItemICS[], calName = "Windwiderstand Termine"): string {
|
||||||
|
const now = new Date();
|
||||||
|
const lines: string[] = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//windwiderstand.de//calendar//DE",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"METHOD:PUBLISH",
|
||||||
|
foldLine(`X-WR-CALNAME:${escapeText(calName)}`),
|
||||||
|
"X-WR-TIMEZONE:Europe/Berlin",
|
||||||
|
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
|
||||||
|
"X-PUBLISHED-TTL:PT1H",
|
||||||
|
];
|
||||||
|
for (const item of items) {
|
||||||
|
const start = item.start;
|
||||||
|
const end = item.end ?? new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
const uid = item.uid ?? deterministicUid(item.title, start);
|
||||||
|
lines.push(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
`UID:${uid}`,
|
||||||
|
`DTSTAMP:${fmtICSDate(now)}`,
|
||||||
|
`DTSTART:${fmtICSDate(start)}`,
|
||||||
|
`DTEND:${fmtICSDate(end)}`,
|
||||||
|
foldLine(`SUMMARY:${escapeText(item.title)}`),
|
||||||
|
);
|
||||||
|
if (item.description) lines.push(foldLine(`DESCRIPTION:${escapeText(item.description)}`));
|
||||||
|
if (item.location) lines.push(foldLine(`LOCATION:${escapeText(item.location)}`));
|
||||||
|
if (item.url) lines.push(foldLine(`URL:${item.url}`));
|
||||||
|
lines.push("END:VEVENT");
|
||||||
|
}
|
||||||
|
lines.push("END:VCALENDAR");
|
||||||
|
return lines.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triggers a browser download of a multi-event ICS file. */
|
||||||
|
export function downloadICSFeed(items: CalendarItemICS[], filename: string): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "termine.ics";
|
||||||
|
const ics = buildICSFeed(items);
|
||||||
|
const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = safeName.endsWith(".ics") ? safeName : `${safeName}.ics`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-link to Google Calendar's pre-filled create form. */
|
||||||
|
export function googleCalUrl(item: CalendarItemICS): string {
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
||||||
|
const end = item.end ?? new Date(item.start.getTime() + 60 * 60 * 1000);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: "TEMPLATE",
|
||||||
|
text: item.title,
|
||||||
|
dates: `${fmt(item.start)}/${fmt(end)}`,
|
||||||
|
});
|
||||||
|
if (item.description) params.set("details", item.description);
|
||||||
|
if (item.location) params.set("location", item.location);
|
||||||
|
return `https://calendar.google.com/calendar/render?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** OpenStreetMap search URL for an address or place name. */
|
/** OpenStreetMap search URL for an address or place name. */
|
||||||
export function mapsUrl(location: string): string {
|
export function mapsUrl(location: string): string {
|
||||||
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
|
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type {
|
import type { CalendarBlockData, CalendarItemData } from "$lib/block-types";
|
||||||
CalendarBlockData,
|
|
||||||
CalendarItemData,
|
|
||||||
} from "$lib/block-types";
|
|
||||||
import { t as tStatic, T } from "$lib/translations";
|
import { t as tStatic, T } from "$lib/translations";
|
||||||
import type { Translations } from "$lib/translations";
|
import type { Translations } from "$lib/translations";
|
||||||
import {
|
import {
|
||||||
downloadICS,
|
downloadICS,
|
||||||
|
downloadICSFeed,
|
||||||
|
googleCalUrl,
|
||||||
mapsUrl,
|
mapsUrl,
|
||||||
type CalendarItemICS,
|
type CalendarItemICS,
|
||||||
} from "$lib/calendar-ics";
|
} from "$lib/calendar-ics";
|
||||||
@@ -23,7 +22,11 @@
|
|||||||
spanDayKeys: string[];
|
spanDayKeys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
|
let {
|
||||||
|
block,
|
||||||
|
translations = {},
|
||||||
|
}: { block: CalendarBlockData; translations?: Translations | null } =
|
||||||
|
$props();
|
||||||
|
|
||||||
function t(key: string, replacements?: Record<string, string | number>) {
|
function t(key: string, replacements?: Record<string, string | number>) {
|
||||||
return tStatic(translations ?? null, key, replacements);
|
return tStatic(translations ?? null, key, replacements);
|
||||||
@@ -115,8 +118,7 @@
|
|||||||
const start = parseDate(x.terminZeit);
|
const start = parseDate(x.terminZeit);
|
||||||
const end = parseDate(x.terminEnde);
|
const end = parseDate(x.terminEnde);
|
||||||
if (!start) return null;
|
if (!start) return null;
|
||||||
const isMultiDay =
|
const isMultiDay = !!end && dayKey(end) !== dayKey(start);
|
||||||
!!end && dayKey(end) !== dayKey(start);
|
|
||||||
return {
|
return {
|
||||||
...x,
|
...x,
|
||||||
start,
|
start,
|
||||||
@@ -264,7 +266,10 @@
|
|||||||
const fmtEnd = end.toLocaleDateString("de-DE", {
|
const fmtEnd = end.toLocaleDateString("de-DE", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
year: end.getFullYear() !== start.getFullYear() ? "numeric" : undefined,
|
year:
|
||||||
|
end.getFullYear() !== start.getFullYear()
|
||||||
|
? "numeric"
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
return t(T.calendar_multi_day_range, { from: fmtStart, to: fmtEnd });
|
return t(T.calendar_multi_day_range, { from: fmtStart, to: fmtEnd });
|
||||||
}
|
}
|
||||||
@@ -289,11 +294,18 @@
|
|||||||
function liveStatus(ev: EventCardItem): string {
|
function liveStatus(ev: EventCardItem): string {
|
||||||
const startMs = ev.start.getTime();
|
const startMs = ev.start.getTime();
|
||||||
const endMs = (ev.end ?? ev.start).getTime();
|
const endMs = (ev.end ?? ev.start).getTime();
|
||||||
const dayLengthMs =
|
const dayLengthMs = ev.isMultiDay
|
||||||
ev.isMultiDay ? endMs - startMs : 24 * 60 * 60 * 1000;
|
? endMs - startMs
|
||||||
if (nowMs >= startMs && nowMs <= endMs + (ev.isMultiDay ? 0 : dayLengthMs)) {
|
: 24 * 60 * 60 * 1000;
|
||||||
|
if (
|
||||||
|
nowMs >= startMs &&
|
||||||
|
nowMs <= endMs + (ev.isMultiDay ? 0 : dayLengthMs)
|
||||||
|
) {
|
||||||
// Only flag "running" if event has a meaningful duration window
|
// Only flag "running" if event has a meaningful duration window
|
||||||
if (nowMs <= endMs || (ev.timeStr && nowMs <= startMs + 4 * 60 * 60 * 1000)) {
|
if (
|
||||||
|
nowMs <= endMs ||
|
||||||
|
(ev.timeStr && nowMs <= startMs + 4 * 60 * 60 * 1000)
|
||||||
|
) {
|
||||||
return t(T.calendar_running_now);
|
return t(T.calendar_running_now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,7 +350,8 @@
|
|||||||
function urgencyOf(ev: EventCardItem): Urgency {
|
function urgencyOf(ev: EventCardItem): Urgency {
|
||||||
const startMs = ev.start.getTime();
|
const startMs = ev.start.getTime();
|
||||||
const endMs = (ev.end ?? ev.start).getTime();
|
const endMs = (ev.end ?? ev.start).getTime();
|
||||||
if (nowMs >= startMs && nowMs <= endMs + 4 * 60 * 60 * 1000) return "now";
|
if (nowMs >= startMs && nowMs <= endMs + 4 * 60 * 60 * 1000)
|
||||||
|
return "now";
|
||||||
const today = new Date(nowMs);
|
const today = new Date(nowMs);
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const target = new Date(ev.start);
|
const target = new Date(ev.start);
|
||||||
@@ -368,7 +381,13 @@
|
|||||||
s
|
s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[äöüß]/g, (c) =>
|
.replace(/[äöüß]/g, (c) =>
|
||||||
c === "ä" ? "ae" : c === "ö" ? "oe" : c === "ü" ? "ue" : "ss",
|
c === "ä"
|
||||||
|
? "ae"
|
||||||
|
: c === "ö"
|
||||||
|
? "oe"
|
||||||
|
: c === "ü"
|
||||||
|
? "ue"
|
||||||
|
: "ss",
|
||||||
)
|
)
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
@@ -386,9 +405,75 @@
|
|||||||
t(T.calendar_weekday_so),
|
t(T.calendar_weekday_so),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 href = getEventLinkHref(ev.link);
|
||||||
|
const url = href || window.location.href;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let widgetEl: HTMLElement | null = $state(null);
|
let widgetEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
feedHost = window.location.host;
|
||||||
tickTimer = setInterval(() => {
|
tickTimer = setInterval(() => {
|
||||||
nowMs = Date.now();
|
nowMs = Date.now();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
@@ -400,10 +485,15 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
||||||
data-block="Calendar" data-block-type="calendar"
|
data-block="Calendar"
|
||||||
|
data-block-type="calendar"
|
||||||
data-block-slug={block._slug}
|
data-block-slug={block._slug}
|
||||||
>
|
>
|
||||||
{#snippet eventAccordion(item: EventCardItem, openByDefault: boolean, isNext: boolean)}
|
{#snippet eventAccordion(
|
||||||
|
item: EventCardItem,
|
||||||
|
openByDefault: boolean,
|
||||||
|
isNext: boolean,
|
||||||
|
)}
|
||||||
{@const eventHref = getEventLinkHref(item.link)}
|
{@const eventHref = getEventLinkHref(item.link)}
|
||||||
{@const live = liveStatus(item)}
|
{@const live = liveStatus(item)}
|
||||||
{@const title = item.title || t(T.calendar_untitled)}
|
{@const title = item.title || t(T.calendar_untitled)}
|
||||||
@@ -427,64 +517,109 @@
|
|||||||
als Block scannbar, große Tag-Zahl als Anker. Bei Multi-
|
als Block scannbar, große Tag-Zahl als Anker. Bei Multi-
|
||||||
Day fällt das Block-Layout auf Range zurück. -->
|
Day fällt das Block-Layout auf Range zurück. -->
|
||||||
{#if item.isMultiDay && item.end}
|
{#if item.isMultiDay && item.end}
|
||||||
<div class="event-date-range shrink-0 w-20 text-center leading-tight">
|
<div
|
||||||
<div class="text-[10px] uppercase tracking-wide text-stein-500">
|
class="event-date-range shrink-0 w-20 text-center leading-tight"
|
||||||
{item.start.toLocaleDateString("de-DE", { month: "short" })}
|
>
|
||||||
|
<div
|
||||||
|
class="text-[10px] uppercase tracking-wide text-stein-500"
|
||||||
|
>
|
||||||
|
{item.start.toLocaleDateString("de-DE", {
|
||||||
|
month: "short",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-base font-bold text-stein-900 tabular-nums">
|
<div
|
||||||
|
class="text-base font-bold text-stein-900 tabular-nums"
|
||||||
|
>
|
||||||
{item.start.getDate()}–{item.end.getDate()}
|
{item.start.getDate()}–{item.end.getDate()}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-stein-500">
|
<div class="text-[10px] text-stein-500">
|
||||||
{item.start.getFullYear() === item.end.getFullYear()
|
{item.start.getFullYear() ===
|
||||||
? item.end.toLocaleDateString("de-DE", { month: "short" })
|
item.end.getFullYear()
|
||||||
|
? item.end.toLocaleDateString("de-DE", {
|
||||||
|
month: "short",
|
||||||
|
})
|
||||||
: item.end.getFullYear()}
|
: item.end.getFullYear()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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
|
||||||
<div class="text-[10px] uppercase tracking-wide text-himmel-300">
|
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"
|
||||||
{item.start.toLocaleDateString("de-DE", { weekday: "short" })}
|
>
|
||||||
|
<div
|
||||||
|
class="text-[10px] uppercase tracking-wide text-himmel-300"
|
||||||
|
>
|
||||||
|
{item.start.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "short",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-himmel-50 tabular-nums">
|
<div
|
||||||
|
class="text-2xl font-bold text-himmel-50 tabular-nums"
|
||||||
|
>
|
||||||
{item.start.getDate()}
|
{item.start.getDate()}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] uppercase tracking-wide text-himmel-300">
|
<div
|
||||||
{item.start.toLocaleDateString("de-DE", { month: "short" })}
|
class="text-[10px] uppercase tracking-wide text-himmel-300"
|
||||||
|
>
|
||||||
|
{item.start.toLocaleDateString("de-DE", {
|
||||||
|
month: "short",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-baseline gap-2 flex-wrap">
|
<div class="flex items-baseline gap-2 flex-wrap">
|
||||||
<span class="text-sm font-semibold text-stein-900 leading-snug">
|
<span
|
||||||
|
class="text-sm font-semibold text-stein-900 leading-snug"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{#if live}
|
{#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">
|
<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}
|
{live}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600">
|
<div
|
||||||
|
class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-stein-600"
|
||||||
|
>
|
||||||
{#if item.timeStr && !item.isMultiDay}
|
{#if item.timeStr && !item.isMultiDay}
|
||||||
<span class="inline-flex items-center gap-1 tabular-nums">
|
<span
|
||||||
<Icon icon="mdi:clock-outline" class="size-3" aria-hidden="true" />
|
class="inline-flex items-center gap-1 tabular-nums"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:clock-outline"
|
||||||
|
class="size-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{item.timeStr}
|
{item.timeStr}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if locText}
|
{#if locText}
|
||||||
<span class="inline-flex items-center gap-1 min-w-0">
|
<span
|
||||||
<Icon icon="mdi:map-marker-outline" class="size-3 shrink-0" aria-hidden="true" />
|
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 class="truncate">{locText}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.tags && item.tags.length > 0}
|
{#if item.tags && item.tags.length > 0}
|
||||||
{#each item.tags.slice(0, 2) as tg}
|
{#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">
|
<span
|
||||||
|
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
|
||||||
|
>
|
||||||
{tg}
|
{tg}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if item.tags.length > 2}
|
{#if item.tags.length > 2}
|
||||||
<span class="text-[10px] text-stein-500">+{item.tags.length - 2}</span>
|
<span class="text-[10px] text-stein-500"
|
||||||
|
>+{item.tags.length - 2}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -495,17 +630,27 @@
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="px-3 pb-3 pt-0 pl-[4.75rem] text-sm text-stein-700 space-y-2">
|
<div
|
||||||
<div class="text-[11px] uppercase tracking-wide text-stein-500 -mt-1">
|
class="px-3 pb-3 pt-0 pl-[4.75rem] text-sm text-stein-700 space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-[11px] uppercase tracking-wide text-stein-500 -mt-1"
|
||||||
|
>
|
||||||
{relativeDays(item.start)}
|
{relativeDays(item.start)}
|
||||||
</div>
|
</div>
|
||||||
{#if item.description}
|
{#if item.description}
|
||||||
<p class="font-light text-stein-700 m-0! leading-relaxed">{item.description}</p>
|
<p
|
||||||
|
class="font-light text-stein-700 m-0! leading-relaxed"
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.tags && item.tags.length > 2}
|
{#if item.tags && item.tags.length > 2}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each item.tags as tg}
|
{#each item.tags as tg}
|
||||||
<span class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs">
|
<span
|
||||||
|
class="bg-himmel-100 text-himmel-800 text-[10px] px-1.5 py-0.5 rounded-xs"
|
||||||
|
>
|
||||||
{tg}
|
{tg}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -520,7 +665,11 @@
|
|||||||
class="btn-tertiary btn-sm"
|
class="btn-tertiary btn-sm"
|
||||||
>
|
>
|
||||||
{t(T.calendar_more_info)}
|
{t(T.calendar_more_info)}
|
||||||
<Icon icon="mdi:open-in-new" class="size-3.5" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:open-in-new"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if locText}
|
{#if locText}
|
||||||
@@ -530,24 +679,73 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:map-marker" class="size-3.5 shrink-0" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:map-marker"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t(T.calendar_open_maps)}
|
{t(T.calendar_open_maps)}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => downloadICS(toICS(item), fileSlug(title))}
|
onclick={() =>
|
||||||
|
downloadICS(toICS(item), fileSlug(title))}
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:download" class="size-3.5" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:download"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t(T.calendar_download_ics)}
|
{t(T.calendar_download_ics)}
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
href={googleCalUrl(toICS(item))}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:google"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t(T.calendar_add_to_google)}
|
||||||
|
</a>
|
||||||
|
{#if feedHost}
|
||||||
|
{@const shareKey =
|
||||||
|
item._slug ?? String(item.start.getTime())}
|
||||||
|
{@const shared = shareToastKey === shareKey}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() =>
|
||||||
|
shareEvent(item, title, locText)}
|
||||||
|
class="btn-outline btn-sm"
|
||||||
|
aria-label={t(T.calendar_share_aria)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={shared
|
||||||
|
? "mdi:check"
|
||||||
|
: "mdi:share-variant"}
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{shared
|
||||||
|
? t(T.calendar_share_copied)
|
||||||
|
: t(T.calendar_share)}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => jumpToEventInGrid(item)}
|
onclick={() => jumpToEventInGrid(item)}
|
||||||
class="btn-outline btn-sm"
|
class="btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:calendar-search" class="size-3.5" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:calendar-search"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t(T.calendar_jump_to_month)}
|
{t(T.calendar_jump_to_month)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,14 +763,18 @@
|
|||||||
{#if allTags.length > 0}
|
{#if allTags.length > 0}
|
||||||
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
|
<!-- Tag-Filter chips. "Alle" = activeTag null. Sichtbar nur, wenn
|
||||||
mindestens ein Termin Tags hat — sonst Lärm. -->
|
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">
|
<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">
|
<span class="text-[11px] uppercase tracking-wide text-stein-500">
|
||||||
{t(T.calendar_filter_by_tag)}
|
{t(T.calendar_filter_by_tag)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (activeTag = null)}
|
onclick={() => (activeTag = null)}
|
||||||
class="text-[11px] px-2 py-0.5 rounded-xs border {!activeTag ? 'bg-himmel-700 text-himmel-50 border-himmel-700' : 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
class="text-[11px] px-2 py-0.5 rounded-xs border {!activeTag
|
||||||
|
? 'bg-himmel-700 text-himmel-50 border-himmel-700'
|
||||||
|
: 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
||||||
aria-pressed={!activeTag}
|
aria-pressed={!activeTag}
|
||||||
>
|
>
|
||||||
{t(T.calendar_all_tags)}
|
{t(T.calendar_all_tags)}
|
||||||
@@ -581,7 +783,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
||||||
class="text-[11px] px-2 py-0.5 rounded-xs border {activeTag === tag ? 'bg-himmel-700 text-himmel-50 border-himmel-700' : 'border-stein-300 text-stein-700 hover:bg-stein-100'}"
|
class="text-[11px] px-2 py-0.5 rounded-xs border {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}
|
aria-pressed={activeTag === tag}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
@@ -590,6 +795,58 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<!-- Kalender-Widget: oben, kompakt auf Mobile-Breite gecappt (sonst
|
||||||
werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
|
werden aspect-square cells auf Desktop gigantisch). Monat-Nav +
|
||||||
Tag-Grid. Klick auf Tag filtert Liste unten. -->
|
Tag-Grid. Klick auf Tag filtert Liste unten. -->
|
||||||
@@ -605,7 +862,11 @@
|
|||||||
aria-label={t(T.calendar_prev_month)}
|
aria-label={t(T.calendar_prev_month)}
|
||||||
onclick={prevMonth}
|
onclick={prevMonth}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:chevron-left"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
||||||
@@ -617,13 +878,21 @@
|
|||||||
aria-label={t(T.calendar_next_month)}
|
aria-label={t(T.calendar_next_month)}
|
||||||
onclick={nextMonth}
|
onclick={nextMonth}
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
|
<Icon
|
||||||
|
icon="mdi:chevron-right"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
|
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
|
||||||
{#each weekdays as w}
|
{#each weekdays as w}
|
||||||
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
|
<div
|
||||||
|
class="py-1.5 text-xs font-medium text-himmel-100 opacity-80"
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#each calendarDays as d}
|
{#each calendarDays as d}
|
||||||
{#if d === null}
|
{#if d === null}
|
||||||
@@ -647,11 +916,21 @@
|
|||||||
onclick={() => selectDay(key)}
|
onclick={() => selectDay(key)}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
aria-current={isToday ? "date" : undefined}
|
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*/, '')}"
|
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="text-xs">{d.getDate()}</span>
|
||||||
<span
|
<span
|
||||||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
|
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture
|
||||||
|
? 'bg-wald-400'
|
||||||
|
: 'bg-error'}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{dayEvents.length}
|
{dayEvents.length}
|
||||||
@@ -661,12 +940,16 @@
|
|||||||
<div
|
<div
|
||||||
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
||||||
? 'text-himmel-100'
|
? 'text-himmel-100'
|
||||||
: 'text-himmel-100 opacity-50'} {isToday ? 'today-cell' : ''}"
|
: 'text-himmel-100 opacity-50'} {isToday
|
||||||
|
? 'today-cell'
|
||||||
|
: ''}"
|
||||||
aria-current={isToday ? "date" : undefined}
|
aria-current={isToday ? "date" : undefined}
|
||||||
>
|
>
|
||||||
<span class="text-xs">{d.getDate()}</span>
|
<span class="text-xs">{d.getDate()}</span>
|
||||||
{#if isToday}
|
{#if isToday}
|
||||||
<span class="sr-only">{t(T.calendar_today_marker)}</span>
|
<span class="sr-only"
|
||||||
|
>{t(T.calendar_today_marker)}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -683,20 +966,29 @@
|
|||||||
{#if items.length === 0}
|
{#if items.length === 0}
|
||||||
<p class="text-sm text-stein-500 p-4">{t(T.calendar_no_events)}</p>
|
<p class="text-sm text-stein-500 p-4">{t(T.calendar_no_events)}</p>
|
||||||
{:else if selectedDay}
|
{: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="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">
|
<div class="text-xs font-medium text-stein-700 truncate">
|
||||||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
{new Date(selectedDay + "T12:00:00").toLocaleDateString(
|
||||||
|
"de-DE",
|
||||||
|
{
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedDay = null)}
|
onclick={() => (selectedDay = null)}
|
||||||
class="inline-flex items-center gap-1 text-[11px] text-himmel-700 hover:text-himmel-900 hover:underline shrink-0"
|
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" />
|
<Icon
|
||||||
|
icon="mdi:close"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t(T.calendar_clear_filter)}
|
{t(T.calendar_clear_filter)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,7 +998,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if futureEventsByDay.length > 0}
|
{:else if futureEventsByDay.length > 0}
|
||||||
<div class="flex items-baseline justify-between gap-2 px-3 py-2 border-b border-stein-200">
|
<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!">
|
<h4 class="text-xs font-medium text-stein-500 m-0!">
|
||||||
{t(T.calendar_all_upcoming)}
|
{t(T.calendar_all_upcoming)}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -717,24 +1011,45 @@
|
|||||||
<ul class="m-0! p-0! list-none">
|
<ul class="m-0! p-0! list-none">
|
||||||
{#each futureEventsByDay as group, gIdx}
|
{#each futureEventsByDay as group, gIdx}
|
||||||
<li class="day-group">
|
<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">
|
<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)}
|
{fmtGroupHeader(group.date)}
|
||||||
<span class="ml-2 text-stein-500 normal-case tracking-normal lowercase">· {relativeDays(group.date)}</span>
|
<span
|
||||||
|
class="ml-2 text-stein-500 normal-case tracking-normal lowercase"
|
||||||
|
>· {relativeDays(group.date)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ul class="m-0! p-0! list-none">
|
<ul class="m-0! p-0! list-none">
|
||||||
{#each group.events as item, eIdx}
|
{#each group.events as item, eIdx}
|
||||||
{@render eventAccordion(item, gIdx === 0, gIdx === 0 && eIdx === 0)}
|
{@render eventAccordion(
|
||||||
|
item,
|
||||||
|
gIdx === 0,
|
||||||
|
gIdx === 0 && eIdx === 0,
|
||||||
|
)}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if pastEvents.length > 0}
|
{#if pastEvents.length > 0}
|
||||||
<details class="border-t border-stein-200 bg-stein-50 group/past">
|
<details
|
||||||
<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">
|
class="border-t border-stein-200 bg-stein-50 group/past"
|
||||||
<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>
|
<summary
|
||||||
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
|
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>
|
</summary>
|
||||||
<ul class="m-0! p-0! list-none opacity-80">
|
<ul class="m-0! p-0! list-none opacity-80">
|
||||||
{#each pastEvents as item}
|
{#each pastEvents as item}
|
||||||
@@ -747,10 +1062,20 @@
|
|||||||
<!-- Nur Vergangene da, keine Zukunft → past-Liste expanded
|
<!-- Nur Vergangene da, keine Zukunft → past-Liste expanded
|
||||||
damit Panel nicht leer wirkt. -->
|
damit Panel nicht leer wirkt. -->
|
||||||
<details open class="bg-stein-50 group/past">
|
<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">
|
<summary
|
||||||
<Icon icon="mdi:chevron-right" class="size-3.5 transition-transform group-open/past:rotate-90" aria-hidden="true" />
|
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"
|
||||||
<span class="font-medium uppercase tracking-wide">{t(T.calendar_past_events)}</span>
|
>
|
||||||
<span class="text-stein-500 tabular-nums">· {pastEvents.length}</span>
|
<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>
|
</summary>
|
||||||
<ul class="m-0! p-0! list-none opacity-80">
|
<ul class="m-0! p-0! list-none opacity-80">
|
||||||
{#each pastEvents as item}
|
{#each pastEvents as item}
|
||||||
@@ -759,7 +1084,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-sm text-stein-500 p-4">{t(T.calendar_no_future_events)}</p>
|
<p class="text-sm text-stein-500 p-4">
|
||||||
|
{t(T.calendar_no_future_events)}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,9 +72,15 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
|
"body": "<path fill=\"currentColor\" d=\"M5 20h14v-2H5m14-9h-4V3H9v6H5l7 7z\"/>"
|
||||||
},
|
},
|
||||||
|
"google": {
|
||||||
|
"body": "<path fill=\"currentColor\" d=\"M21.35 11.1h-9.17v2.73h6.51c-.33 3.81-3.5 5.44-6.5 5.44C8.36 19.27 5 16.25 5 12c0-4.1 3.2-7.27 7.2-7.27c3.09 0 4.9 1.97 4.9 1.97L19 4.72S16.56 2 12.1 2C6.42 2 2.03 6.8 2.03 12c0 5.05 4.13 10 10.22 10c5.35 0 9.25-3.67 9.25-9.09c0-1.15-.15-1.81-.15-1.81\"/>"
|
||||||
|
},
|
||||||
"calendar-search": {
|
"calendar-search": {
|
||||||
"body": "<path fill=\"currentColor\" d=\"M15.5 12c2.5 0 4.5 2 4.5 4.5c0 .88-.25 1.71-.69 2.4l3.08 3.1L21 23.39l-3.12-3.07c-.69.43-1.51.68-2.38.68c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5m0 2a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5M19 8H5v11h4.5c.31.75.76 1.42 1.31 2H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v8.03c-.5-.81-1.2-1.49-2-2.03z\"/>"
|
"body": "<path fill=\"currentColor\" d=\"M15.5 12c2.5 0 4.5 2 4.5 4.5c0 .88-.25 1.71-.69 2.4l3.08 3.1L21 23.39l-3.12-3.07c-.69.43-1.51.68-2.38.68c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5m0 2a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5M19 8H5v11h4.5c.31.75.76 1.42 1.31 2H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8V1h2v2h1a2 2 0 0 1 2 2v8.03c-.5-.81-1.2-1.49-2-2.03z\"/>"
|
||||||
},
|
},
|
||||||
|
"calendar-plus": {
|
||||||
|
"body": "<path fill=\"currentColor\" d=\"M19 19V8H5v11zM16 1h2v2h1a2 2 0 0 1 2 2v14c0 1.11-.89 2-2 2H5a2 2 0 0 1-2-2V5c0-1.11.89-2 2-2h1V1h2v2h8zm-5 8.5h2v3h3v2h-3v3h-2v-3H8v-2h3z\"/>"
|
||||||
|
},
|
||||||
"arrow-right": {
|
"arrow-right": {
|
||||||
"body": "<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>"
|
"body": "<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ const TRANSLATION_KEYS = [
|
|||||||
"calendar_untitled",
|
"calendar_untitled",
|
||||||
"calendar_show_more",
|
"calendar_show_more",
|
||||||
"calendar_show_less",
|
"calendar_show_less",
|
||||||
|
"calendar_subscribe",
|
||||||
|
"calendar_subscribe_aria",
|
||||||
|
"calendar_export_all",
|
||||||
|
"calendar_add_to_google",
|
||||||
|
"calendar_share",
|
||||||
|
"calendar_share_copied",
|
||||||
|
"calendar_share_aria",
|
||||||
"post_overview_all",
|
"post_overview_all",
|
||||||
"blog_search_label",
|
"blog_search_label",
|
||||||
"blog_search_placeholder",
|
"blog_search_placeholder",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { getCalendarItems } from '$lib/cms';
|
||||||
|
import { buildICSFeed, type CalendarItemICS } from '$lib/calendar-ics';
|
||||||
|
import type { CalendarItemData } from '$lib/block-types';
|
||||||
|
|
||||||
|
function parseDate(iso: string | undefined | null): Date | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const siteName = env.PUBLIC_SITE_NAME || 'Windwiderstand';
|
||||||
|
|
||||||
|
let rawItems: Awaited<ReturnType<typeof getCalendarItems>> = [];
|
||||||
|
try {
|
||||||
|
rawItems = await getCalendarItems();
|
||||||
|
} catch {
|
||||||
|
rawItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const icsItems: CalendarItemICS[] = rawItems
|
||||||
|
.flatMap((raw) => {
|
||||||
|
const item = raw as unknown as CalendarItemData;
|
||||||
|
const start = parseDate(item.terminZeit);
|
||||||
|
if (!start) return [];
|
||||||
|
const end = parseDate(item.terminEnde ?? null) ?? undefined;
|
||||||
|
const entry: CalendarItemICS = {
|
||||||
|
title: item.title || 'Termin',
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
description: (item as { description?: string }).description ?? undefined,
|
||||||
|
location: locationText((item as { location?: unknown }).location) || undefined,
|
||||||
|
};
|
||||||
|
return [entry];
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = buildICSFeed(icsItems, `${siteName} – Termine`);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/calendar; charset=utf-8',
|
||||||
|
'Content-Disposition': 'inline; filename="windwiderstand-termine.ics"',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user