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 @@
- {#snippet eventCard(item: EventCardItem)} + {#snippet eventAccordion(item: EventCardItem, openByDefault: boolean, isNext: boolean)} {@const eventHref = getEventLinkHref(item.link)} -
  • -
    - {#if item.date} -
    - {item.date.toLocaleDateString("de-DE", { - weekday: "short", - day: "numeric", - month: "short", - })} -
    - {#if item.timeStr} - / + {@const live = liveStatus(item)} + {@const title = item.title || t(T.calendar_untitled)} + {@const urgency = urgencyOf(item)} + {@const hasBody = !!( + item.description || + eventHref || + item.location || + 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} - {/if} - {#if item.timeStr} -
    - {item.timeStr} - {t(T.calendar_time_suffix)} +
    +
    + + {title} + + {#if live} + + {live} + + {/if} +
    +
    + {#if item.timeStr && !item.isMultiDay} + + + {/if} + {#if item.location} + + + {/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} +
    - {/if} -
    - {#if eventHref} - - {item.title} - - {:else} -
    {item.title}
    - {/if} - - {#if item.description} -

    - {item.description} -

    - {#if eventHref} - - {t(T.calendar_more_info)} - - {/if} - {:else if eventHref} - - {t(T.calendar_more_info)} - {/if} +
    +
    +
    + {relativeDays(item.start)} +
    + {#if item.location} + + + {/if} + {#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} + + + + +
    +
    +
  • {/snippet} @@ -237,7 +569,35 @@ {/if} -
    + {#if allTags.length > 0} + +
    + + {t(T.calendar_filter_by_tag)} + + + {#each allTags as tag} + + {/each} +
    + {/if} + +
    @@ -273,9 +633,10 @@ {:else} {@const key = dayKey(d)} {@const isCurrentMonth = d.getMonth() === month} - {@const events = eventsByDay.get(key) ?? []} - {@const hasEvents = events.length > 0} + {@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} @@ -309,41 +677,175 @@
    - -
    + +
    {#if items.length === 0} -

    {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", - })} -

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

      - {t(T.calendar_next_events)} -

      -
        - {#each nextUpcomingEvents as item} - {@render eventCard(item)} + {: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)} +
          +
            + {#each group.events as item, eIdx} + {@render eventAccordion(item, gIdx === 0, gIdx === 0 && eIdx === 0)} + {/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_select_day)}

        +

        {t(T.calendar_no_future_events)}

        {/if}
    + +