diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index f5fb0f3..31035ee 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -137,9 +137,11 @@ export default class MapsController { vine.compile( vine.object({ name: vine.string().trim().minLength(1).maxLength(255), - longitude: vine.number(), - latitude: vine.number(), + longitude: vine.number().min(-180).max(180), + latitude: vine.number().min(-90).max(90), color: vine.string().trim().maxLength(20).optional(), + notes: vine.string().trim().nullable().optional(), + marker_type: vine.string().trim().maxLength(20).optional(), }) ) ) @@ -148,6 +150,8 @@ export default class MapsController { longitude: payload.longitude, latitude: payload.latitude, color: payload.color ?? 'orange', + notes: payload.notes ?? null, + marker_type: payload.marker_type ?? 'pin', }) return marker } diff --git a/admin/inertia/components/maps/CreateMapMarkerPopup.tsx b/admin/inertia/components/maps/CreateMapMarkerPopup.tsx new file mode 100644 index 0000000..b55c66f --- /dev/null +++ b/admin/inertia/components/maps/CreateMapMarkerPopup.tsx @@ -0,0 +1,115 @@ +import { Popup } from 'react-map-gl/maplibre' + +import { PIN_COLORS } from '~/hooks/useMapMarkers' +import type { PinColorId } from '~/hooks/useMapMarkers' + +const MAX_MARKER_NOTES_LENGTH = 1000 + +const inputClass = + 'block w-full appearance-none rounded border border-gray-300 bg-transparent px-2 py-1 text-sm text-gray-900 leading-normal placeholder:text-gray-400 focus:border-gray-500 focus:outline-none' + +type CreateMapMarkerPopupProps = { + longitude: number + latitude: number + markerName: string + markerNotes: string + markerColor: PinColorId + onNameChange: (value: string) => void + onNotesChange: (value: string) => void + onColorChange: (value: PinColorId) => void + onSave: () => void + onCancel: () => void +} + +export default function CreateMapMarkerPopup({ + longitude, + latitude, + markerName, + markerNotes, + markerColor, + onNameChange, + onNotesChange, + onColorChange, + onSave, + onCancel, + }: CreateMapMarkerPopupProps) { + return ( + + + onNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSave() + if (e.key === 'Escape') onCancel() + }} + className={inputClass} + /> + + { + onNotesChange(e.target.value) + e.currentTarget.style.height = 'auto' + e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` + }} + className={`mt-1 min-h-[64px] resize-none overflow-hidden ${inputClass}`} + /> + + + {markerNotes.length}/{MAX_MARKER_NOTES_LENGTH} + + + + {PIN_COLORS.map((color) => ( + onColorChange(color.id)} + title={color.label} + className="rounded-full p-0.5 transition-transform" + style={{ + outline: + markerColor === color.id ? `2px solid ${color.hex}` : '2px solid transparent', + outlineOffset: '1px', + }} + > + + + ))} + + + + + Cancel + + + + Save + + + + + ) +} diff --git a/admin/inertia/components/maps/EditMapMarkerPopup.tsx b/admin/inertia/components/maps/EditMapMarkerPopup.tsx new file mode 100644 index 0000000..1b5919c --- /dev/null +++ b/admin/inertia/components/maps/EditMapMarkerPopup.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { Popup } from 'react-map-gl/maplibre' + +import type { MapMarker, PinColorId } from '~/hooks/useMapMarkers' +import { PIN_COLORS } from '~/hooks/useMapMarkers' + +const MAX_MARKER_NOTES_LENGTH = 1000 + +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 EditMapMarkerPopupProps = { + marker: MapMarker + onSave: (id: number, name: string, notes: string, color: PinColorId) => void + onCancel: () => void +} + +export default function EditMapMarkerPopup({ + marker, + onSave, + onCancel, + }: EditMapMarkerPopupProps) { + const [name, setName] = useState(marker.name) + const [notes, setNotes] = useState(marker.notes ?? '') + const [color, setColor] = useState(marker.color) + + const handleSave = () => { + if (!name.trim()) return + onSave(marker.id, name.trim(), notes.trim(), color) + } + + return ( + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') onCancel() + }} + className={inputClass} + /> + + { + setNotes(e.target.value) + e.currentTarget.style.height = 'auto' + e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` + }} + className={`mt-1 min-h-[64px] resize-none overflow-hidden ${inputClass}`} + /> + + + {notes.length}/{MAX_MARKER_NOTES_LENGTH} + + + + {PIN_COLORS.map((pinColor) => ( + setColor(pinColor.id)} + title={pinColor.label} + className="rounded-full p-0.5 transition-transform" + style={{ + outline: color === pinColor.id ? `2px solid ${pinColor.hex}` : '2px solid transparent', + outlineOffset: '1px', + }} + > + + + ))} + + + + + Cancel + + + + Save + + + + + ) +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index e541dd4..d738638 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' @@ -13,34 +12,26 @@ 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 ViewMapMarkerPopup from './ViewMapMarkerPopup' +import MapMarkerFormPopup from './MapMarkerFormPopup' +import ScaleUnitControl from './ScaleUnitControl' 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 [markerNotes, setMarkerNotes] = useState('') - const [markerColor, setMarkerColor] = useState('orange') const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [editingMarkerId, setEditingMarkerId] = useState(null) 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 - }) - }, []) - useEffect(() => { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) @@ -50,30 +41,16 @@ export default function MapComponent() { } }, []) - const handleMapClick = useCallback((e: MapLayerMouseEvent) => { - setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) - setMarkerName('') - setMarkerNotes('') - 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, - markerNotes.trim() || undefined - ) - - setPlacingMarker(null) - setMarkerName('') - setMarkerNotes('') - setMarkerColor('orange') - } - }, [placingMarker, markerName, markerNotes, markerColor, addMarker]) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { + setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) + setSelectedMarkerId(null) + setEditingMarkerId(null) + }, []) const handleFlyTo = useCallback((longitude: number, latitude: number) => { mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 }) @@ -81,13 +58,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 ( @@ -110,52 +94,7 @@ export default function MapComponent() { - - - - { - if (scaleUnit !== 'metric') toggleScaleUnit() - }} - style={{ - background: scaleUnit === 'metric' ? '#424420' : 'white', - color: scaleUnit === 'metric' ? 'white' : '#666', - border: 'none', - padding: '4px 8px', - cursor: 'pointer', - }} - > - Metric - - - { - if (scaleUnit !== 'imperial') toggleScaleUnit() - }} - style={{ - background: scaleUnit === 'imperial' ? '#424420' : 'white', - color: scaleUnit === 'imperial' ? 'white' : '#666', - border: 'none', - padding: '4px 8px', - cursor: 'pointer', - }} - > - Imperial - - - + {markers.map((marker) => ( 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} - - {selectedMarker.notes && ( - - {selectedMarker.notes} - - )} - - )} - {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" - /> + onSave={async ({ name, notes, color }) => { + await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined) + setPlacingMarker(null) + }} + onCancel={() => setPlacingMarker(null)} + /> + )} - setMarkerNotes(e.target.value)} - rows={3} - className="mt-1 block w-full resize-none rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" - /> + {selectedMarker && editingMarkerId !== selectedMarker.id && ( + setSelectedMarkerId(null)} + onEdit={() => setEditingMarkerId(selectedMarker.id)} + /> + )} - - {PIN_COLORS.map((c) => ( - setMarkerColor(c.id)} - title={c.label} - className="rounded-full p-0.5 transition-transform" - style={{ - outline: - markerColor === c.id ? `2px solid ${c.hex}` : '2px solid transparent', - outlineOffset: '1px', - }} - > - - - ))} - + {selectedMarker && editingMarkerId !== selectedMarker.id && ( + setSelectedMarkerId(null)} + onEdit={() => setEditingMarkerId(selectedMarker.id)} + /> + )} - - setPlacingMarker(null)} - className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors" - > - Cancel - + {selectedMarker && editingMarkerId === selectedMarker.id && ( + { + if (!id) return - - Save - - - - + await updateMarker(id, { + name, + notes: notes || null, + color, + }) + + setEditingMarkerId(null) + }} + 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..b5bd839 --- /dev/null +++ b/admin/inertia/components/maps/MapMarkerFormPopup.tsx @@ -0,0 +1,127 @@ +import { 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 + }) => void + onCancel: () => void +} + +export default function MapMarkerFormPopup({ + longitude, + latitude, + initialMarker, + onSave, + onCancel, + }: MapMarkerFormPopupProps) { + const [name, setName] = useState(initialMarker?.name ?? '') + const [notes, setNotes] = useState(initialMarker?.notes ?? '') + const [color, setColor] = useState(initialMarker?.color ?? 'orange') + + const handleSave = () => { + if (!name.trim()) return + + onSave({ + id: initialMarker?.id, + name: name.trim(), + notes: notes.trim(), + color, + }) + } + + return ( + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') onCancel() + }} + className={inputClass} + /> + + { + setNotes(e.target.value) + e.currentTarget.style.height = 'auto' + e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` + }} + className={`mt-1 min-h-[64px] resize-none overflow-hidden ${inputClass}`} + /> + + + {notes.length}/{MAX_MARKER_NOTES_LENGTH} + + + + {PIN_COLORS.map((pinColor) => ( + setColor(pinColor.id)} + title={pinColor.label} + className="rounded-full p-0.5 transition-transform" + style={{ + outline: + color === pinColor.id ? `2px solid ${pinColor.hex}` : '2px solid transparent', + outlineOffset: '1px', + }} + > + + + ))} + + + + + Cancel + + + + Save + + + + + ) +} diff --git a/admin/inertia/components/maps/ScaleUnitControl.tsx b/admin/inertia/components/maps/ScaleUnitControl.tsx new file mode 100644 index 0000000..06d94f9 --- /dev/null +++ b/admin/inertia/components/maps/ScaleUnitControl.tsx @@ -0,0 +1,56 @@ +type ScaleUnit = 'imperial' | 'metric' + +type ScaleUnitControlProps = { + scaleUnit: ScaleUnit + onChange: (unit: ScaleUnit) => void +} + +export default function ScaleUnitControl({ scaleUnit, onChange }: ScaleUnitControlProps) { + return ( + + + { + if (scaleUnit !== 'metric') onChange('metric') + }} + style={{ + background: scaleUnit === 'metric' ? '#424420' : 'white', + color: scaleUnit === 'metric' ? 'white' : '#666', + border: 'none', + padding: '4px 8px', + cursor: 'pointer', + }} + > + Metric + + + { + if (scaleUnit !== 'imperial') onChange('imperial') + }} + style={{ + background: scaleUnit === 'imperial' ? '#424420' : 'white', + color: scaleUnit === 'imperial' ? 'white' : '#666', + border: 'none', + padding: '4px 8px', + cursor: 'pointer', + }} + > + Imperial + + + + ) +} diff --git a/admin/inertia/components/maps/ViewMapMarkerPopup.tsx b/admin/inertia/components/maps/ViewMapMarkerPopup.tsx new file mode 100644 index 0000000..99e3f9c --- /dev/null +++ b/admin/inertia/components/maps/ViewMapMarkerPopup.tsx @@ -0,0 +1,48 @@ +import { Popup } from 'react-map-gl/maplibre' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +import type { MapMarker } from '~/hooks/useMapMarkers' + +type ViewMapMarkerPopupProps = { + marker: MapMarker + onClose: () => void + onEdit: () => void +} + +export default function ViewMapMarkerPopup({ + marker, + onClose, + onEdit, + }: ViewMapMarkerPopupProps) { + return ( + + {marker.name} + + {marker.notes && ( + + + {marker.notes} + + + )} + + + + Edit + + + + ) +} diff --git a/admin/inertia/css/app.css b/admin/inertia/css/app.css index 9a7781d..16d17d1 100644 --- a/admin/inertia/css/app.css +++ b/admin/inertia/css/app.css @@ -126,13 +126,15 @@ body { color: #f7eedc; } -[data-theme="dark"] .maplibregl-popup-content input { +[data-theme="dark"] .maplibregl-popup-content input, +[data-theme="dark"] .maplibregl-popup-content textarea { background: #353420; color: #f7eedc; border-color: #424420; } -[data-theme="dark"] .maplibregl-popup-content input::placeholder { +[data-theme="dark"] .maplibregl-popup-content input::placeholder, +[data-theme="dark"] .maplibregl-popup-content textarea::placeholder { color: #8f8f82; } @@ -155,4 +157,4 @@ body { [data-theme="dark"] .maplibregl-popup-close-button:hover { color: #f7eedc; background: #353420; -} \ No newline at end of file +} diff --git a/admin/package-lock.json b/admin/package-lock.json index ae7ef36..f2ebfeb 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -4383,6 +4383,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4399,6 +4400,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4415,6 +4417,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4431,6 +4434,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4447,6 +4451,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4463,6 +4468,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4479,6 +4485,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4495,6 +4502,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4511,6 +4519,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4527,6 +4536,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [