@atmo-dev/kit 0.4.0 → 0.5.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 (144) hide show
  1. package/README.md +35 -0
  2. package/dist/events/DatePicker.svelte +222 -0
  3. package/dist/events/DatePicker.svelte.d.ts +12 -0
  4. package/dist/events/DateTimePicker.svelte +112 -0
  5. package/dist/events/DateTimePicker.svelte.d.ts +10 -0
  6. package/dist/events/EventAttendees.svelte +199 -0
  7. package/dist/events/EventAttendees.svelte.d.ts +19 -0
  8. package/dist/events/EventCard.svelte +148 -0
  9. package/dist/events/EventCard.svelte.d.ts +8 -0
  10. package/dist/events/EventComments.svelte +98 -0
  11. package/dist/events/EventComments.svelte.d.ts +8 -0
  12. package/dist/events/EventEditor.svelte +540 -0
  13. package/dist/events/EventEditor.svelte.d.ts +18 -0
  14. package/dist/events/EventRsvp.svelte +178 -0
  15. package/dist/events/EventRsvp.svelte.d.ts +16 -0
  16. package/dist/events/EventView.svelte +488 -0
  17. package/dist/events/EventView.svelte.d.ts +23 -0
  18. package/dist/events/ImageDropper.svelte +66 -0
  19. package/dist/events/ImageDropper.svelte.d.ts +7 -0
  20. package/dist/events/Map.svelte +36 -0
  21. package/dist/events/Map.svelte.d.ts +8 -0
  22. package/dist/events/PostToBlueskyModal.svelte +246 -0
  23. package/dist/events/PostToBlueskyModal.svelte.d.ts +22 -0
  24. package/dist/events/ShareModal.svelte +174 -0
  25. package/dist/events/ShareModal.svelte.d.ts +23 -0
  26. package/dist/events/StarterPackButton.svelte +72 -0
  27. package/dist/events/StarterPackButton.svelte.d.ts +10 -0
  28. package/dist/events/ThemeApply.svelte +30 -0
  29. package/dist/events/ThemeApply.svelte.d.ts +7 -0
  30. package/dist/events/ThemeBackground.svelte +33 -0
  31. package/dist/events/ThemeBackground.svelte.d.ts +7 -0
  32. package/dist/events/ThemePicker.svelte +104 -0
  33. package/dist/events/ThemePicker.svelte.d.ts +7 -0
  34. package/dist/events/ThumbnailPresets.svelte +71 -0
  35. package/dist/events/ThumbnailPresets.svelte.d.ts +11 -0
  36. package/dist/events/TimePicker.svelte +117 -0
  37. package/dist/events/TimePicker.svelte.d.ts +10 -0
  38. package/dist/events/TimezonePicker.svelte +67 -0
  39. package/dist/events/TimezonePicker.svelte.d.ts +6 -0
  40. package/dist/events/VodPlayer.svelte +154 -0
  41. package/dist/events/VodPlayer.svelte.d.ts +14 -0
  42. package/dist/events/VodTranscript.svelte +75 -0
  43. package/dist/events/VodTranscript.svelte.d.ts +8 -0
  44. package/dist/events/atproto-helpers.d.ts +21 -0
  45. package/dist/events/atproto-helpers.js +61 -0
  46. package/dist/events/cal/helper.d.ts +1 -0
  47. package/dist/events/cal/helper.js +20 -0
  48. package/dist/events/cal/ical.d.ts +22 -0
  49. package/dist/events/cal/ical.js +188 -0
  50. package/dist/events/cal/sanitize.d.ts +3 -0
  51. package/dist/events/cal/sanitize.js +25 -0
  52. package/dist/events/conference.d.ts +65 -0
  53. package/dist/events/conference.js +61 -0
  54. package/dist/events/contrail.d.ts +49 -0
  55. package/dist/events/contrail.js +12 -0
  56. package/dist/events/date-format.d.ts +22 -0
  57. package/dist/events/date-format.js +43 -0
  58. package/dist/events/editor/LinkedRecordsPicker.svelte +104 -0
  59. package/dist/events/editor/LinkedRecordsPicker.svelte.d.ts +12 -0
  60. package/dist/events/editor/LinksSection.svelte +154 -0
  61. package/dist/events/editor/LinksSection.svelte.d.ts +13 -0
  62. package/dist/events/editor/LocationSection.svelte +163 -0
  63. package/dist/events/editor/LocationSection.svelte.d.ts +8 -0
  64. package/dist/events/editor/RecurringModal.svelte +313 -0
  65. package/dist/events/editor/RecurringModal.svelte.d.ts +30 -0
  66. package/dist/events/editor/ThemeSection.svelte +24 -0
  67. package/dist/events/editor/ThemeSection.svelte.d.ts +7 -0
  68. package/dist/events/editor/ThumbnailSection.svelte +181 -0
  69. package/dist/events/editor/ThumbnailSection.svelte.d.ts +13 -0
  70. package/dist/events/editor/adapter.d.ts +73 -0
  71. package/dist/events/editor/adapter.js +9 -0
  72. package/dist/events/editor/location.d.ts +16 -0
  73. package/dist/events/editor/location.js +50 -0
  74. package/dist/events/editor/save.d.ts +43 -0
  75. package/dist/events/editor/save.js +168 -0
  76. package/dist/events/editor/types.d.ts +39 -0
  77. package/dist/events/editor/types.js +9 -0
  78. package/dist/events/event-media.d.ts +4 -0
  79. package/dist/events/event-media.js +4 -0
  80. package/dist/events/event-types.d.ts +79 -0
  81. package/dist/events/event-types.js +11 -0
  82. package/dist/events/event-view/AddToCalendarButton.svelte +31 -0
  83. package/dist/events/event-view/AddToCalendarButton.svelte.d.ts +9 -0
  84. package/dist/events/event-view/EventBadges.svelte +20 -0
  85. package/dist/events/event-view/EventBadges.svelte.d.ts +7 -0
  86. package/dist/events/event-view/EventDateBlock.svelte +43 -0
  87. package/dist/events/event-view/EventDateBlock.svelte.d.ts +7 -0
  88. package/dist/events/event-view/EventHostedBy.svelte +61 -0
  89. package/dist/events/event-view/EventHostedBy.svelte.d.ts +16 -0
  90. package/dist/events/event-view/EventLinksList.svelte +24 -0
  91. package/dist/events/event-view/EventLinksList.svelte.d.ts +9 -0
  92. package/dist/events/event-view/EventLocationBlock.svelte +62 -0
  93. package/dist/events/event-view/EventLocationBlock.svelte.d.ts +7 -0
  94. package/dist/events/event-view/EventLocationMap.svelte +66 -0
  95. package/dist/events/event-view/EventLocationMap.svelte.d.ts +7 -0
  96. package/dist/events/event-view/ExternalRsvpNotice.svelte +34 -0
  97. package/dist/events/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
  98. package/dist/events/event-view/StreamPlacePlayer.svelte +209 -0
  99. package/dist/events/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
  100. package/dist/events/event-view/format.d.ts +10 -0
  101. package/dist/events/event-view/format.js +112 -0
  102. package/dist/events/event-view/locations.d.ts +36 -0
  103. package/dist/events/event-view/locations.js +243 -0
  104. package/dist/events/event-view/plyr-icons.d.ts +3 -0
  105. package/dist/events/event-view/plyr-icons.js +46 -0
  106. package/dist/events/linked-records.d.ts +18 -0
  107. package/dist/events/linked-records.js +11 -0
  108. package/dist/events/profile-url.d.ts +3 -0
  109. package/dist/events/profile-url.js +11 -0
  110. package/dist/events/schedule/ConferenceTimetable.svelte +340 -0
  111. package/dist/events/schedule/ConferenceTimetable.svelte.d.ts +22 -0
  112. package/dist/events/schedule/DaySchedule.svelte +298 -0
  113. package/dist/events/schedule/DaySchedule.svelte.d.ts +26 -0
  114. package/dist/events/schedule/ScheduleEventCell.svelte +166 -0
  115. package/dist/events/schedule/ScheduleEventCell.svelte.d.ts +25 -0
  116. package/dist/events/schedule/schedule-utils.d.ts +44 -0
  117. package/dist/events/schedule/schedule-utils.js +166 -0
  118. package/dist/events/starter-pack-url.d.ts +3 -0
  119. package/dist/events/starter-pack-url.js +16 -0
  120. package/dist/events/theme.d.ts +9 -0
  121. package/dist/events/theme.js +36 -0
  122. package/dist/events/themes/Blobs.svelte +64 -0
  123. package/dist/events/themes/Blobs.svelte.d.ts +18 -0
  124. package/dist/events/themes/Butterflies.svelte +183 -0
  125. package/dist/events/themes/Butterflies.svelte.d.ts +3 -0
  126. package/dist/events/themes/Fireflies.svelte +134 -0
  127. package/dist/events/themes/Fireflies.svelte.d.ts +3 -0
  128. package/dist/events/themes/Kaleidoscope.svelte +183 -0
  129. package/dist/events/themes/Kaleidoscope.svelte.d.ts +3 -0
  130. package/dist/events/themes/Matrix.svelte +153 -0
  131. package/dist/events/themes/Matrix.svelte.d.ts +3 -0
  132. package/dist/events/themes/Stars.svelte +98 -0
  133. package/dist/events/themes/Stars.svelte.d.ts +3 -0
  134. package/dist/events/thumbnails/designs.d.ts +18 -0
  135. package/dist/events/thumbnails/designs.js +316 -0
  136. package/dist/events/view-data.d.ts +38 -0
  137. package/dist/events/view-data.js +1 -0
  138. package/dist/events-data.d.ts +15 -0
  139. package/dist/events-data.js +9 -0
  140. package/dist/events-editor.d.ts +2 -0
  141. package/dist/events-editor.js +2 -0
  142. package/dist/events.d.ts +19 -0
  143. package/dist/events.js +17 -0
  144. package/package.json +46 -4
