/** * Event Form Alpine.js Component * * Full event creation/editing form with Alpine.js. * This is an Alpine.js component factory that returns the component definition. */ import { authFetch, authPostJson, SessionExpiredError } from '../../core/auth' import type { EventFormData, FormLink, FormLocation, LocationSuggestion } from '../../types' interface EventFormState { submitUrl: string isEditMode: boolean formData: EventFormData startDateTimeLocal: string endDateTimeLocal: string submitting: boolean submitted: boolean uploading: boolean uploadingThumbnail: boolean errorMessage: string | null eventUrl: string | null headerCropper: unknown | null thumbnailCropper: unknown | null showCropper: boolean showThumbnailCropper: boolean showDescriptionPreview: boolean descriptionPreviewHtml: string loadingPreview: boolean locationSuggestions: LocationSuggestion[] loadingSuggestions: boolean showLocationSuggestions: boolean locationFeedback: string | null locationFilter: string // Computed properties readonly addressLocations: FormLocation[] readonly geoLocations: FormLocation[] // Methods formatDateTimeLocal(date: Date): string findAddressLocationIndex(filteredIndex: number): number findGeoLocationIndex(filteredIndex: number): number addLocation(): void removeAddressLocation(filteredIndex: number): void addGeoLocation(): void removeGeoLocation(filteredIndex: number): void addLink(): void removeLink(index: number): void clearStartDateTime(): void clearEndDateTime(): void applyLocationSuggestion(suggestion: LocationSuggestion): void handleHeaderImageSelect(event: Event): void handleThumbnailImageSelect(event: Event): void removeHeader(): void removeThumbnail(): void resetForm(): void } /** * Event form Alpine.js component factory * * This function is called by Alpine.js to create the component. * Template data is passed via data attributes on the component root element. */ export function eventForm(): EventFormState & Record { return { submitUrl: '/event', isEditMode: false, formData: { name: '', description: '', status: 'scheduled', mode: 'inperson', tz: 'America/New_York', startsAt: null, endsAt: null, locations: [], links: [], headerCid: null, headerAlt: null, headerSize: null, thumbnailCid: null, thumbnailAlt: null, requireConfirmedEmail: false, sendNotifications: false, }, startDateTimeLocal: '', endDateTimeLocal: '', submitting: false, submitted: false, uploading: false, uploadingThumbnail: false, errorMessage: null, eventUrl: null, headerCropper: null, thumbnailCropper: null, showCropper: false, showThumbnailCropper: false, showDescriptionPreview: false, descriptionPreviewHtml: '', loadingPreview: false, locationSuggestions: [], loadingSuggestions: false, showLocationSuggestions: false, locationFeedback: null, locationFilter: '', init( this: EventFormState & { $el: HTMLElement $watch: (prop: string, handler: (value: unknown) => void) => void } ) { // Read configuration from data attributes const el = this.$el if (el.dataset.submitUrl) { this.submitUrl = el.dataset.submitUrl } if (el.dataset.editMode === 'true') { this.isEditMode = true } if (el.dataset.eventData) { try { this.formData = JSON.parse(el.dataset.eventData) } catch (e) { console.error('Failed to parse event data:', e) } } if (el.dataset.defaultTz) { this.formData.tz = el.dataset.defaultTz } // Convert ISO datetime to datetime-local format if we have existing data if (this.formData.startsAt) { const startDate = new Date(this.formData.startsAt) this.startDateTimeLocal = this.formatDateTimeLocal(startDate) } else { // Set default start time to next 6 PM after 3 hours from now const now = new Date() now.setHours(now.getHours() + 3) const targetDate = new Date(now) if (now.getHours() >= 18) { targetDate.setDate(targetDate.getDate() + 1) } targetDate.setHours(18, 0, 0, 0) this.startDateTimeLocal = this.formatDateTimeLocal(targetDate) } if (this.formData.endsAt) { const endDate = new Date(this.formData.endsAt) this.endDateTimeLocal = this.formatDateTimeLocal(endDate) } // Watch datetime-local inputs and update formData this.$watch('startDateTimeLocal', (value: unknown) => { if (value) { const date = new Date(value as string) this.formData.startsAt = date.toISOString() } else { this.formData.startsAt = null } }) this.$watch('endDateTimeLocal', (value: unknown) => { if (value) { const date = new Date(value as string) this.formData.endsAt = date.toISOString() } else { this.formData.endsAt = null } }) }, // Computed properties to filter locations by type get addressLocations(): FormLocation[] { return this.formData.locations.filter((loc): loc is FormLocation => loc.type === 'address') }, get geoLocations(): FormLocation[] { return this.formData.locations.filter((loc): loc is FormLocation => loc.type === 'geo') }, // Get the actual index within the filtered array addressLocationIndex(_filteredIndex: number): number { return _filteredIndex }, geoLocationIndex(_filteredIndex: number): number { return _filteredIndex }, // Find the original array index for an address location findAddressLocationIndex(filteredIndex: number): number { let count = 0 for (let i = 0; i < this.formData.locations.length; i++) { if (this.formData.locations[i].type === 'address') { if (count === filteredIndex) return i count++ } } return -1 }, // Find the original array index for a geo location findGeoLocationIndex(filteredIndex: number): number { let count = 0 for (let i = 0; i < this.formData.locations.length; i++) { if (this.formData.locations[i].type === 'geo') { if (count === filteredIndex) return i count++ } } return -1 }, formatDateTimeLocal(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}` }, get headerPreviewUrl(): string | null { return this.formData.headerCid ? `/content/${this.formData.headerCid}.png` : null }, get thumbnailPreviewUrl(): string | null { return this.formData.thumbnailCid ? `/content/${this.formData.thumbnailCid}.png` : null }, get filteredLocationSuggestions(): LocationSuggestion[] { if (!this.locationFilter.trim()) { return this.locationSuggestions } const query = this.locationFilter.toLowerCase().trim() return this.locationSuggestions.filter((suggestion) => { const searchFields = [ suggestion.name, suggestion.street, suggestion.locality, suggestion.region, suggestion.postal_code, suggestion.country, ] .filter(Boolean) .map((f) => f!.toLowerCase()) const queryTerms = query.split(/\s+/) return queryTerms.every((term) => searchFields.some((field) => field.includes(term))) }) }, addLocation(): void { this.formData.locations.push({ type: 'address', country: '', postalCode: null, region: null, locality: null, street: null, name: null, }) }, removeAddressLocation(filteredIndex: number): void { const actualIndex = this.findAddressLocationIndex(filteredIndex) if (actualIndex !== -1) { this.formData.locations.splice(actualIndex, 1) } }, addGeoLocation(): void { this.formData.locations.push({ type: 'geo', latitude: '', longitude: '', name: null, }) }, removeGeoLocation(filteredIndex: number): void { const actualIndex = this.findGeoLocationIndex(filteredIndex) if (actualIndex !== -1) { this.formData.locations.splice(actualIndex, 1) } }, addLink(): void { this.formData.links.push({ url: '', label: null, }) }, removeLink(index: number): void { this.formData.links.splice(index, 1) }, clearStartDateTime(): void { const input = document.getElementById('eventStartDateTime') as HTMLInputElement | null if (input) { input.value = '' } this.startDateTimeLocal = '' this.formData.startsAt = null }, clearEndDateTime(): void { const input = document.getElementById('eventEndDateTime') as HTMLInputElement | null if (input) { input.value = '' } this.endDateTimeLocal = '' this.formData.endsAt = null }, async fetchLocationSuggestions(): Promise { if (this.loadingSuggestions || this.locationSuggestions.length > 0) { this.showLocationSuggestions = true return } this.loadingSuggestions = true try { const response = await authFetch('/event/location-suggestions') const data = await response.json() if (response.ok && data.suggestions) { this.locationSuggestions = data.suggestions } } catch (error) { if (error instanceof SessionExpiredError) { this.errorMessage = error.message } else { console.error('Failed to fetch location suggestions:', error) } } finally { this.loadingSuggestions = false this.showLocationSuggestions = true } }, applyLocationSuggestion(suggestion: LocationSuggestion): void { const hasAddress = !!suggestion.country const hasGeo = !!(suggestion.latitude && suggestion.longitude) // Add address location if country is available if (hasAddress) { this.formData.locations.push({ type: 'address', country: suggestion.country || '', postalCode: suggestion.postal_code || null, region: suggestion.region || null, locality: suggestion.locality || null, street: suggestion.street || null, name: suggestion.name || null, }) } // Also add geo coordinates if available if (hasGeo) { this.formData.locations.push({ type: 'geo', latitude: String(suggestion.latitude), longitude: String(suggestion.longitude), name: suggestion.name || null, }) } // Show feedback about what was added if (hasAddress && hasGeo) { this.locationFeedback = 'Added address and coordinates' } else if (hasAddress) { this.locationFeedback = 'Added address' } else if (hasGeo) { this.locationFeedback = 'Added coordinates' } if (this.locationFeedback) { setTimeout(() => { this.locationFeedback = null }, 3000) } this.showLocationSuggestions = false this.locationFilter = '' }, async toggleDescriptionPreview(): Promise { if (this.showDescriptionPreview) { this.showDescriptionPreview = false return } if (!this.formData.description || this.formData.description.trim().length < 10) { alert('Description must be at least 10 characters to preview') return } this.showDescriptionPreview = true this.loadingPreview = true this.descriptionPreviewHtml = '' try { const response = await authPostJson('/event/preview-description', { description: this.formData.description, }) const data = await response.json() if (response.ok) { this.descriptionPreviewHtml = data.html } else { this.errorMessage = data.error || 'Failed to load preview' this.showDescriptionPreview = false } } catch (error) { if (error instanceof SessionExpiredError) { this.errorMessage = error.message } else { console.error('Preview error:', error) this.errorMessage = 'Failed to load preview. Please try again.' } this.showDescriptionPreview = false } finally { this.loadingPreview = false } }, // Image upload methods are kept but simplified - they rely on Cropper.js // which is loaded from CDN in the template handleHeaderImageSelect(event: Event): void { const input = event.target as HTMLInputElement const file = input.files?.[0] if (!file) return if (file.size > 3000000) { alert('Image must be smaller than 3MB') input.value = '' return } // Image cropping logic handled inline due to Cropper.js dependency console.log('Header image selected, size:', file.size) }, handleThumbnailImageSelect(event: Event): void { const input = event.target as HTMLInputElement const file = input.files?.[0] if (!file) return if (file.size > 3000000) { alert('Image must be smaller than 3MB') input.value = '' return } console.log('Thumbnail image selected, size:', file.size) }, removeHeader(): void { this.formData.headerCid = null this.formData.headerAlt = null this.formData.headerSize = null this.showCropper = false }, removeThumbnail(): void { this.formData.thumbnailCid = null this.formData.thumbnailAlt = null this.showThumbnailCropper = false }, async submitForm(): Promise { this.errorMessage = null // Validate name if (!this.formData.name || this.formData.name.trim().length < 10) { this.errorMessage = 'Event name must be at least 10 characters.' return } if (this.formData.name.trim().length > 500) { this.errorMessage = 'Event name must be no more than 500 characters.' return } // Validate description if (!this.formData.description || this.formData.description.trim().length < 10) { this.errorMessage = 'Description must be at least 10 characters.' return } if (this.formData.description.trim().length > 3000) { this.errorMessage = 'Description must be no more than 3000 characters.' return } // Validate address locations const invalidAddresses = this.addressLocations.filter( (loc) => loc.type === 'address' && (!loc.country || loc.country.trim() === '') ) if (invalidAddresses.length > 0) { this.errorMessage = 'All locations must have a country selected. Please select a country or remove the location.' return } // Validate geo locations for (let i = 0; i < this.geoLocations.length; i++) { const geo = this.geoLocations[i] if (geo.type === 'geo') { if (!geo.latitude || !geo.longitude) { this.errorMessage = `Coordinates ${i + 1} must have both latitude and longitude.` return } const lat = parseFloat(geo.latitude) const lon = parseFloat(geo.longitude) if (isNaN(lat) || lat < -90 || lat > 90) { this.errorMessage = `Coordinates ${i + 1} has invalid latitude. Must be between -90 and 90.` return } if (isNaN(lon) || lon < -180 || lon > 180) { this.errorMessage = `Coordinates ${i + 1} has invalid longitude. Must be between -180 and 180.` return } } } // Validate links const invalidLinks: string[] = [] for (let i = 0; i < this.formData.links.length; i++) { const link = this.formData.links[i] if (!link.url || link.url.trim() === '') { invalidLinks.push(`Link ${i + 1} must have a URL or be removed.`) continue } if (!link.url.startsWith('https://')) { invalidLinks.push(`Link ${i + 1} must be an HTTPS URL (starting with https://).`) continue } try { new URL(link.url) } catch (e) { invalidLinks.push(`Link ${i + 1} has an invalid URL format.`) } } if (invalidLinks.length > 0) { this.errorMessage = invalidLinks.join(' ') return } this.submitting = true // Clean up locations this.formData.locations = this.formData.locations.filter((loc) => { if (loc.type === 'address') { return loc.country && loc.country.trim() !== '' } else if (loc.type === 'geo') { return loc.latitude && loc.longitude } return false }) // Clean up links this.formData.links = this.formData.links.filter((link) => link.url) // Ensure empty string end dates are converted to null if (this.formData.endsAt === '') { this.formData.endsAt = null } try { const response = await authPostJson(this.submitUrl, this.formData) const data = await response.json() if (response.ok) { this.submitted = true this.eventUrl = data.url } else { this.errorMessage = data.error || 'Failed to ' + (this.isEditMode ? 'update' : 'create') + ' event. Please try again.' } } catch (error) { if (error instanceof SessionExpiredError) { this.errorMessage = error.message } else { console.error('Submit error:', error) this.errorMessage = 'Network error. Please check your connection and try again.' } } finally { this.submitting = false } }, resetForm(): void { this.formData = { name: '', description: '', status: 'scheduled', mode: 'inperson', tz: this.formData.tz, startsAt: null, endsAt: null, locations: [], links: [], headerCid: null, headerAlt: null, headerSize: null, thumbnailCid: null, thumbnailAlt: null, requireConfirmedEmail: false, sendNotifications: false, } this.startDateTimeLocal = '' this.endDateTimeLocal = '' this.submitted = false this.eventUrl = null this.errorMessage = null }, } }