@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,39 @@
1
+ <script lang="ts">
2
+ import { Button, Modal } from '@foxui/core';
3
+ import ThemePicker from '../ThemePicker.svelte';
4
+ import { themeBackgrounds, type EventTheme } from '../theme.js';
5
+
6
+ let { theme = $bindable() }: { theme: EventTheme } = $props();
7
+
8
+ let showModal = $state(false);
9
+ </script>
10
+
11
+ <div>
12
+ <p class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase">
13
+ Theme
14
+ </p>
15
+ <Button variant="secondary" size="sm" onclick={() => (showModal = true)}>
16
+ <svg
17
+ xmlns="http://www.w3.org/2000/svg"
18
+ fill="none"
19
+ viewBox="0 0 24 24"
20
+ stroke-width="1.5"
21
+ stroke="currentColor"
22
+ class="size-4"
23
+ >
24
+ <path
25
+ stroke-linecap="round"
26
+ stroke-linejoin="round"
27
+ d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z"
28
+ />
29
+ </svg>
30
+ {themeBackgrounds[theme.name] || theme.name}
31
+ </Button>
32
+ </div>
33
+
34
+ <Modal bind:open={showModal}>
35
+ <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Event theme</p>
36
+ <div class="mt-4">
37
+ <ThemePicker bind:theme />
38
+ </div>
39
+ </Modal>
@@ -0,0 +1,7 @@
1
+ import { type EventTheme } from '../theme.js';
2
+ type $$ComponentProps = {
3
+ theme: EventTheme;
4
+ };
5
+ declare const ThemeSection: import("svelte").Component<$$ComponentProps, {}, "theme">;
6
+ type ThemeSection = ReturnType<typeof ThemeSection>;
7
+ export default ThemeSection;
@@ -0,0 +1,219 @@
1
+ <script lang="ts">
2
+ import { Button, Modal } from '@foxui/core';
3
+ import Avatar from 'svelte-boring-avatars';
4
+ import ThumbnailPresets from '../ThumbnailPresets.svelte';
5
+ import { designs, hashSeed, resolveAccentColor } from '../thumbnails/designs.js';
6
+
7
+ let {
8
+ rkey,
9
+ name,
10
+ dateStr,
11
+ accent,
12
+ thumbnailFile = $bindable(),
13
+ thumbnailPreview = $bindable(),
14
+ thumbnailChanged = $bindable(),
15
+ selectedPreset = $bindable()
16
+ }: {
17
+ rkey: string;
18
+ name: string;
19
+ dateStr: string;
20
+ accent: string;
21
+ thumbnailFile: File | null;
22
+ thumbnailPreview: string | null;
23
+ thumbnailChanged: boolean;
24
+ selectedPreset: string | null;
25
+ } = $props();
26
+
27
+ let fileInput: HTMLInputElement | undefined = $state();
28
+ let presetPreviewCanvas: HTMLCanvasElement | undefined = $state();
29
+ let showModal = $state(false);
30
+ let isDragOver = $state(false);
31
+
32
+ const seed = $derived(hashSeed(rkey));
33
+
34
+ function setThumbnail(file: File) {
35
+ thumbnailFile = file;
36
+ thumbnailChanged = true;
37
+ selectedPreset = null;
38
+ if (thumbnailPreview) URL.revokeObjectURL(thumbnailPreview);
39
+ thumbnailPreview = URL.createObjectURL(file);
40
+ }
41
+
42
+ function onFileChange(e: Event) {
43
+ const input = e.target as HTMLInputElement;
44
+ const file = input.files?.[0];
45
+ if (!file) return;
46
+ setThumbnail(file);
47
+ showModal = false;
48
+ }
49
+
50
+ function onDragOver(e: DragEvent) {
51
+ e.preventDefault();
52
+ isDragOver = true;
53
+ }
54
+
55
+ function onDragLeave(e: DragEvent) {
56
+ e.preventDefault();
57
+ isDragOver = false;
58
+ }
59
+
60
+ function onDrop(e: DragEvent) {
61
+ e.preventDefault();
62
+ isDragOver = false;
63
+ const file = e.dataTransfer?.files?.[0];
64
+ if (file?.type.startsWith('image/')) {
65
+ setThumbnail(file);
66
+ }
67
+ }
68
+
69
+ function removeThumbnail() {
70
+ thumbnailFile = null;
71
+ thumbnailChanged = true;
72
+ selectedPreset = null;
73
+ if (thumbnailPreview) {
74
+ URL.revokeObjectURL(thumbnailPreview);
75
+ thumbnailPreview = null;
76
+ }
77
+ if (fileInput) fileInput.value = '';
78
+ }
79
+
80
+ // Render preset preview canvas whenever the selection, name, date, or accent changes.
81
+ $effect(() => {
82
+ if (selectedPreset && presetPreviewCanvas && designs[selectedPreset]) {
83
+ const ctx = presetPreviewCanvas.getContext('2d');
84
+ if (!ctx) return;
85
+ presetPreviewCanvas.width = 800;
86
+ presetPreviewCanvas.height = 800;
87
+ designs[selectedPreset](
88
+ ctx,
89
+ 800,
90
+ 800,
91
+ name || 'Event',
92
+ dateStr,
93
+ seed,
94
+ resolveAccentColor(accent)
95
+ );
96
+ }
97
+ });
98
+ </script>
99
+
100
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
101
+ <div
102
+ class="order-1 max-w-sm md:order-0 md:col-start-1 md:max-w-none"
103
+ ondragover={onDragOver}
104
+ ondragleave={onDragLeave}
105
+ ondrop={onDrop}
106
+ >
107
+ <input
108
+ bind:this={fileInput}
109
+ type="file"
110
+ accept="image/*"
111
+ onchange={onFileChange}
112
+ class="hidden"
113
+ />
114
+ <div class="group relative">
115
+ {#if thumbnailPreview}
116
+ <img
117
+ src={thumbnailPreview}
118
+ alt="Thumbnail preview"
119
+ class="border-base-200 dark:border-base-800 aspect-square w-full rounded-2xl border object-cover"
120
+ />
121
+ {:else if selectedPreset && designs[selectedPreset]}
122
+ <div
123
+ class="border-base-200 dark:border-base-800 aspect-square w-full overflow-hidden rounded-2xl border"
124
+ >
125
+ <canvas bind:this={presetPreviewCanvas} class="h-full w-full"></canvas>
126
+ </div>
127
+ {:else}
128
+ <div
129
+ class="bg-base-100 dark:bg-base-900 aspect-square w-full overflow-hidden rounded-2xl [&>svg]:h-full [&>svg]:w-full"
130
+ >
131
+ <Avatar
132
+ size={400}
133
+ name={rkey}
134
+ variant="marble"
135
+ colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']}
136
+ square
137
+ />
138
+ </div>
139
+ {/if}
140
+ <button
141
+ type="button"
142
+ onclick={() => (showModal = true)}
143
+ class="absolute inset-0 flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-2xl bg-black/0 text-white/0 transition-colors group-hover:bg-black/40 group-hover:text-white/90 {isDragOver
144
+ ? 'bg-black/40 text-white/90'
145
+ : ''}"
146
+ >
147
+ <svg
148
+ xmlns="http://www.w3.org/2000/svg"
149
+ fill="none"
150
+ viewBox="0 0 24 24"
151
+ stroke-width="1.5"
152
+ stroke="currentColor"
153
+ class="size-6"
154
+ >
155
+ <path
156
+ stroke-linecap="round"
157
+ stroke-linejoin="round"
158
+ d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"
159
+ />
160
+ </svg>
161
+ <span class="text-sm font-medium">Change thumbnail</span>
162
+ </button>
163
+ {#if thumbnailPreview || selectedPreset}
164
+ <Button
165
+ variant="ghost"
166
+ size="iconSm"
167
+ onclick={removeThumbnail}
168
+ class="bg-base-900/70 absolute top-2 right-2 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
169
+ >
170
+ <svg
171
+ xmlns="http://www.w3.org/2000/svg"
172
+ viewBox="0 0 20 20"
173
+ fill="currentColor"
174
+ class="size-3.5"
175
+ >
176
+ <path
177
+ d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
178
+ />
179
+ </svg>
180
+ </Button>
181
+ {/if}
182
+ </div>
183
+ </div>
184
+
185
+ <Modal bind:open={showModal}>
186
+ <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Choose thumbnail</p>
187
+ <div class="mt-4 flex max-h-[70vh] flex-col gap-6 overflow-y-auto">
188
+ <Button variant="secondary" class="w-full" onclick={() => fileInput?.click()}>
189
+ <svg
190
+ xmlns="http://www.w3.org/2000/svg"
191
+ fill="none"
192
+ viewBox="0 0 24 24"
193
+ stroke-width="1.5"
194
+ stroke="currentColor"
195
+ class="size-4"
196
+ >
197
+ <path
198
+ stroke-linecap="round"
199
+ stroke-linejoin="round"
200
+ d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
201
+ />
202
+ </svg>
203
+ Upload own thumbnail
204
+ </Button>
205
+ <ThumbnailPresets
206
+ {name}
207
+ {dateStr}
208
+ {accent}
209
+ {seed}
210
+ bind:selected={selectedPreset}
211
+ onselect={() => {
212
+ showModal = false;
213
+ thumbnailPreview = null;
214
+ thumbnailFile = null;
215
+ thumbnailChanged = true;
216
+ }}
217
+ />
218
+ </div>
219
+ </Modal>
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ rkey: string;
3
+ name: string;
4
+ dateStr: string;
5
+ accent: string;
6
+ thumbnailFile: File | null;
7
+ thumbnailPreview: string | null;
8
+ thumbnailChanged: boolean;
9
+ selectedPreset: string | null;
10
+ };
11
+ declare const ThumbnailSection: import("svelte").Component<$$ComponentProps, {}, "thumbnailFile" | "thumbnailPreview" | "thumbnailChanged" | "selectedPreset">;
12
+ type ThumbnailSection = ReturnType<typeof ThumbnailSection>;
13
+ export default ThumbnailSection;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Adapter contract for `EventEditor`, `EventView` and their child components.
3
+ *
4
+ * The package never reaches into atproto/session/navigation directly. Consumers
5
+ * implement this interface (typically with their own atcute client + router)
6
+ * and pass it in as a prop. The atmo app provides `createInAppAdapter`; other
7
+ * hosts (e.g. blento) provide their own.
8
+ */
9
+ export type EditorBlobRef = {
10
+ $type: 'blob';
11
+ ref: {
12
+ $link: string;
13
+ };
14
+ mimeType: string;
15
+ size: number;
16
+ };
17
+ export type EditorViewer = {
18
+ isLoggedIn: boolean;
19
+ did: string | null;
20
+ handle?: string;
21
+ displayName?: string;
22
+ avatar?: string;
23
+ };
24
+ export type EditorAdapter = {
25
+ features: {
26
+ delete: boolean;
27
+ recurring: boolean;
28
+ privateMode: boolean;
29
+ };
30
+ putRecord(opts: {
31
+ collection: string;
32
+ rkey: string;
33
+ record: Record<string, unknown>;
34
+ }): Promise<{
35
+ uri: string;
36
+ }>;
37
+ createRecord(opts: {
38
+ collection: string;
39
+ rkey?: string;
40
+ record: Record<string, unknown>;
41
+ }): Promise<{
42
+ uri: string;
43
+ cid?: string;
44
+ }>;
45
+ deleteRecord(opts: {
46
+ collection: string;
47
+ rkey: string;
48
+ }): Promise<void>;
49
+ uploadBlob(blob: Blob): Promise<EditorBlobRef>;
50
+ getRecord(opts: {
51
+ did: string;
52
+ collection: string;
53
+ rkey: string;
54
+ }): Promise<{
55
+ value: Record<string, unknown>;
56
+ }>;
57
+ resolveHandle(handle: string): Promise<string>;
58
+ onSaved(result: {
59
+ uri: string;
60
+ rkey: string;
61
+ isNew: boolean;
62
+ spaceKey?: string;
63
+ }): void;
64
+ onDeleted?(): void;
65
+ requestLogin(): void;
66
+ notifyUpdate?(uri: string): Promise<void>;
67
+ createPrivateEvent?(opts: {
68
+ key: string;
69
+ record: Record<string, unknown>;
70
+ }): Promise<{
71
+ spaceUri: string;
72
+ rkey: string;
73
+ spaceKey: string;
74
+ }>;
75
+ /** Put a record inside a permissioned space. Required for RSVPs to private events. */
76
+ putSpaceRecord?(opts: {
77
+ spaceUri: string;
78
+ collection: string;
79
+ rkey: string;
80
+ record: Record<string, unknown>;
81
+ }): Promise<{
82
+ ok: boolean;
83
+ }>;
84
+ deleteSpaceRecord?(opts: {
85
+ spaceUri: string;
86
+ collection: string;
87
+ rkey: string;
88
+ }): Promise<void>;
89
+ /** Mint an invite token for a private space. */
90
+ createSpaceInvite?(opts: {
91
+ spaceUri: string;
92
+ kind: 'read-join' | 'join';
93
+ maxUses?: number;
94
+ expiresAt?: number;
95
+ }): Promise<{
96
+ token: string;
97
+ }>;
98
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Adapter contract for `EventEditor`, `EventView` and their child components.
3
+ *
4
+ * The package never reaches into atproto/session/navigation directly. Consumers
5
+ * implement this interface (typically with their own atcute client + router)
6
+ * and pass it in as a prop. The atmo app provides `createInAppAdapter`; other
7
+ * hosts (e.g. blento) provide their own.
8
+ */
9
+ export {};
@@ -0,0 +1,42 @@
1
+ import { type Token } from '@atcute/bluesky-richtext-parser';
2
+ import type { FlatEventRecord } from '../contrail.js';
3
+ import type { EventTheme } from '../theme.js';
4
+ import type { EventLocation, EventMode, Visibility } from './types.js';
5
+ export declare function tokensToFacets(tokens: Token[], resolveHandle: (handle: string) => Promise<string>): Promise<Record<string, unknown>[]>;
6
+ /** Render a selected thumbnail preset to a PNG File so it can be uploaded
7
+ * as a blob like a user-provided image. Returns null if the preset design
8
+ * is missing or the canvas fails to produce a blob. */
9
+ export declare function renderPresetThumbnail(args: {
10
+ design: string;
11
+ seed: number;
12
+ name: string;
13
+ dateStr: string;
14
+ accent?: string;
15
+ }): Promise<File | null>;
16
+ export declare function buildThumbnailMedia(args: {
17
+ isNew: boolean;
18
+ thumbnailChanged: boolean;
19
+ thumbnailFile: File | null;
20
+ existingMedia: Array<Record<string, unknown>>;
21
+ uploadBlob: (blob: Blob) => Promise<Record<string, unknown>>;
22
+ }): Promise<Array<Record<string, unknown>> | undefined>;
23
+ export declare function buildEventRecord(args: {
24
+ eventData: FlatEventRecord | null;
25
+ isNew: boolean;
26
+ name: string;
27
+ description: string;
28
+ startsAt: string;
29
+ endsAt: string;
30
+ timezone: string;
31
+ mode: EventMode;
32
+ visibility: Visibility;
33
+ theme: EventTheme;
34
+ links: Array<{
35
+ uri: string;
36
+ name: string;
37
+ }>;
38
+ location: EventLocation | null;
39
+ locationChanged: boolean;
40
+ media: Array<Record<string, unknown>> | undefined;
41
+ resolveHandle: (handle: string) => Promise<string>;
42
+ }): Promise<Record<string, unknown>>;
@@ -0,0 +1,154 @@
1
+ import { compressImage } from '../atproto-helpers.js';
2
+ import { tokenize } from '@atcute/bluesky-richtext-parser';
3
+ import { datetimeLocalToISO } from '../date-format.js';
4
+ import { designs, resolveAccentColor } from '../thumbnails/designs.js';
5
+ export async function tokensToFacets(tokens, resolveHandle) {
6
+ const encoder = new TextEncoder();
7
+ const facets = [];
8
+ let byteOffset = 0;
9
+ for (const token of tokens) {
10
+ const tokenBytes = encoder.encode(token.raw);
11
+ const byteStart = byteOffset;
12
+ const byteEnd = byteOffset + tokenBytes.length;
13
+ if (token.type === 'mention') {
14
+ try {
15
+ const did = await resolveHandle(token.handle);
16
+ if (did) {
17
+ facets.push({
18
+ index: { byteStart, byteEnd },
19
+ features: [{ $type: 'app.bsky.richtext.facet#mention', did }]
20
+ });
21
+ }
22
+ }
23
+ catch {
24
+ // skip unresolvable mentions
25
+ }
26
+ }
27
+ else if (token.type === 'autolink') {
28
+ facets.push({
29
+ index: { byteStart, byteEnd },
30
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: token.url }]
31
+ });
32
+ }
33
+ else if (token.type === 'topic') {
34
+ facets.push({
35
+ index: { byteStart, byteEnd },
36
+ features: [{ $type: 'app.bsky.richtext.facet#tag', tag: token.name }]
37
+ });
38
+ }
39
+ byteOffset = byteEnd;
40
+ }
41
+ return facets;
42
+ }
43
+ /** Render a selected thumbnail preset to a PNG File so it can be uploaded
44
+ * as a blob like a user-provided image. Returns null if the preset design
45
+ * is missing or the canvas fails to produce a blob. */
46
+ export async function renderPresetThumbnail(args) {
47
+ const drawer = designs[args.design];
48
+ if (!drawer)
49
+ return null;
50
+ const canvas = document.createElement('canvas');
51
+ canvas.width = 800;
52
+ canvas.height = 800;
53
+ const ctx = canvas.getContext('2d');
54
+ if (!ctx)
55
+ return null;
56
+ drawer(ctx, 800, 800, args.name.trim() || 'Event', args.dateStr, args.seed, resolveAccentColor(args.accent));
57
+ const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
58
+ if (!blob)
59
+ return null;
60
+ return new File([blob], 'thumbnail.png', { type: 'image/png' });
61
+ }
62
+ export async function buildThumbnailMedia(args) {
63
+ const { isNew, thumbnailChanged, thumbnailFile, existingMedia, uploadBlob } = args;
64
+ if (!isNew && !thumbnailChanged) {
65
+ return existingMedia.length > 0 ? existingMedia : undefined;
66
+ }
67
+ if (!thumbnailFile) {
68
+ const remaining = existingMedia.filter((m) => m.role !== 'thumbnail');
69
+ return remaining.length > 0 ? remaining : undefined;
70
+ }
71
+ const compressed = await compressImage(thumbnailFile);
72
+ const blobRef = await uploadBlob(compressed.blob);
73
+ if (!blobRef)
74
+ return existingMedia.length > 0 ? existingMedia : undefined;
75
+ return [
76
+ ...existingMedia.filter((m) => m.role !== 'thumbnail'),
77
+ {
78
+ role: 'thumbnail',
79
+ content: blobRef,
80
+ aspect_ratio: {
81
+ width: compressed.aspectRatio.width,
82
+ height: compressed.aspectRatio.height
83
+ }
84
+ }
85
+ ];
86
+ }
87
+ export async function buildEventRecord(args) {
88
+ const { eventData, isNew, name, description, startsAt, endsAt, timezone, mode, visibility, theme, links, location, locationChanged, media, resolveHandle } = args;
89
+ const createdAt = isNew
90
+ ? new Date().toISOString()
91
+ : eventData?.createdAt || new Date().toISOString();
92
+ // Spread original record to preserve unspecced fields (e.g. additionalData)
93
+ const record = {
94
+ ...(eventData ? { ...eventData } : {}),
95
+ $type: 'community.lexicon.calendar.event',
96
+ createdWith: 'https://atmo.rsvp',
97
+ name: name.trim(),
98
+ mode: `community.lexicon.calendar.event#${mode}`,
99
+ status: 'community.lexicon.calendar.event#scheduled',
100
+ startsAt: datetimeLocalToISO(startsAt, timezone),
101
+ timezone,
102
+ createdAt,
103
+ theme
104
+ };
105
+ // Remove flattened fields that aren't part of the actual record
106
+ for (const k of [
107
+ 'cid',
108
+ 'did',
109
+ 'rkey',
110
+ 'uri',
111
+ 'rsvps',
112
+ 'rsvpsCount',
113
+ 'rsvpsGoingCount',
114
+ 'rsvpsInterestedCount',
115
+ 'rsvpsNotgoingCount'
116
+ ]) {
117
+ delete record[k];
118
+ }
119
+ const trimmedDescription = description.trim();
120
+ if (trimmedDescription) {
121
+ record.description = trimmedDescription;
122
+ const tokens = tokenize(trimmedDescription);
123
+ const facets = await tokensToFacets(tokens, resolveHandle);
124
+ if (facets.length > 0)
125
+ record.facets = facets;
126
+ }
127
+ if (endsAt)
128
+ record.endsAt = datetimeLocalToISO(endsAt, timezone);
129
+ if (media)
130
+ record.media = media;
131
+ if (links.length > 0)
132
+ record.uris = links;
133
+ if (isNew || locationChanged) {
134
+ if (location) {
135
+ record.locations = [
136
+ {
137
+ $type: 'community.lexicon.location.address',
138
+ ...location
139
+ }
140
+ ];
141
+ }
142
+ // If changed/new but no location, locations stays undefined (removed/absent)
143
+ }
144
+ else if (eventData?.locations && eventData.locations.length > 0) {
145
+ record.locations = eventData.locations;
146
+ }
147
+ const existingPrefs = (record.preferences ??
148
+ {});
149
+ record.preferences = {
150
+ ...existingPrefs,
151
+ showInDiscovery: visibility !== 'unlisted'
152
+ };
153
+ return record;
154
+ }
@@ -0,0 +1,39 @@
1
+ export type EventMode = 'inperson' | 'virtual' | 'hybrid';
2
+ export type Visibility = 'public' | 'private' | 'unlisted';
3
+ export interface EventLocation {
4
+ street?: string;
5
+ locality?: string;
6
+ region?: string;
7
+ country?: string;
8
+ }
9
+ /**
10
+ * Optional autofill payload for a brand-new event. EventEditor populates its
11
+ * fields from this on mount (only when there is no `eventData`), while leaving
12
+ * `isNew` true so the save path still treats the result as a creation. Use
13
+ * `additionalData` to carry atmo-specific extras (e.g. an external source link
14
+ * + rsvp mode) into the saved record.
15
+ */
16
+ export interface EventEditorPrefill {
17
+ name?: string;
18
+ description?: string;
19
+ /** ISO 8601 string. */
20
+ startsAt?: string;
21
+ /** ISO 8601 string. */
22
+ endsAt?: string;
23
+ timezone?: string;
24
+ mode?: EventMode;
25
+ location?: EventLocation;
26
+ links?: Array<{
27
+ uri: string;
28
+ name: string;
29
+ }>;
30
+ additionalData?: Record<string, unknown>;
31
+ /**
32
+ * Pre-supplied cover image. When set, the editor uses this instead of
33
+ * auto-generating a preset thumbnail and the file is uploaded as a blob on
34
+ * save.
35
+ */
36
+ thumbnailFile?: File;
37
+ }
38
+ export declare function stripModePrefix(modeStr: string): EventMode;
39
+ export declare function getLocationDisplayString(loc: EventLocation): string;
@@ -0,0 +1,9 @@
1
+ export function stripModePrefix(modeStr) {
2
+ const stripped = modeStr.replace('community.lexicon.calendar.event#', '');
3
+ if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson')
4
+ return stripped;
5
+ return 'inperson';
6
+ }
7
+ export function getLocationDisplayString(loc) {
8
+ return [loc.street, loc.locality, loc.region, loc.country].filter(Boolean).join(', ');
9
+ }