The smokesignal.events web application
at main 626 lines 19 kB view raw
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}