@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,589 @@
1
+ <script lang="ts">
2
+ import { getCDNImageBlobUrl } from './atproto-helpers.js';
3
+ import {
4
+ Avatar as FoxAvatar,
5
+ Button,
6
+ ToggleGroup,
7
+ ToggleGroupItem
8
+ } from '@foxui/core';
9
+ import { onMount } from 'svelte';
10
+ import { DEV as dev } from 'esm-env';
11
+ import { PlainTextEditor } from '@foxui/text';
12
+ import DateTimePicker from './DateTimePicker.svelte';
13
+ import TimezonePicker from './TimezonePicker.svelte';
14
+ import { parseDateTime } from '@internationalized/date';
15
+ import { isoToDatetimeLocalInTz } from './date-format.js';
16
+ import type { FlatEventRecord } from './contrail.js';
17
+ import ThemeApply from './ThemeApply.svelte';
18
+ import ThemeBackground from './ThemeBackground.svelte';
19
+ import { defaultTheme, randomAccentColor, type EventTheme } from './theme.js';
20
+
21
+ import type { Readable } from 'svelte/store';
22
+ import { get } from 'svelte/store';
23
+ import type { Editor } from 'svelte-tiptap';
24
+
25
+ import ThumbnailSection from './editor/ThumbnailSection.svelte';
26
+ import LocationSection from './editor/LocationSection.svelte';
27
+ import LinksSection from './editor/LinksSection.svelte';
28
+ import ThemeSection from './editor/ThemeSection.svelte';
29
+ import RecurringModal from './editor/RecurringModal.svelte';
30
+ import {
31
+ stripModePrefix,
32
+ type EventEditorPrefill,
33
+ type EventLocation,
34
+ type EventMode,
35
+ type Visibility
36
+ } from './editor/types';
37
+ import { buildEventRecord, buildThumbnailMedia, renderPresetThumbnail } from './editor/save';
38
+ import { DEFAULT_PRESET, hashSeed } from './thumbnails/designs';
39
+ import type { EditorAdapter, EditorViewer } from './editor/adapter';
40
+
41
+ let {
42
+ eventData = null,
43
+ actorDid,
44
+ rkey,
45
+ privateMode = false,
46
+ adapter,
47
+ viewer,
48
+ initialTheme,
49
+ prefill = null
50
+ }: {
51
+ eventData: FlatEventRecord | null;
52
+ actorDid: string;
53
+ rkey: string;
54
+ /** If true, save writes into a permissioned space instead of the user's public PDS. */
55
+ privateMode?: boolean;
56
+ adapter: EditorAdapter;
57
+ viewer: EditorViewer;
58
+ /** Override default theme for new events (e.g. inherit embedder's palette). */
59
+ initialTheme?: Partial<EventTheme>;
60
+ /** Autofill payload for new events (e.g. imported from Luma/Meetup). */
61
+ prefill?: EventEditorPrefill | null;
62
+ } = $props();
63
+
64
+ let isNew = $derived(eventData === null);
65
+
66
+ // svelte-ignore state_referenced_locally
67
+ let thumbnailChanged = $state(
68
+ eventData === null && (prefill?.thumbnailFile ?? null) !== null
69
+ );
70
+
71
+ // Initial values: prefer prefill (only honored for brand-new events) so the
72
+ // title editor and date inputs see the imported values on their first render
73
+ // — TipTap reads `content` only once at mount.
74
+ // svelte-ignore state_referenced_locally
75
+ const initialPrefill = eventData === null ? prefill : null;
76
+ const initialTimezone =
77
+ initialPrefill?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
78
+
79
+ // svelte-ignore state_referenced_locally
80
+ let name = $state(initialPrefill?.name ?? eventData?.name ?? '');
81
+ // svelte-ignore state_referenced_locally
82
+ let description = $state(initialPrefill?.description ?? '');
83
+ // svelte-ignore state_referenced_locally
84
+ let startsAt = $state(
85
+ initialPrefill?.startsAt ? isoToDatetimeLocalInTz(initialPrefill.startsAt, initialTimezone) : ''
86
+ );
87
+ // svelte-ignore state_referenced_locally
88
+ let endsAt = $state(
89
+ initialPrefill?.endsAt ? isoToDatetimeLocalInTz(initialPrefill.endsAt, initialTimezone) : ''
90
+ );
91
+ // svelte-ignore state_referenced_locally
92
+ let timezone = $state(initialTimezone);
93
+ // svelte-ignore state_referenced_locally
94
+ let mode: EventMode = $state(initialPrefill?.mode ?? 'inperson');
95
+ // svelte-ignore state_referenced_locally
96
+ let visibility: Visibility = $state(privateMode && dev ? 'private' : 'public');
97
+ // svelte-ignore state_referenced_locally
98
+ let eventTheme: EventTheme = $state(
99
+ eventData === null
100
+ ? {
101
+ ...defaultTheme,
102
+ accentColor: initialTheme?.accentColor ?? randomAccentColor(),
103
+ ...(initialTheme?.baseColor ? { baseColor: initialTheme.baseColor } : {})
104
+ }
105
+ : { ...defaultTheme }
106
+ );
107
+ const initialThumbnailFile = initialPrefill?.thumbnailFile ?? null;
108
+ // svelte-ignore state_referenced_locally
109
+ let thumbnailFile: File | null = $state(initialThumbnailFile);
110
+ // svelte-ignore state_referenced_locally
111
+ let thumbnailPreview: string | null = $state(
112
+ initialThumbnailFile ? URL.createObjectURL(initialThumbnailFile) : null
113
+ );
114
+ // svelte-ignore state_referenced_locally
115
+ let selectedPreset: string | null = $state(
116
+ initialThumbnailFile ? null : eventData === null ? DEFAULT_PRESET : null
117
+ );
118
+ let submitting = $state(false);
119
+ let error: string | null = $state(null);
120
+ let titleEditor: Readable<Editor> | undefined = $state(undefined);
121
+
122
+ // svelte-ignore state_referenced_locally
123
+ let location: EventLocation | null = $state(initialPrefill?.location ? { ...initialPrefill.location } : null);
124
+ // svelte-ignore state_referenced_locally
125
+ let locationChanged = $state(initialPrefill?.location != null);
126
+
127
+ // svelte-ignore state_referenced_locally
128
+ let links: Array<{ uri: string; name: string }> = $state(
129
+ initialPrefill?.links ? initialPrefill.links.map((l) => ({ ...l })) : []
130
+ );
131
+
132
+ let showRecurringModal = $state(false);
133
+
134
+ function populateLocationFromEventData() {
135
+ if (!eventData) return;
136
+ if (eventData.locations && eventData.locations.length > 0) {
137
+ const loc = eventData.locations.find(
138
+ (v) => v.$type === 'community.lexicon.location.address'
139
+ ) as { street?: string; locality?: string; region?: string; country?: string } | undefined;
140
+ if (loc) {
141
+ const street = loc.street || undefined;
142
+ const locality = loc.locality || undefined;
143
+ const region = loc.region || undefined;
144
+ const country = loc.country || undefined;
145
+ location = {
146
+ ...(street && { street }),
147
+ ...(locality && { locality }),
148
+ ...(region && { region }),
149
+ ...(country && { country })
150
+ };
151
+ }
152
+ }
153
+ locationChanged = false;
154
+ }
155
+
156
+ function populateThumbnailFromEventData() {
157
+ if (!eventData) return;
158
+ if (eventData.media && eventData.media.length > 0) {
159
+ const media = eventData.media.find((m) => m.role === 'thumbnail');
160
+ if (media?.content) {
161
+ const url = getCDNImageBlobUrl({ did: actorDid, blob: media.content });
162
+ if (url) {
163
+ thumbnailPreview = url;
164
+ thumbnailChanged = false;
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ function populateFromEventData() {
171
+ if (!eventData) return;
172
+ name = eventData.name || '';
173
+ description = eventData.description || '';
174
+ // Restore the event's authored timezone first so the wall-clock fields we
175
+ // populate below land in that zone (not the viewer's browser zone).
176
+ if (eventData.timezone) timezone = eventData.timezone;
177
+ startsAt = eventData.startsAt ? isoToDatetimeLocalInTz(eventData.startsAt, timezone) : '';
178
+ endsAt = eventData.endsAt ? isoToDatetimeLocalInTz(eventData.endsAt, timezone) : '';
179
+ mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson';
180
+ const prefs = (eventData as unknown as { preferences?: { showInDiscovery?: boolean } })
181
+ .preferences;
182
+ if (privateMode && dev) visibility = 'private';
183
+ else if (prefs && prefs.showInDiscovery === false) visibility = 'unlisted';
184
+ else visibility = 'public';
185
+ links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : [];
186
+ if (eventData.theme) eventTheme = { ...eventData.theme };
187
+ populateLocationFromEventData();
188
+ populateThumbnailFromEventData();
189
+ }
190
+
191
+ onMount(() => {
192
+ if (!isNew) populateFromEventData();
193
+ if (titleEditor) get(titleEditor)?.commands.focus();
194
+ });
195
+
196
+ let hostName = $derived(viewer.displayName || viewer.handle || viewer.did || '');
197
+
198
+ let thumbnailDateStr = $derived.by(() => {
199
+ if (!startsAt) return '';
200
+ const d = new Date(startsAt);
201
+ if (isNaN(d.getTime())) return '';
202
+ return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
203
+ });
204
+
205
+ // Trim a CalendarDateTime.toString() ("YYYY-MM-DDTHH:mm:ss[.sss]") down to
206
+ // the "YYYY-MM-DDTHH:mm" shape that <input type="datetime-local"> expects.
207
+ function cdtToDatetimeLocal(s: string): string {
208
+ return s.slice(0, 16);
209
+ }
210
+
211
+ // Auto-set end date to 1 hour after start if empty
212
+ $effect(() => {
213
+ if (startsAt && !endsAt) {
214
+ endsAt = cdtToDatetimeLocal(parseDateTime(startsAt).add({ hours: 1 }).toString());
215
+ }
216
+ });
217
+
218
+ // Auto-adjust end date if start moves past it
219
+ $effect(() => {
220
+ if (startsAt && endsAt) {
221
+ const s = parseDateTime(startsAt);
222
+ const e = parseDateTime(endsAt);
223
+ if (s.compare(e) >= 0) {
224
+ endsAt = cdtToDatetimeLocal(s.add({ hours: 1 }).toString());
225
+ }
226
+ }
227
+ });
228
+
229
+ async function handleSubmit() {
230
+ error = null;
231
+
232
+ if (!name.trim()) return void (error = 'Name is required.');
233
+ if (!startsAt) return void (error = 'Start date is required.');
234
+ if (!endsAt) return void (error = 'End date is required.');
235
+ if (!viewer.isLoggedIn || !viewer.did) return void (error = 'You must be logged in.');
236
+
237
+ submitting = true;
238
+
239
+ try {
240
+ // Generate thumbnail from preset if selected and no custom upload
241
+ if (selectedPreset && !thumbnailFile) {
242
+ const rendered = await renderPresetThumbnail({
243
+ design: selectedPreset,
244
+ seed: hashSeed(rkey),
245
+ name,
246
+ dateStr: thumbnailDateStr,
247
+ accent: eventTheme.accentColor
248
+ });
249
+ if (rendered) {
250
+ thumbnailFile = rendered;
251
+ thumbnailChanged = true;
252
+ }
253
+ }
254
+
255
+ const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>;
256
+ const media = await buildThumbnailMedia({
257
+ isNew,
258
+ thumbnailChanged,
259
+ thumbnailFile,
260
+ existingMedia,
261
+ uploadBlob: (blob) =>
262
+ adapter.uploadBlob(blob) as unknown as Promise<Record<string, unknown>>
263
+ });
264
+
265
+ const record = await buildEventRecord({
266
+ eventData,
267
+ isNew,
268
+ name,
269
+ description,
270
+ startsAt,
271
+ endsAt,
272
+ timezone,
273
+ mode,
274
+ visibility,
275
+ theme: eventTheme,
276
+ links,
277
+ location,
278
+ locationChanged,
279
+ media,
280
+ resolveHandle: (handle) => adapter.resolveHandle(handle)
281
+ });
282
+
283
+ if (isNew && prefill?.additionalData) {
284
+ const existing = (record.additionalData ?? {}) as Record<string, unknown>;
285
+ record.additionalData = { ...existing, ...prefill.additionalData };
286
+ }
287
+
288
+ if (visibility === 'private') {
289
+ if (!adapter.createPrivateEvent) {
290
+ error = 'Private events are not supported here.';
291
+ return;
292
+ }
293
+ const {
294
+ spaceUri,
295
+ rkey: eventRkey,
296
+ spaceKey
297
+ } = await adapter.createPrivateEvent({
298
+ key: rkey,
299
+ record
300
+ });
301
+ adapter.onSaved({
302
+ uri: spaceUri,
303
+ rkey: eventRkey,
304
+ isNew: true,
305
+ spaceKey
306
+ });
307
+ return;
308
+ }
309
+
310
+ const result = await adapter.putRecord({
311
+ collection: 'community.lexicon.calendar.event',
312
+ rkey,
313
+ record
314
+ });
315
+
316
+ await adapter.notifyUpdate?.(result.uri);
317
+ adapter.onSaved({ uri: result.uri, rkey, isNew });
318
+ } catch (e) {
319
+ console.error(`Failed to ${isNew ? 'create' : 'save'} event:`, e);
320
+ error = `Failed to ${isNew ? 'create' : 'save'} event. Please try again.`;
321
+ } finally {
322
+ submitting = false;
323
+ }
324
+ }
325
+
326
+ let showDeleteConfirm = $state(false);
327
+ let deleting = $state(false);
328
+
329
+ async function handleDelete() {
330
+ deleting = true;
331
+ try {
332
+ await adapter.deleteRecord({
333
+ collection: 'community.lexicon.calendar.event',
334
+ rkey
335
+ });
336
+ const eventUri = `at://${viewer.did}/community.lexicon.calendar.event/${rkey}`;
337
+ await adapter.notifyUpdate?.(eventUri);
338
+ adapter.onDeleted?.();
339
+ } catch (e) {
340
+ console.error('Failed to delete event:', e);
341
+ error = 'Failed to delete event. Please try again.';
342
+ } finally {
343
+ deleting = false;
344
+ showDeleteConfirm = false;
345
+ }
346
+ }
347
+ </script>
348
+
349
+ <ThemeApply accentColor={eventTheme.accentColor} baseColor={eventTheme.baseColor} />
350
+ <ThemeBackground theme={eventTheme} />
351
+
352
+ <div class="px-6 py-12 sm:py-12">
353
+ <div class="mx-auto max-w-3xl">
354
+ {#if !viewer.isLoggedIn}
355
+ <div
356
+ class="border-base-200 dark:border-base-800 bg-base-100 dark:bg-base-900/50 rounded-2xl border p-8 text-center"
357
+ >
358
+ <p class="text-base-600 dark:text-base-400 mb-4">
359
+ Log in to {isNew ? 'create an event' : 'edit this event'}.
360
+ </p>
361
+ <Button onclick={() => adapter.requestLogin()}>Log in</Button>
362
+ </div>
363
+ {:else}
364
+ <form
365
+ onsubmit={(e) => {
366
+ e.preventDefault();
367
+ handleSubmit();
368
+ }}
369
+ >
370
+ <!-- Two-column layout mirroring detail page -->
371
+ <div
372
+ class="grid grid-cols-1 gap-8 md:grid-cols-[14rem_1fr] md:gap-x-10 md:gap-y-6 lg:grid-cols-[16rem_1fr]"
373
+ >
374
+ <ThumbnailSection
375
+ {rkey}
376
+ {name}
377
+ dateStr={thumbnailDateStr}
378
+ accent={eventTheme.accentColor}
379
+ bind:thumbnailFile
380
+ bind:thumbnailPreview
381
+ bind:thumbnailChanged
382
+ bind:selectedPreset
383
+ />
384
+
385
+ <!-- Right column: event details -->
386
+ <div class="order-2 min-w-0 md:order-0 md:col-start-2 md:row-span-5 md:row-start-1">
387
+ <!-- Name -->
388
+ <div class="mb-2 min-h-14">
389
+ <PlainTextEditor
390
+ content={name}
391
+ bind:editor={titleEditor}
392
+ placeholder="Event name"
393
+ onupdate={() => {
394
+ if (titleEditor) {
395
+ const text = get(titleEditor)?.getText() ?? '';
396
+ if (text.includes('\n')) {
397
+ const cleaned = text.replace(/\n/g, ' ');
398
+ get(titleEditor)?.commands.setContent(cleaned);
399
+ name = cleaned;
400
+ } else {
401
+ name = text;
402
+ }
403
+ }
404
+ }}
405
+ class="text-base-900 dark:text-base-50 placeholder:text-base-500 dark:placeholder:text-base-500 w-full text-3xl leading-tight font-bold focus:outline-none sm:text-4xl"
406
+ />
407
+ </div>
408
+
409
+ <!-- Mode toggle -->
410
+ <div class="mb-3">
411
+ <ToggleGroup
412
+ type="single"
413
+ bind:value={
414
+ () => mode,
415
+ (val) => {
416
+ if (val) mode = val;
417
+ }
418
+ }
419
+ class="w-fit"
420
+ size="xs"
421
+ >
422
+ <ToggleGroupItem value="inperson">In Person</ToggleGroupItem>
423
+ <ToggleGroupItem value="virtual">Virtual</ToggleGroupItem>
424
+ <ToggleGroupItem value="hybrid">Hybrid</ToggleGroupItem>
425
+ </ToggleGroup>
426
+ </div>
427
+
428
+ <!-- Visibility toggle -->
429
+ <div class="mb-8">
430
+ <ToggleGroup
431
+ type="single"
432
+ bind:value={
433
+ () => visibility,
434
+ (val) => {
435
+ if (val) visibility = val as Visibility;
436
+ }
437
+ }
438
+ class="w-fit"
439
+ size="xs"
440
+ disabled={!isNew && visibility === 'private'}
441
+ >
442
+ <ToggleGroupItem value="public">Public</ToggleGroupItem>
443
+ {#if dev && adapter.features.privateMode}
444
+ <ToggleGroupItem value="private">Private</ToggleGroupItem>
445
+ {/if}
446
+ <ToggleGroupItem value="unlisted">Unlisted</ToggleGroupItem>
447
+ </ToggleGroup>
448
+ <div class="text-base-500 dark:text-base-400 mt-1.5 text-xs">
449
+ {#if visibility === 'public'}
450
+ Anyone can view and it appears in discovery.
451
+ {:else if visibility === 'private'}
452
+ Only people you add (or who redeem an invite link) can see it.
453
+ {:else}
454
+ Public to anyone with the link, but hidden from discovery.
455
+ {/if}
456
+ </div>
457
+ </div>
458
+
459
+ <!-- Date row -->
460
+ <div class="mb-4 flex items-stretch gap-3">
461
+ <div class="flex flex-col gap-2">
462
+ <div class="flex items-center gap-2">
463
+ <span class="text-base-500 dark:text-base-400 w-9 text-sm">Start</span>
464
+ <DateTimePicker bind:value={startsAt} required />
465
+ </div>
466
+ <div class="flex items-center gap-2">
467
+ <span class="text-base-500 dark:text-base-400 w-9 text-sm">End</span>
468
+ <DateTimePicker bind:value={endsAt} minValue={startsAt} referenceTime={startsAt} />
469
+ </div>
470
+ </div>
471
+ <div class="hidden sm:flex">
472
+ <TimezonePicker bind:value={timezone} />
473
+ </div>
474
+ </div>
475
+
476
+ <LocationSection bind:location bind:locationChanged />
477
+
478
+ <!-- About Event -->
479
+ <div class="mt-8 mb-8">
480
+ <p
481
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
482
+ >
483
+ About
484
+ </p>
485
+ <textarea
486
+ bind:value={description}
487
+ rows={4}
488
+ placeholder="What's this event about? @mentions, #hashtags and links will be detected automatically."
489
+ class="text-base-700 dark:text-base-300 placeholder:text-base-500 dark:placeholder:text-base-500 w-full resize-none border-0 bg-transparent px-0 leading-relaxed focus:border-0 focus:ring-0 focus:outline-none"
490
+ style="field-sizing: content;"
491
+ ></textarea>
492
+ </div>
493
+
494
+ {#if error}
495
+ <p class="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
496
+ {/if}
497
+
498
+ <Button type="submit" disabled={submitting || !name.trim() || !startsAt || !endsAt}>
499
+ {submitting
500
+ ? isNew
501
+ ? 'Publishing...'
502
+ : 'Saving...'
503
+ : isNew
504
+ ? 'Publish Event'
505
+ : 'Save Changes'}
506
+ </Button>
507
+ {#if !isNew && adapter.features.recurring}
508
+ <Button
509
+ type="button"
510
+ variant="secondary"
511
+ disabled={submitting || !name.trim() || !startsAt || !endsAt}
512
+ onclick={() => (showRecurringModal = true)}
513
+ >
514
+ Add recurring events
515
+ </Button>
516
+ {/if}
517
+ </div>
518
+
519
+ <!-- Hosted By -->
520
+ <div class="order-3 md:order-0 md:col-start-1">
521
+ <p
522
+ class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase"
523
+ >
524
+ Hosted By
525
+ </p>
526
+ <div class="flex items-center gap-2.5">
527
+ <FoxAvatar src={viewer.avatar} alt={hostName} class="size-8 shrink-0" />
528
+ <span class="text-base-900 dark:text-base-100 truncate text-sm font-medium">
529
+ {hostName}
530
+ </span>
531
+ </div>
532
+ </div>
533
+
534
+ <div class="order-4 space-y-6 md:order-0 md:col-start-1">
535
+ <LinksSection bind:links />
536
+ <ThemeSection bind:theme={eventTheme} />
537
+ </div>
538
+ </div>
539
+
540
+ {#if !isNew && adapter.features.delete}
541
+ <div class="border-base-200 dark:border-base-800 mt-12 border-t pt-8">
542
+ {#if showDeleteConfirm}
543
+ <div class="flex items-center gap-3">
544
+ <p class="text-sm text-red-600 dark:text-red-400">
545
+ Are you sure? This cannot be undone.
546
+ </p>
547
+ <Button
548
+ variant="secondary"
549
+ size="sm"
550
+ onclick={() => (showDeleteConfirm = false)}
551
+ disabled={deleting}
552
+ >
553
+ Cancel
554
+ </Button>
555
+ <Button size="sm" onclick={handleDelete} disabled={deleting} variant="primary" class="red">
556
+ {deleting ? 'Deleting...' : 'Delete'}
557
+ </Button>
558
+ </div>
559
+ {:else}
560
+ <Button variant="primary" class="red" onclick={() => (showDeleteConfirm = true)}>Delete event</Button>
561
+ {/if}
562
+ </div>
563
+ {/if}
564
+ </form>
565
+ {/if}
566
+ </div>
567
+ </div>
568
+
569
+ <RecurringModal
570
+ bind:open={showRecurringModal}
571
+ {rkey}
572
+ {eventData}
573
+ {isNew}
574
+ {name}
575
+ {startsAt}
576
+ {endsAt}
577
+ {mode}
578
+ {timezone}
579
+ {description}
580
+ {links}
581
+ {location}
582
+ {thumbnailDateStr}
583
+ {thumbnailFile}
584
+ {thumbnailChanged}
585
+ {selectedPreset}
586
+ {adapter}
587
+ {viewer}
588
+ accent={eventTheme.accentColor}
589
+ />
@@ -0,0 +1,20 @@
1
+ import type { FlatEventRecord } from './contrail.js';
2
+ import { type EventTheme } from './theme.js';
3
+ import { type EventEditorPrefill } from './editor/types';
4
+ import type { EditorAdapter, EditorViewer } from './editor/adapter';
5
+ type $$ComponentProps = {
6
+ eventData: FlatEventRecord | null;
7
+ actorDid: string;
8
+ rkey: string;
9
+ /** If true, save writes into a permissioned space instead of the user's public PDS. */
10
+ privateMode?: boolean;
11
+ adapter: EditorAdapter;
12
+ viewer: EditorViewer;
13
+ /** Override default theme for new events (e.g. inherit embedder's palette). */
14
+ initialTheme?: Partial<EventTheme>;
15
+ /** Autofill payload for new events (e.g. imported from Luma/Meetup). */
16
+ prefill?: EventEditorPrefill | null;
17
+ };
18
+ declare const EventEditor: import("svelte").Component<$$ComponentProps, {}, "">;
19
+ type EventEditor = ReturnType<typeof EventEditor>;
20
+ export default EventEditor;