From 6fb2592eaed6d3c79559985352e4f2e463b63419 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Tue, 12 May 2026 16:50:56 +0200 Subject: [PATCH] feat(calendar): add feed subscription, bulk export, share & Google Cal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 Co-Authored-By: Claude Sonnet 4.6 --- src/lib/calendar-ics.ts | 66 + .../components/blocks/CalendarBlock.svelte | 1935 ++++++++++------- src/lib/iconify-mdi-subset.generated.json | 6 + src/lib/translations.ts | 7 + src/routes/api/kalender.ics/+server.ts | 57 + 5 files changed, 1267 insertions(+), 804 deletions(-) create mode 100644 src/routes/api/kalender.ics/+server.ts diff --git a/src/lib/calendar-ics.ts b/src/lib/calendar-ics.ts index 029d8db..5818fa0 100644 --- a/src/lib/calendar-ics.ts +++ b/src/lib/calendar-ics.ts @@ -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)}`; diff --git a/src/lib/components/blocks/CalendarBlock.svelte b/src/lib/components/blocks/CalendarBlock.svelte index fba8398..4cdb5f0 100644 --- a/src/lib/components/blocks/CalendarBlock.svelte +++ b/src/lib/components/blocks/CalendarBlock.svelte @@ -1,840 +1,1167 @@ + const selectedDayEvents = $derived( + selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [], + ); -
- {#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 - )} -
  • -
    - - - {#if item.isMultiDay && item.end} -
    -
    - {item.start.toLocaleDateString("de-DE", { month: "short" })} -
    -
    - {item.start.getDate()}–{item.end.getDate()} -
    -
    - {item.start.getFullYear() === item.end.getFullYear() - ? item.end.toLocaleDateString("de-DE", { month: "short" }) - : item.end.getFullYear()} -
    -
    - {:else} -
    -
    - {item.start.toLocaleDateString("de-DE", { weekday: "short" })} -
    -
    - {item.start.getDate()} -
    -
    - {item.start.toLocaleDateString("de-DE", { month: "short" })} -
    -
    - {/if} -
    -
    - - {title} - - {#if live} - - {live} - - {/if} -
    -
    - {#if item.timeStr && !item.isMultiDay} - - - {/if} - {#if locText} - - - {/if} - {#if item.tags && item.tags.length > 0} - {#each item.tags.slice(0, 2) as tg} - - {tg} - - {/each} - {#if item.tags.length > 2} - +{item.tags.length - 2} - {/if} - {/if} -
    -
    -
    -
    -
    - {relativeDays(item.start)} -
    - {#if item.description} -

    {item.description}

    - {/if} - {#if item.tags && item.tags.length > 2} -
    - {#each item.tags as tg} - - {tg} - - {/each} -
    - {/if} -
    - {#if eventHref} - - {t(T.calendar_more_info)} - - {/if} - {#if locText} - - - {/if} - - -
    -
    -
    -
  • - {/snippet} + const todayKey = $derived.by(() => dayKey(new Date(nowMs))); - {#if block.title} -

    - {block.title} -

    - {/if} + // ── 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]; + }); - {#if allTags.length > 0} - -
    - - {t(T.calendar_filter_by_tag)} - - - {#each allTags as tag} - - {/each} -
    - {/if} - - -
    -
    -
    - - {monthLabel} - -
    - -
    - {#each weekdays as w} -
    {w}
    - {/each} - {#each calendarDays as d} - {#if d === null} - - {: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} - {#if hasEvents} - - {:else} -
    - {d.getDate()} - {#if isToday} - {t(T.calendar_today_marker)} - {/if} -
    - {/if} - {/if} - {/each} -
    -
    -
    - - -
    - {#if items.length === 0} -

    {t(T.calendar_no_events)}

    - {:else if selectedDay} -
    -
    - {new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", { + // ── 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", - })} -
    - -
    -
      - {#each selectedDayEvents as item} - {@render eventAccordion(item, true, false)} - {/each} -
    - {:else if futureEventsByDay.length > 0} -
    -

    - {t(T.calendar_all_upcoming)} -

    - - {t(T.calendar_event_count, { n: futureEvents.length })} - -
    -
      - {#each futureEventsByDay as group, gIdx} -
    • -
      - {fmtGroupHeader(group.date)} - · {relativeDays(group.date)} +
      + + + {#if item.isMultiDay && item.end} +
      +
      + {item.start.toLocaleDateString("de-DE", { + month: "short", + })} +
      +
      + {item.start.getDate()}–{item.end.getDate()} +
      +
      + {item.start.getFullYear() === + item.end.getFullYear() + ? item.end.toLocaleDateString("de-DE", { + month: "short", + }) + : item.end.getFullYear()} +
      +
      + {:else} +
      +
      + {item.start.toLocaleDateString("de-DE", { + weekday: "short", + })} +
      +
      + {item.start.getDate()} +
      +
      + {item.start.toLocaleDateString("de-DE", { + month: "short", + })} +
      +
      + {/if} +
      +
      + + {title} + + {#if live} + + {live} + + {/if} +
      +
      + {#if item.timeStr && !item.isMultiDay} + + + {/if} + {#if locText} + + + {/if} + {#if item.tags && item.tags.length > 0} + {#each item.tags.slice(0, 2) as tg} + + {tg} + + {/each} + {#if item.tags.length > 2} + +{item.tags.length - 2} + {/if} + {/if} +
      +
      +
      +
      +
      + {relativeDays(item.start)} +
      + {#if item.description} +

      + {item.description} +

      + {/if} + {#if item.tags && item.tags.length > 2} +
      + {#each item.tags as tg} + + {tg} + + {/each} +
      + {/if} +
      + {#if eventHref} + + {t(T.calendar_more_info)} + + {/if} + {#if locText} + + + {/if} + + + + {#if feedHost} + {@const shareKey = + item._slug ?? String(item.start.getTime())} + {@const shared = shareToastKey === shareKey} + + {/if} + +
      +
      +
      +
    • + {/snippet} + + {#if block.title} +

      + {block.title} +

      + {/if} + + {#if allTags.length > 0} + +
      + + {t(T.calendar_filter_by_tag)} + + + {#each allTags as tag} + + {/each} +
      + {/if} + + + {#if feedHost} +
      + {#if isApplePlatform} + + + {:else} + + + {/if} + {#if items.length > 0} + + {/if} +
      + {/if} + + +
      +
      +
      + + {monthLabel} + +
      + +
      + {#each weekdays as w} +
      + {w} +
      + {/each} + {#each calendarDays as d} + {#if d === null} + + {: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} + {#if hasEvents} + + {:else} +
      + {d.getDate()} + {#if isToday} + {t(T.calendar_today_marker)} + {/if} +
      + {/if} + {/if} + {/each} +
      +
      +
      + + +
      + {#if items.length === 0} +

      {t(T.calendar_no_events)}

      + {:else if selectedDay} +
      +
      + {new Date(selectedDay + "T12:00:00").toLocaleDateString( + "de-DE", + { + weekday: "long", + day: "numeric", + month: "long", + }, + )} +
      +
        - {#each group.events as item, eIdx} - {@render eventAccordion(item, gIdx === 0, gIdx === 0 && eIdx === 0)} - {/each} + {#each selectedDayEvents as item} + {@render eventAccordion(item, true, false)} + {/each}
      - - {/each} -
    - {#if pastEvents.length > 0} -
    - - -
      - {#each pastEvents as item} - {@render eventAccordion(item, false, false)} - {/each} -
    -
    - {/if} - {:else if pastEvents.length > 0} - -
    - - -
      - {#each pastEvents as item} - {@render eventAccordion(item, false, false)} - {/each} -
    -
    - {:else} -

    {t(T.calendar_no_future_events)}

    - {/if} -
    +
    + + +
      + {#each pastEvents as item} + {@render eventAccordion(item, false, false)} + {/each} +
    +
    + {:else} +

    + {t(T.calendar_no_future_events)} +

    + {/if} +
    diff --git a/src/lib/iconify-mdi-subset.generated.json b/src/lib/iconify-mdi-subset.generated.json index ad34277..a6c6e80 100644 --- a/src/lib/iconify-mdi-subset.generated.json +++ b/src/lib/iconify-mdi-subset.generated.json @@ -72,9 +72,15 @@ "download": { "body": "" }, + "google": { + "body": "" + }, "calendar-search": { "body": "" }, + "calendar-plus": { + "body": "" + }, "arrow-right": { "body": "" }, diff --git a/src/lib/translations.ts b/src/lib/translations.ts index 6ad8e54..6d59791 100644 --- a/src/lib/translations.ts +++ b/src/lib/translations.ts @@ -137,6 +137,13 @@ const TRANSLATION_KEYS = [ "calendar_untitled", "calendar_show_more", "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", "blog_search_label", "blog_search_placeholder", diff --git a/src/routes/api/kalender.ics/+server.ts b/src/routes/api/kalender.ics/+server.ts new file mode 100644 index 0000000..d05c0c2 --- /dev/null +++ b/src/routes/api/kalender.ics/+server.ts @@ -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> = []; + 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', + }, + }); +};