feat(calendar): add feed subscription, bulk export, share & Google Cal
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m3s

- /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:
Peter Meier
2026-05-12 16:50:56 +02:00
parent 7fa52bb629
commit 6fb2592eae
5 changed files with 1267 additions and 804 deletions
+57
View File
@@ -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',
},
});
};