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)}`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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