@@ -0,0 +1,178 @@
1
+ <script lang="ts">
2
+ import * as TID from '@atcute/tid';
3
+ import { Check, Star, X } from '@sketchyicons/svelte';
4
+ import { Avatar, Button, Icon, launchConfetti } from '../index.js';
5
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
6
+
7
+ let {
8
+ eventUri,
9
+ eventCid,
10
+ rsvpExpected = true,
11
+ initialRsvpStatus = null,
12
+ initialRsvpRkey = null,
13
+ adapter,
14
+ viewer,
15
+ onrsvp,
16
+ oncancel,
17
+ onlogin
18
+ }: {
19
+ eventUri: string;
20
+ eventCid: string | null;
21
+ rsvpExpected?: boolean;
22
+ initialRsvpStatus?: 'going' | 'interested' | 'notgoing' | null;
23
+ initialRsvpRkey?: string | null;
24
+ adapter: EditorAdapter;
25
+ viewer: EditorViewer;
26
+ onrsvp?: (status: 'going' | 'interested', rkey: string) => void;
27
+ oncancel?: () => void;
28
+ onlogin?: () => void;
29
+ } = $props();
30
+
31
+ let rsvpStatusOverride: 'going' | 'interested' | 'notgoing' | null | undefined =
32
+ $state(undefined);
33
+ let rsvpRkeyOverride: string | null | undefined = $state(undefined);
34
+ let rsvpSubmitting = $state(false);
35
+ let rsvpError: string | null = $state(null);
36
+
37
+ let rsvpStatus = $derived(
38
+ rsvpStatusOverride !== undefined ? rsvpStatusOverride : initialRsvpStatus
39
+ );
40
+ let rsvpRkey = $derived(rsvpRkeyOverride !== undefined ? rsvpRkeyOverride : initialRsvpRkey);
41
+
42
+ async function submitRsvp(status: 'going' | 'interested') {
43
+ if (rsvpExpected === false || !viewer.isLoggedIn || !viewer.did) return;
44
+ rsvpSubmitting = true;
45
+ rsvpError = null;
46
+ try {
47
+ const key = rsvpRkey ?? TID.now();
48
+ const record = {
49
+ $type: 'community.lexicon.calendar.rsvp',
50
+ createdWith: 'https://atmo.rsvp',
51
+ status: `community.lexicon.calendar.rsvp#${status}`,
52
+ subject: {
53
+ uri: eventUri,
54
+ ...(eventCid ? { cid: eventCid } : {})
55
+ },
56
+ createdAt: new Date().toISOString()
57
+ };
58
+
59
+ await adapter.putRecord({
60
+ collection: 'community.lexicon.calendar.rsvp',
61
+ rkey: key,
62
+ record
63
+ });
64
+ adapter.notifyUpdate?.(`at://${viewer.did}/community.lexicon.calendar.rsvp/${key}`);
65
+ rsvpStatusOverride = status;
66
+ rsvpRkeyOverride = key;
67
+ launchConfetti();
68
+ onrsvp?.(status, key);
69
+ } catch (e) {
70
+ console.error('Failed to submit RSVP:', e);
71
+ rsvpError = 'Couldn’t save your RSVP. Please try again.';
72
+ } finally {
73
+ rsvpSubmitting = false;
74
+ }
75
+ }
76
+
77
+ async function cancelRsvp() {
78
+ if (!viewer.isLoggedIn || !viewer.did || !rsvpRkey) return;
79
+ rsvpSubmitting = true;
80
+ rsvpError = null;
81
+ try {
82
+ await adapter.deleteRecord({
83
+ collection: 'community.lexicon.calendar.rsvp',
84
+ rkey: rsvpRkey
85
+ });
86
+ adapter.notifyUpdate?.(`at://${viewer.did}/community.lexicon.calendar.rsvp/${rsvpRkey}`);
87
+ rsvpStatusOverride = null;
88
+ rsvpRkeyOverride = null;
89
+ oncancel?.();
90
+ } catch (e) {
91
+ console.error('Failed to cancel RSVP:', e);
92
+ rsvpError = 'Couldn’t remove your RSVP. Please try again.';
93
+ } finally {
94
+ rsvpSubmitting = false;
95
+ }
96
+ }
97
+ </script>
98
+
99
+ {#if rsvpExpected !== false || (viewer.isLoggedIn && rsvpStatus)}
100
+ <div
101
+ class="mt-7 mb-2 flex min-h-25 flex-col justify-center rounded-panel bg-recess p-4 [box-shadow:var(--ui-recess-shadow)]"
102
+ >
103
+ {#if !viewer.isLoggedIn}
104
+ <div class="flex items-center justify-between gap-4">
105
+ <p class="text-sm text-base-600 dark:text-base-400">Log in to RSVP to this event</p>
106
+
107
+ <Button
108
+ onclick={() => {
109
+ onlogin?.();
110
+ adapter.requestLogin();
111
+ }}>Log in to RSVP</Button
112
+ >
113
+ </div>
114
+ {:else if rsvpStatus === 'going'}
115
+ <div class="flex items-center justify-between">
116
+ <div class="flex items-center gap-3">
117
+ <div class="flex size-8 items-center justify-center rounded-full bg-accent-soft">
118
+ <Icon img={Check} size={16} class="text-action" />
119
+ </div>
120
+ <p class="font-semibold text-base-900 dark:text-base-50">You're Going</p>
121
+ </div>
122
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
123
+ </div>
124
+ {:else if rsvpStatus === 'interested'}
125
+ <div class="flex items-center justify-between">
126
+ <div class="flex items-center gap-3">
127
+ <div class="flex size-8 items-center justify-center rounded-full bg-accent-soft">
128
+ <Icon img={Star} size={16} class="text-action" />
129
+ </div>
130
+ <p class="font-semibold text-base-900 dark:text-base-50">You're Interested</p>
131
+ </div>
132
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
133
+ </div>
134
+ {:else if rsvpStatus === 'notgoing'}
135
+ <div class="flex items-center justify-between">
136
+ <div class="flex items-center gap-3">
137
+ <div
138
+ class="flex size-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"
139
+ >
140
+ <Icon img={X} size={16} class="text-red-600 dark:text-red-400" />
141
+ </div>
142
+ <p class="font-semibold text-base-900 dark:text-base-50">Not Going</p>
143
+ </div>
144
+ <Button onclick={cancelRsvp} disabled={rsvpSubmitting} variant="ghost">Remove</Button>
145
+ </div>
146
+ {:else}
147
+ {#if viewer.isLoggedIn}
148
+ <div class="mb-4 flex items-center gap-2">
149
+ <span class="text-sm text-muted">Attend as</span>
150
+ <Avatar
151
+ src={viewer.avatar}
152
+ name={viewer.displayName || viewer.handle || viewer.did || 'Account'}
153
+ class="size-5"
154
+ />
155
+ <span class="truncate text-sm font-medium text-base-700 dark:text-base-300">
156
+ {viewer.displayName || viewer.handle || viewer.did}
157
+ </span>
158
+ </div>
159
+ {/if}
160
+ <div class="flex gap-3">
161
+ <Button onclick={() => submitRsvp('going')} disabled={rsvpSubmitting} class="flex-1">
162
+ {rsvpSubmitting ? '...' : 'Going'}
163
+ </Button>
164
+ <Button
165
+ onclick={() => submitRsvp('interested')}
166
+ disabled={rsvpSubmitting}
167
+ variant="secondary"
168
+ class="flex-1"
169
+ >
170
+ {rsvpSubmitting ? '...' : 'Interested'}
171
+ </Button>
172
+ </div>
173
+ {/if}
174
+ {#if rsvpError}
175
+ <p role="alert" class="mt-3 text-sm text-red-600 dark:text-red-400">{rsvpError}</p>
176
+ {/if}
177
+ </div>
178
+ {/if}
@@ -0,0 +1,16 @@
1
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
2
+ type $$ComponentProps = {
3
+ eventUri: string;
4
+ eventCid: string | null;
5
+ rsvpExpected?: boolean;
6
+ initialRsvpStatus?: 'going' | 'interested' | 'notgoing' | null;
7
+ initialRsvpRkey?: string | null;
8
+ adapter: EditorAdapter;
9
+ viewer: EditorViewer;
10
+ onrsvp?: (status: 'going' | 'interested', rkey: string) => void;
11
+ oncancel?: () => void;
12
+ onlogin?: () => void;
13
+ };
14
+ declare const EventRsvp: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type EventRsvp = ReturnType<typeof EventRsvp>;
16
+ export default EventRsvp;
@@ -0,0 +1,488 @@
1
+ <script lang="ts">
2
+ import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
3
+ import type { EventViewData } from './view-data.js';
4
+ import { getCDNImageBlobUrl } from './atproto-helpers.js';
5
+ import { getEventThumbnail } from './event-media.js';
6
+ import { Button, ToggleGroup, ToggleGroupItem } from '../index.js';
7
+ import ShareModal from './ShareModal.svelte';
8
+ import EventComments from './EventComments.svelte';
9
+ import Avatar from 'svelte-boring-avatars';
10
+ import EventRsvp from './EventRsvp.svelte';
11
+ import EventCard from './EventCard.svelte';
12
+ import EventAttendees, { type AttendeeCounts } from './EventAttendees.svelte';
13
+ import VodPlayer, { type VodPlayerApi } from './VodPlayer.svelte';
14
+ import StreamPlacePlayer from './event-view/StreamPlacePlayer.svelte';
15
+ import { launchConfetti } from '../index.js';
16
+ import ThemeBackground from './ThemeBackground.svelte';
17
+ import ThemeApply from './ThemeApply.svelte';
18
+ import { defaultTheme, type EventTheme } from './theme.js';
19
+ import { onMount, type Snippet } from 'svelte';
20
+ import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
21
+ import ConferenceTimetable from './schedule/ConferenceTimetable.svelte';
22
+ import { isConferenceEvent, toScheduleEvents, getConferenceRooms } from './conference.js';
23
+
24
+ import EventBadges from './event-view/EventBadges.svelte';
25
+ import EventDateBlock from './event-view/EventDateBlock.svelte';
26
+ import EventLocationBlock from './event-view/EventLocationBlock.svelte';
27
+ import EventLocationMap from './event-view/EventLocationMap.svelte';
28
+ import EventHostedBy from './event-view/EventHostedBy.svelte';
29
+ import EventLinksList from './event-view/EventLinksList.svelte';
30
+ import { GrainAlbumView } from '../index.js';
31
+ import { eventStreamLink, parseGrainLink } from './linked-records.js';
32
+ import AddToCalendarButton from './event-view/AddToCalendarButton.svelte';
33
+ import ExternalRsvpNotice from './event-view/ExternalRsvpNotice.svelte';
34
+ import { buildDescriptionHtml } from './event-view/format';
35
+ import { getLocations, resolveGeoLocation, type GeoLocation } from './event-view/locations.js';
36
+
37
+ let {
38
+ data,
39
+ adapter,
40
+ viewer,
41
+ pageUrl,
42
+ embedMode = false,
43
+ shareUrlOverride,
44
+ extraActions,
45
+ attendeeActions
46
+ }: {
47
+ data: EventViewData;
48
+ adapter: EditorAdapter;
49
+ viewer: EditorViewer;
50
+ /** Current page URL — used for the OG image link and the calendar button. */
51
+ pageUrl: URL;
52
+ embedMode?: boolean;
53
+ /** When set, the share modal / Bluesky post embed use this URL instead
54
+ * of the canonical atmo.rsvp event URL. Useful for embedders that want
55
+ * share links to point at their own event page. */
56
+ shareUrlOverride?: string;
57
+ /** App-owned actions such as private reporting; omitted in embeds. */
58
+ extraActions?: Snippet;
59
+ /** App-owned actions below the attendee lists, with current RSVP counts. */
60
+ attendeeActions?: Snippet<[AttendeeCounts]>;
61
+ } = $props();
62
+
63
+ let eventData: FlatEventRecord = $derived(data.eventData);
64
+ let did: string = $derived(data.actorDid);
65
+ let rkey: string = $derived(data.rkey);
66
+ let hostProfile = $derived(data.hostProfile);
67
+ let attendees = $derived(data.attendees);
68
+
69
+ let theme: EventTheme = $derived(eventData.theme ?? defaultTheme);
70
+
71
+ let hostUrl = $derived(`/p/${hostProfile?.handle || did}`);
72
+ let eventPath = $derived(eventUrl(eventData, hostProfile?.handle || did));
73
+ let shareUrl = $derived(
74
+ shareUrlOverride
75
+ ? shareUrlOverride
76
+ : typeof window !== 'undefined'
77
+ ? `${window.location.origin}${eventPath}`
78
+ : eventPath
79
+ );
80
+
81
+ // Times are always rendered in the viewer's local timezone — the stored UTC
82
+ // instant is what the Date constructor parses, and toLocaleString/Time uses
83
+ // the browser's zone by default.
84
+ let startDate = $derived(new Date(eventData.startsAt));
85
+ let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null);
86
+
87
+ let locations = $derived(getLocations(eventData.locations));
88
+ let geoLocation: GeoLocation | null = $state(null);
89
+
90
+ $effect(() => {
91
+ let active = true;
92
+ geoLocation = null;
93
+ void resolveGeoLocation(locations).then((resolved) => {
94
+ if (active) geoLocation = resolved;
95
+ });
96
+ return () => {
97
+ active = false;
98
+ };
99
+ });
100
+
101
+ let showShareModal = $state(false);
102
+ let shareModalTitle = $state('Event created!');
103
+ let shareModalText: string | undefined = $state(undefined);
104
+ // True only when the share modal was opened via the post-creation flow by
105
+ // the event's host. Drives the "show comments on event page" checkbox and
106
+ // the bskyPostRef write — RSVP shares should never overwrite the comments
107
+ // root, even when the RSVPer is the host.
108
+ let canSetEventComments = $state(false);
109
+ let isHost = $derived(!!viewer.did && viewer.did === did);
110
+ let hasComments = $derived(!!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri);
111
+ let descriptionHtml = $derived(
112
+ buildDescriptionHtml(eventData.description, eventData.facets, viewer.preferredSocialClient)
113
+ );
114
+ let aboutCommentsTab = $state<string>('about');
115
+ let photoLinks = $derived(eventData.uris?.filter((link) => parseGrainLink(link.uri)) ?? []);
116
+ let contentTabs = $derived([
117
+ ...(descriptionHtml ? [{ value: 'about', label: 'About' }] : []),
118
+ ...(hasComments ? [{ value: 'comments', label: 'Comments' }] : []),
119
+ ...(photoLinks.length ? [{ value: 'photos', label: 'Photos' }] : [])
120
+ ]);
121
+ let activeTab = $derived(
122
+ contentTabs.some((tab) => tab.value === aboutCommentsTab)
123
+ ? aboutCommentsTab
124
+ : contentTabs[0]?.value
125
+ );
126
+ let photosOpened = $state(false);
127
+ $effect(() => {
128
+ if (activeTab === 'photos') photosOpened = true;
129
+ });
130
+
131
+ onMount(() => {
132
+ const url = new URL(window.location.href);
133
+ if (url.searchParams.has('created')) {
134
+ url.searchParams.delete('created');
135
+ history.replaceState({}, '', url.pathname);
136
+ launchConfetti();
137
+ shareModalTitle = 'Event created!';
138
+ shareModalText = `I'm hosting "${eventData.name}"!\n\n${shareUrl}`;
139
+ canSetEventComments = isHost;
140
+ showShareModal = true;
141
+ }
142
+ });
143
+
144
+ let thumbnailImage = $derived.by(() => {
145
+ if (!eventData.media || eventData.media.length === 0) return null;
146
+ const media = getEventThumbnail(eventData.media);
147
+ if (!media?.content) return null;
148
+ const url = getCDNImageBlobUrl({ did, blob: media.content });
149
+ if (!url) return null;
150
+ return { url, alt: media.alt || eventData.name, aspectRatio: media.aspect_ratio };
151
+ });
152
+
153
+ let bannerImage = $derived.by(() => {
154
+ if (!eventData.media || eventData.media.length === 0) return null;
155
+ const media = eventData.media.find((m) => m.role === 'header');
156
+ if (!media?.content) return null;
157
+ const url = getCDNImageBlobUrl({ did, blob: media.content });
158
+ if (!url) return null;
159
+ return { url, alt: media.alt || eventData.name, aspectRatio: media.aspect_ratio };
160
+ });
161
+
162
+ // Prefer thumbnail, then flyer; fall back to header/banner image.
163
+ let displayImage = $derived(thumbnailImage ?? bannerImage);
164
+ let imageAspectRatio = $derived.by(() => {
165
+ const ratio = displayImage?.aspectRatio;
166
+ if (!ratio || ratio.width <= 0 || ratio.height <= 0) return null;
167
+ const value = ratio.width / ratio.height;
168
+ return Number.isFinite(value) && value > 0 ? value : null;
169
+ });
170
+ let showBanner = $derived(
171
+ (!thumbnailImage && !!bannerImage) || (imageAspectRatio !== null && imageAspectRatio >= 2)
172
+ );
173
+ let frameAspectRatio = $derived.by(() => {
174
+ if (imageAspectRatio === null) return showBanner ? 3 : 1;
175
+ // Keep natural proportions near each layout; crop extreme shapes to a capped frame.
176
+ return showBanner
177
+ ? Math.min(4, Math.max(2, imageAspectRatio))
178
+ : Math.min(1.5, Math.max(0.75, imageAspectRatio));
179
+ });
180
+
181
+ let isOngoing = $derived(isEventOngoing(eventData.startsAt, eventData.endsAt));
182
+ let isPast = $derived(endDate ? endDate < new Date() : false);
183
+
184
+ let streamPlaceHandle = $derived.by(() => {
185
+ const uris = eventData.uris;
186
+ if (!uris) return null;
187
+ for (const { uri } of uris) {
188
+ const stream = eventStreamLink(uri);
189
+ if (stream) return stream.actor;
190
+ }
191
+ return null;
192
+ });
193
+
194
+ let eventUri = $derived(`at://${did}/community.lexicon.calendar.event/${rkey}`);
195
+
196
+ let ogImageUrl = $derived(data.ogImage ?? `${pageUrl.origin}${pageUrl.pathname}/og.png`);
197
+
198
+ let isOwner = $derived(!embedMode && viewer.isLoggedIn && viewer.did === did);
199
+
200
+ let speakers = $derived(data.speakerProfiles ?? []);
201
+
202
+ // Conference: when this event is a conference, render its talks as a timetable.
203
+ let isConference = $derived(isConferenceEvent(eventData));
204
+ let eventActor = $derived(hostProfile?.handle || did);
205
+ let conferenceRooms = $derived(getConferenceRooms(eventData));
206
+ let scheduleEvents = $derived(
207
+ isConference ? toScheduleEvents(data.conferenceTalks ?? [], eventUri) : []
208
+ );
209
+
210
+ // Imported events can opt out of atmo's own RSVPs (rsvpMode === 'external_only').
211
+ // In that case we hide the RSVP controls and link out to the original event page.
212
+ let externalSource = $derived(
213
+ (eventData.additionalData as Record<string, unknown> | undefined)?.externalSource as
214
+ { url?: string; rsvpMode?: 'external_only' | 'atmo_too' } | undefined
215
+ );
216
+ let rsvpExternalOnly = $derived(
217
+ externalSource?.rsvpMode === 'external_only' && !!externalSource?.url
218
+ );
219
+
220
+ let vodCurrentTime = $state(0);
221
+ let vodApi: VodPlayerApi | undefined = $state();
222
+
223
+ let attendeesRef: EventAttendees | undefined = $state();
224
+
225
+ function handleRsvp(status: 'going' | 'interested') {
226
+ if (!viewer.did) return;
227
+ attendeesRef?.addAttendee({
228
+ did: viewer.did,
229
+ status,
230
+ avatar: viewer.avatar,
231
+ name: viewer.displayName || viewer.handle || viewer.did,
232
+ handle: viewer.handle,
233
+ url: `/p/${encodeURIComponent(viewer.handle || viewer.did)}`
234
+ });
235
+ if (status === 'interested') return;
236
+ shareModalTitle = "You're going!";
237
+ shareModalText = `I'm going to "${eventData.name}".\n\n${shareUrl}`;
238
+ canSetEventComments = false;
239
+ showShareModal = true;
240
+ }
241
+
242
+ function handleRsvpCancel() {
243
+ if (!viewer.did) return;
244
+ attendeesRef?.removeAttendee(viewer.did);
245
+ }
246
+ </script>
247
+
248
+ <ThemeApply accentColor={theme.accentColor} baseColor={theme.baseColor} />
249
+ <div class="motion-reduce:hidden">
250
+ <ThemeBackground {theme} />
251
+ </div>
252
+
253
+ <div class="min-h-screen px-6 py-12 sm:py-12">
254
+ <div class="mx-auto max-w-3xl">
255
+ <!-- Full-width banner for headers and wide thumbnails -->
256
+ {#if showBanner && displayImage}
257
+ <img
258
+ src={displayImage.url}
259
+ alt={displayImage.alt}
260
+ loading="eager"
261
+ class="mb-8 block w-full rounded-panel border border-line object-cover"
262
+ style:aspect-ratio={frameAspectRatio}
263
+ />
264
+ {/if}
265
+
266
+ <!-- Two-column layout: image left, details right -->
267
+ <div
268
+ 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]"
269
+ >
270
+ <!-- Thumbnail image and owner controls (left column) -->
271
+ {#if !showBanner || isOwner}
272
+ <div class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none">
273
+ {#if displayImage && !showBanner}
274
+ <img
275
+ src={displayImage.url}
276
+ alt={displayImage.alt}
277
+ loading="eager"
278
+ class="block w-full rounded-panel border border-line object-cover"
279
+ style:aspect-ratio={frameAspectRatio}
280
+ />
281
+ {:else if !showBanner}
282
+ <div
283
+ class="aspect-square w-full overflow-hidden rounded-panel border border-line [&>svg]:h-full [&>svg]:w-full"
284
+ >
285
+ <Avatar
286
+ size={256}
287
+ name={data.rkey}
288
+ variant="marble"
289
+ colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
290
+ square
291
+ />
292
+ </div>
293
+ {/if}
294
+ {#if isOwner}
295
+ <Button href="./{rkey}/edit" class={showBanner ? 'w-full' : 'mt-9 w-full'}>
296
+ Edit Event
297
+ </Button>
298
+ {#if isConference}
299
+ <Button href="./{rkey}/talks" variant="secondary" class="mt-2 w-full">
300
+ Manage talks
301
+ </Button>
302
+ {/if}
303
+ {/if}
304
+ </div>
305
+ {/if}
306
+
307
+ <!-- Right column: event details -->
308
+ <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-2 md:row-start-1">
309
+ <div class="mb-2">
310
+ <h1
311
+ class="text-3xl leading-tight font-semibold tracking-tight text-base-900 sm:text-4xl dark:text-base-50"
312
+ >
313
+ {eventData.name}
314
+ </h1>
315
+ </div>
316
+
317
+ <EventBadges mode={eventData.mode} {isOngoing} />
318
+
319
+ <EventDateBlock {startDate} {endDate} />
320
+
321
+ <EventLocationBlock {locations} />
322
+
323
+ <!-- Part of: link back to the parent conference -->
324
+ {#if data.parentEvent}
325
+ {@const parentActor = data.parentEventActor ?? data.parentEvent.did}
326
+ <div
327
+ class="mt-8 mb-2 justify-center rounded-2xl border border-base-200 bg-base-100 p-4 dark:border-base-800 dark:bg-base-950/50"
328
+ >
329
+ <p class="mb-3 text-xs font-semibold tracking-wider text-muted uppercase">Part of</p>
330
+ <EventCard event={data.parentEvent} actor={parentActor} />
331
+ <Button
332
+ href={data.parentScheduleUrl ?? `/p/${parentActor}/e/${data.parentEvent.rkey}`}
333
+ size="lg"
334
+ class="mt-6 w-full"
335
+ >
336
+ See full schedule
337
+ </Button>
338
+ </div>
339
+ {/if}
340
+
341
+ {#if !isPast}
342
+ {#if eventData.rsvpExpected !== false && rsvpExternalOnly && externalSource?.url}
343
+ <ExternalRsvpNotice url={externalSource.url} />
344
+ {:else}
345
+ <EventRsvp
346
+ {eventUri}
347
+ eventCid={eventData.cid ?? null}
348
+ rsvpExpected={eventData.rsvpExpected}
349
+ initialRsvpStatus={data.viewerRsvpStatus}
350
+ initialRsvpRkey={data.viewerRsvpRkey}
351
+ {adapter}
352
+ {viewer}
353
+ onrsvp={handleRsvp}
354
+ oncancel={handleRsvpCancel}
355
+ />
356
+ {/if}
357
+ {/if}
358
+
359
+ <!-- Live stream -->
360
+ {#if isOngoing && streamPlaceHandle}
361
+ <div class="mt-8 mb-8">
362
+ <p class="mb-3 text-xs font-semibold tracking-wider text-muted uppercase">Live now</p>
363
+ <StreamPlacePlayer handle={streamPlaceHandle} title={eventData.name} />
364
+ </div>
365
+ {/if}
366
+
367
+ <!-- About, comments and linked photo albums -->
368
+ {#if contentTabs.length}
369
+ <div class="mt-8 mb-8">
370
+ {#if contentTabs.length > 1}
371
+ <ToggleGroup
372
+ type="single"
373
+ bind:value={
374
+ () => activeTab ?? '',
375
+ (value) => {
376
+ if (contentTabs.some((tab) => tab.value === value)) aboutCommentsTab = value;
377
+ }
378
+ }
379
+ class="mb-4 w-fit"
380
+ size="xs"
381
+ aria-label="Event content"
382
+ >
383
+ {#each contentTabs as tab (tab.value)}<ToggleGroupItem value={tab.value}
384
+ >{tab.label}</ToggleGroupItem
385
+ >{/each}
386
+ </ToggleGroup>
387
+ {:else}
388
+ <p class="mb-3 text-xs font-semibold tracking-wider text-muted uppercase">
389
+ {contentTabs[0].label}
390
+ </p>
391
+ {/if}
392
+ {#if activeTab === 'about'}
393
+ <div
394
+ class="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 text-base-700 dark:text-base-300"
395
+ >
396
+ <!-- HTML is sanitized by buildDescriptionHtml before rendering. -->
397
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
398
+ {@html descriptionHtml}
399
+ </div>
400
+ {:else if activeTab === 'comments' && eventData.bskyPostRef?.uri}
401
+ <EventComments
402
+ postUri={eventData.bskyPostRef.uri}
403
+ socialClient={viewer.preferredSocialClient}
404
+ />
405
+ {/if}
406
+ {#if photosOpened && photoLinks.length}
407
+ <div hidden={activeTab !== 'photos'} class="space-y-8">
408
+ {#each photoLinks as link, i (`${link.uri}-${i}`)}<GrainAlbumView
409
+ reference={link.uri}
410
+ title={link.name}
411
+ loadAlbum={adapter.linkedRecords?.getGrainGallery}
412
+ />{/each}
413
+ </div>
414
+ {/if}
415
+ </div>
416
+ {/if}
417
+
418
+ <!-- Recording -->
419
+ {#if data.vod}
420
+ <div class="mt-8 mb-8">
421
+ <p class="mb-3 text-xs font-semibold tracking-wider text-muted uppercase">Recording</p>
422
+ <VodPlayer
423
+ playlistUrl={data.vod.playlistUrl}
424
+ title={eventData.name}
425
+ subtitlesUrl="/vods/{rkey}-karaoke.vtt"
426
+ bind:currentTime={vodCurrentTime}
427
+ bind:api={vodApi}
428
+ />
429
+ </div>
430
+ {/if}
431
+
432
+ <EventLocationMap {geoLocation} />
433
+ </div>
434
+
435
+ <!-- Left column: sidebar info -->
436
+ <div class="order-3 space-y-6 md:order-0 md:col-start-1">
437
+ <EventHostedBy {hostProfile} {hostUrl} {did} {speakers} />
438
+
439
+ <EventLinksList uris={eventData.uris?.filter((link) => !parseGrainLink(link.uri))} />
440
+
441
+ <AddToCalendarButton {eventData} {eventUri} pageHref={pageUrl.href} />
442
+ {@render extraActions?.()}
443
+
444
+ <EventAttendees
445
+ bind:this={attendeesRef}
446
+ going={attendees.going}
447
+ interested={attendees.interested}
448
+ goingCount={attendees.goingCount}
449
+ interestedCount={attendees.interestedCount}
450
+ footer={embedMode ? undefined : attendeeActions}
451
+ />
452
+ </div>
453
+ </div>
454
+
455
+ <!-- Conference timetable: talks belonging to this conference event -->
456
+ {#if isConference && scheduleEvents.length > 0}
457
+ <div class="mt-12">
458
+ <ConferenceTimetable
459
+ {scheduleEvents}
460
+ tz={data.conferenceTimezone ?? eventData.timezone ?? 'UTC'}
461
+ {eventActor}
462
+ {adapter}
463
+ {viewer}
464
+ rooms={conferenceRooms}
465
+ rsvpStatuses={data.conferenceRsvpStatuses}
466
+ rsvpRkeys={data.conferenceRsvpRkeys}
467
+ eventVods={data.conferenceVods}
468
+ loggedIn={data.loggedIn}
469
+ />
470
+ </div>
471
+ {/if}
472
+ </div>
473
+ </div>
474
+
475
+ <ShareModal
476
+ bind:open={showShareModal}
477
+ url={shareUrl}
478
+ title={shareModalTitle}
479
+ shareText={shareModalText}
480
+ eventName={eventData.name}
481
+ {ogImageUrl}
482
+ {canSetEventComments}
483
+ eventDid={did}
484
+ eventRkey={rkey}
485
+ eventDescription={eventData.description}
486
+ {adapter}
487
+ {viewer}
488
+ />