diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 5532604..e1b2b73 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -337,9 +337,11 @@ export interface OrganisationsBlockData { export interface LiveStrommixBlockData { _type?: "live_strommix"; _slug?: string; - /** `full` = card with argument + breakdown disclosure. `compact` = single - * horizontal strip for sidebar / homepage placement. Default: `full`. */ - variant?: "full" | "compact"; + /** `full` = card with argument + breakdown disclosure + history accordion. + * `hero` = pct + status + sparkline + trend, no sub-cards or accordion. + * `compact` = single horizontal strip for sidebar / homepage placement. + * Default: `full`. */ + variant?: "full" | "hero" | "compact"; intro_headline?: string; intro_text?: string; threshold_dunkelflaute?: number; @@ -380,8 +382,17 @@ export interface LiveStrommixBlockData { layout?: BlockLayout; } -/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. */ -export type CalendarItemData = components["schemas"]["calendar_item"]; +/** Kalender-Item (calendar_item) – aus OpenAPI; terminZeit = ISO date-time. + * Lokale Erweiterungen (terminEnde, location, tags) sind im RustyCMS-Schema + * ergänzt und werden bis zur OpenAPI-Regen hier inline mitgeführt. */ +export type CalendarItemData = components["schemas"]["calendar_item"] & { + /** Optionales Endedatum für Mehrtagesveranstaltungen (ISO date-time). */ + terminEnde?: string; + /** Optionale Veranstaltungsort-Beschreibung (Adresse oder Name). */ + location?: string; + /** Optionale Tags zur Filterung. */ + tags?: string[]; +}; /** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */ export type CalendarBlockData = Omit< diff --git a/src/lib/calendar-ics.ts b/src/lib/calendar-ics.ts new file mode 100644 index 0000000..37a07e6 --- /dev/null +++ b/src/lib/calendar-ics.ts @@ -0,0 +1,146 @@ +/** + * Helpers for "send calendar entry to my own calendar" flows. + * + * - `buildICS()` produces a single-event RFC-5545 string suitable for + * download. Apple Calendar, Outlook and Google all import it. + * - `googleCalUrl()` returns a deep link to Google Calendar's event- + * create form pre-filled with the event details. + * - `mapsUrl()` returns a Google Maps search URL for an address string. + * + * All client-only — no Node APIs touched. Safe in SSR contexts (the + * functions just return strings; the actual download is triggered by + * the caller via `URL.createObjectURL`). + */ + +export type CalendarItemICS = { + title: string; + start: Date; + /** Optional end date. If omitted, default duration is 1 hour. */ + end?: Date | null; + description?: string; + location?: string; + url?: string; + /** Stable identifier; falls back to start-timestamp + title hash. */ + uid?: string; +}; + +/** Folds long lines per RFC-5545 §3.1 (75 octets max). Most clients + * tolerate longer lines but Outlook is strict — better safe. */ +function foldLine(line: string): string { + if (line.length <= 75) return line; + const out: string[] = []; + let i = 0; + while (i < line.length) { + const chunk = line.slice(i, i + (i === 0 ? 75 : 74)); + out.push((i === 0 ? "" : " ") + chunk); + i += i === 0 ? 75 : 74; + } + return out.join("\r\n"); +} + +/** Escape per RFC-5545 §3.3.11 — backslash, comma, semicolon, newline. */ +function escapeText(s: string): string { + return s + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;"); +} + +/** UTC timestamp formatted as `YYYYMMDDTHHMMSSZ`. */ +function fmtICSDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + d.getUTCFullYear().toString() + + pad(d.getUTCMonth() + 1) + + pad(d.getUTCDate()) + + "T" + + pad(d.getUTCHours()) + + pad(d.getUTCMinutes()) + + pad(d.getUTCSeconds()) + + "Z" + ); +} + +function deterministicUid(title: string, start: Date): string { + let hash = 0; + for (let i = 0; i < title.length; i++) { + hash = (hash * 31 + title.charCodeAt(i)) | 0; + } + return `${start.getTime()}-${Math.abs(hash).toString(36)}@windwiderstand.de`; +} + +export function buildICS(item: CalendarItemICS): string { + const start = item.start; + const end = + item.end ?? + new Date(start.getTime() + 60 * 60 * 1000); // 1h default + const uid = item.uid ?? deterministicUid(item.title, start); + const now = new Date(); + + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//windwiderstand.de//calendar//DE", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "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", "END:VCALENDAR"); + return lines.join("\r\n"); +} + +/** Trigger a browser download of an ICS file. Caller should pass a + * filesystem-friendly filename; we still strip path separators + * defensively. No-op on the server (`window` undefined). */ +export function downloadICS(item: CalendarItemICS, filename: string): void { + if (typeof window === "undefined") return; + const safeName = filename.replace(/[/\\:*?"<>|]/g, "_") || "event.ics"; + const ics = buildICS(item); + 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); +} + +/** Google-Calendar's render-template URL. Times must be UTC, in + * YYYYMMDDTHHMMSSZ format, joined by `/`. */ +export function googleCalUrl(item: CalendarItemICS): string { + const start = item.start; + const end = + item.end ?? new Date(start.getTime() + 60 * 60 * 1000); + const dates = `${fmtICSDate(start)}/${fmtICSDate(end)}`; + const params = new URLSearchParams({ + action: "TEMPLATE", + text: item.title, + dates, + }); + if (item.description) params.set("details", item.description); + if (item.location) params.set("location", item.location); + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + +/** Generic maps URL — Google Maps search by query. Safe with addresses + * or place names. */ +export function mapsUrl(location: string): string { + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location)}`; +} diff --git a/src/lib/components/blocks/CalendarBlock.svelte b/src/lib/components/blocks/CalendarBlock.svelte index f23b8a9..ec6a5d8 100644 --- a/src/lib/components/blocks/CalendarBlock.svelte +++ b/src/lib/components/blocks/CalendarBlock.svelte @@ -1,4 +1,5 @@
- {item.description} -
- {#if eventHref} - - {t(T.calendar_more_info)} - - - {/if} - {:else if eventHref} - - {t(T.calendar_more_info)} - - {/if} +{item.description}
+ {/if} + {#if item.tags && item.tags.length > 2} +{t(T.calendar_no_events)}
+{t(T.calendar_no_events)}
{:else if selectedDay} -- {new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", { - weekday: "long", - day: "numeric", - month: "long", - })} -
-- {t(T.calendar_next_events)} -
-{t(T.calendar_select_day)}
+{t(T.calendar_no_future_events)}
{/if}