diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index bac307d..b906932 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -3,7 +3,6 @@ import Map, { NavigationControl, ScaleControl, Marker, - Popup, MapProvider, } from 'react-map-gl/maplibre' import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' @@ -12,55 +11,59 @@ import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' import { useEffect, useRef, useState, useCallback } from 'react' -type ScaleUnit = 'imperial' | 'metric' import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' -import type { PinColorId } from '~/hooks/useMapMarkers' import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' +import ViewMapMarkerPopup from './ViewMapMarkerPopup' +import MapMarkerFormPopup from './MapMarkerFormPopup' +import ScaleUnitToggle from './ScaleUnitToggle' + +type ScaleUnit = 'imperial' | 'metric' export default function MapComponent() { const mapRef = useRef(null) - const { markers, addMarker, deleteMarker } = useMapMarkers() + const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers() + const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) - const [markerName, setMarkerName] = useState('') - const [markerColor, setMarkerColor] = useState('orange') const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [editingMarkerId, setEditingMarkerId] = useState(null) + const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false) + const [scaleUnit, setScaleUnit] = useState( () => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric' ) - const toggleScaleUnit = useCallback(() => { - setScaleUnit((prev) => { - const next = prev === 'metric' ? 'imperial' : 'metric' - localStorage.setItem('nomad:map-scale-unit', next) - return next - }) - }, []) + const confirmDiscardMarkerChanges = useCallback(() => { + if (!hasUnsavedMarkerChanges) return true + + return window.confirm('Discard unsaved marker changes?') + }, [hasUnsavedMarkerChanges]) - // Add the PMTiles protocol to maplibre-gl useEffect(() => { - let protocol = new Protocol() + const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) + return () => { maplibregl.removeProtocol('pmtiles') } }, []) - const handleMapClick = useCallback((e: MapLayerMouseEvent) => { - setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) - setMarkerName('') - setMarkerColor('orange') - setSelectedMarkerId(null) + const handleScaleUnitChange = useCallback((unit: ScaleUnit) => { + setScaleUnit(unit) + localStorage.setItem('nomad:map-scale-unit', unit) }, []) - const handleSaveMarker = useCallback(() => { - if (placingMarker && markerName.trim()) { - addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor) - setPlacingMarker(null) - setMarkerName('') - setMarkerColor('orange') - } - }, [placingMarker, markerName, markerColor, addMarker]) + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + if (!confirmDiscardMarkerChanges()) return + + setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) + setSelectedMarkerId(null) + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) + }, + [confirmDiscardMarkerChanges] + ) const handleFlyTo = useCallback((longitude: number, latitude: number) => { mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 }) @@ -68,13 +71,20 @@ export default function MapComponent() { const handleDeleteMarker = useCallback( (id: number) => { - if (selectedMarkerId === id) setSelectedMarkerId(null) + if (selectedMarkerId === id) { + setSelectedMarkerId(null) + } + + if (editingMarkerId === id) { + setEditingMarkerId(null) + } + deleteMarker(id) }, - [selectedMarkerId, deleteMarker] + [selectedMarkerId, editingMarkerId, deleteMarker] ) - const selectedMarker = selectedMarkerId ? markers.find((m) => m.id === selectedMarkerId) : null + const selectedMarker = selectedMarkerId ? markers.find((marker) => marker.id === selectedMarkerId) : null return ( @@ -97,46 +107,8 @@ export default function MapComponent() { -
-
- - -
-
+ - {/* Existing markers */} {markers.map((marker) => ( { e.originalEvent.stopPropagation() + + if (!confirmDiscardMarkerChanges()) return + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) setPlacingMarker(null) + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) }} > c.id === marker.color)?.hex} + color={PIN_COLORS.find((color) => color.id === marker.color)?.hex} active={marker.id === selectedMarkerId} /> ))} - {/* Popup for selected marker */} - {selectedMarker && ( - setSelectedMarkerId(null)} - closeOnClick={false} - > -
{selectedMarker.name}
-
- )} - - {/* Popup for placing a new marker */} {placingMarker && ( - setPlacingMarker(null)} - closeOnClick={false} - > -
- setMarkerName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveMarker() - if (e.key === 'Escape') setPlacingMarker(null) - }} - className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" - /> -
- {PIN_COLORS.map((c) => ( - - ))} -
-
- - -
-
-
+ onDirtyChange={setHasUnsavedMarkerChanges} + onSave={async ({ name, notes, color }) => { + await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined) + setPlacingMarker(null) + setHasUnsavedMarkerChanges(false) + }} + onCancel={() => { + if (!confirmDiscardMarkerChanges()) return + + setPlacingMarker(null) + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) + }} + /> + )} + + {selectedMarker && editingMarkerId !== selectedMarker.id && ( + setSelectedMarkerId(null)} + onEdit={() => setEditingMarkerId(selectedMarker.id)} + /> + )} + + {selectedMarker && editingMarkerId === selectedMarker.id && ( + { + if (!id) return + + await updateMarker(id, { + name, + notes: notes || null, + color, + }) + + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) + }} + onCancel={() => setEditingMarkerId(null)} + /> )} - {/* Marker panel overlay */} Promise | void + onCancel: () => void + onDirtyChange?: (dirty: boolean) => void +} + +export default function MapMarkerFormPopup({ + longitude, + latitude, + initialMarker, + onSave, + onCancel, + onDirtyChange, + }: MapMarkerFormPopupProps) { + const [name, setName] = useState(initialMarker?.name ?? '') + const [notes, setNotes] = useState(initialMarker?.notes ?? '') + const [color, setColor] = useState(initialMarker?.color ?? 'orange') + const [isSaving, setIsSaving] = useState(false) + + const textareaRef = useRef(null) + + const resizeTextarea = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) return + + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + }, []) + + useLayoutEffect(() => { + resizeTextarea() + }, [resizeTextarea]) + + const isDirty = + name !== (initialMarker?.name ?? '') || + notes !== (initialMarker?.notes ?? '') || + color !== (initialMarker?.color ?? 'orange') + + useEffect(() => { + onDirtyChange?.(isDirty) + }, [isDirty, onDirtyChange]) + + const handleSave = async () => { + if (!name.trim() || isSaving) return + + try { + setIsSaving(true) + + await onSave({ + id: initialMarker?.id, + name: name.trim(), + notes: notes.trim(), + color, + }) + } finally { + setIsSaving(false) + } + } + + return ( + +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') onCancel() + }} + className={inputClass} + /> + +