The smokesignal.events web application
1/**
2 * Event Form Alpine.js Component
3 *
4 * Full event creation/editing form with Alpine.js.
5 * This is an Alpine.js component factory that returns the component definition.
6 */
7
8import { authFetch, authPostJson, SessionExpiredError } from '../../core/auth'
9import type { EventFormData, FormLink, FormLocation, LocationSuggestion } from '../../types'
10
11interface EventFormState {
12 submitUrl: string
13 isEditMode: boolean
14 formData: EventFormData
15 startDateTimeLocal: string
16 endDateTimeLocal: string
17 submitting: boolean
18 submitted: boolean
19 uploading: boolean
20 uploadingThumbnail: boolean
21 errorMessage: string | null
22 eventUrl: string | null
23 headerCropper: unknown | null
24 thumbnailCropper: unknown | null
25 showCropper: boolean
26 showThumbnailCropper: boolean
27 showDescriptionPreview: boolean
28 descriptionPreviewHtml: string
29 loadingPreview: boolean
30 locationSuggestions: LocationSuggestion[]
31 loadingSuggestions: boolean
32 showLocationSuggestions: boolean
33 locationFeedback: string | null
34 locationFilter: string
35 // Computed properties
36 readonly addressLocations: FormLocation[]
37 readonly geoLocations: FormLocation[]
38 // Methods
39 formatDateTimeLocal(date: Date): string
40 findAddressLocationIndex(filteredIndex: number): number
41 findGeoLocationIndex(filteredIndex: number): number
42 addLocation(): void
43 removeAddressLocation(filteredIndex: number): void
44 addGeoLocation(): void
45 removeGeoLocation(filteredIndex: number): void
46 addLink(): void
47 removeLink(index: number): void
48 clearStartDateTime(): void
49 clearEndDateTime(): void
50 applyLocationSuggestion(suggestion: LocationSuggestion): void
51 handleHeaderImageSelect(event: Event): void
52 handleThumbnailImageSelect(event: Event): void
53 removeHeader(): void
54 removeThumbnail(): void
55 resetForm(): void
56}
57
58/**
59 * Event form Alpine.js component factory
60 *
61 * This function is called by Alpine.js to create the component.
62 * Template data is passed via data attributes on the component root element.
63 */
64export function eventForm(): EventFormState & Record<string, unknown> {
65 return {
66 submitUrl: '/event',
67 isEditMode: false,
68 formData: {
69 name: '',
70 description: '',
71 status: 'scheduled',
72 mode: 'inperson',
73 tz: 'America/New_York',
74 startsAt: null,
75 endsAt: null,
76 locations: [],
77 links: [],
78 headerCid: null,
79 headerAlt: null,
80 headerSize: null,
81 thumbnailCid: null,
82 thumbnailAlt: null,
83 requireConfirmedEmail: false,
84 sendNotifications: false,
85 },
86 startDateTimeLocal: '',
87 endDateTimeLocal: '',
88 submitting: false,
89 submitted: false,
90 uploading: false,
91 uploadingThumbnail: false,
92 errorMessage: null,
93 eventUrl: null,
94 headerCropper: null,
95 thumbnailCropper: null,
96 showCropper: false,
97 showThumbnailCropper: false,
98 showDescriptionPreview: false,
99 descriptionPreviewHtml: '',
100 loadingPreview: false,
101 locationSuggestions: [],
102 loadingSuggestions: false,
103 showLocationSuggestions: false,
104 locationFeedback: null,
105 locationFilter: '',
106
107 init(
108 this: EventFormState & {
109 $el: HTMLElement
110 $watch: (prop: string, handler: (value: unknown) => void) => void
111 }
112 ) {
113 // Read configuration from data attributes
114 const el = this.$el
115 if (el.dataset.submitUrl) {
116 this.submitUrl = el.dataset.submitUrl
117 }
118 if (el.dataset.editMode === 'true') {
119 this.isEditMode = true
120 }
121 if (el.dataset.eventData) {
122 try {
123 this.formData = JSON.parse(el.dataset.eventData)
124 } catch (e) {
125 console.error('Failed to parse event data:', e)
126 }
127 }
128 if (el.dataset.defaultTz) {
129 this.formData.tz = el.dataset.defaultTz
130 }
131
132 // Convert ISO datetime to datetime-local format if we have existing data
133 if (this.formData.startsAt) {
134 const startDate = new Date(this.formData.startsAt)
135 this.startDateTimeLocal = this.formatDateTimeLocal(startDate)
136 } else {
137 // Set default start time to next 6 PM after 3 hours from now
138 const now = new Date()
139 now.setHours(now.getHours() + 3)
140
141 const targetDate = new Date(now)
142 if (now.getHours() >= 18) {
143 targetDate.setDate(targetDate.getDate() + 1)
144 }
145 targetDate.setHours(18, 0, 0, 0)
146
147 this.startDateTimeLocal = this.formatDateTimeLocal(targetDate)
148 }
149
150 if (this.formData.endsAt) {
151 const endDate = new Date(this.formData.endsAt)
152 this.endDateTimeLocal = this.formatDateTimeLocal(endDate)
153 }
154
155 // Watch datetime-local inputs and update formData
156 this.$watch('startDateTimeLocal', (value: unknown) => {
157 if (value) {
158 const date = new Date(value as string)
159 this.formData.startsAt = date.toISOString()
160 } else {
161 this.formData.startsAt = null
162 }
163 })
164
165 this.$watch('endDateTimeLocal', (value: unknown) => {
166 if (value) {
167 const date = new Date(value as string)
168 this.formData.endsAt = date.toISOString()
169 } else {
170 this.formData.endsAt = null
171 }
172 })
173 },
174
175 // Computed properties to filter locations by type
176 get addressLocations(): FormLocation[] {
177 return this.formData.locations.filter((loc): loc is FormLocation => loc.type === 'address')
178 },
179
180 get geoLocations(): FormLocation[] {
181 return this.formData.locations.filter((loc): loc is FormLocation => loc.type === 'geo')
182 },
183
184 // Get the actual index within the filtered array
185 addressLocationIndex(_filteredIndex: number): number {
186 return _filteredIndex
187 },
188
189 geoLocationIndex(_filteredIndex: number): number {
190 return _filteredIndex
191 },
192
193 // Find the original array index for an address location
194 findAddressLocationIndex(filteredIndex: number): number {
195 let count = 0
196 for (let i = 0; i < this.formData.locations.length; i++) {
197 if (this.formData.locations[i].type === 'address') {
198 if (count === filteredIndex) return i
199 count++
200 }
201 }
202 return -1
203 },
204
205 // Find the original array index for a geo location
206 findGeoLocationIndex(filteredIndex: number): number {
207 let count = 0
208 for (let i = 0; i < this.formData.locations.length; i++) {
209 if (this.formData.locations[i].type === 'geo') {
210 if (count === filteredIndex) return i
211 count++
212 }
213 }
214 return -1
215 },
216
217 formatDateTimeLocal(date: Date): string {
218 const year = date.getFullYear()
219 const month = String(date.getMonth() + 1).padStart(2, '0')
220 const day = String(date.getDate()).padStart(2, '0')
221 const hours = String(date.getHours()).padStart(2, '0')
222 const minutes = String(date.getMinutes()).padStart(2, '0')
223 return `${year}-${month}-${day}T${hours}:${minutes}`
224 },
225
226 get headerPreviewUrl(): string | null {
227 return this.formData.headerCid ? `/content/${this.formData.headerCid}.png` : null
228 },
229
230 get thumbnailPreviewUrl(): string | null {
231 return this.formData.thumbnailCid ? `/content/${this.formData.thumbnailCid}.png` : null
232 },
233
234 get filteredLocationSuggestions(): LocationSuggestion[] {
235 if (!this.locationFilter.trim()) {
236 return this.locationSuggestions
237 }
238 const query = this.locationFilter.toLowerCase().trim()
239 return this.locationSuggestions.filter((suggestion) => {
240 const searchFields = [
241 suggestion.name,
242 suggestion.street,
243 suggestion.locality,
244 suggestion.region,
245 suggestion.postal_code,
246 suggestion.country,
247 ]
248 .filter(Boolean)
249 .map((f) => f!.toLowerCase())
250
251 const queryTerms = query.split(/\s+/)
252 return queryTerms.every((term) => searchFields.some((field) => field.includes(term)))
253 })
254 },
255
256 addLocation(): void {
257 this.formData.locations.push({
258 type: 'address',
259 country: '',
260 postalCode: null,
261 region: null,
262 locality: null,
263 street: null,
264 name: null,
265 })
266 },
267
268 removeAddressLocation(filteredIndex: number): void {
269 const actualIndex = this.findAddressLocationIndex(filteredIndex)
270 if (actualIndex !== -1) {
271 this.formData.locations.splice(actualIndex, 1)
272 }
273 },
274
275 addGeoLocation(): void {
276 this.formData.locations.push({
277 type: 'geo',
278 latitude: '',
279 longitude: '',
280 name: null,
281 })
282 },
283
284 removeGeoLocation(filteredIndex: number): void {
285 const actualIndex = this.findGeoLocationIndex(filteredIndex)
286 if (actualIndex !== -1) {
287 this.formData.locations.splice(actualIndex, 1)
288 }
289 },
290
291 addLink(): void {
292 this.formData.links.push({
293 url: '',
294 label: null,
295 })
296 },
297
298 removeLink(index: number): void {
299 this.formData.links.splice(index, 1)
300 },
301
302 clearStartDateTime(): void {
303 const input = document.getElementById('eventStartDateTime') as HTMLInputElement | null
304 if (input) {
305 input.value = ''
306 }
307 this.startDateTimeLocal = ''
308 this.formData.startsAt = null
309 },
310
311 clearEndDateTime(): void {
312 const input = document.getElementById('eventEndDateTime') as HTMLInputElement | null
313 if (input) {
314 input.value = ''
315 }
316 this.endDateTimeLocal = ''
317 this.formData.endsAt = null
318 },
319
320 async fetchLocationSuggestions(): Promise<void> {
321 if (this.loadingSuggestions || this.locationSuggestions.length > 0) {
322 this.showLocationSuggestions = true
323 return
324 }
325 this.loadingSuggestions = true
326 try {
327 const response = await authFetch('/event/location-suggestions')
328 const data = await response.json()
329 if (response.ok && data.suggestions) {
330 this.locationSuggestions = data.suggestions
331 }
332 } catch (error) {
333 if (error instanceof SessionExpiredError) {
334 this.errorMessage = error.message
335 } else {
336 console.error('Failed to fetch location suggestions:', error)
337 }
338 } finally {
339 this.loadingSuggestions = false
340 this.showLocationSuggestions = true
341 }
342 },
343
344 applyLocationSuggestion(suggestion: LocationSuggestion): void {
345 const hasAddress = !!suggestion.country
346 const hasGeo = !!(suggestion.latitude && suggestion.longitude)
347
348 // Add address location if country is available
349 if (hasAddress) {
350 this.formData.locations.push({
351 type: 'address',
352 country: suggestion.country || '',
353 postalCode: suggestion.postal_code || null,
354 region: suggestion.region || null,
355 locality: suggestion.locality || null,
356 street: suggestion.street || null,
357 name: suggestion.name || null,
358 })
359 }
360 // Also add geo coordinates if available
361 if (hasGeo) {
362 this.formData.locations.push({
363 type: 'geo',
364 latitude: String(suggestion.latitude),
365 longitude: String(suggestion.longitude),
366 name: suggestion.name || null,
367 })
368 }
369
370 // Show feedback about what was added
371 if (hasAddress && hasGeo) {
372 this.locationFeedback = 'Added address and coordinates'
373 } else if (hasAddress) {
374 this.locationFeedback = 'Added address'
375 } else if (hasGeo) {
376 this.locationFeedback = 'Added coordinates'
377 }
378
379 if (this.locationFeedback) {
380 setTimeout(() => {
381 this.locationFeedback = null
382 }, 3000)
383 }
384
385 this.showLocationSuggestions = false
386 this.locationFilter = ''
387 },
388
389 async toggleDescriptionPreview(): Promise<void> {
390 if (this.showDescriptionPreview) {
391 this.showDescriptionPreview = false
392 return
393 }
394
395 if (!this.formData.description || this.formData.description.trim().length < 10) {
396 alert('Description must be at least 10 characters to preview')
397 return
398 }
399
400 this.showDescriptionPreview = true
401 this.loadingPreview = true
402 this.descriptionPreviewHtml = ''
403
404 try {
405 const response = await authPostJson('/event/preview-description', {
406 description: this.formData.description,
407 })
408
409 const data = await response.json()
410
411 if (response.ok) {
412 this.descriptionPreviewHtml = data.html
413 } else {
414 this.errorMessage = data.error || 'Failed to load preview'
415 this.showDescriptionPreview = false
416 }
417 } catch (error) {
418 if (error instanceof SessionExpiredError) {
419 this.errorMessage = error.message
420 } else {
421 console.error('Preview error:', error)
422 this.errorMessage = 'Failed to load preview. Please try again.'
423 }
424 this.showDescriptionPreview = false
425 } finally {
426 this.loadingPreview = false
427 }
428 },
429
430 // Image upload methods are kept but simplified - they rely on Cropper.js
431 // which is loaded from CDN in the template
432 handleHeaderImageSelect(event: Event): void {
433 const input = event.target as HTMLInputElement
434 const file = input.files?.[0]
435 if (!file) return
436
437 if (file.size > 3000000) {
438 alert('Image must be smaller than 3MB')
439 input.value = ''
440 return
441 }
442
443 // Image cropping logic handled inline due to Cropper.js dependency
444 console.log('Header image selected, size:', file.size)
445 },
446
447 handleThumbnailImageSelect(event: Event): void {
448 const input = event.target as HTMLInputElement
449 const file = input.files?.[0]
450 if (!file) return
451
452 if (file.size > 3000000) {
453 alert('Image must be smaller than 3MB')
454 input.value = ''
455 return
456 }
457
458 console.log('Thumbnail image selected, size:', file.size)
459 },
460
461 removeHeader(): void {
462 this.formData.headerCid = null
463 this.formData.headerAlt = null
464 this.formData.headerSize = null
465 this.showCropper = false
466 },
467
468 removeThumbnail(): void {
469 this.formData.thumbnailCid = null
470 this.formData.thumbnailAlt = null
471 this.showThumbnailCropper = false
472 },
473
474 async submitForm(): Promise<void> {
475 this.errorMessage = null
476
477 // Validate name
478 if (!this.formData.name || this.formData.name.trim().length < 10) {
479 this.errorMessage = 'Event name must be at least 10 characters.'
480 return
481 }
482 if (this.formData.name.trim().length > 500) {
483 this.errorMessage = 'Event name must be no more than 500 characters.'
484 return
485 }
486
487 // Validate description
488 if (!this.formData.description || this.formData.description.trim().length < 10) {
489 this.errorMessage = 'Description must be at least 10 characters.'
490 return
491 }
492 if (this.formData.description.trim().length > 3000) {
493 this.errorMessage = 'Description must be no more than 3000 characters.'
494 return
495 }
496
497 // Validate address locations
498 const invalidAddresses = this.addressLocations.filter(
499 (loc) => loc.type === 'address' && (!loc.country || loc.country.trim() === '')
500 )
501 if (invalidAddresses.length > 0) {
502 this.errorMessage =
503 'All locations must have a country selected. Please select a country or remove the location.'
504 return
505 }
506
507 // Validate geo locations
508 for (let i = 0; i < this.geoLocations.length; i++) {
509 const geo = this.geoLocations[i]
510 if (geo.type === 'geo') {
511 if (!geo.latitude || !geo.longitude) {
512 this.errorMessage = `Coordinates ${i + 1} must have both latitude and longitude.`
513 return
514 }
515 const lat = parseFloat(geo.latitude)
516 const lon = parseFloat(geo.longitude)
517 if (isNaN(lat) || lat < -90 || lat > 90) {
518 this.errorMessage = `Coordinates ${i + 1} has invalid latitude. Must be between -90 and 90.`
519 return
520 }
521 if (isNaN(lon) || lon < -180 || lon > 180) {
522 this.errorMessage = `Coordinates ${i + 1} has invalid longitude. Must be between -180 and 180.`
523 return
524 }
525 }
526 }
527
528 // Validate links
529 const invalidLinks: string[] = []
530 for (let i = 0; i < this.formData.links.length; i++) {
531 const link = this.formData.links[i]
532
533 if (!link.url || link.url.trim() === '') {
534 invalidLinks.push(`Link ${i + 1} must have a URL or be removed.`)
535 continue
536 }
537
538 if (!link.url.startsWith('https://')) {
539 invalidLinks.push(`Link ${i + 1} must be an HTTPS URL (starting with https://).`)
540 continue
541 }
542
543 try {
544 new URL(link.url)
545 } catch (e) {
546 invalidLinks.push(`Link ${i + 1} has an invalid URL format.`)
547 }
548 }
549
550 if (invalidLinks.length > 0) {
551 this.errorMessage = invalidLinks.join(' ')
552 return
553 }
554
555 this.submitting = true
556
557 // Clean up locations
558 this.formData.locations = this.formData.locations.filter((loc) => {
559 if (loc.type === 'address') {
560 return loc.country && loc.country.trim() !== ''
561 } else if (loc.type === 'geo') {
562 return loc.latitude && loc.longitude
563 }
564 return false
565 })
566
567 // Clean up links
568 this.formData.links = this.formData.links.filter((link) => link.url)
569
570 // Ensure empty string end dates are converted to null
571 if (this.formData.endsAt === '') {
572 this.formData.endsAt = null
573 }
574
575 try {
576 const response = await authPostJson(this.submitUrl, this.formData)
577
578 const data = await response.json()
579
580 if (response.ok) {
581 this.submitted = true
582 this.eventUrl = data.url
583 } else {
584 this.errorMessage =
585 data.error ||
586 'Failed to ' + (this.isEditMode ? 'update' : 'create') + ' event. Please try again.'
587 }
588 } catch (error) {
589 if (error instanceof SessionExpiredError) {
590 this.errorMessage = error.message
591 } else {
592 console.error('Submit error:', error)
593 this.errorMessage = 'Network error. Please check your connection and try again.'
594 }
595 } finally {
596 this.submitting = false
597 }
598 },
599
600 resetForm(): void {
601 this.formData = {
602 name: '',
603 description: '',
604 status: 'scheduled',
605 mode: 'inperson',
606 tz: this.formData.tz,
607 startsAt: null,
608 endsAt: null,
609 locations: [],
610 links: [],
611 headerCid: null,
612 headerAlt: null,
613 headerSize: null,
614 thumbnailCid: null,
615 thumbnailAlt: null,
616 requireConfirmedEmail: false,
617 sendNotifications: false,
618 }
619 this.startDateTimeLocal = ''
620 this.endDateTimeLocal = ''
621 this.submitted = false
622 this.eventUrl = null
623 this.errorMessage = null
624 },
625 }
626}