Updated the map markers so they can be edited as well as to permit markdown in the notes field.
This commit is contained in:
parent
5a55c8e632
commit
596d9f7172
|
|
@ -137,9 +137,11 @@ export default class MapsController {
|
||||||
vine.compile(
|
vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine.string().trim().minLength(1).maxLength(255),
|
name: vine.string().trim().minLength(1).maxLength(255),
|
||||||
longitude: vine.number(),
|
longitude: vine.number().min(-180).max(180),
|
||||||
latitude: vine.number(),
|
latitude: vine.number().min(-90).max(90),
|
||||||
color: vine.string().trim().maxLength(20).optional(),
|
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,
|
longitude: payload.longitude,
|
||||||
latitude: payload.latitude,
|
latitude: payload.latitude,
|
||||||
color: payload.color ?? 'orange',
|
color: payload.color ?? 'orange',
|
||||||
|
notes: payload.notes ?? null,
|
||||||
|
marker_type: payload.marker_type ?? 'pin',
|
||||||
})
|
})
|
||||||
return marker
|
return marker
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<Popup
|
||||||
|
longitude={longitude}
|
||||||
|
latitude={latitude}
|
||||||
|
anchor="bottom"
|
||||||
|
onClose={onCancel}
|
||||||
|
closeOnClick={false}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="Name this location"
|
||||||
|
value={markerName}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') onSave()
|
||||||
|
if (e.key === 'Escape') onCancel()
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder="Add notes (optional)"
|
||||||
|
value={markerNotes}
|
||||||
|
rows={2}
|
||||||
|
maxLength={MAX_MARKER_NOTES_LENGTH}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
|
{markerNotes.length}/{MAX_MARKER_NOTES_LENGTH}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1 items-center">
|
||||||
|
{PIN_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: color.hex }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1.5 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!markerName.trim()}
|
||||||
|
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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<PinColorId>(marker.color)
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
onSave(marker.id, name.trim(), notes.trim(), color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={marker.longitude}
|
||||||
|
latitude={marker.latitude}
|
||||||
|
anchor="bottom"
|
||||||
|
offset={[0, -36] as [number, number]}
|
||||||
|
onClose={onCancel}
|
||||||
|
closeOnClick={false}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="Name this location"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave()
|
||||||
|
if (e.key === 'Escape') onCancel()
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder="Add notes (optional)"
|
||||||
|
value={notes}
|
||||||
|
rows={2}
|
||||||
|
maxLength={MAX_MARKER_NOTES_LENGTH}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
|
{notes.length}/{MAX_MARKER_NOTES_LENGTH}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1 items-center">
|
||||||
|
{PIN_COLORS.map((pinColor) => (
|
||||||
|
<button
|
||||||
|
key={pinColor.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: pinColor.hex }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1.5 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ import Map, {
|
||||||
NavigationControl,
|
NavigationControl,
|
||||||
ScaleControl,
|
ScaleControl,
|
||||||
Marker,
|
Marker,
|
||||||
Popup,
|
|
||||||
MapProvider,
|
MapProvider,
|
||||||
} from 'react-map-gl/maplibre'
|
} from 'react-map-gl/maplibre'
|
||||||
import type { MapRef, MapLayerMouseEvent } 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 { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
|
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||||
import type { PinColorId } from '~/hooks/useMapMarkers'
|
|
||||||
import MarkerPin from './MarkerPin'
|
import MarkerPin from './MarkerPin'
|
||||||
import MarkerPanel from './MarkerPanel'
|
import MarkerPanel from './MarkerPanel'
|
||||||
|
import ViewMapMarkerPopup from './ViewMapMarkerPopup'
|
||||||
|
import MapMarkerFormPopup from './MapMarkerFormPopup'
|
||||||
|
import ScaleUnitControl from './ScaleUnitControl'
|
||||||
|
|
||||||
type ScaleUnit = 'imperial' | 'metric'
|
type ScaleUnit = 'imperial' | 'metric'
|
||||||
|
|
||||||
export default function MapComponent() {
|
export default function MapComponent() {
|
||||||
const mapRef = useRef<MapRef>(null)
|
const mapRef = useRef<MapRef>(null)
|
||||||
const { markers, addMarker, deleteMarker } = useMapMarkers()
|
const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers()
|
||||||
|
|
||||||
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null)
|
||||||
const [markerName, setMarkerName] = useState('')
|
|
||||||
const [markerNotes, setMarkerNotes] = useState('')
|
|
||||||
const [markerColor, setMarkerColor] = useState<PinColorId>('orange')
|
|
||||||
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
|
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
|
||||||
|
const [editingMarkerId, setEditingMarkerId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
||||||
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
|
() => (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(() => {
|
useEffect(() => {
|
||||||
const protocol = new Protocol()
|
const protocol = new Protocol()
|
||||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||||
|
|
@ -50,30 +41,16 @@ export default function MapComponent() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
const handleScaleUnitChange = useCallback((unit: ScaleUnit) => {
|
||||||
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
|
setScaleUnit(unit)
|
||||||
setMarkerName('')
|
localStorage.setItem('nomad:map-scale-unit', unit)
|
||||||
setMarkerNotes('')
|
|
||||||
setMarkerColor('orange')
|
|
||||||
setSelectedMarkerId(null)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSaveMarker = useCallback(() => {
|
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||||||
if (placingMarker && markerName.trim()) {
|
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
|
||||||
addMarker(
|
setSelectedMarkerId(null)
|
||||||
markerName.trim(),
|
setEditingMarkerId(null)
|
||||||
placingMarker.lng,
|
}, [])
|
||||||
placingMarker.lat,
|
|
||||||
markerColor,
|
|
||||||
markerNotes.trim() || undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
setPlacingMarker(null)
|
|
||||||
setMarkerName('')
|
|
||||||
setMarkerNotes('')
|
|
||||||
setMarkerColor('orange')
|
|
||||||
}
|
|
||||||
}, [placingMarker, markerName, markerNotes, markerColor, addMarker])
|
|
||||||
|
|
||||||
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
|
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
|
||||||
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||||
|
|
@ -81,13 +58,20 @@ export default function MapComponent() {
|
||||||
|
|
||||||
const handleDeleteMarker = useCallback(
|
const handleDeleteMarker = useCallback(
|
||||||
(id: number) => {
|
(id: number) => {
|
||||||
if (selectedMarkerId === id) setSelectedMarkerId(null)
|
if (selectedMarkerId === id) {
|
||||||
|
setSelectedMarkerId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingMarkerId === id) {
|
||||||
|
setEditingMarkerId(null)
|
||||||
|
}
|
||||||
|
|
||||||
deleteMarker(id)
|
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 (
|
return (
|
||||||
<MapProvider>
|
<MapProvider>
|
||||||
|
|
@ -110,52 +94,7 @@ export default function MapComponent() {
|
||||||
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
|
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
|
||||||
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
|
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
|
||||||
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
|
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
|
||||||
|
<ScaleUnitControl scaleUnit={scaleUnit} onChange={handleScaleUnitChange} />
|
||||||
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
borderRadius: '4px',
|
|
||||||
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (scaleUnit !== 'metric') toggleScaleUnit()
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: scaleUnit === 'metric' ? '#424420' : 'white',
|
|
||||||
color: scaleUnit === 'metric' ? 'white' : '#666',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px 8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Metric
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (scaleUnit !== 'imperial') toggleScaleUnit()
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: scaleUnit === 'imperial' ? '#424420' : 'white',
|
|
||||||
color: scaleUnit === 'imperial' ? 'white' : '#666',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px 8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Imperial
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{markers.map((marker) => (
|
{markers.map((marker) => (
|
||||||
<Marker
|
<Marker
|
||||||
|
|
@ -167,103 +106,62 @@ export default function MapComponent() {
|
||||||
e.originalEvent.stopPropagation()
|
e.originalEvent.stopPropagation()
|
||||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||||
setPlacingMarker(null)
|
setPlacingMarker(null)
|
||||||
|
setEditingMarkerId(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkerPin
|
<MarkerPin
|
||||||
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
|
color={PIN_COLORS.find((color) => color.id === marker.color)?.hex}
|
||||||
active={marker.id === selectedMarkerId}
|
active={marker.id === selectedMarkerId}
|
||||||
/>
|
/>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{selectedMarker && (
|
|
||||||
<Popup
|
|
||||||
longitude={selectedMarker.longitude}
|
|
||||||
latitude={selectedMarker.latitude}
|
|
||||||
anchor="bottom"
|
|
||||||
offset={[0, -36] as [number, number]}
|
|
||||||
onClose={() => setSelectedMarkerId(null)}
|
|
||||||
closeOnClick={false}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{selectedMarker.name}</div>
|
|
||||||
|
|
||||||
{selectedMarker.notes && (
|
|
||||||
<div className="mt-1 text-xs text-gray-500 whitespace-pre-wrap">
|
|
||||||
{selectedMarker.notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Popup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{placingMarker && (
|
{placingMarker && (
|
||||||
<Popup
|
<MapMarkerFormPopup
|
||||||
longitude={placingMarker.lng}
|
longitude={placingMarker.lng}
|
||||||
latitude={placingMarker.lat}
|
latitude={placingMarker.lat}
|
||||||
anchor="bottom"
|
onSave={async ({ name, notes, color }) => {
|
||||||
onClose={() => setPlacingMarker(null)}
|
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
|
||||||
closeOnClick={false}
|
setPlacingMarker(null)
|
||||||
>
|
}}
|
||||||
<div className="p-1">
|
onCancel={() => setPlacingMarker(null)}
|
||||||
<input
|
/>
|
||||||
autoFocus
|
)}
|
||||||
type="text"
|
|
||||||
placeholder="Name this location"
|
|
||||||
value={markerName}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<textarea
|
{selectedMarker && editingMarkerId !== selectedMarker.id && (
|
||||||
placeholder="Add notes (optional)"
|
<ViewMapMarkerPopup
|
||||||
value={markerNotes}
|
marker={selectedMarker}
|
||||||
onChange={(e) => setMarkerNotes(e.target.value)}
|
onClose={() => setSelectedMarkerId(null)}
|
||||||
rows={3}
|
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||||
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"
|
/>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
<div className="mt-1.5 flex gap-1 items-center">
|
{selectedMarker && editingMarkerId !== selectedMarker.id && (
|
||||||
{PIN_COLORS.map((c) => (
|
<ViewMapMarkerPopup
|
||||||
<button
|
marker={selectedMarker}
|
||||||
key={c.id}
|
onClose={() => setSelectedMarkerId(null)}
|
||||||
type="button"
|
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||||
onClick={() => 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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: c.hex }} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1.5 flex gap-1.5 justify-end">
|
{selectedMarker && editingMarkerId === selectedMarker.id && (
|
||||||
<button
|
<MapMarkerFormPopup
|
||||||
type="button"
|
longitude={selectedMarker.longitude}
|
||||||
onClick={() => setPlacingMarker(null)}
|
latitude={selectedMarker.latitude}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
initialMarker={selectedMarker}
|
||||||
>
|
onSave={async ({ id, name, notes, color }) => {
|
||||||
Cancel
|
if (!id) return
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
await updateMarker(id, {
|
||||||
type="button"
|
name,
|
||||||
onClick={handleSaveMarker}
|
notes: notes || null,
|
||||||
disabled={!markerName.trim()}
|
color,
|
||||||
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
|
})
|
||||||
>
|
|
||||||
Save
|
setEditingMarkerId(null)
|
||||||
</button>
|
}}
|
||||||
</div>
|
onCancel={() => setEditingMarkerId(null)}
|
||||||
</div>
|
/>
|
||||||
</Popup>
|
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<PinColorId>(initialMarker?.color ?? 'orange')
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
id: initialMarker?.id,
|
||||||
|
name: name.trim(),
|
||||||
|
notes: notes.trim(),
|
||||||
|
color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={longitude}
|
||||||
|
latitude={latitude}
|
||||||
|
anchor="bottom"
|
||||||
|
offset={[0, -36] as [number, number]}
|
||||||
|
onClose={onCancel}
|
||||||
|
closeOnClick={false}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
placeholder="Name this location"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave()
|
||||||
|
if (e.key === 'Escape') onCancel()
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
placeholder="Add notes (optional)"
|
||||||
|
value={notes}
|
||||||
|
rows={2}
|
||||||
|
maxLength={MAX_MARKER_NOTES_LENGTH}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[11px] text-gray-400">
|
||||||
|
{notes.length}/{MAX_MARKER_NOTES_LENGTH}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1 items-center">
|
||||||
|
{PIN_COLORS.map((pinColor) => (
|
||||||
|
<button
|
||||||
|
key={pinColor.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: pinColor.hex }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex gap-1.5 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
type ScaleUnit = 'imperial' | 'metric'
|
||||||
|
|
||||||
|
type ScaleUnitControlProps = {
|
||||||
|
scaleUnit: ScaleUnit
|
||||||
|
onChange: (unit: ScaleUnit) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScaleUnitControl({ scaleUnit, onChange }: ScaleUnitControlProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', bottom: '30px', left: '10px', zIndex: 2 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: '0 0 0 2px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (scaleUnit !== 'metric') onChange('metric')
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: scaleUnit === 'metric' ? '#424420' : 'white',
|
||||||
|
color: scaleUnit === 'metric' ? 'white' : '#666',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Metric
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (scaleUnit !== 'imperial') onChange('imperial')
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: scaleUnit === 'imperial' ? '#424420' : 'white',
|
||||||
|
color: scaleUnit === 'imperial' ? 'white' : '#666',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Imperial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Popup
|
||||||
|
longitude={marker.longitude}
|
||||||
|
latitude={marker.latitude}
|
||||||
|
anchor="bottom"
|
||||||
|
offset={[0, -36] as [number, number]}
|
||||||
|
onClose={onClose}
|
||||||
|
closeOnClick={false}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{marker.name}</div>
|
||||||
|
|
||||||
|
{marker.notes && (
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{marker.notes}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530]"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -126,13 +126,15 @@ body {
|
||||||
color: #f7eedc;
|
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;
|
background: #353420;
|
||||||
color: #f7eedc;
|
color: #f7eedc;
|
||||||
border-color: #424420;
|
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;
|
color: #8f8f82;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,4 +157,4 @@ body {
|
||||||
[data-theme="dark"] .maplibregl-popup-close-button:hover {
|
[data-theme="dark"] .maplibregl-popup-close-button:hover {
|
||||||
color: #f7eedc;
|
color: #f7eedc;
|
||||||
background: #353420;
|
background: #353420;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4383,6 +4383,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4399,6 +4400,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4415,6 +4417,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4431,6 +4434,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4447,6 +4451,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4463,6 +4468,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4479,6 +4485,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4495,6 +4502,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4511,6 +4519,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4527,6 +4536,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue