@atmo-dev/events-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/DatePicker.svelte +231 -0
  2. package/dist/DatePicker.svelte.d.ts +11 -0
  3. package/dist/DateTimePicker.svelte +101 -0
  4. package/dist/DateTimePicker.svelte.d.ts +9 -0
  5. package/dist/EventAttendees.svelte +203 -0
  6. package/dist/EventAttendees.svelte.d.ts +13 -0
  7. package/dist/EventCard.svelte +131 -0
  8. package/dist/EventCard.svelte.d.ts +8 -0
  9. package/dist/EventComments.svelte +99 -0
  10. package/dist/EventComments.svelte.d.ts +6 -0
  11. package/dist/EventEditor.svelte +589 -0
  12. package/dist/EventEditor.svelte.d.ts +20 -0
  13. package/dist/EventRsvp.svelte +237 -0
  14. package/dist/EventRsvp.svelte.d.ts +17 -0
  15. package/dist/EventView.svelte +433 -0
  16. package/dist/EventView.svelte.d.ts +16 -0
  17. package/dist/ImageDropper.svelte +66 -0
  18. package/dist/ImageDropper.svelte.d.ts +7 -0
  19. package/dist/Map.svelte +27 -0
  20. package/dist/Map.svelte.d.ts +8 -0
  21. package/dist/PostToBlueskyModal.svelte +244 -0
  22. package/dist/PostToBlueskyModal.svelte.d.ts +22 -0
  23. package/dist/ShareModal.svelte +160 -0
  24. package/dist/ShareModal.svelte.d.ts +23 -0
  25. package/dist/ThemeApply.svelte +50 -0
  26. package/dist/ThemeApply.svelte.d.ts +7 -0
  27. package/dist/ThemeBackground.svelte +33 -0
  28. package/dist/ThemeBackground.svelte.d.ts +7 -0
  29. package/dist/ThemePicker.svelte +102 -0
  30. package/dist/ThemePicker.svelte.d.ts +7 -0
  31. package/dist/ThumbnailPresets.svelte +68 -0
  32. package/dist/ThumbnailPresets.svelte.d.ts +11 -0
  33. package/dist/TimePicker.svelte +188 -0
  34. package/dist/TimePicker.svelte.d.ts +9 -0
  35. package/dist/TimezonePicker.svelte +132 -0
  36. package/dist/TimezonePicker.svelte.d.ts +6 -0
  37. package/dist/VodPlayer.svelte +137 -0
  38. package/dist/VodPlayer.svelte.d.ts +14 -0
  39. package/dist/VodTranscript.svelte +72 -0
  40. package/dist/VodTranscript.svelte.d.ts +8 -0
  41. package/dist/atproto-helpers.d.ts +21 -0
  42. package/dist/atproto-helpers.js +61 -0
  43. package/dist/cal/helper.d.ts +1 -0
  44. package/dist/cal/helper.js +20 -0
  45. package/dist/cal/ical.d.ts +22 -0
  46. package/dist/cal/ical.js +188 -0
  47. package/dist/cal/sanitize.d.ts +3 -0
  48. package/dist/cal/sanitize.js +25 -0
  49. package/dist/contrail.d.ts +54 -0
  50. package/dist/contrail.js +22 -0
  51. package/dist/date-format.d.ts +22 -0
  52. package/dist/date-format.js +43 -0
  53. package/dist/editor/LinksSection.svelte +144 -0
  54. package/dist/editor/LinksSection.svelte.d.ts +10 -0
  55. package/dist/editor/LocationSection.svelte +215 -0
  56. package/dist/editor/LocationSection.svelte.d.ts +8 -0
  57. package/dist/editor/RecurringModal.svelte +270 -0
  58. package/dist/editor/RecurringModal.svelte.d.ts +30 -0
  59. package/dist/editor/ThemeSection.svelte +39 -0
  60. package/dist/editor/ThemeSection.svelte.d.ts +7 -0
  61. package/dist/editor/ThumbnailSection.svelte +219 -0
  62. package/dist/editor/ThumbnailSection.svelte.d.ts +13 -0
  63. package/dist/editor/adapter.d.ts +98 -0
  64. package/dist/editor/adapter.js +9 -0
  65. package/dist/editor/save.d.ts +42 -0
  66. package/dist/editor/save.js +154 -0
  67. package/dist/editor/types.d.ts +39 -0
  68. package/dist/editor/types.js +9 -0
  69. package/dist/event-types.d.ts +70 -0
  70. package/dist/event-types.js +11 -0
  71. package/dist/event-view/AddToCalendarButton.svelte +42 -0
  72. package/dist/event-view/AddToCalendarButton.svelte.d.ts +9 -0
  73. package/dist/event-view/EventBadges.svelte +20 -0
  74. package/dist/event-view/EventBadges.svelte.d.ts +7 -0
  75. package/dist/event-view/EventDateBlock.svelte +43 -0
  76. package/dist/event-view/EventDateBlock.svelte.d.ts +7 -0
  77. package/dist/event-view/EventHostedBy.svelte +63 -0
  78. package/dist/event-view/EventHostedBy.svelte.d.ts +16 -0
  79. package/dist/event-view/EventLinksList.svelte +37 -0
  80. package/dist/event-view/EventLinksList.svelte.d.ts +9 -0
  81. package/dist/event-view/EventLocationBlock.svelte +48 -0
  82. package/dist/event-view/EventLocationBlock.svelte.d.ts +7 -0
  83. package/dist/event-view/EventLocationMap.svelte +72 -0
  84. package/dist/event-view/EventLocationMap.svelte.d.ts +8 -0
  85. package/dist/event-view/ExternalRsvpNotice.svelte +44 -0
  86. package/dist/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
  87. package/dist/event-view/InviteShareFlow.svelte +177 -0
  88. package/dist/event-view/InviteShareFlow.svelte.d.ts +15 -0
  89. package/dist/event-view/StreamPlacePlayer.svelte +222 -0
  90. package/dist/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
  91. package/dist/event-view/format.d.ts +26 -0
  92. package/dist/event-view/format.js +145 -0
  93. package/dist/index.d.ts +18 -0
  94. package/dist/index.js +18 -0
  95. package/dist/profile-url.d.ts +1 -0
  96. package/dist/profile-url.js +7 -0
  97. package/dist/theme.d.ts +9 -0
  98. package/dist/theme.js +22 -0
  99. package/dist/themes/Blobs.svelte +35 -0
  100. package/dist/themes/Blobs.svelte.d.ts +26 -0
  101. package/dist/themes/Butterflies.svelte +185 -0
  102. package/dist/themes/Butterflies.svelte.d.ts +3 -0
  103. package/dist/themes/Fireflies.svelte +134 -0
  104. package/dist/themes/Fireflies.svelte.d.ts +3 -0
  105. package/dist/themes/Kaleidoscope.svelte +177 -0
  106. package/dist/themes/Kaleidoscope.svelte.d.ts +3 -0
  107. package/dist/themes/Matrix.svelte +150 -0
  108. package/dist/themes/Matrix.svelte.d.ts +3 -0
  109. package/dist/themes/Stars.svelte +98 -0
  110. package/dist/themes/Stars.svelte.d.ts +3 -0
  111. package/dist/thumbnails/designs.d.ts +18 -0
  112. package/dist/thumbnails/designs.js +316 -0
  113. package/package.json +95 -0
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+
4
+ type Word = { text: string; start: number; end: number };
5
+ type Segment = { start: number; end: number; text: string; words: Word[] };
6
+
7
+ let {
8
+ transcriptUrl,
9
+ currentTime = 0,
10
+ onseek
11
+ }: {
12
+ transcriptUrl: string;
13
+ currentTime?: number;
14
+ onseek?: (time: number) => void;
15
+ } = $props();
16
+
17
+ let segments: Segment[] = $state([]);
18
+ let loaded = $state(false);
19
+
20
+ onMount(() => {
21
+ fetchTranscript();
22
+ });
23
+
24
+ async function fetchTranscript() {
25
+ try {
26
+ const res = await fetch(transcriptUrl);
27
+ segments = await res.json();
28
+ } catch {
29
+ segments = [];
30
+ }
31
+ loaded = true;
32
+ }
33
+
34
+ function formatTime(seconds: number): string {
35
+ const m = Math.floor(seconds / 60);
36
+ const s = Math.floor(seconds % 60);
37
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
38
+ }
39
+
40
+ function wordClass(word: Word): string {
41
+ if (currentTime >= word.start && currentTime < word.end) {
42
+ return 'text-accent-500 font-semibold';
43
+ }
44
+ if (word.end <= currentTime) {
45
+ return 'text-base-900 dark:text-base-100';
46
+ }
47
+ return 'text-base-400 dark:text-base-500';
48
+ }
49
+ </script>
50
+
51
+ {#if loaded && segments.length > 0}
52
+ <div class="max-h-[400px] overflow-y-auto rounded-xl px-4 py-6">
53
+ {#each segments as segment, i (segment.start)}
54
+ <p class="mb-2">
55
+ <button
56
+ type="button"
57
+ onclick={() => onseek?.(segment.start)}
58
+ class="text-base-400 dark:text-base-500 mr-1 cursor-pointer font-mono text-xs transition-colors hover:text-accent-500"
59
+ >
60
+ {formatTime(segment.start)}
61
+ </button>
62
+ {#each segment.words as word, wi (`${segment.start}-${wi}`)}
63
+ <button
64
+ type="button"
65
+ onclick={() => onseek?.(word.start)}
66
+ class="{wordClass(word)} mr-1.5 cursor-pointer text-sm leading-relaxed transition-colors hover:text-accent-500"
67
+ >{word.text.trim()}</button>
68
+ {/each}
69
+ </p>
70
+ {/each}
71
+ </div>
72
+ {/if}
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ transcriptUrl: string;
3
+ currentTime?: number;
4
+ onseek?: (time: number) => void;
5
+ };
6
+ declare const VodTranscript: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type VodTranscript = ReturnType<typeof VodTranscript>;
8
+ export default VodTranscript;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Pure browser-safe atproto helpers used by the UI components. Does not depend
3
+ * on any session/client state — those flow through the EditorAdapter.
4
+ */
5
+ export declare function getCDNImageBlobUrl({ did, blob, format }: {
6
+ did: string;
7
+ blob: {
8
+ $type: 'blob';
9
+ ref: {
10
+ $link: string;
11
+ };
12
+ };
13
+ format?: 'webp' | 'jpeg' | 'png';
14
+ }): string;
15
+ export declare function compressImage(file: File | Blob, maxSize?: number, maxDimension?: number): Promise<{
16
+ blob: Blob;
17
+ aspectRatio: {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ }>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure browser-safe atproto helpers used by the UI components. Does not depend
3
+ * on any session/client state — those flow through the EditorAdapter.
4
+ */
5
+ export function getCDNImageBlobUrl({ did, blob, format = 'webp' }) {
6
+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
7
+ }
8
+ export function compressImage(file, maxSize = 900 * 1024, maxDimension = 2048) {
9
+ return new Promise((resolve, reject) => {
10
+ const img = new Image();
11
+ const reader = new FileReader();
12
+ reader.onload = (e) => {
13
+ if (!e.target?.result) {
14
+ return reject(new Error('Failed to read file.'));
15
+ }
16
+ img.src = e.target.result;
17
+ };
18
+ reader.onerror = (err) => reject(err);
19
+ reader.readAsDataURL(file);
20
+ img.onload = () => {
21
+ let width = img.width;
22
+ let height = img.height;
23
+ if (file.size <= maxSize) {
24
+ return resolve({ blob: file, aspectRatio: { width, height } });
25
+ }
26
+ if (width > maxDimension || height > maxDimension) {
27
+ if (width > height) {
28
+ height = Math.round((maxDimension / width) * height);
29
+ width = maxDimension;
30
+ }
31
+ else {
32
+ width = Math.round((maxDimension / height) * width);
33
+ height = maxDimension;
34
+ }
35
+ }
36
+ const canvas = document.createElement('canvas');
37
+ canvas.width = width;
38
+ canvas.height = height;
39
+ const ctx = canvas.getContext('2d');
40
+ if (!ctx)
41
+ return reject(new Error('Failed to get canvas context.'));
42
+ ctx.drawImage(img, 0, 0, width, height);
43
+ let quality = 0.9;
44
+ function attemptCompression() {
45
+ canvas.toBlob((blob) => {
46
+ if (!blob)
47
+ return reject(new Error('Compression failed.'));
48
+ if (blob.size <= maxSize || quality < 0.3) {
49
+ resolve({ blob, aspectRatio: { width, height } });
50
+ }
51
+ else {
52
+ quality -= 0.1;
53
+ attemptCompression();
54
+ }
55
+ }, 'image/webp', quality);
56
+ }
57
+ attemptCompression();
58
+ };
59
+ img.onerror = (err) => reject(err);
60
+ });
61
+ }
@@ -0,0 +1 @@
1
+ export declare function validateLink(link: string | undefined, tryAdding?: boolean): string | undefined;
@@ -0,0 +1,20 @@
1
+ export function validateLink(link, tryAdding = true) {
2
+ if (!link)
3
+ return;
4
+ try {
5
+ new URL(link);
6
+ return link;
7
+ }
8
+ catch {
9
+ if (!tryAdding)
10
+ return;
11
+ try {
12
+ link = 'https://' + link;
13
+ new URL(link);
14
+ return link;
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,22 @@
1
+ import type { EventData } from '../event-types.js';
2
+ export interface ICalAttendee {
3
+ name: string;
4
+ status: 'going' | 'interested';
5
+ url?: string;
6
+ }
7
+ export interface ICalEvent {
8
+ eventData: EventData;
9
+ uid: string;
10
+ url?: string;
11
+ organizer?: string;
12
+ imageUrl?: string;
13
+ attendees?: ICalAttendee[];
14
+ }
15
+ /**
16
+ * Generate a complete iCal feed from multiple events.
17
+ */
18
+ export declare function generateICalFeed(events: ICalEvent[], calendarName: string): string;
19
+ /**
20
+ * Generate iCal content for a single event (for client-side download).
21
+ */
22
+ export declare function generateICalEvent(eventData: EventData, atUri: string, eventUrl?: string): string;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Escape text for iCal fields (RFC 5545 Section 3.3.11).
3
+ * Backslashes, semicolons, commas, and newlines must be escaped.
4
+ */
5
+ function escapeText(text) {
6
+ return text
7
+ .replace(/\\/g, '\\\\')
8
+ .replace(/;/g, '\\;')
9
+ .replace(/,/g, '\\,')
10
+ .replace(/\n/g, '\\n');
11
+ }
12
+ /**
13
+ * Fold long lines per RFC 5545 (max 75 octets per line).
14
+ * Continuation lines start with a single space.
15
+ */
16
+ function foldLine(line) {
17
+ const maxLen = 75;
18
+ if (line.length <= maxLen)
19
+ return line;
20
+ const parts = [];
21
+ parts.push(line.slice(0, maxLen));
22
+ let i = maxLen;
23
+ while (i < line.length) {
24
+ parts.push(' ' + line.slice(i, i + maxLen - 1));
25
+ i += maxLen - 1;
26
+ }
27
+ return parts.join('\r\n');
28
+ }
29
+ /**
30
+ * Convert an ISO 8601 date string to iCal DATETIME format (UTC).
31
+ * e.g. "2026-02-22T15:00:00Z" -> "20260222T150000Z"
32
+ */
33
+ function toICalDate(isoString) {
34
+ const d = new Date(isoString);
35
+ const pad = (n) => n.toString().padStart(2, '0');
36
+ return (d.getUTCFullYear().toString() +
37
+ pad(d.getUTCMonth() + 1) +
38
+ pad(d.getUTCDate()) +
39
+ 'T' +
40
+ pad(d.getUTCHours()) +
41
+ pad(d.getUTCMinutes()) +
42
+ pad(d.getUTCSeconds()) +
43
+ 'Z');
44
+ }
45
+ /**
46
+ * Extract a location string from event locations array.
47
+ */
48
+ function getLocationString(locations) {
49
+ if (!locations || locations.length === 0)
50
+ return undefined;
51
+ const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
52
+ if (!loc)
53
+ return undefined;
54
+ const street = loc.street || undefined;
55
+ const locality = loc.locality || undefined;
56
+ const region = loc.region || undefined;
57
+ const parts = [street, locality, region].filter(Boolean);
58
+ return parts.length > 0 ? parts.join(', ') : undefined;
59
+ }
60
+ function getModeLabel(mode) {
61
+ if (mode.includes('virtual'))
62
+ return 'Virtual';
63
+ if (mode.includes('hybrid'))
64
+ return 'Hybrid';
65
+ if (mode.includes('inperson'))
66
+ return 'In-Person';
67
+ return 'Event';
68
+ }
69
+ function toAbsoluteUrl(pathOrUrl, baseUrl) {
70
+ if (!pathOrUrl)
71
+ return undefined;
72
+ try {
73
+ return new URL(pathOrUrl, baseUrl).toString();
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /**
80
+ * Generate a single VEVENT block.
81
+ */
82
+ function generateVEvent(event) {
83
+ const { eventData, uid, url, organizer, imageUrl } = event;
84
+ // Skip events with invalid or missing start dates
85
+ const startTime = new Date(eventData.startsAt);
86
+ if (isNaN(startTime.getTime()))
87
+ return null;
88
+ const lines = [];
89
+ lines.push('BEGIN:VEVENT');
90
+ lines.push(`UID:${escapeText(uid)}`);
91
+ lines.push(`DTSTART:${toICalDate(eventData.startsAt)}`);
92
+ if (eventData.endsAt) {
93
+ lines.push(`DTEND:${toICalDate(eventData.endsAt)}`);
94
+ }
95
+ else {
96
+ // Default to 1 hour duration when no end time is specified
97
+ const defaultEnd = new Date(startTime.getTime() + 60 * 60 * 1000);
98
+ lines.push(`DTEND:${toICalDate(defaultEnd.toISOString())}`);
99
+ }
100
+ lines.push(`SUMMARY:${escapeText(eventData.name)}`);
101
+ // Description: text + links
102
+ const descParts = [];
103
+ if (eventData.description) {
104
+ descParts.push(eventData.description);
105
+ }
106
+ if (eventData.uris && eventData.uris.length > 0) {
107
+ descParts.push('');
108
+ descParts.push('Links:');
109
+ for (const link of eventData.uris) {
110
+ descParts.push(link.name ? `${link.name}: ${link.uri}` : link.uri);
111
+ }
112
+ }
113
+ if (url) {
114
+ descParts.push('');
115
+ descParts.push(`Event page: ${url}`);
116
+ }
117
+ if (descParts.length > 0) {
118
+ lines.push(`DESCRIPTION:${escapeText(descParts.join('\n'))}`);
119
+ }
120
+ const locationParts = [];
121
+ const room = eventData.additionalData?.room;
122
+ if (typeof room === 'string' && room && room !== 'none') {
123
+ locationParts.push(room);
124
+ }
125
+ const address = getLocationString(eventData.locations);
126
+ if (address) {
127
+ locationParts.push(address);
128
+ }
129
+ if (locationParts.length > 0) {
130
+ lines.push(`LOCATION:${escapeText(locationParts.join(', '))}`);
131
+ }
132
+ if (url) {
133
+ lines.push(`URL:${url}`);
134
+ }
135
+ // Categories from event mode
136
+ if (eventData.mode) {
137
+ lines.push(`CATEGORIES:${escapeText(getModeLabel(eventData.mode))}`);
138
+ }
139
+ // Organizer
140
+ if (organizer) {
141
+ const organizerUrl = toAbsoluteUrl(`/${organizer}`, url) ?? url;
142
+ lines.push(`ORGANIZER;CN=${escapeText(organizer)}:${organizerUrl}`);
143
+ }
144
+ // Attendees
145
+ if (event.attendees) {
146
+ for (const attendee of event.attendees) {
147
+ const partstat = attendee.status === 'going' ? 'ACCEPTED' : 'TENTATIVE';
148
+ const attendeeUrl = toAbsoluteUrl(attendee.url, url) ?? url;
149
+ if (!attendeeUrl)
150
+ continue;
151
+ lines.push(`ATTENDEE;CN=${escapeText(attendee.name)};PARTSTAT=${partstat}:${attendeeUrl}`);
152
+ }
153
+ }
154
+ // Image (supported by Apple Calendar, Google Calendar)
155
+ if (imageUrl) {
156
+ lines.push(`IMAGE;VALUE=URI;DISPLAY=BADGE:${imageUrl}`);
157
+ }
158
+ lines.push(`DTSTAMP:${toICalDate(new Date().toISOString())}`);
159
+ // Reminder 15 minutes before
160
+ lines.push('BEGIN:VALARM');
161
+ lines.push('TRIGGER:-PT15M');
162
+ lines.push('ACTION:DISPLAY');
163
+ lines.push(`DESCRIPTION:${escapeText(eventData.name)}`);
164
+ lines.push('END:VALARM');
165
+ lines.push('END:VEVENT');
166
+ return lines.map(foldLine).join('\r\n');
167
+ }
168
+ /**
169
+ * Generate a complete iCal feed from multiple events.
170
+ */
171
+ export function generateICalFeed(events, calendarName) {
172
+ const lines = [];
173
+ lines.push('BEGIN:VCALENDAR');
174
+ lines.push('VERSION:2.0');
175
+ lines.push('PRODID:-//Blento//Events//EN');
176
+ lines.push(`X-WR-CALNAME:${escapeText(calendarName)}`);
177
+ lines.push('CALSCALE:GREGORIAN');
178
+ lines.push('METHOD:PUBLISH');
179
+ const vevents = events.map(generateVEvent).filter((v) => v !== null);
180
+ const result = lines.map(foldLine).join('\r\n') + '\r\n' + vevents.join('\r\n') + '\r\nEND:VCALENDAR\r\n';
181
+ return result;
182
+ }
183
+ /**
184
+ * Generate iCal content for a single event (for client-side download).
185
+ */
186
+ export function generateICalEvent(eventData, atUri, eventUrl) {
187
+ return generateICalFeed([{ eventData, uid: atUri, url: eventUrl }], eventData.name);
188
+ }
@@ -0,0 +1,3 @@
1
+ export declare function sanitize(dirty: string, config?: {
2
+ ADD_ATTR?: string[];
3
+ }): string;
@@ -0,0 +1,25 @@
1
+ import { BROWSER } from 'esm-env';
2
+ // Lightweight regex-based sanitizer for SSR where DOMPurify is not available.
3
+ // Strips common XSS vectors.
4
+ function regexSanitize(html) {
5
+ return html
6
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '')
7
+ .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '')
8
+ .replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '')
9
+ .replace(/<embed\b[^>]*\/?>/gi, '')
10
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style\s*>/gi, '')
11
+ .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
12
+ .replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="')
13
+ .replace(/src\s*=\s*["']?\s*javascript\s*:/gi, 'src="');
14
+ }
15
+ let _purify = null;
16
+ if (BROWSER) {
17
+ import('dompurify').then((mod) => {
18
+ _purify = (html, config) => mod.default.sanitize(html, config);
19
+ });
20
+ }
21
+ export function sanitize(dirty, config) {
22
+ if (_purify)
23
+ return _purify(dirty, config);
24
+ return regexSanitize(dirty);
25
+ }
@@ -0,0 +1,54 @@
1
+ import type { EventData } from './event-types.js';
2
+ /**
3
+ * Subset of contrail types and helpers needed by the UI package. Server-side
4
+ * functions (notifyContrailOfUpdate, listEventRecordsFromContrail, etc.) live
5
+ * in the consumer app, not here.
6
+ */
7
+ export type FlatEventRecord = EventData & {
8
+ cid?: string | null;
9
+ did: string;
10
+ rkey: string;
11
+ uri: string;
12
+ /** Populated when the event was read from a permissioned space. */
13
+ space?: string;
14
+ rsvps?: {
15
+ going?: Array<{
16
+ did: string;
17
+ createdAt?: string;
18
+ }>;
19
+ interested?: Array<{
20
+ did: string;
21
+ createdAt?: string;
22
+ }>;
23
+ notgoing?: Array<{
24
+ did: string;
25
+ createdAt?: string;
26
+ }>;
27
+ };
28
+ rsvpsCount?: number;
29
+ rsvpsGoingCount?: number;
30
+ rsvpsInterestedCount?: number;
31
+ rsvpsNotgoingCount?: number;
32
+ };
33
+ export type HostProfile = {
34
+ did: string;
35
+ handle?: string;
36
+ displayName?: string;
37
+ avatar?: string;
38
+ };
39
+ export type AttendeeInfo = {
40
+ did: string;
41
+ status: 'going' | 'interested';
42
+ avatar?: string;
43
+ name: string;
44
+ handle?: string;
45
+ url: string;
46
+ };
47
+ export declare const RSVP_GOING = "community.lexicon.calendar.rsvp#going";
48
+ export declare const RSVP_INTERESTED = "community.lexicon.calendar.rsvp#interested";
49
+ /** Build the canonical path for an event. Private events (those with a `space`
50
+ * field) live under `/p/<actor>/e/<rkey>/s/<skey>` so the page knows both
51
+ * which event to show and which space to look in. Public events use
52
+ * `/p/<actor>/e/<rkey>`. */
53
+ export declare function eventUrl(event: FlatEventRecord, actor?: string): string;
54
+ export declare function isEventOngoing(startsAt: string, endsAt?: string | null): boolean;
@@ -0,0 +1,22 @@
1
+ export const RSVP_GOING = 'community.lexicon.calendar.rsvp#going';
2
+ export const RSVP_INTERESTED = 'community.lexicon.calendar.rsvp#interested';
3
+ /** Build the canonical path for an event. Private events (those with a `space`
4
+ * field) live under `/p/<actor>/e/<rkey>/s/<skey>` so the page knows both
5
+ * which event to show and which space to look in. Public events use
6
+ * `/p/<actor>/e/<rkey>`. */
7
+ export function eventUrl(event, actor) {
8
+ const who = actor || event.did;
9
+ if (event.space) {
10
+ const m = event.space.match(/^ats?:\/\/[^/]+\/[^/]+\/([^/]+)$/);
11
+ const skey = m?.[1];
12
+ if (skey)
13
+ return `/p/${who}/e/${event.rkey}/s/${skey}`;
14
+ }
15
+ return `/p/${who}/e/${event.rkey}`;
16
+ }
17
+ export function isEventOngoing(startsAt, endsAt) {
18
+ if (!endsAt)
19
+ return false;
20
+ const now = new Date();
21
+ return new Date(startsAt) <= now && new Date(endsAt) >= now;
22
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string,
3
+ * interpreting the wall-clock components in the given IANA timezone.
4
+ */
5
+ export declare function datetimeLocalToISO(dt: string, tz: string): string;
6
+ /**
7
+ * Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose
8
+ * components represent the wall-clock time in the given IANA timezone.
9
+ */
10
+ export declare function isoToDatetimeLocalInTz(iso: string, tz: string): string;
11
+ /**
12
+ * Format an ISO timestamp using Intl options, rendered in the event's timezone
13
+ * when available. Falls back to the viewer's local zone for legacy events that
14
+ * predate the timezone field.
15
+ */
16
+ export declare function formatInTz(iso: string, tz: string | undefined, options: Intl.DateTimeFormatOptions, locale?: string): string;
17
+ /**
18
+ * Returns the parts of an ISO timestamp in the given timezone (or viewer-local
19
+ * when tz is falsy). Useful when the caller wants numeric components like the
20
+ * day-of-month rendered in the event's zone.
21
+ */
22
+ export declare function partsInTz(iso: string, tz: string | undefined, options: Intl.DateTimeFormatOptions, locale?: string): Record<string, string>;
@@ -0,0 +1,43 @@
1
+ import { parseAbsolute, parseDateTime } from '@internationalized/date';
2
+ /**
3
+ * Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string,
4
+ * interpreting the wall-clock components in the given IANA timezone.
5
+ */
6
+ export function datetimeLocalToISO(dt, tz) {
7
+ return parseDateTime(dt).toDate(tz).toISOString();
8
+ }
9
+ /**
10
+ * Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose
11
+ * components represent the wall-clock time in the given IANA timezone.
12
+ */
13
+ export function isoToDatetimeLocalInTz(iso, tz) {
14
+ const zdt = parseAbsolute(iso, tz);
15
+ const pad = (n) => n.toString().padStart(2, '0');
16
+ return `${zdt.year}-${pad(zdt.month)}-${pad(zdt.day)}T${pad(zdt.hour)}:${pad(zdt.minute)}`;
17
+ }
18
+ /**
19
+ * Format an ISO timestamp using Intl options, rendered in the event's timezone
20
+ * when available. Falls back to the viewer's local zone for legacy events that
21
+ * predate the timezone field.
22
+ */
23
+ export function formatInTz(iso, tz, options, locale = 'en-US') {
24
+ const date = new Date(iso);
25
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: tz || undefined }).format(date);
26
+ }
27
+ /**
28
+ * Returns the parts of an ISO timestamp in the given timezone (or viewer-local
29
+ * when tz is falsy). Useful when the caller wants numeric components like the
30
+ * day-of-month rendered in the event's zone.
31
+ */
32
+ export function partsInTz(iso, tz, options, locale = 'en-US') {
33
+ const date = new Date(iso);
34
+ const parts = new Intl.DateTimeFormat(locale, {
35
+ ...options,
36
+ timeZone: tz || undefined
37
+ }).formatToParts(date);
38
+ const out = {};
39
+ for (const p of parts)
40
+ if (p.type !== 'literal')
41
+ out[p.type] = p.value;
42
+ return out;
43
+ }