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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function mapsUrl(location: string): string {
|
||||
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(location)}`;
|
||||
|
||||
Reference in New Issue
Block a user