diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 07fe1c6..18812b3 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' @@ -14,12 +13,13 @@ import { Protocol } from 'pmtiles' import { useEffect, useRef, useState, useCallback } 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' @@ -35,13 +35,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 [scaleUnit, setScaleUnit] = useState( () => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric' @@ -56,6 +56,12 @@ export default function MapComponent({ const [showCoordinates, setShowCoordinates] = useState(false) + const confirmDiscardMarkerChanges = useCallback(() => { + if (!hasUnsavedMarkerChanges) return true + + return window.confirm('Discard unsaved marker changes?') + }, [hasUnsavedMarkerChanges]) + useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) @@ -91,7 +97,7 @@ export default function MapComponent({ !showCoordinatesEnabled || isHoveringUI || isDraggingMap || - target?.closest('.maplibregl-control-container, .maplibregl-ctrl') + target?.closest('.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-popup') ) { hideCoordinates() return @@ -114,21 +120,17 @@ export default function MapComponent({ [hideCoordinates, isHoveringUI, isDraggingMap, showCoordinatesEnabled] ) - const handleMapClick = useCallback((e: MapLayerMouseEvent) => { - setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) - setMarkerName('') - setMarkerColor('orange') - setSelectedMarkerId(null) - }, []) + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + if (!confirmDiscardMarkerChanges()) return - const handleSaveMarker = useCallback(() => { - if (placingMarker && markerName.trim()) { - addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor) - setPlacingMarker(null) - setMarkerName('') - setMarkerColor('orange') - } - }, [placingMarker, markerName, markerColor, addMarker]) + 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 }) @@ -136,13 +138,22 @@ 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 ( @@ -157,7 +168,7 @@ export default function MapComponent({ if ( target?.closest( - '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale' + '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale, .maplibregl-popup' ) ) { hideCoordinates() @@ -199,6 +210,12 @@ export default function MapComponent({ + + {showCoordinates && cursorLngLat && ( )} - - {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} /> ))} - {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((c) => ( - - ))} -
+ 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)} + /> )} 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} + /> + +