diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 76c151a..d8d6e24 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' @@ -11,15 +10,16 @@ import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Protocol } from 'pmtiles' -import { useEffect, useRef, useState, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' -import type { PinColorId } from '~/hooks/useMapMarkers' import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' import CoordinateOverlay from './CoordinateOverlay' import ScaleUnitToggle from './ScaleUnitToggle' +import ViewMapMarkerPopup from './ViewMapMarkerPopup' +import MapMarkerFormPopup from './MapMarkerFormPopup' type ScaleUnit = 'imperial' | 'metric' @@ -80,13 +80,13 @@ export default function MapComponent({ const mapRef = useRef(null) const animationFrameRef = useRef(null) - const { markers, addMarker, deleteMarker } = useMapMarkers() + const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers() const [isDraggingMap, setIsDraggingMap] = useState(false) 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 [showCoordinates, setShowCoordinates] = useState(false) const [scaleUnit, setScaleUnit] = useState( @@ -100,6 +100,16 @@ export default function MapComponent({ y: number } | null>(null) + const confirmDiscardMarkerChanges = useCallback(() => { + if (!hasUnsavedMarkerChanges) return true + return window.confirm('Discard unsaved marker changes?') + }, [hasUnsavedMarkerChanges]) + + const hideCoordinates = useCallback(() => { + setShowCoordinates(false) + setCursorLngLat(null) + }, []) + const flyToLocationParams = useCallback(() => { const location = getMapLocationParams() if (!location) return @@ -128,16 +138,27 @@ export default function MapComponent({ } }, []) - const hideCoordinates = useCallback(() => { - setShowCoordinates(false) - setCursorLngLat(null) - }, []) - const handleScaleUnitChange = useCallback((unit: ScaleUnit) => { setScaleUnit(unit) localStorage.setItem('nomad:map-scale-unit', unit) }, []) + const handleMapLoad = useCallback(() => { + flyToLocationParams() + }, [flyToLocationParams]) + + 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 handleMouseMove = useCallback( (e: MapLayerMouseEvent) => { const target = e.originalEvent.target as HTMLElement | null @@ -169,26 +190,6 @@ export default function MapComponent({ [hideCoordinates, isHoveringUI, isDraggingMap, showCoordinatesEnabled] ) - const handleMapLoad = useCallback(() => { - flyToLocationParams() - }, [flyToLocationParams]) - - const handleMapClick = useCallback((e: MapLayerMouseEvent) => { - setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) - setMarkerName('') - setMarkerColor('orange') - setSelectedMarkerId(null) - }, []) - - 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 handleFlyTo = useCallback((longitude: number, latitude: number) => { mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 }) }, []) @@ -196,9 +197,11 @@ export default function MapComponent({ const handleDeleteMarker = useCallback( (id: number) => { if (selectedMarkerId === id) setSelectedMarkerId(null) + if (editingMarkerId === id) setEditingMarkerId(null) + deleteMarker(id) }, - [selectedMarkerId, deleteMarker] + [selectedMarkerId, editingMarkerId, deleteMarker] ) const selectedMarker = selectedMarkerId @@ -284,8 +287,13 @@ export default function MapComponent({ anchor="bottom" onClick={(e) => { e.originalEvent.stopPropagation() + + if (!confirmDiscardMarkerChanges()) return + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) setPlacingMarker(null) + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) }} > ))} - {selectedMarker && ( - setSelectedMarkerId(null)} - closeOnClick={false} - > -
{selectedMarker.name}
-
- )} - {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" - /> + 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 -
- {PIN_COLORS.map((color) => ( - - ))} -
+ 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={() => { + if (!confirmDiscardMarkerChanges()) return + + setEditingMarkerId(null) + setHasUnsavedMarkerChanges(false) + }} + /> )} diff --git a/admin/inertia/components/maps/MapMarkerFormPopup.tsx b/admin/inertia/components/maps/MapMarkerFormPopup.tsx new file mode 100644 index 0000000..94ff34b --- /dev/null +++ b/admin/inertia/components/maps/MapMarkerFormPopup.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Popup } from 'react-map-gl/maplibre' + +import { PIN_COLORS } from '~/hooks/useMapMarkers' +import type { MapMarker, PinColorId } from '~/hooks/useMapMarkers' + +const MAX_MARKER_NOTES_LENGTH = 500 + +const inputClass = + 'block w-full rounded border border-gray-300 bg-transparent px-2 py-1 text-sm text-gray-900 leading-normal placeholder:text-gray-400 focus:outline-none focus:border-gray-500' + +type MapMarkerFormPopupProps = { + longitude: number + latitude: number + initialMarker?: MapMarker + onSave: (values: { + id?: number + name: string + notes: string + color: PinColorId + }) => 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} + /> + +