@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,237 @@
1
+ <script lang="ts">
2
+ import * as TID from '@atcute/tid';
3
+ import { Avatar, Button } from '@foxui/core';
4
+ import { launchConfetti } from '@foxui/visual';
5
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
6
+
7
+ let {
8
+ eventUri,
9
+ eventCid,
10
+ initialRsvpStatus = null,
11
+ initialRsvpRkey = null,
12
+ spaceUri = null,
13
+ adapter,
14
+ viewer,
15
+ onrsvp,
16
+ oncancel,
17
+ onlogin
18
+ }: {
19
+ eventUri: string;
20
+ eventCid: string | null;
21
+ initialRsvpStatus?: 'going' | 'interested' | 'notgoing' | null;
22
+ initialRsvpRkey?: string | null;
23
+ /** If set, RSVPs write into this space instead of the user's public PDS. */
24
+ spaceUri?: string | null;
25
+ adapter: EditorAdapter;
26
+ viewer: EditorViewer;
27
+ onrsvp?: (status: 'going' | 'interested', rkey: string) => void;
28
+ oncancel?: () => void;
29
+ onlogin?: () => void;
30
+ } = $props();
31
+
32
+ let rsvpStatusOverride: 'going' | 'interested' | 'notgoing' | null | undefined = $state(
33
+ undefined
34
+ );
35
+ let rsvpRkeyOverride: string | null | undefined = $state(undefined);
36
+ let rsvpSubmitting = $state(false);
37
+
38
+ let rsvpStatus = $derived(rsvpStatusOverride !== undefined ? rsvpStatusOverride : initialRsvpStatus);
39
+ let rsvpRkey = $derived(rsvpRkeyOverride !== undefined ? rsvpRkeyOverride : initialRsvpRkey);
40
+
41
+ async function submitRsvp(status: 'going' | 'interested') {
42
+ if (!viewer.isLoggedIn || !viewer.did) return;
43
+ rsvpSubmitting = true;
44
+ try {
45
+ const key = rsvpRkey ?? TID.now();
46
+ const record = {
47
+ $type: 'community.lexicon.calendar.rsvp',
48
+ createdWith: 'https://atmo.rsvp',
49
+ status: `community.lexicon.calendar.rsvp#${status}`,
50
+ subject: {
51
+ uri: eventUri,
52
+ ...(eventCid ? { cid: eventCid } : {})
53
+ },
54
+ createdAt: new Date().toISOString()
55
+ };
56
+
57
+ let ok = false;
58
+ if (spaceUri) {
59
+ if (!adapter.putSpaceRecord) {
60
+ console.error('putSpaceRecord not supported by this adapter');
61
+ return;
62
+ }
63
+ const result = await adapter.putSpaceRecord({
64
+ spaceUri,
65
+ collection: 'community.lexicon.calendar.rsvp',
66
+ rkey: key,
67
+ record
68
+ });
69
+ ok = result.ok;
70
+ } else {
71
+ try {
72
+ await adapter.putRecord({
73
+ collection: 'community.lexicon.calendar.rsvp',
74
+ rkey: key,
75
+ record
76
+ });
77
+ ok = true;
78
+ adapter.notifyUpdate?.(
79
+ `at://${viewer.did}/community.lexicon.calendar.rsvp/${key}`
80
+ );
81
+ } catch (e) {
82
+ console.error('RSVP putRecord failed:', e);
83
+ }
84
+ }
85
+
86
+ if (ok) {
87
+ rsvpStatusOverride = status;
88
+ rsvpRkeyOverride = key;
89
+ launchConfetti();
90
+ onrsvp?.(status, key);
91
+ }
92
+ } catch (e) {
93
+ console.error('Failed to submit RSVP:', e);
94
+ } finally {
95
+ rsvpSubmitting = false;
96
+ }
97
+ }
98
+
99
+ async function cancelRsvp() {
100
+ if (!viewer.isLoggedIn || !viewer.did || !rsvpRkey) return;
101
+ rsvpSubmitting = true;
102
+ try {
103
+ if (spaceUri) {
104
+ if (!adapter.deleteSpaceRecord) {
105
+ console.error('deleteSpaceRecord not supported by this adapter');
106
+ return;
107
+ }
108
+ await adapter.deleteSpaceRecord({
109
+ spaceUri,
110
+ collection: 'community.lexicon.calendar.rsvp',
111
+ rkey: rsvpRkey
112
+ });
113
+ } else {
114
+ await adapter.deleteRecord({
115
+ collection: 'community.lexicon.calendar.rsvp',
116
+ rkey: rsvpRkey
117
+ });
118
+ adapter.notifyUpdate?.(
119
+ `at://${viewer.did}/community.lexicon.calendar.rsvp/${rsvpRkey}`
120
+ );
121
+ }
122
+ rsvpStatusOverride = null;
123
+ rsvpRkeyOverride = null;
124
+ oncancel?.();
125
+ } catch (e) {
126
+ console.error('Failed to cancel RSVP:', e);
127
+ } finally {
128
+ rsvpSubmitting = false;
129
+ }
130
+ }
131
+ </script>
132
+
133
+ <div
134
+ class="border-base-200 dark:border-base-800 bg-base-100 items-between dark:bg-base-950/50 mt-8 mb-2 flex h-25 flex-col justify-center rounded-2xl border p-4"
135
+ >
136
+ {#if !viewer.isLoggedIn}
137
+ <div class="flex items-center justify-between gap-4">
138
+ <p class="text-base-600 dark:text-base-400 text-sm">Log in to RSVP to this event</p>
139
+
140
+ <Button onclick={() => { onlogin?.(); adapter.requestLogin(); }}>Log in to RSVP</Button>
141
+ </div>
142
+ {:else if rsvpStatus === 'going'}
143
+ <div class="flex items-center justify-between">
144
+ <div class="flex items-center gap-3">
145
+ <div
146
+ class="flex size-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
147
+ >
148
+ <svg
149
+ xmlns="http://www.w3.org/2000/svg"
150
+ viewBox="0 0 20 20"
151
+ fill="currentColor"
152
+ class="size-4 text-green-600 dark:text-green-400"
153
+ >
154
+ <path
155
+ fill-rule="evenodd"
156
+ d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
157
+ clip-rule="evenodd"
158
+ />
159
+ </svg>
160
+ </div>
161
+ <p class="text-base-900 dark:text-base-50 font-semibold">You're Going</p>
162
+ </div>
163
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
164
+ </div>
165
+ {:else if rsvpStatus === 'interested'}
166
+ <div class="flex items-center justify-between">
167
+ <div class="flex items-center gap-3">
168
+ <div
169
+ class="flex size-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30"
170
+ >
171
+ <svg
172
+ xmlns="http://www.w3.org/2000/svg"
173
+ viewBox="0 0 20 20"
174
+ fill="currentColor"
175
+ class="size-4 text-amber-600 dark:text-amber-400"
176
+ >
177
+ <path
178
+ fill-rule="evenodd"
179
+ d="M10.868 2.884c-.321-.772-1.415-.772-1.736 0l-1.83 4.401-4.753.381c-.833.067-1.171 1.107-.536 1.651l3.62 3.102-1.106 4.637c-.194.813.691 1.456 1.405 1.02L10 15.591l4.069 2.485c.713.436 1.598-.207 1.404-1.02l-1.106-4.637 3.62-3.102c.635-.544.297-1.584-.536-1.65l-4.752-.382-1.831-4.401Z"
180
+ clip-rule="evenodd"
181
+ />
182
+ </svg>
183
+ </div>
184
+ <p class="text-base-900 dark:text-base-50 font-semibold">You're Interested</p>
185
+ </div>
186
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
187
+ </div>
188
+ {:else if rsvpStatus === 'notgoing'}
189
+ <div class="flex items-center justify-between">
190
+ <div class="flex items-center gap-3">
191
+ <div
192
+ class="flex size-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"
193
+ >
194
+ <svg
195
+ xmlns="http://www.w3.org/2000/svg"
196
+ viewBox="0 0 20 20"
197
+ fill="currentColor"
198
+ class="size-4 text-red-600 dark:text-red-400"
199
+ >
200
+ <path
201
+ d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
202
+ />
203
+ </svg>
204
+ </div>
205
+ <p class="text-base-900 dark:text-base-50 font-semibold">Not Going</p>
206
+ </div>
207
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
208
+ </div>
209
+ {:else}
210
+ {#if viewer.isLoggedIn}
211
+ <div class="mb-4 flex items-center gap-2">
212
+ <span class="text-base-500 dark:text-base-400 text-sm">Attend as</span>
213
+ <Avatar
214
+ src={viewer.avatar}
215
+ alt={viewer.displayName || viewer.handle || viewer.did || ''}
216
+ class="size-5"
217
+ />
218
+ <span class="text-base-700 dark:text-base-300 truncate text-sm font-medium">
219
+ {viewer.displayName || viewer.handle || viewer.did}
220
+ </span>
221
+ </div>
222
+ {/if}
223
+ <div class="flex gap-3">
224
+ <Button onclick={() => submitRsvp('going')} disabled={rsvpSubmitting} class="flex-1">
225
+ {rsvpSubmitting ? '...' : 'Going'}
226
+ </Button>
227
+ <Button
228
+ onclick={() => submitRsvp('interested')}
229
+ disabled={rsvpSubmitting}
230
+ variant="secondary"
231
+ class="flex-1"
232
+ >
233
+ {rsvpSubmitting ? '...' : 'Interested'}
234
+ </Button>
235
+ </div>
236
+ {/if}
237
+ </div>
@@ -0,0 +1,17 @@
1
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
2
+ type $$ComponentProps = {
3
+ eventUri: string;
4
+ eventCid: string | null;
5
+ initialRsvpStatus?: 'going' | 'interested' | 'notgoing' | null;
6
+ initialRsvpRkey?: string | null;
7
+ /** If set, RSVPs write into this space instead of the user's public PDS. */
8
+ spaceUri?: string | null;
9
+ adapter: EditorAdapter;
10
+ viewer: EditorViewer;
11
+ onrsvp?: (status: 'going' | 'interested', rkey: string) => void;
12
+ oncancel?: () => void;
13
+ onlogin?: () => void;
14
+ };
15
+ declare const EventRsvp: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type EventRsvp = ReturnType<typeof EventRsvp>;
17
+ export default EventRsvp;
@@ -0,0 +1,433 @@
1
+ <script lang="ts">
2
+ import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
3
+ import { getCDNImageBlobUrl } from './atproto-helpers.js';
4
+ import { Button, ToggleGroup, ToggleGroupItem } from '@foxui/core';
5
+ import ShareModal from './ShareModal.svelte';
6
+ import EventComments from './EventComments.svelte';
7
+ import Avatar from 'svelte-boring-avatars';
8
+ import EventRsvp from './EventRsvp.svelte';
9
+ import EventCard from './EventCard.svelte';
10
+ import EventAttendees from './EventAttendees.svelte';
11
+ import VodPlayer, { type VodPlayerApi } from './VodPlayer.svelte';
12
+ import StreamPlacePlayer from './event-view/StreamPlacePlayer.svelte';
13
+ import { launchConfetti } from '@foxui/visual';
14
+ import ThemeBackground from './ThemeBackground.svelte';
15
+ import ThemeApply from './ThemeApply.svelte';
16
+ import { defaultTheme, type EventTheme } from './theme.js';
17
+ import { onMount } from 'svelte';
18
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
19
+
20
+ import EventBadges from './event-view/EventBadges.svelte';
21
+ import EventDateBlock from './event-view/EventDateBlock.svelte';
22
+ import EventLocationBlock from './event-view/EventLocationBlock.svelte';
23
+ import EventLocationMap from './event-view/EventLocationMap.svelte';
24
+ import EventHostedBy from './event-view/EventHostedBy.svelte';
25
+ import EventLinksList from './event-view/EventLinksList.svelte';
26
+ import AddToCalendarButton from './event-view/AddToCalendarButton.svelte';
27
+ import InviteShareFlow from './event-view/InviteShareFlow.svelte';
28
+ import ExternalRsvpNotice from './event-view/ExternalRsvpNotice.svelte';
29
+ import { buildDescriptionHtml, getLocationData, resolveGeoLocation, type GeoLocation } from './event-view/format';
30
+
31
+ let {
32
+ data,
33
+ adapter,
34
+ viewer,
35
+ pageUrl,
36
+ embedMode = false,
37
+ shareUrlOverride
38
+ }: {
39
+ data: any;
40
+ adapter: EditorAdapter;
41
+ viewer: EditorViewer;
42
+ /** Current page URL — used for the OG image link and the calendar button. */
43
+ pageUrl: URL;
44
+ embedMode?: boolean;
45
+ /** When set, the share modal / Bluesky post embed use this URL instead
46
+ * of the canonical atmo.rsvp event URL. Useful for embedders that want
47
+ * share links to point at their own event page. */
48
+ shareUrlOverride?: string;
49
+ } = $props();
50
+
51
+ let eventData: FlatEventRecord = $derived(data.eventData);
52
+ let did: string = $derived(data.actorDid);
53
+ let rkey: string = $derived(data.rkey);
54
+ let hostProfile = $derived(data.hostProfile);
55
+ let attendees = $derived(data.attendees);
56
+
57
+ let theme: EventTheme = $derived(eventData.theme ?? defaultTheme);
58
+
59
+ let hostUrl = $derived(`/p/${hostProfile?.handle || did}`);
60
+ let eventPath = $derived(eventUrl(eventData, hostProfile?.handle || did));
61
+ let shareUrl = $derived(
62
+ shareUrlOverride
63
+ ? shareUrlOverride
64
+ : typeof window !== 'undefined'
65
+ ? `${window.location.origin}${eventPath}`
66
+ : eventPath
67
+ );
68
+
69
+ // Times are always rendered in the viewer's local timezone — the stored UTC
70
+ // instant is what the Date constructor parses, and toLocaleString/Time uses
71
+ // the browser's zone by default.
72
+ let startDate = $derived(new Date(eventData.startsAt));
73
+ let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null);
74
+
75
+ let locationData = $derived(getLocationData(eventData.locations));
76
+ let geoLocation: GeoLocation | null = $state(null);
77
+
78
+ let showShareModal = $state(false);
79
+ let shareModalTitle = $state('Event created!');
80
+ let shareModalText: string | undefined = $state(undefined);
81
+ // True only when the share modal was opened via the post-creation flow by
82
+ // the event's host. Drives the "show comments on event page" checkbox and
83
+ // the bskyPostRef write — RSVP shares should never overwrite the comments
84
+ // root, even when the RSVPer is the host.
85
+ let canSetEventComments = $state(false);
86
+ let isHost = $derived(!!viewer.did && viewer.did === did);
87
+ let hasComments = $derived(
88
+ !!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri
89
+ );
90
+ let aboutCommentsTab = $state<'about' | 'comments'>('about');
91
+
92
+ onMount(async () => {
93
+ geoLocation = await resolveGeoLocation(eventData.locations, locationData);
94
+
95
+ const url = new URL(window.location.href);
96
+ if (url.searchParams.has('created')) {
97
+ url.searchParams.delete('created');
98
+ history.replaceState({}, '', url.pathname);
99
+ launchConfetti();
100
+ shareModalTitle = 'Event created!';
101
+ shareModalText = `I'm hosting "${eventData.name}"!\n\n${shareUrl}`;
102
+ canSetEventComments = isHost;
103
+ showShareModal = true;
104
+ }
105
+ });
106
+
107
+ let thumbnailImage = $derived.by(() => {
108
+ if (!eventData.media || eventData.media.length === 0) return null;
109
+ const media = eventData.media.find((m) => m.role === 'thumbnail');
110
+ if (!media?.content) return null;
111
+ const url = getCDNImageBlobUrl({ did, blob: media.content });
112
+ if (!url) return null;
113
+ return { url, alt: media.alt || eventData.name };
114
+ });
115
+
116
+ let bannerImage = $derived.by(() => {
117
+ if (!eventData.media || eventData.media.length === 0) return null;
118
+ const media = eventData.media.find((m) => m.role === 'header');
119
+ if (!media?.content) return null;
120
+ const url = getCDNImageBlobUrl({ did, blob: media.content });
121
+ if (!url) return null;
122
+ return { url, alt: media.alt || eventData.name };
123
+ });
124
+
125
+ // Prefer thumbnail; fall back to header/banner image
126
+ let displayImage = $derived(thumbnailImage ?? bannerImage);
127
+ let isBannerOnly = $derived(!thumbnailImage && !!bannerImage);
128
+
129
+ let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt));
130
+ let isPast = $derived(endDate ? endDate < new Date() : false);
131
+
132
+ let streamPlaceHandle = $derived.by(() => {
133
+ const uris = eventData.uris;
134
+ if (!uris) return null;
135
+ for (const { uri } of uris) {
136
+ const m = uri.match(/^https?:\/\/stream\.place\/([^/?#]+)/i);
137
+ if (m) return m[1];
138
+ }
139
+ return null;
140
+ });
141
+
142
+ let descriptionHtml = $derived(
143
+ buildDescriptionHtml(eventData.description, eventData.facets)
144
+ );
145
+
146
+ let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`);
147
+
148
+ let ogImageUrl = $derived(data.ogImage ?? `${pageUrl.origin}${pageUrl.pathname}/og.png`);
149
+
150
+ let isOwner = $derived(!embedMode && viewer.isLoggedIn && viewer.did === did);
151
+
152
+ let speakers = $derived(data.speakerProfiles ?? []);
153
+
154
+ // Imported events can opt out of atmo's own RSVPs (rsvpMode === 'external_only').
155
+ // In that case we hide the RSVP controls and link out to the original event page.
156
+ let externalSource = $derived(
157
+ (eventData.additionalData as Record<string, unknown> | undefined)?.externalSource as
158
+ | { url?: string; rsvpMode?: 'external_only' | 'atmo_too' }
159
+ | undefined
160
+ );
161
+ let rsvpExternalOnly = $derived(
162
+ externalSource?.rsvpMode === 'external_only' && !!externalSource?.url
163
+ );
164
+
165
+ let vodCurrentTime = $state(0);
166
+ let vodApi: VodPlayerApi | undefined = $state();
167
+
168
+ let attendeesRef: EventAttendees | undefined = $state();
169
+
170
+ function handleRsvp(status: 'going' | 'interested') {
171
+ if (!viewer.did) return;
172
+ attendeesRef?.addAttendee({
173
+ did: viewer.did,
174
+ status,
175
+ avatar: viewer.avatar,
176
+ name: viewer.displayName || viewer.handle || viewer.did,
177
+ handle: viewer.handle,
178
+ url: `/${viewer.handle || viewer.did}`
179
+ });
180
+ if (status === 'interested') return;
181
+ shareModalTitle = "You're going!";
182
+ shareModalText = `I'm going to "${eventData.name}".\n\n${shareUrl}`;
183
+ canSetEventComments = false;
184
+ showShareModal = true;
185
+ }
186
+
187
+ function handleRsvpCancel() {
188
+ if (!viewer.did) return;
189
+ attendeesRef?.removeAttendee(viewer.did);
190
+ }
191
+ </script>
192
+
193
+ <svelte:head>
194
+ <title>{eventData.name}</title>
195
+ <meta name="description" content={eventData.description || `Event: ${eventData.name}`} />
196
+ <meta property="og:title" content={eventData.name} />
197
+ <meta property="og:description" content={eventData.description || `Event: ${eventData.name}`} />
198
+ <meta name="twitter:card" content="summary_large_image" />
199
+ <meta name="twitter:title" content={eventData.name} />
200
+ <meta name="twitter:description" content={eventData.description || `Event: ${eventData.name}`} />
201
+ <meta name="twitter:image" content={ogImageUrl} />
202
+ </svelte:head>
203
+
204
+ <ThemeApply accentColor={theme.accentColor} baseColor={theme.baseColor} />
205
+ <ThemeBackground {theme} />
206
+
207
+ <div class="min-h-screen px-6 py-12 sm:py-12">
208
+ <div class="mx-auto max-w-3xl">
209
+ <!-- Banner image (full width, only when no thumbnail) -->
210
+ {#if isBannerOnly && displayImage}
211
+ <img
212
+ src={displayImage.url}
213
+ alt={displayImage.alt}
214
+ class="border-base-200 dark:border-base-800 mb-8 aspect-3/1 w-full rounded-2xl border object-cover"
215
+ />
216
+ {/if}
217
+
218
+ <!-- Two-column layout: image left, details right -->
219
+ <div
220
+ class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:grid-rows-[auto_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]"
221
+ >
222
+ <!-- Thumbnail image (left column) -->
223
+ {#if !isBannerOnly}
224
+ <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none">
225
+ {#if displayImage}
226
+ <img
227
+ src={displayImage.url}
228
+ alt={displayImage.alt}
229
+ class="border-base-200 dark:border-base-800 bg-base-200 dark:bg-base-950/50 aspect-square w-full rounded-2xl border object-cover"
230
+ />
231
+ {:else}
232
+ <div
233
+ class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border [&>svg]:h-full [&>svg]:w-full"
234
+ >
235
+ <Avatar
236
+ size={256}
237
+ name={data.rkey}
238
+ variant="marble"
239
+ colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
240
+ square
241
+ />
242
+ </div>
243
+ {/if}
244
+ {#if isOwner}
245
+ <Button href="./{rkey}/edit" class="mt-9 w-full">Edit Event</Button>
246
+ {#if data.spaceUri}
247
+ <InviteShareFlow
248
+ spaceUri={data.spaceUri}
249
+ spaceKey={data.spaceKey}
250
+ {did}
251
+ {rkey}
252
+ eventName={eventData.name}
253
+ {hostProfile}
254
+ {adapter}
255
+ {viewer}
256
+ />
257
+ {/if}
258
+ {/if}
259
+ </div>
260
+ {/if}
261
+
262
+ <!-- Right column: event details -->
263
+ <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1">
264
+ <div class="mb-2">
265
+ <h1 class="text-base-900 dark:text-base-50 text-3xl leading-tight font-bold sm:text-4xl">
266
+ {eventData.name}
267
+ </h1>
268
+ </div>
269
+
270
+ <EventBadges mode={eventData.mode} {isOngoing} />
271
+
272
+ <EventDateBlock {startDate} {endDate} />
273
+
274
+ <EventLocationBlock {locationData} />
275
+
276
+ <!-- Part of -->
277
+ {#if data.parentEvent}
278
+ <div
279
+ class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-950/50 mt-8 mb-2 justify-center rounded-2xl border p-4"
280
+ >
281
+ <p
282
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
283
+ >
284
+ Part of
285
+ </p>
286
+ <EventCard event={data.parentEvent} actor="atprotocol.dev" />
287
+ <Button href="/p/atmosphereconf.org" size="lg" class="mt-6 w-full">
288
+ See full schedule
289
+ </Button>
290
+ </div>
291
+ {/if}
292
+
293
+ {#if did === 'did:plc:lehcqqkwzcwvjvw66uthu5oq' && rkey === '3lte3c7x43l2e'}
294
+ <Button href="/p/atmosphereconf.org" size="lg" class="mb-4 w-full">
295
+ See full schedule
296
+ </Button>
297
+ {/if}
298
+
299
+ {#if !isPast}
300
+ {#if rsvpExternalOnly && externalSource?.url}
301
+ <ExternalRsvpNotice url={externalSource.url} />
302
+ {:else}
303
+ <EventRsvp
304
+ {eventUri}
305
+ eventCid={eventData.cid ?? null}
306
+ initialRsvpStatus={data.viewerRsvpStatus}
307
+ initialRsvpRkey={data.viewerRsvpRkey}
308
+ spaceUri={data.spaceUri ?? null}
309
+ {adapter}
310
+ {viewer}
311
+ onrsvp={handleRsvp}
312
+ oncancel={handleRsvpCancel}
313
+ />
314
+ {/if}
315
+ {/if}
316
+
317
+ <!-- Live stream -->
318
+ {#if isOngoing && streamPlaceHandle}
319
+ <div class="mt-8 mb-8">
320
+ <p
321
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
322
+ >
323
+ Live now
324
+ </p>
325
+ <StreamPlacePlayer handle={streamPlaceHandle} title={eventData.name} />
326
+ </div>
327
+ {/if}
328
+
329
+ <!-- About + Comments -->
330
+ {#if descriptionHtml || hasComments}
331
+ <div class="mt-8 mb-8">
332
+ {#if descriptionHtml && hasComments}
333
+ <ToggleGroup
334
+ type="single"
335
+ bind:value={
336
+ () => aboutCommentsTab,
337
+ (val) => {
338
+ if (val === 'about' || val === 'comments') aboutCommentsTab = val;
339
+ }
340
+ }
341
+ class="mb-4 w-fit"
342
+ size="xs"
343
+ >
344
+ <ToggleGroupItem value="about">About</ToggleGroupItem>
345
+ <ToggleGroupItem value="comments">Comments</ToggleGroupItem>
346
+ </ToggleGroup>
347
+
348
+ {#if aboutCommentsTab === 'about'}
349
+ <div
350
+ class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word"
351
+ >
352
+ {@html descriptionHtml}
353
+ </div>
354
+ {:else if eventData.bskyPostRef?.uri}
355
+ <EventComments postUri={eventData.bskyPostRef.uri} />
356
+ {/if}
357
+ {:else if descriptionHtml}
358
+ <p
359
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
360
+ >
361
+ About
362
+ </p>
363
+ <div
364
+ class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word"
365
+ >
366
+ {@html descriptionHtml}
367
+ </div>
368
+ {:else if eventData.bskyPostRef?.uri}
369
+ <p
370
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
371
+ >
372
+ Comments
373
+ </p>
374
+ <EventComments postUri={eventData.bskyPostRef.uri} />
375
+ {/if}
376
+ </div>
377
+ {/if}
378
+
379
+ <!-- Recording -->
380
+ {#if data.vod}
381
+ <div class="mt-8 mb-8">
382
+ <p
383
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
384
+ >
385
+ Recording
386
+ </p>
387
+ <VodPlayer
388
+ playlistUrl={data.vod.playlistUrl}
389
+ title={eventData.name}
390
+ subtitlesUrl="/vods/{rkey}-karaoke.vtt"
391
+ bind:currentTime={vodCurrentTime}
392
+ bind:api={vodApi}
393
+ />
394
+ </div>
395
+ {/if}
396
+
397
+ <EventLocationMap {locationData} {geoLocation} />
398
+ </div>
399
+
400
+ <!-- Left column: sidebar info -->
401
+ <div class="order-3 space-y-6 md:order-0 md:col-start-1">
402
+ <EventHostedBy {hostProfile} {hostUrl} {did} {speakers} />
403
+
404
+ <EventLinksList uris={eventData.uris} />
405
+
406
+ <AddToCalendarButton {eventData} {eventUri} pageHref={pageUrl.href} />
407
+
408
+ <EventAttendees
409
+ bind:this={attendeesRef}
410
+ going={attendees.going}
411
+ interested={attendees.interested}
412
+ goingCount={attendees.goingCount}
413
+ interestedCount={attendees.interestedCount}
414
+ />
415
+ </div>
416
+ </div>
417
+ </div>
418
+ </div>
419
+
420
+ <ShareModal
421
+ bind:open={showShareModal}
422
+ url={shareUrl}
423
+ title={shareModalTitle}
424
+ shareText={shareModalText}
425
+ eventName={eventData.name}
426
+ {ogImageUrl}
427
+ {canSetEventComments}
428
+ eventDid={did}
429
+ eventRkey={rkey}
430
+ eventDescription={eventData.description}
431
+ {adapter}
432
+ {viewer}
433
+ />