@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.
- package/dist/DatePicker.svelte +231 -0
- package/dist/DatePicker.svelte.d.ts +11 -0
- package/dist/DateTimePicker.svelte +101 -0
- package/dist/DateTimePicker.svelte.d.ts +9 -0
- package/dist/EventAttendees.svelte +203 -0
- package/dist/EventAttendees.svelte.d.ts +13 -0
- package/dist/EventCard.svelte +131 -0
- package/dist/EventCard.svelte.d.ts +8 -0
- package/dist/EventComments.svelte +99 -0
- package/dist/EventComments.svelte.d.ts +6 -0
- package/dist/EventEditor.svelte +589 -0
- package/dist/EventEditor.svelte.d.ts +20 -0
- package/dist/EventRsvp.svelte +237 -0
- package/dist/EventRsvp.svelte.d.ts +17 -0
- package/dist/EventView.svelte +433 -0
- package/dist/EventView.svelte.d.ts +16 -0
- package/dist/ImageDropper.svelte +66 -0
- package/dist/ImageDropper.svelte.d.ts +7 -0
- package/dist/Map.svelte +27 -0
- package/dist/Map.svelte.d.ts +8 -0
- package/dist/PostToBlueskyModal.svelte +244 -0
- package/dist/PostToBlueskyModal.svelte.d.ts +22 -0
- package/dist/ShareModal.svelte +160 -0
- package/dist/ShareModal.svelte.d.ts +23 -0
- package/dist/ThemeApply.svelte +50 -0
- package/dist/ThemeApply.svelte.d.ts +7 -0
- package/dist/ThemeBackground.svelte +33 -0
- package/dist/ThemeBackground.svelte.d.ts +7 -0
- package/dist/ThemePicker.svelte +102 -0
- package/dist/ThemePicker.svelte.d.ts +7 -0
- package/dist/ThumbnailPresets.svelte +68 -0
- package/dist/ThumbnailPresets.svelte.d.ts +11 -0
- package/dist/TimePicker.svelte +188 -0
- package/dist/TimePicker.svelte.d.ts +9 -0
- package/dist/TimezonePicker.svelte +132 -0
- package/dist/TimezonePicker.svelte.d.ts +6 -0
- package/dist/VodPlayer.svelte +137 -0
- package/dist/VodPlayer.svelte.d.ts +14 -0
- package/dist/VodTranscript.svelte +72 -0
- package/dist/VodTranscript.svelte.d.ts +8 -0
- package/dist/atproto-helpers.d.ts +21 -0
- package/dist/atproto-helpers.js +61 -0
- package/dist/cal/helper.d.ts +1 -0
- package/dist/cal/helper.js +20 -0
- package/dist/cal/ical.d.ts +22 -0
- package/dist/cal/ical.js +188 -0
- package/dist/cal/sanitize.d.ts +3 -0
- package/dist/cal/sanitize.js +25 -0
- package/dist/contrail.d.ts +54 -0
- package/dist/contrail.js +22 -0
- package/dist/date-format.d.ts +22 -0
- package/dist/date-format.js +43 -0
- package/dist/editor/LinksSection.svelte +144 -0
- package/dist/editor/LinksSection.svelte.d.ts +10 -0
- package/dist/editor/LocationSection.svelte +215 -0
- package/dist/editor/LocationSection.svelte.d.ts +8 -0
- package/dist/editor/RecurringModal.svelte +270 -0
- package/dist/editor/RecurringModal.svelte.d.ts +30 -0
- package/dist/editor/ThemeSection.svelte +39 -0
- package/dist/editor/ThemeSection.svelte.d.ts +7 -0
- package/dist/editor/ThumbnailSection.svelte +219 -0
- package/dist/editor/ThumbnailSection.svelte.d.ts +13 -0
- package/dist/editor/adapter.d.ts +98 -0
- package/dist/editor/adapter.js +9 -0
- package/dist/editor/save.d.ts +42 -0
- package/dist/editor/save.js +154 -0
- package/dist/editor/types.d.ts +39 -0
- package/dist/editor/types.js +9 -0
- package/dist/event-types.d.ts +70 -0
- package/dist/event-types.js +11 -0
- package/dist/event-view/AddToCalendarButton.svelte +42 -0
- package/dist/event-view/AddToCalendarButton.svelte.d.ts +9 -0
- package/dist/event-view/EventBadges.svelte +20 -0
- package/dist/event-view/EventBadges.svelte.d.ts +7 -0
- package/dist/event-view/EventDateBlock.svelte +43 -0
- package/dist/event-view/EventDateBlock.svelte.d.ts +7 -0
- package/dist/event-view/EventHostedBy.svelte +63 -0
- package/dist/event-view/EventHostedBy.svelte.d.ts +16 -0
- package/dist/event-view/EventLinksList.svelte +37 -0
- package/dist/event-view/EventLinksList.svelte.d.ts +9 -0
- package/dist/event-view/EventLocationBlock.svelte +48 -0
- package/dist/event-view/EventLocationBlock.svelte.d.ts +7 -0
- package/dist/event-view/EventLocationMap.svelte +72 -0
- package/dist/event-view/EventLocationMap.svelte.d.ts +8 -0
- package/dist/event-view/ExternalRsvpNotice.svelte +44 -0
- package/dist/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
- package/dist/event-view/InviteShareFlow.svelte +177 -0
- package/dist/event-view/InviteShareFlow.svelte.d.ts +15 -0
- package/dist/event-view/StreamPlacePlayer.svelte +222 -0
- package/dist/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
- package/dist/event-view/format.d.ts +26 -0
- package/dist/event-view/format.js +145 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/profile-url.d.ts +1 -0
- package/dist/profile-url.js +7 -0
- package/dist/theme.d.ts +9 -0
- package/dist/theme.js +22 -0
- package/dist/themes/Blobs.svelte +35 -0
- package/dist/themes/Blobs.svelte.d.ts +26 -0
- package/dist/themes/Butterflies.svelte +185 -0
- package/dist/themes/Butterflies.svelte.d.ts +3 -0
- package/dist/themes/Fireflies.svelte +134 -0
- package/dist/themes/Fireflies.svelte.d.ts +3 -0
- package/dist/themes/Kaleidoscope.svelte +177 -0
- package/dist/themes/Kaleidoscope.svelte.d.ts +3 -0
- package/dist/themes/Matrix.svelte +150 -0
- package/dist/themes/Matrix.svelte.d.ts +3 -0
- package/dist/themes/Stars.svelte +98 -0
- package/dist/themes/Stars.svelte.d.ts +3 -0
- package/dist/thumbnails/designs.d.ts +18 -0
- package/dist/thumbnails/designs.js +316 -0
- 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;
|