@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,144 @@
1
+ <script lang="ts">
2
+ import { Button, Input, PopoverContent, PopoverRoot, PopoverTrigger } from '@foxui/core';
3
+ import { validateLink } from '../cal/helper.js';
4
+
5
+ type Link = { uri: string; name: string };
6
+
7
+ let { links = $bindable() }: { links: Link[] } = $props();
8
+
9
+ let showPopup = $state(false);
10
+ let newUri = $state('');
11
+ let newName = $state('');
12
+ let error = $state('');
13
+
14
+ function addLink() {
15
+ const raw = newUri.trim();
16
+ if (!raw) return;
17
+ const uri = validateLink(raw);
18
+ if (!uri) {
19
+ error = 'Please enter a valid URL';
20
+ return;
21
+ }
22
+ links.push({ uri, name: newName.trim() });
23
+ newUri = '';
24
+ newName = '';
25
+ error = '';
26
+ showPopup = false;
27
+ }
28
+
29
+ function removeLink(index: number) {
30
+ links.splice(index, 1);
31
+ }
32
+
33
+ function cancel() {
34
+ showPopup = false;
35
+ error = '';
36
+ newUri = '';
37
+ newName = '';
38
+ }
39
+ </script>
40
+
41
+ <div>
42
+ <p class="text-base-500 dark:text-base-400 mb-4 text-xs font-semibold tracking-wider uppercase">
43
+ Links
44
+ </p>
45
+ <div class="space-y-3">
46
+ {#each links as link, i (i)}
47
+ <div class="group flex items-center gap-1.5">
48
+ <svg
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ fill="none"
51
+ viewBox="0 0 24 24"
52
+ stroke-width="1.5"
53
+ stroke="currentColor"
54
+ class="text-base-700 dark:text-base-300 size-3.5 shrink-0"
55
+ >
56
+ <path
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
60
+ />
61
+ </svg>
62
+ <span class="text-base-700 dark:text-base-300 truncate text-sm">
63
+ {link.name || link.uri.replace(/^https?:\/\//, '')}
64
+ </span>
65
+ <Button
66
+ variant="ghost"
67
+ size="iconSm"
68
+ onclick={() => removeLink(i)}
69
+ class="ml-auto shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
70
+ >
71
+ <svg
72
+ xmlns="http://www.w3.org/2000/svg"
73
+ viewBox="0 0 20 20"
74
+ fill="currentColor"
75
+ class="size-3.5"
76
+ >
77
+ <path
78
+ 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"
79
+ />
80
+ </svg>
81
+ </Button>
82
+ </div>
83
+ {/each}
84
+ </div>
85
+
86
+ <div class="mt-3">
87
+ <PopoverRoot bind:open={showPopup}>
88
+ <PopoverTrigger>
89
+ <Button size="sm">
90
+ <svg
91
+ xmlns="http://www.w3.org/2000/svg"
92
+ fill="none"
93
+ viewBox="0 0 24 24"
94
+ stroke-width="1.5"
95
+ stroke="currentColor"
96
+ class="size-4"
97
+ >
98
+ <path
99
+ stroke-linecap="round"
100
+ stroke-linejoin="round"
101
+ d="M12 4.5v15m7.5-7.5h-15"
102
+ />
103
+ </svg>
104
+ Add link
105
+ </Button>
106
+ </PopoverTrigger>
107
+ <PopoverContent side="bottom" sideOffset={8} class="w-64 p-3">
108
+ <Input
109
+ type="url"
110
+ bind:value={newUri}
111
+ placeholder="https://..."
112
+ variant="secondary"
113
+ class="mb-2"
114
+ onkeydown={(e) => {
115
+ if (e.key === 'Enter') {
116
+ e.preventDefault();
117
+ addLink();
118
+ }
119
+ }}
120
+ />
121
+ <Input
122
+ type="text"
123
+ bind:value={newName}
124
+ placeholder="Label (optional)"
125
+ variant="secondary"
126
+ class="mb-2"
127
+ onkeydown={(e) => {
128
+ if (e.key === 'Enter') {
129
+ e.preventDefault();
130
+ addLink();
131
+ }
132
+ }}
133
+ />
134
+ {#if error}
135
+ <p class="mb-2 text-xs text-red-500">{error}</p>
136
+ {/if}
137
+ <div class="flex justify-end gap-2">
138
+ <Button variant="ghost" size="sm" onclick={cancel}>Cancel</Button>
139
+ <Button onclick={addLink} size="sm" disabled={!newUri.trim()}>Add</Button>
140
+ </div>
141
+ </PopoverContent>
142
+ </PopoverRoot>
143
+ </div>
144
+ </div>
@@ -0,0 +1,10 @@
1
+ type Link = {
2
+ uri: string;
3
+ name: string;
4
+ };
5
+ type $$ComponentProps = {
6
+ links: Link[];
7
+ };
8
+ declare const LinksSection: import("svelte").Component<$$ComponentProps, {}, "links">;
9
+ type LinksSection = ReturnType<typeof LinksSection>;
10
+ export default LinksSection;
@@ -0,0 +1,215 @@
1
+ <script lang="ts">
2
+ import { Button, Input, Modal } from '@foxui/core';
3
+ import { getLocationDisplayString, type EventLocation } from './types';
4
+
5
+ let {
6
+ location = $bindable(),
7
+ locationChanged = $bindable()
8
+ }: {
9
+ location: EventLocation | null;
10
+ locationChanged: boolean;
11
+ } = $props();
12
+
13
+ let showModal = $state(false);
14
+ let searchText = $state('');
15
+ let searching = $state(false);
16
+ let error = $state('');
17
+ let result: { displayName: string; location: EventLocation } | null = $state(null);
18
+
19
+ async function search() {
20
+ const q = searchText.trim();
21
+ if (!q) return;
22
+ error = '';
23
+ searching = true;
24
+ result = null;
25
+
26
+ try {
27
+ const response = await fetch('/api/geocoding?q=' + encodeURIComponent(q));
28
+ if (!response.ok) throw new Error('response not ok');
29
+ const data: Record<string, unknown> = await response.json();
30
+ if (!data || data.error) throw new Error('no results');
31
+
32
+ const addr = (data.address || {}) as Record<string, string>;
33
+ const road = addr.road || '';
34
+ const houseNumber = addr.house_number || '';
35
+ const street = road ? (houseNumber ? `${road} ${houseNumber}` : road) : '';
36
+ const locality =
37
+ addr.city || addr.town || addr.village || addr.municipality || addr.hamlet || '';
38
+ const region = addr.state || addr.county || '';
39
+ const country = addr.country || '';
40
+
41
+ result = {
42
+ displayName: (data.display_name as string) || q,
43
+ location: {
44
+ ...(street && { street }),
45
+ ...(locality && { locality }),
46
+ ...(region && { region }),
47
+ ...(country && { country })
48
+ }
49
+ };
50
+ } catch {
51
+ error = "Couldn't find that location.";
52
+ } finally {
53
+ searching = false;
54
+ }
55
+ }
56
+
57
+ function confirm() {
58
+ if (result) {
59
+ location = result.location;
60
+ locationChanged = true;
61
+ }
62
+ showModal = false;
63
+ searchText = '';
64
+ result = null;
65
+ error = '';
66
+ }
67
+
68
+ function remove() {
69
+ location = null;
70
+ locationChanged = true;
71
+ }
72
+ </script>
73
+
74
+ {#if location}
75
+ <div class="mb-6 flex items-center gap-4">
76
+ <div
77
+ class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border"
78
+ >
79
+ <svg
80
+ xmlns="http://www.w3.org/2000/svg"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ stroke-width="1.5"
84
+ stroke="currentColor"
85
+ class="text-base-900 dark:text-base-200 size-5"
86
+ >
87
+ <path
88
+ stroke-linecap="round"
89
+ stroke-linejoin="round"
90
+ d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
91
+ />
92
+ <path
93
+ stroke-linecap="round"
94
+ stroke-linejoin="round"
95
+ d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
96
+ />
97
+ </svg>
98
+ </div>
99
+ <p class="text-base-900 dark:text-base-50 flex-1 font-semibold">
100
+ {getLocationDisplayString(location)}
101
+ </p>
102
+ <Button variant="ghost" size="iconSm" onclick={remove} class="shrink-0">
103
+ <svg
104
+ xmlns="http://www.w3.org/2000/svg"
105
+ viewBox="0 0 20 20"
106
+ fill="currentColor"
107
+ class="size-3.5"
108
+ >
109
+ <path
110
+ 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"
111
+ />
112
+ </svg>
113
+ </Button>
114
+ </div>
115
+ {:else}
116
+ <div class="mb-6">
117
+ <Button variant="secondary" onclick={() => (showModal = true)}>
118
+ <svg
119
+ xmlns="http://www.w3.org/2000/svg"
120
+ fill="none"
121
+ viewBox="0 0 24 24"
122
+ stroke-width="1.5"
123
+ stroke="currentColor"
124
+ class="size-4"
125
+ >
126
+ <path
127
+ stroke-linecap="round"
128
+ stroke-linejoin="round"
129
+ d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
130
+ />
131
+ <path
132
+ stroke-linecap="round"
133
+ stroke-linejoin="round"
134
+ d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
135
+ />
136
+ </svg>
137
+ Add location
138
+ </Button>
139
+ </div>
140
+ {/if}
141
+
142
+ <Modal bind:open={showModal}>
143
+ <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add location</p>
144
+ <form
145
+ onsubmit={(e) => {
146
+ e.preventDefault();
147
+ search();
148
+ }}
149
+ class="mt-2"
150
+ >
151
+ <div class="flex gap-2">
152
+ <Input type="text" class="flex-1" bind:value={searchText} />
153
+ <Button type="submit" disabled={searching || !searchText.trim()}>
154
+ {searching ? 'Searching...' : 'Search'}
155
+ </Button>
156
+ </div>
157
+ </form>
158
+
159
+ {#if error}
160
+ <p class="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
161
+ {/if}
162
+
163
+ {#if result}
164
+ <div
165
+ class="border-base-200 dark:border-base-700 bg-base-50 dark:bg-base-900 mt-4 overflow-hidden rounded-xl border p-4"
166
+ >
167
+ <div class="flex items-start gap-3">
168
+ <svg
169
+ xmlns="http://www.w3.org/2000/svg"
170
+ fill="none"
171
+ viewBox="0 0 24 24"
172
+ stroke-width="1.5"
173
+ stroke="currentColor"
174
+ class="text-base-500 mt-0.5 size-5 shrink-0"
175
+ >
176
+ <path
177
+ stroke-linecap="round"
178
+ stroke-linejoin="round"
179
+ d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
180
+ />
181
+ <path
182
+ stroke-linecap="round"
183
+ stroke-linejoin="round"
184
+ d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
185
+ />
186
+ </svg>
187
+ <div class="min-w-0 flex-1">
188
+ <p class="text-base-900 dark:text-base-50 font-medium">
189
+ {getLocationDisplayString(result.location)}
190
+ </p>
191
+ <p class="text-base-500 dark:text-base-400 mt-0.5 truncate text-xs">
192
+ {result.displayName}
193
+ </p>
194
+ </div>
195
+ </div>
196
+ <div class="mt-4 flex justify-end">
197
+ <Button onclick={confirm}>Use this location</Button>
198
+ </div>
199
+ </div>
200
+ {/if}
201
+
202
+ <p class="text-base-400 dark:text-base-500 mt-4 text-xs">
203
+ Geocoding by <a
204
+ href="https://nominatim.openstreetmap.org/"
205
+ class="hover:text-base-600 dark:hover:text-base-400 underline"
206
+ target="_blank">Nominatim</a
207
+ >
208
+ / &copy;
209
+ <a
210
+ href="https://www.openstreetmap.org/copyright"
211
+ class="hover:text-base-600 dark:hover:text-base-400 underline"
212
+ target="_blank">OpenStreetMap contributors</a
213
+ >
214
+ </p>
215
+ </Modal>
@@ -0,0 +1,8 @@
1
+ import { type EventLocation } from './types';
2
+ type $$ComponentProps = {
3
+ location: EventLocation | null;
4
+ locationChanged: boolean;
5
+ };
6
+ declare const LocationSection: import("svelte").Component<$$ComponentProps, {}, "location" | "locationChanged">;
7
+ type LocationSection = ReturnType<typeof LocationSection>;
8
+ export default LocationSection;
@@ -0,0 +1,270 @@
1
+ <script lang="ts">
2
+ import { Button, Checkbox, Input, Modal, ToggleGroup, ToggleGroupItem } from '@foxui/core';
3
+ import { parseDateTime } from '@internationalized/date';
4
+ import * as TID from '@atcute/tid';
5
+ import type { FlatEventRecord } from '../contrail.js';
6
+ import type { EventLocation, EventMode } from './types';
7
+ import { buildThumbnailMedia, renderPresetThumbnail } from './save';
8
+ import { hashSeed } from '../thumbnails/designs.js';
9
+ import type { EditorAdapter, EditorViewer } from './adapter';
10
+
11
+ let {
12
+ open = $bindable(),
13
+ rkey,
14
+ eventData,
15
+ isNew,
16
+ name,
17
+ startsAt,
18
+ endsAt,
19
+ mode,
20
+ timezone,
21
+ description,
22
+ links,
23
+ location,
24
+ thumbnailDateStr,
25
+ thumbnailFile,
26
+ thumbnailChanged,
27
+ selectedPreset,
28
+ accent,
29
+ adapter,
30
+ viewer
31
+ }: {
32
+ open: boolean;
33
+ rkey: string;
34
+ eventData: FlatEventRecord | null;
35
+ isNew: boolean;
36
+ name: string;
37
+ startsAt: string;
38
+ endsAt: string;
39
+ mode: EventMode;
40
+ timezone: string;
41
+ description: string;
42
+ links: Array<{ uri: string; name: string }>;
43
+ location: EventLocation | null;
44
+ thumbnailDateStr: string;
45
+ thumbnailFile: File | null;
46
+ thumbnailChanged: boolean;
47
+ selectedPreset: string | null;
48
+ accent: string;
49
+ adapter: EditorAdapter;
50
+ viewer: EditorViewer;
51
+ } = $props();
52
+
53
+ let interval = $state(1);
54
+ let unit: 'days' | 'weeks' | 'months' | 'years' = $state('weeks');
55
+ let count = $state(4);
56
+ let numberInTitle = $state(false);
57
+ let creating = $state(false);
58
+ let errorMsg: string | null = $state(null);
59
+ let created = $state(0);
60
+
61
+ let titleNumberMatch = $derived(name.match(/#?(\d+)\s*$/));
62
+ let detectedStartNumber = $derived(titleNumberMatch ? parseInt(titleNumberMatch[1]) : null);
63
+
64
+ $effect(() => {
65
+ if (detectedStartNumber !== null) numberInTitle = true;
66
+ });
67
+
68
+ async function handleCreate() {
69
+ if (!name.trim() || !startsAt || !viewer.isLoggedIn || !viewer.did) return;
70
+
71
+ creating = true;
72
+ errorMsg = null;
73
+ created = 0;
74
+
75
+ try {
76
+ // Recurring instances advance by wall-clock duration (e.g. "every week
77
+ // at 10am"), so operate on CalendarDateTime — not absolute instants —
78
+ // to preserve the wall time across DST transitions.
79
+ const baseStart = parseDateTime(startsAt);
80
+ const baseEnd = endsAt ? parseDateTime(endsAt) : null;
81
+ const durationMs = baseEnd
82
+ ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime()
83
+ : 0;
84
+ const baseName =
85
+ numberInTitle && titleNumberMatch
86
+ ? name.replace(/#?\d+\s*$/, '').trimEnd()
87
+ : name.trim();
88
+ const startNum = detectedStartNumber ?? 1;
89
+ const hasHash = titleNumberMatch ? titleNumberMatch[0].includes('#') : false;
90
+
91
+ // Generate thumbnail from preset if selected and no custom upload.
92
+ let fileForUpload = thumbnailFile;
93
+ let hasNewThumbnail = thumbnailChanged;
94
+ if (selectedPreset && !fileForUpload) {
95
+ const rendered = await renderPresetThumbnail({
96
+ design: selectedPreset,
97
+ seed: hashSeed(rkey),
98
+ name,
99
+ dateStr: thumbnailDateStr,
100
+ accent
101
+ });
102
+ if (rendered) {
103
+ fileForUpload = rendered;
104
+ hasNewThumbnail = true;
105
+ }
106
+ }
107
+
108
+ const existingMedia = (eventData?.media ?? []) as Array<Record<string, unknown>>;
109
+ const media = await buildThumbnailMedia({
110
+ isNew,
111
+ thumbnailChanged: hasNewThumbnail,
112
+ thumbnailFile: fileForUpload,
113
+ existingMedia,
114
+ uploadBlob: (blob) =>
115
+ adapter.uploadBlob(blob) as unknown as Promise<Record<string, unknown>>
116
+ });
117
+
118
+ const parentUri = `at://${viewer.did}/community.lexicon.calendar.event/${rkey}`;
119
+
120
+ for (let i = 0; i < count; i++) {
121
+ const offset = i + 1;
122
+ const step = offset * interval;
123
+ const eventStart =
124
+ unit === 'days'
125
+ ? baseStart.add({ days: step })
126
+ : unit === 'weeks'
127
+ ? baseStart.add({ weeks: step })
128
+ : unit === 'months'
129
+ ? baseStart.add({ months: step })
130
+ : baseStart.add({ years: step });
131
+
132
+ const eventStartIso = eventStart.toDate(timezone).toISOString();
133
+ // Preserve the original absolute duration (handles events that
134
+ // span midnight or odd wall-clock lengths correctly).
135
+ const eventEndIso = durationMs
136
+ ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString()
137
+ : null;
138
+
139
+ let eventName = baseName;
140
+ if (numberInTitle) {
141
+ const num = startNum + (i + 1);
142
+ eventName = hasHash ? `${baseName} #${num}` : `${baseName} ${num}`;
143
+ }
144
+
145
+ const newRkey = TID.now();
146
+ const record: Record<string, unknown> = {
147
+ $type: 'community.lexicon.calendar.event',
148
+ createdWith: 'https://atmo.rsvp',
149
+ name: eventName,
150
+ mode: `community.lexicon.calendar.event#${mode}`,
151
+ status: 'community.lexicon.calendar.event#scheduled',
152
+ startsAt: eventStartIso,
153
+ timezone,
154
+ createdAt: new Date().toISOString(),
155
+ recurringEventOf: parentUri
156
+ };
157
+
158
+ const trimmedDescription = description.trim();
159
+ if (trimmedDescription) record.description = trimmedDescription;
160
+ if (eventEndIso) record.endsAt = eventEndIso;
161
+ if (media) record.media = media;
162
+ if (links.length > 0) record.uris = links;
163
+ if (location) {
164
+ record.locations = [
165
+ {
166
+ $type: 'community.lexicon.location.address',
167
+ ...location
168
+ }
169
+ ];
170
+ }
171
+
172
+ try {
173
+ const result = await adapter.putRecord({
174
+ collection: 'community.lexicon.calendar.event',
175
+ rkey: newRkey,
176
+ record
177
+ });
178
+ await adapter.notifyUpdate?.(result.uri);
179
+ created = i + 1;
180
+ } catch {
181
+ errorMsg = `Failed to create event ${i + 1}. Stopping.`;
182
+ return;
183
+ }
184
+ }
185
+
186
+ open = false;
187
+ } catch (e) {
188
+ console.error('Failed to create recurring events:', e);
189
+ errorMsg = 'Failed to create recurring events. Please try again.';
190
+ } finally {
191
+ creating = false;
192
+ }
193
+ }
194
+ </script>
195
+
196
+ <Modal bind:open>
197
+ <p class="text-base-900 dark:text-base-50 text-lg font-semibold">Add recurring events</p>
198
+ <p class="text-base-500 dark:text-base-400 mt-1 text-sm">
199
+ Create multiple copies of this event at regular intervals.
200
+ </p>
201
+
202
+ <div class="mt-4 space-y-4">
203
+ <div>
204
+ <!-- svelte-ignore a11y_label_has_associated_control -->
205
+ <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
206
+ Number of events to create
207
+ </label>
208
+ <Input type="number" bind:value={count} min={1} max={52} class="w-24" />
209
+ </div>
210
+
211
+ <div>
212
+ <!-- svelte-ignore a11y_label_has_associated_control -->
213
+ <label class="text-base-700 dark:text-base-300 mb-1 block text-sm font-medium">
214
+ Repeat every
215
+ </label>
216
+ <div class="flex items-center gap-2">
217
+ <Input type="number" bind:value={interval} min={1} max={99} class="w-20" />
218
+ <ToggleGroup type="single" bind:value={unit}>
219
+ <ToggleGroupItem value="days">days</ToggleGroupItem>
220
+ <ToggleGroupItem value="weeks">weeks</ToggleGroupItem>
221
+ <ToggleGroupItem value="months">months</ToggleGroupItem>
222
+ <ToggleGroupItem value="years">years</ToggleGroupItem>
223
+ </ToggleGroup>
224
+ </div>
225
+ </div>
226
+
227
+ <div>
228
+ <div class="flex items-center gap-2">
229
+ <Checkbox bind:checked={numberInTitle} sizeVariant="sm" />
230
+ <span class="text-base-700 dark:text-base-300 text-sm font-medium">Number in title</span>
231
+ </div>
232
+ <p class="text-base-500 dark:text-base-400 mt-1 text-xs">
233
+ {#if numberInTitle && detectedStartNumber !== null}
234
+ Titles will count up from #{detectedStartNumber + 1}
235
+ {:else if numberInTitle}
236
+ A number will be appended to each title
237
+ {:else}
238
+ Append a number to each event title
239
+ {/if}
240
+ </p>
241
+ </div>
242
+ </div>
243
+
244
+ {#if errorMsg}
245
+ <p class="mt-4 text-sm text-red-600 dark:text-red-400">{errorMsg}</p>
246
+ {/if}
247
+
248
+ {#if creating && created > 0}
249
+ <p class="text-base-500 dark:text-base-400 mt-4 text-sm">
250
+ Created {created} of {count} events...
251
+ </p>
252
+ {/if}
253
+
254
+ {#if created > 0 && !creating}
255
+ <p class="mt-4 text-sm text-green-600 dark:text-green-400">
256
+ Successfully created {created} recurring events!
257
+ </p>
258
+ {/if}
259
+
260
+ <div class="mt-4 flex justify-end gap-2">
261
+ <Button variant="secondary" onclick={() => (open = false)} disabled={creating}>
262
+ {created > 0 && !creating ? 'Close' : 'Cancel'}
263
+ </Button>
264
+ {#if !created || creating}
265
+ <Button onclick={handleCreate} disabled={creating || count < 1}>
266
+ {creating ? `Creating...` : `Create ${count} event${count === 1 ? '' : 's'}`}
267
+ </Button>
268
+ {/if}
269
+ </div>
270
+ </Modal>
@@ -0,0 +1,30 @@
1
+ import type { FlatEventRecord } from '../contrail.js';
2
+ import type { EventLocation, EventMode } from './types';
3
+ import type { EditorAdapter, EditorViewer } from './adapter';
4
+ type $$ComponentProps = {
5
+ open: boolean;
6
+ rkey: string;
7
+ eventData: FlatEventRecord | null;
8
+ isNew: boolean;
9
+ name: string;
10
+ startsAt: string;
11
+ endsAt: string;
12
+ mode: EventMode;
13
+ timezone: string;
14
+ description: string;
15
+ links: Array<{
16
+ uri: string;
17
+ name: string;
18
+ }>;
19
+ location: EventLocation | null;
20
+ thumbnailDateStr: string;
21
+ thumbnailFile: File | null;
22
+ thumbnailChanged: boolean;
23
+ selectedPreset: string | null;
24
+ accent: string;
25
+ adapter: EditorAdapter;
26
+ viewer: EditorViewer;
27
+ };
28
+ declare const RecurringModal: import("svelte").Component<$$ComponentProps, {}, "open">;
29
+ type RecurringModal = ReturnType<typeof RecurringModal>;
30
+ export default RecurringModal;