The smokesignal.events web application
at main 300 lines 8.5 kB view raw
1/** 2 * LFG Form Alpine.js Component 3 * 4 * Looking For Group form with map location selection. 5 * This is an Alpine.js component factory that returns the component definition. 6 */ 7 8import type { Map as MaplibreMap, MapMouseEvent } from 'maplibre-gl' 9import { authPostJson, SessionExpiredError } from '../../core/auth' 10import { 11 loadMapLibraries, 12 createMap, 13 h3ToGeoJsonFeature, 14 addHexagonLayers, 15 updateHexagonSource, 16 toMapCenter, 17 getH3Module, 18} from '../maps/map-utils' 19 20// H3 resolution 7 gives ~5 km² area (roughly 1.2km edge) 21const H3_RESOLUTION = 7 22 23interface LfgFormState { 24 latitude: string 25 longitude: string 26 h3Index: string 27 tags: string[] 28 tagInput: string 29 durationHours: string 30 map: MaplibreMap | null 31 submitting: boolean 32 errors: { 33 location: string | null 34 tags: string | null 35 duration: string | null 36 general: string | null 37 } 38 // Methods 39 init(): void 40 initMap(): Promise<void> 41 handleMapClick(lng: number, lat: number): void 42 selectLocation(h3Index: string): void 43 clearLocation(): void 44 addTag(): void 45 addTagFromSuggestion(tag: string): void 46 removeTag(index: number): void 47 canSubmit(): boolean 48 clearErrors(): void 49 submitForm(): Promise<void> 50} 51 52type AlpineThis = LfgFormState & { 53 $el: HTMLElement 54 $nextTick: (fn: () => void) => void 55} 56 57/** 58 * LFG form Alpine.js component factory 59 * 60 * This function is called by Alpine.js to create the component. 61 */ 62export function lfgForm(): LfgFormState { 63 return { 64 latitude: '', 65 longitude: '', 66 h3Index: '', 67 tags: [], 68 tagInput: '', 69 durationHours: '48', 70 map: null, 71 submitting: false, 72 errors: { 73 location: null, 74 tags: null, 75 duration: null, 76 general: null, 77 }, 78 79 init(this: AlpineThis) { 80 // Read default duration from data attribute 81 const el = this.$el 82 if (el.dataset.defaultDuration) { 83 this.durationHours = el.dataset.defaultDuration 84 } 85 86 this.$nextTick(() => { 87 this.initMap() 88 }) 89 }, 90 91 async initMap(this: LfgFormState): Promise<void> { 92 try { 93 const { maplibregl } = await loadMapLibraries() 94 95 // Initialize map centered on default location 96 const defaultLat = 40.7128 97 const defaultLon = -74.006 98 99 this.map = createMap(maplibregl, { 100 container: 'lfg-map', 101 center: toMapCenter(defaultLat, defaultLon), 102 zoom: 12, 103 }) 104 105 this.map.on('load', () => { 106 if (!this.map) return 107 108 // Add hexagon layers 109 addHexagonLayers(this.map) 110 111 // Handle map clicks 112 this.map.on('click', (e: MapMouseEvent) => { 113 // Check if clicking on an existing hex 114 const features = this.map?.queryRenderedFeatures(e.point, { 115 layers: ['hexagons-fill'], 116 }) 117 118 if (features && features.length > 0) { 119 // Clicked on existing hex - toggle off 120 this.clearLocation() 121 } else { 122 // Clicked on empty space - select new location 123 this.handleMapClick(e.lngLat.lng, e.lngLat.lat) 124 } 125 }) 126 127 // Cursor style when hovering over hex 128 this.map.on('mouseenter', 'hexagons-fill', () => { 129 if (this.map) { 130 this.map.getCanvas().style.cursor = 'pointer' 131 } 132 }) 133 134 this.map.on('mouseleave', 'hexagons-fill', () => { 135 if (this.map) { 136 this.map.getCanvas().style.cursor = '' 137 } 138 }) 139 }) 140 141 // Try to get user's location 142 if (navigator.geolocation) { 143 navigator.geolocation.getCurrentPosition( 144 (position) => { 145 const lat = position.coords.latitude 146 const lon = position.coords.longitude 147 this.map?.flyTo({ center: toMapCenter(lat, lon), zoom: 12 }) 148 }, 149 () => { 150 // Geolocation denied or failed, use default 151 } 152 ) 153 } 154 } catch (err) { 155 console.error('Failed to load map libraries:', err) 156 } 157 }, 158 159 handleMapClick(this: LfgFormState, lng: number, lat: number): void { 160 const h3 = getH3Module() 161 if (!h3) { 162 console.warn('H3 not loaded') 163 return 164 } 165 166 // Get H3 cell at resolution 167 const clickedH3Index = h3.latLngToCell(lat, lng, H3_RESOLUTION) 168 169 // Check if clicking on already selected cell - toggle off 170 if (this.h3Index === clickedH3Index) { 171 this.clearLocation() 172 return 173 } 174 175 // Select new location 176 this.selectLocation(clickedH3Index) 177 this.errors.location = null 178 }, 179 180 selectLocation(this: LfgFormState, h3Index: string): void { 181 const h3 = getH3Module() 182 if (!this.map || !h3) return 183 184 this.h3Index = h3Index 185 186 // Create feature for the selected hex 187 const feature = h3ToGeoJsonFeature(h3, h3Index, { 188 fillColor: '#00d1b2', 189 fillOpacity: 0.3, 190 strokeColor: '#00d1b2', 191 strokeWidth: 3, 192 }) 193 194 // Update the source 195 updateHexagonSource(this.map, [feature]) 196 197 // Get center coordinates for the API 198 const center = h3.cellToLatLng(h3Index) 199 this.latitude = center[0].toString() 200 this.longitude = center[1].toString() 201 }, 202 203 clearLocation(this: LfgFormState): void { 204 if (this.map) { 205 // Clear the hexagon source 206 updateHexagonSource(this.map, []) 207 } 208 this.h3Index = '' 209 this.latitude = '' 210 this.longitude = '' 211 }, 212 213 addTag(this: LfgFormState): void { 214 const tag = this.tagInput.trim().replace(/[^a-zA-Z0-9-]/g, '') 215 // Check for duplicates case-insensitively 216 const tagLower = tag.toLowerCase() 217 const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower) 218 if (tag && !isDuplicate && this.tags.length < 10) { 219 this.tags.push(tag) 220 this.errors.tags = null 221 } 222 this.tagInput = '' 223 }, 224 225 addTagFromSuggestion(this: LfgFormState, tag: string): void { 226 // Check for duplicates case-insensitively 227 const tagLower = tag.toLowerCase() 228 const isDuplicate = this.tags.some((t) => t.toLowerCase() === tagLower) 229 if (!isDuplicate && this.tags.length < 10) { 230 this.tags.push(tag) 231 this.errors.tags = null 232 } 233 }, 234 235 removeTag(this: LfgFormState, index: number): void { 236 this.tags.splice(index, 1) 237 }, 238 239 canSubmit(this: LfgFormState): boolean { 240 return !!this.h3Index && this.tags.length >= 1 241 }, 242 243 clearErrors(this: LfgFormState): void { 244 this.errors = { 245 location: null, 246 tags: null, 247 duration: null, 248 general: null, 249 } 250 }, 251 252 async submitForm(this: LfgFormState): Promise<void> { 253 if (!this.canSubmit() || this.submitting) return 254 255 this.clearErrors() 256 this.submitting = true 257 258 const payload = { 259 latitude: parseFloat(this.latitude), 260 longitude: parseFloat(this.longitude), 261 tags: this.tags, 262 duration_hours: parseInt(this.durationHours, 10), 263 } 264 265 try { 266 const response = await authPostJson('/lfg', payload) 267 268 if (response.ok) { 269 // Success - redirect to LFG page which will show matches view 270 window.location.href = '/lfg' 271 } else { 272 const data = (await response.json()) as { error?: string; message?: string } 273 if (data.error) { 274 // Map error codes to fields 275 if (data.error.includes('location') || data.error.includes('coordinate')) { 276 this.errors.location = data.message || 'Invalid location' 277 } else if (data.error.includes('tag')) { 278 this.errors.tags = data.message || 'Invalid tags' 279 } else if (data.error.includes('duration')) { 280 this.errors.duration = data.message || 'Invalid duration' 281 } else { 282 this.errors.general = data.message || 'An error occurred' 283 } 284 } else { 285 this.errors.general = 'An error occurred. Please try again.' 286 } 287 } 288 } catch (err) { 289 if (err instanceof SessionExpiredError) { 290 this.errors.general = err.message 291 } else { 292 console.error('LFG submission error:', err) 293 this.errors.general = 'Network error. Please check your connection and try again.' 294 } 295 } finally { 296 this.submitting = false 297 } 298 }, 299 } 300}