@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,177 @@
1
+ <script lang="ts">
2
+ import { Button, Checkbox, Input, Label, Modal } from '@foxui/core';
3
+ import DateTimePicker from '../DateTimePicker.svelte';
4
+ import ShareModal from '../ShareModal.svelte';
5
+ import { datetimeLocalToISO } from '../date-format.js';
6
+ import type { HostProfile } from '../contrail.js';
7
+ import type { EditorAdapter, EditorViewer } from '../editor/adapter.js';
8
+
9
+ let {
10
+ spaceUri,
11
+ spaceKey,
12
+ did,
13
+ rkey,
14
+ eventName,
15
+ hostProfile,
16
+ adapter,
17
+ viewer
18
+ }: {
19
+ spaceUri: string;
20
+ spaceKey: string;
21
+ did: string;
22
+ rkey: string;
23
+ eventName: string;
24
+ hostProfile: HostProfile | null | undefined;
25
+ adapter: EditorAdapter;
26
+ viewer: EditorViewer;
27
+ } = $props();
28
+
29
+ let inviteUrl: string | null = $state(null);
30
+ let inviteBusy = $state(false);
31
+ let inviteError: string | null = $state(null);
32
+ let showInviteModal = $state(false);
33
+
34
+ // Invite-options dialog (shown before the share modal) — owner picks
35
+ // whether to allow anonymous reads, max uses, and expiry.
36
+ let showInviteForm = $state(false);
37
+ let inviteAllowAnonRead = $state(true);
38
+ let inviteMaxUsesText = $state('');
39
+ let inviteHasExpiry = $state(false);
40
+ let inviteExpiresAt = $state('');
41
+ const inviteTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
42
+
43
+ function openInviteForm() {
44
+ inviteError = null;
45
+ inviteAllowAnonRead = true;
46
+ inviteMaxUsesText = '';
47
+ inviteHasExpiry = false;
48
+ showInviteForm = true;
49
+ }
50
+
51
+ async function submitInviteForm() {
52
+ if (inviteBusy) return;
53
+ inviteBusy = true;
54
+ inviteError = null;
55
+ try {
56
+ let maxUses: number | undefined;
57
+ if (inviteMaxUsesText.trim()) {
58
+ const n = Number(inviteMaxUsesText);
59
+ if (!Number.isInteger(n) || n < 1) {
60
+ throw new Error('Max uses must be a positive integer.');
61
+ }
62
+ maxUses = n;
63
+ }
64
+
65
+ let expiresAt: number | undefined;
66
+ if (inviteHasExpiry && inviteExpiresAt.trim()) {
67
+ const iso = datetimeLocalToISO(inviteExpiresAt, inviteTimezone);
68
+ const ts = new Date(iso).getTime();
69
+ if (!Number.isFinite(ts)) throw new Error('Invalid expiry date.');
70
+ if (ts <= Date.now()) throw new Error('Expiry must be in the future.');
71
+ expiresAt = ts;
72
+ }
73
+
74
+ if (!adapter.createSpaceInvite) {
75
+ throw new Error('createSpaceInvite not supported by this adapter');
76
+ }
77
+ const result = await adapter.createSpaceInvite({
78
+ spaceUri,
79
+ kind: inviteAllowAnonRead ? 'read-join' : 'join',
80
+ ...(maxUses != null ? { maxUses } : {}),
81
+ ...(expiresAt != null ? { expiresAt } : {})
82
+ });
83
+ inviteUrl = `${window.location.origin}/p/${hostProfile?.handle || did}/e/${rkey}/s/${spaceKey}?invite=${result.token}`;
84
+ showInviteForm = false;
85
+ showInviteModal = true;
86
+ } catch (e) {
87
+ inviteError = e instanceof Error ? e.message : String(e);
88
+ } finally {
89
+ inviteBusy = false;
90
+ }
91
+ }
92
+ </script>
93
+
94
+ <Button variant="secondary" class="mt-3 w-full" onclick={openInviteForm}>
95
+ Share invite link
96
+ </Button>
97
+
98
+ <Modal bind:open={showInviteForm} interactOutsideBehavior={inviteBusy ? 'ignore' : 'close'}>
99
+ <h2 class="mb-4 text-lg font-semibold">Create invite link</h2>
100
+
101
+ <form
102
+ class="space-y-4"
103
+ onsubmit={(e) => {
104
+ e.preventDefault();
105
+ submitInviteForm();
106
+ }}
107
+ >
108
+ <div class="flex items-start gap-2">
109
+ <Checkbox id="invite-allow-anon" bind:checked={inviteAllowAnonRead} disabled={inviteBusy} />
110
+ <div>
111
+ <Label for="invite-allow-anon">Allow viewing event without being logged in</Label>
112
+ <p class="text-base-500 dark:text-base-400 mt-0.5 text-xs">
113
+ Anyone with this link can read the event details. Signed-in users can still join with the
114
+ same link.
115
+ </p>
116
+ </div>
117
+ </div>
118
+
119
+ <div>
120
+ <Label for="invite-max-uses">Max uses</Label>
121
+ <Input
122
+ id="invite-max-uses"
123
+ type="number"
124
+ min="1"
125
+ bind:value={inviteMaxUsesText}
126
+ placeholder="Unlimited"
127
+ disabled={inviteBusy}
128
+ />
129
+ <p class="text-base-500 dark:text-base-400 mt-1 text-xs">
130
+ Caps how many people can join — anonymous reads are always unlimited. Leave empty for no
131
+ limit.
132
+ </p>
133
+ </div>
134
+
135
+ <div>
136
+ <div class="mb-1 flex items-center gap-2">
137
+ <Checkbox id="invite-has-expiry" bind:checked={inviteHasExpiry} disabled={inviteBusy} />
138
+ <Label for="invite-has-expiry">Set an expiry</Label>
139
+ </div>
140
+ {#if inviteHasExpiry}
141
+ <DateTimePicker bind:value={inviteExpiresAt} />
142
+ {:else}
143
+ <p class="text-base-500 dark:text-base-400 text-xs">Link never expires.</p>
144
+ {/if}
145
+ </div>
146
+
147
+ {#if inviteError}
148
+ <p class="text-sm text-red-600 dark:text-red-400">{inviteError}</p>
149
+ {/if}
150
+
151
+ <div class="flex justify-end gap-2 pt-2">
152
+ <Button
153
+ type="button"
154
+ variant="secondary"
155
+ onclick={() => (showInviteForm = false)}
156
+ disabled={inviteBusy}
157
+ >
158
+ Cancel
159
+ </Button>
160
+ <Button type="submit" disabled={inviteBusy}>
161
+ {inviteBusy ? 'Creating…' : 'Create invite'}
162
+ </Button>
163
+ </div>
164
+ </form>
165
+ </Modal>
166
+
167
+ {#if inviteUrl}
168
+ <ShareModal
169
+ bind:open={showInviteModal}
170
+ url={inviteUrl}
171
+ title="Invite link"
172
+ shareText={`You're invited to "${eventName}".\n\n${inviteUrl}`}
173
+ {eventName}
174
+ {adapter}
175
+ {viewer}
176
+ />
177
+ {/if}
@@ -0,0 +1,15 @@
1
+ import type { HostProfile } from '../contrail.js';
2
+ import type { EditorAdapter, EditorViewer } from '../editor/adapter.js';
3
+ type $$ComponentProps = {
4
+ spaceUri: string;
5
+ spaceKey: string;
6
+ did: string;
7
+ rkey: string;
8
+ eventName: string;
9
+ hostProfile: HostProfile | null | undefined;
10
+ adapter: EditorAdapter;
11
+ viewer: EditorViewer;
12
+ };
13
+ declare const InviteShareFlow: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type InviteShareFlow = ReturnType<typeof InviteShareFlow>;
15
+ export default InviteShareFlow;
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import 'plyr/dist/plyr.css';
4
+ import type PlyrType from 'plyr';
5
+
6
+ let {
7
+ handle,
8
+ title
9
+ }: {
10
+ handle: string;
11
+ title: string;
12
+ } = $props();
13
+
14
+ let videoEl: HTMLVideoElement | undefined = $state();
15
+ let overlayEl: HTMLAnchorElement | undefined = $state();
16
+ let error = $state(false);
17
+ let controlsVisible = $state(true);
18
+
19
+ let pc: RTCPeerConnection | null = null;
20
+ let plyr: PlyrType | null = null;
21
+
22
+ const posterUrl = $derived(
23
+ `https://stream.place/api/playback/${encodeURIComponent(handle)}/stream.jpg`
24
+ );
25
+
26
+ onMount(() => {
27
+ init();
28
+ return () => {
29
+ if (pc) {
30
+ pc.close();
31
+ pc = null;
32
+ }
33
+ if (videoEl) {
34
+ videoEl.srcObject = null;
35
+ }
36
+ plyr?.destroy();
37
+ };
38
+ });
39
+
40
+ function waitForIceGathering(peer: RTCPeerConnection, timeoutMs = 2000): Promise<void> {
41
+ return new Promise((resolve) => {
42
+ if (peer.iceGatheringState === 'complete') {
43
+ resolve();
44
+ return;
45
+ }
46
+
47
+ let done = false;
48
+ const finish = () => {
49
+ if (done) return;
50
+ done = true;
51
+ peer.removeEventListener('icegatheringstatechange', onStateChange);
52
+ clearTimeout(timer);
53
+ resolve();
54
+ };
55
+
56
+ const onStateChange = () => {
57
+ if (peer.iceGatheringState === 'complete') {
58
+ finish();
59
+ }
60
+ };
61
+
62
+ peer.addEventListener('icegatheringstatechange', onStateChange);
63
+ const timer = setTimeout(finish, timeoutMs);
64
+ });
65
+ }
66
+
67
+ async function init() {
68
+ if (!videoEl) return;
69
+
70
+ try {
71
+ pc = new RTCPeerConnection({
72
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
73
+ bundlePolicy: 'max-bundle'
74
+ });
75
+
76
+ pc.addTransceiver('video', { direction: 'recvonly' });
77
+ pc.addTransceiver('audio', { direction: 'recvonly' });
78
+
79
+ pc.addEventListener('track', (event) => {
80
+ if (!videoEl) return;
81
+ if (event.streams && event.streams[0]) {
82
+ videoEl.srcObject = event.streams[0];
83
+ } else {
84
+ let stream = videoEl.srcObject as MediaStream | null;
85
+ if (!stream) {
86
+ stream = new MediaStream();
87
+ videoEl.srcObject = stream;
88
+ }
89
+ stream.addTrack(event.track);
90
+ }
91
+ });
92
+
93
+ const offer = await pc.createOffer();
94
+ await pc.setLocalDescription(offer);
95
+ await waitForIceGathering(pc, 2000);
96
+
97
+ const sdp = pc.localDescription?.sdp;
98
+ if (!sdp) {
99
+ error = true;
100
+ pc.close();
101
+ pc = null;
102
+ return;
103
+ }
104
+
105
+ const response = await fetch(
106
+ `https://stream.place/api/playback/${encodeURIComponent(handle)}/webrtc?rendition=source`,
107
+ {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/sdp' },
110
+ body: sdp
111
+ }
112
+ );
113
+
114
+ if (!response.ok) {
115
+ error = true;
116
+ pc.close();
117
+ pc = null;
118
+ return;
119
+ }
120
+
121
+ const answerSdp = await response.text();
122
+ await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
123
+
124
+ const { default: Plyr } = await import('plyr');
125
+
126
+ plyr = new Plyr(videoEl, {
127
+ controls: ['play', 'mute', 'volume', 'fullscreen'],
128
+ settings: [],
129
+ ratio: '16:9'
130
+ });
131
+
132
+ plyr.on('controlshidden', () => {
133
+ controlsVisible = false;
134
+ });
135
+ plyr.on('controlsshown', () => {
136
+ controlsVisible = true;
137
+ });
138
+
139
+ // Move the overlay link inside Plyr's container so mousemove over the
140
+ // button bubbles into Plyr's activity tracker — otherwise hovering it
141
+ // hides the controls, which hides the button, which reactivates Plyr
142
+ // (a flicker loop).
143
+ const plyrEl = videoEl.closest('.plyr');
144
+ if (plyrEl && overlayEl) plyrEl.appendChild(overlayEl);
145
+ } catch {
146
+ error = true;
147
+ if (pc) {
148
+ pc.close();
149
+ pc = null;
150
+ }
151
+ }
152
+ }
153
+ </script>
154
+
155
+ {#snippet watchOnStreamPlace(hidden = false)}
156
+ <a
157
+ bind:this={overlayEl}
158
+ href="https://stream.place/{handle}"
159
+ target="_blank"
160
+ rel="noopener noreferrer"
161
+ onclick={() => plyr?.pause()}
162
+ class="absolute top-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm transition-opacity duration-200 hover:bg-black/75 {hidden
163
+ ? 'pointer-events-none opacity-0'
164
+ : 'opacity-100'}"
165
+ >
166
+ Watch on stream.place
167
+ <svg
168
+ xmlns="http://www.w3.org/2000/svg"
169
+ viewBox="0 0 20 20"
170
+ fill="currentColor"
171
+ class="size-3"
172
+ aria-hidden="true"
173
+ >
174
+ <path
175
+ fill-rule="evenodd"
176
+ d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z"
177
+ clip-rule="evenodd"
178
+ />
179
+ <path
180
+ fill-rule="evenodd"
181
+ d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z"
182
+ clip-rule="evenodd"
183
+ />
184
+ </svg>
185
+ </a>
186
+ {/snippet}
187
+
188
+ {#if error}
189
+ <div
190
+ class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-xl border"
191
+ >
192
+ <div
193
+ class="absolute inset-0 bg-cover bg-center"
194
+ style="background-image: url({posterUrl});"
195
+ ></div>
196
+ <div class="absolute inset-0 bg-black/60"></div>
197
+ {@render watchOnStreamPlace()}
198
+ <p class="text-base-100 relative text-sm">Stream is offline</p>
199
+ </div>
200
+ {:else}
201
+ <div
202
+ class="border-base-300 dark:border-base-400/40 relative aspect-video w-full max-w-full overflow-hidden rounded-xl border"
203
+ >
204
+ {@render watchOnStreamPlace(!controlsVisible)}
205
+ <video
206
+ bind:this={videoEl}
207
+ class="h-full w-full"
208
+ aria-label={title}
209
+ poster={posterUrl}
210
+ autoplay
211
+ playsinline
212
+ muted
213
+ crossorigin="anonymous"
214
+ ></video>
215
+ </div>
216
+ {/if}
217
+
218
+ <style>
219
+ * {
220
+ --plyr-color-main: var(--color-accent-500);
221
+ }
222
+ </style>
@@ -0,0 +1,8 @@
1
+ import 'plyr/dist/plyr.css';
2
+ type $$ComponentProps = {
3
+ handle: string;
4
+ title: string;
5
+ };
6
+ declare const StreamPlacePlayer: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type StreamPlacePlayer = ReturnType<typeof StreamPlacePlayer>;
8
+ export default StreamPlacePlayer;
@@ -0,0 +1,26 @@
1
+ import type { FlatEventRecord } from '../contrail.js';
2
+ export declare function formatMonth(date: Date): string;
3
+ export declare function formatDay(date: Date): number;
4
+ export declare function formatWeekday(date: Date): string;
5
+ export declare function formatFullDate(date: Date): string;
6
+ export declare function formatTime(date: Date): string;
7
+ export declare function getModeLabel(mode: string): string;
8
+ /** Foxui accent-color class — applied alongside `variant="primary"` on a Badge
9
+ * to override its `--accent-*` CSS variables (see @foxui/core/dist/theme.css). */
10
+ export declare function getModeColor(mode: string): string;
11
+ export type LocationData = {
12
+ name?: string;
13
+ shortAddress: string;
14
+ fullAddress: string;
15
+ fullString: string;
16
+ googleMapsUrl: string;
17
+ };
18
+ export declare function getLocationData(locations: FlatEventRecord['locations']): LocationData | null;
19
+ export type GeoLocation = {
20
+ lat: number;
21
+ lng: number;
22
+ googleMapsUrl: string;
23
+ osmUrl: string;
24
+ };
25
+ export declare function resolveGeoLocation(locations: FlatEventRecord['locations'], locationData: LocationData | null): Promise<GeoLocation | null>;
26
+ export declare function buildDescriptionHtml(description: string | undefined, facets: unknown): string | null;
@@ -0,0 +1,145 @@
1
+ import { marked } from 'marked';
2
+ import { sanitize } from '../cal/sanitize.js';
3
+ import { getProfileUrl } from '../profile-url.js';
4
+ export function formatMonth(date) {
5
+ return date.toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
6
+ }
7
+ export function formatDay(date) {
8
+ return date.getDate();
9
+ }
10
+ export function formatWeekday(date) {
11
+ return date.toLocaleDateString('en-US', { weekday: 'long' });
12
+ }
13
+ export function formatFullDate(date) {
14
+ const options = { month: 'long', day: 'numeric' };
15
+ if (date.getFullYear() !== new Date().getFullYear()) {
16
+ options.year = 'numeric';
17
+ }
18
+ return date.toLocaleDateString('en-US', options);
19
+ }
20
+ export function formatTime(date) {
21
+ return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
22
+ }
23
+ export function getModeLabel(mode) {
24
+ if (mode.includes('virtual'))
25
+ return 'Virtual';
26
+ if (mode.includes('hybrid'))
27
+ return 'Hybrid';
28
+ if (mode.includes('inperson'))
29
+ return 'In-Person';
30
+ return 'Event';
31
+ }
32
+ /** Foxui accent-color class — applied alongside `variant="primary"` on a Badge
33
+ * to override its `--accent-*` CSS variables (see @foxui/core/dist/theme.css). */
34
+ export function getModeColor(mode) {
35
+ if (mode.includes('virtual'))
36
+ return 'cyan';
37
+ if (mode.includes('hybrid'))
38
+ return 'purple';
39
+ if (mode.includes('inperson'))
40
+ return 'amber';
41
+ return '';
42
+ }
43
+ export function getLocationData(locations) {
44
+ if (!locations?.length)
45
+ return null;
46
+ const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
47
+ if (!loc)
48
+ return null;
49
+ const shortParts = [loc.street, loc.locality].filter(Boolean);
50
+ const fullParts = [loc.street, loc.locality, loc.region, loc.country].filter(Boolean);
51
+ if (fullParts.length === 0)
52
+ return null;
53
+ const shortAddress = shortParts.join(', ');
54
+ const fullAddress = fullParts.join(', ');
55
+ const displayName = loc.name || undefined;
56
+ const fullString = displayName ? `${displayName}, ${fullAddress}` : fullAddress;
57
+ const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullString)}`;
58
+ return { name: displayName, shortAddress, fullAddress, fullString, googleMapsUrl };
59
+ }
60
+ function geoUrls(lat, lng, osmType, osmId) {
61
+ return {
62
+ googleMapsUrl: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
63
+ osmUrl: osmType && osmId
64
+ ? `https://www.openstreetmap.org/${osmType}/${osmId}`
65
+ : `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=17/${lat}/${lng}`
66
+ };
67
+ }
68
+ export async function resolveGeoLocation(locations, locationData) {
69
+ if (!locations?.length)
70
+ return null;
71
+ const geo = locations.find((v) => v.$type === 'community.lexicon.location.geo');
72
+ if (geo?.latitude && geo?.longitude) {
73
+ const lat = parseFloat(geo.latitude);
74
+ const lng = parseFloat(geo.longitude);
75
+ if (!isNaN(lat) && !isNaN(lng))
76
+ return { lat, lng, ...geoUrls(lat, lng) };
77
+ }
78
+ if (!locationData?.fullAddress)
79
+ return null;
80
+ try {
81
+ const r = await fetch(`/api/geocoding?q=${encodeURIComponent(locationData.fullAddress)}`);
82
+ if (!r.ok)
83
+ return null;
84
+ const data = (await r.json());
85
+ if (!data?.lat || !data?.lon)
86
+ return null;
87
+ const lat = parseFloat(data.lat);
88
+ const lng = parseFloat(data.lon);
89
+ if (isNaN(lat) || isNaN(lng))
90
+ return null;
91
+ return { lat, lng, ...geoUrls(lat, lng, data.osm_type, data.osm_id) };
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ const renderer = new marked.Renderer();
98
+ renderer.link = ({ href, text }) => `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`;
99
+ function renderDescription(text, facets) {
100
+ let result = text;
101
+ if (facets && facets.length > 0) {
102
+ const encoded = new TextEncoder().encode(text);
103
+ const decoder = new TextDecoder();
104
+ const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
105
+ const parts = [];
106
+ let cursor = 0;
107
+ for (const facet of sorted) {
108
+ const feature = facet.features?.[0];
109
+ if (!feature)
110
+ continue;
111
+ if (facet.index.byteStart < cursor)
112
+ continue;
113
+ const segmentText = decoder.decode(encoded.slice(facet.index.byteStart, facet.index.byteEnd));
114
+ let mdLink = null;
115
+ switch (feature.$type) {
116
+ case 'app.bsky.richtext.facet#mention': {
117
+ const handle = segmentText.startsWith('@') ? segmentText.slice(1) : segmentText;
118
+ mdLink = `[${segmentText}](${getProfileUrl(handle || feature.did || '')})`;
119
+ break;
120
+ }
121
+ case 'app.bsky.richtext.facet#link':
122
+ mdLink = `[${segmentText}](${feature.uri})`;
123
+ break;
124
+ case 'app.bsky.richtext.facet#tag':
125
+ mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`;
126
+ break;
127
+ }
128
+ if (mdLink) {
129
+ parts.push(decoder.decode(encoded.slice(cursor, facet.index.byteStart)));
130
+ parts.push(mdLink);
131
+ cursor = facet.index.byteEnd;
132
+ }
133
+ }
134
+ parts.push(decoder.decode(encoded.slice(cursor)));
135
+ result = parts.join('');
136
+ }
137
+ return marked.parse(result, { renderer });
138
+ }
139
+ export function buildDescriptionHtml(description, facets) {
140
+ if (!description)
141
+ return null;
142
+ return sanitize(renderDescription(description, facets), {
143
+ ADD_ATTR: ['target']
144
+ });
145
+ }
@@ -0,0 +1,18 @@
1
+ export { default as EventView } from './EventView.svelte';
2
+ export { default as EventEditor } from './EventEditor.svelte';
3
+ export { default as EventCard } from './EventCard.svelte';
4
+ export { default as EventRsvp } from './EventRsvp.svelte';
5
+ export { default as EventComments } from './EventComments.svelte';
6
+ export { default as EventAttendees } from './EventAttendees.svelte';
7
+ export { default as VodPlayer } from './VodPlayer.svelte';
8
+ export { default as VodTranscript } from './VodTranscript.svelte';
9
+ export type { EditorAdapter, EditorBlobRef, EditorViewer } from './editor/adapter.js';
10
+ export type { EventEditorPrefill } from './editor/types.js';
11
+ export type { FlatEventRecord, HostProfile, AttendeeInfo } from './contrail.js';
12
+ export type { EventData, EventLexiconMain, EventLocationVariant, EventStatus, EventMode as EventLexiconMode } from './event-types.js';
13
+ export type { EventTheme } from './theme.js';
14
+ export { defaultTheme, themeBackgrounds, randomAccentColor, accentColors } from './theme.js';
15
+ export { eventUrl, isEventOngoing, RSVP_GOING, RSVP_INTERESTED } from './contrail.js';
16
+ export { getCDNImageBlobUrl, compressImage } from './atproto-helpers.js';
17
+ export { getProfileUrl } from './profile-url.js';
18
+ export { datetimeLocalToISO, isoToDatetimeLocalInTz, formatInTz, partsInTz } from './date-format.js';
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // Components
2
+ export { default as EventView } from './EventView.svelte';
3
+ export { default as EventEditor } from './EventEditor.svelte';
4
+ export { default as EventCard } from './EventCard.svelte';
5
+ export { default as EventRsvp } from './EventRsvp.svelte';
6
+ export { default as EventComments } from './EventComments.svelte';
7
+ export { default as EventAttendees } from './EventAttendees.svelte';
8
+ export { default as VodPlayer } from './VodPlayer.svelte';
9
+ export { default as VodTranscript } from './VodTranscript.svelte';
10
+ // Theme values
11
+ export { defaultTheme, themeBackgrounds, randomAccentColor, accentColors } from './theme.js';
12
+ // Event helpers
13
+ export { eventUrl, isEventOngoing, RSVP_GOING, RSVP_INTERESTED } from './contrail.js';
14
+ // Atproto helpers (browser-safe, no client/session state)
15
+ export { getCDNImageBlobUrl, compressImage } from './atproto-helpers.js';
16
+ export { getProfileUrl } from './profile-url.js';
17
+ // Date/time helpers
18
+ export { datetimeLocalToISO, isoToDatetimeLocalInTz, formatInTz, partsInTz } from './date-format.js';
@@ -0,0 +1 @@
1
+ export declare function getProfileUrl(handleOrDid: string): string;
@@ -0,0 +1,7 @@
1
+ export function getProfileUrl(handleOrDid) {
2
+ const lower = handleOrDid.toLowerCase();
3
+ if (lower.endsWith('.blacksky.team') || lower.endsWith('.blacksky.app')) {
4
+ return `https://blacksky.community/profile/${handleOrDid}`;
5
+ }
6
+ return `https://bsky.app/profile/${handleOrDid}`;
7
+ }
@@ -0,0 +1,9 @@
1
+ export interface EventTheme {
2
+ name: string;
3
+ accentColor: string;
4
+ baseColor: string;
5
+ }
6
+ export declare const accentColors: readonly ["red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose"];
7
+ export declare const defaultTheme: EventTheme;
8
+ export declare function randomAccentColor(): string;
9
+ export declare const themeBackgrounds: Record<string, string>;
package/dist/theme.js ADDED
@@ -0,0 +1,22 @@
1
+ export const accentColors = [
2
+ 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald',
3
+ 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple',
4
+ 'fuchsia', 'pink', 'rose'
5
+ ];
6
+ export const defaultTheme = {
7
+ name: 'minimal',
8
+ accentColor: 'cyan',
9
+ baseColor: 'mist'
10
+ };
11
+ export function randomAccentColor() {
12
+ return accentColors[Math.floor(Math.random() * accentColors.length)];
13
+ }
14
+ export const themeBackgrounds = {
15
+ minimal: 'Minimal',
16
+ blobs: 'Blobs',
17
+ warp: 'Stars',
18
+ matrix: 'Matrix',
19
+ fireflies: 'Fireflies',
20
+ butterflies: 'Butterflies',
21
+ kaleidoscope: 'Kaleidoscope'
22
+ };