mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-27 14:48:26 +02:00
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.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
|
||||
}
|
||||
|
|
|
|||
115
admin/inertia/components/maps/CreateMapMarkerPopup.tsx
Normal file
115
admin/inertia/components/maps/CreateMapMarkerPopup.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
111
admin/inertia/components/maps/EditMapMarkerPopup.tsx
Normal file
111
admin/inertia/components/maps/EditMapMarkerPopup.tsx
Normal file
|
|
@ -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,
|
||||
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<MapRef>(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<PinColorId>('orange')
|
||||
const [selectedMarkerId, setSelectedMarkerId] = useState<number | null>(null)
|
||||
const [editingMarkerId, setEditingMarkerId] = useState<number | null>(null)
|
||||
|
||||
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
||||
() => (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 (
|
||||
<MapProvider>
|
||||
|
|
@ -110,52 +94,7 @@ export default function MapComponent() {
|
|||
<NavigationControl style={{ marginTop: '110px', marginRight: '36px' }} />
|
||||
<FullscreenControl style={{ marginTop: '30px', marginRight: '36px' }} />
|
||||
<ScaleControl position="bottom-left" maxWidth={150} unit={scaleUnit} />
|
||||
|
||||
<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>
|
||||
<ScaleUnitControl scaleUnit={scaleUnit} onChange={handleScaleUnitChange} />
|
||||
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
|
|
@ -167,103 +106,62 @@ export default function MapComponent() {
|
|||
e.originalEvent.stopPropagation()
|
||||
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
|
||||
setPlacingMarker(null)
|
||||
setEditingMarkerId(null)
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</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 && (
|
||||
<Popup
|
||||
<MapMarkerFormPopup
|
||||
longitude={placingMarker.lng}
|
||||
latitude={placingMarker.lat}
|
||||
anchor="bottom"
|
||||
onClose={() => setPlacingMarker(null)}
|
||||
closeOnClick={false}
|
||||
>
|
||||
<div className="p-1">
|
||||
<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"
|
||||
/>
|
||||
onSave={async ({ name, notes, color }) => {
|
||||
await addMarker(name, placingMarker.lng, placingMarker.lat, color, notes || undefined)
|
||||
setPlacingMarker(null)
|
||||
}}
|
||||
onCancel={() => setPlacingMarker(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
placeholder="Add notes (optional)"
|
||||
value={markerNotes}
|
||||
onChange={(e) => 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 && (
|
||||
<ViewMapMarkerPopup
|
||||
marker={selectedMarker}
|
||||
onClose={() => setSelectedMarkerId(null)}
|
||||
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex gap-1 items-center">
|
||||
{PIN_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
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>
|
||||
{selectedMarker && editingMarkerId !== selectedMarker.id && (
|
||||
<ViewMapMarkerPopup
|
||||
marker={selectedMarker}
|
||||
onClose={() => setSelectedMarkerId(null)}
|
||||
onEdit={() => setEditingMarkerId(selectedMarker.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex gap-1.5 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlacingMarker(null)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{selectedMarker && editingMarkerId === selectedMarker.id && (
|
||||
<MapMarkerFormPopup
|
||||
longitude={selectedMarker.longitude}
|
||||
latitude={selectedMarker.latitude}
|
||||
initialMarker={selectedMarker}
|
||||
onSave={async ({ id, name, notes, color }) => {
|
||||
if (!id) return
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveMarker}
|
||||
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>
|
||||
await updateMarker(id, {
|
||||
name,
|
||||
notes: notes || null,
|
||||
color,
|
||||
})
|
||||
|
||||
setEditingMarkerId(null)
|
||||
}}
|
||||
onCancel={() => setEditingMarkerId(null)}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
|
|
|
|||
127
admin/inertia/components/maps/MapMarkerFormPopup.tsx
Normal file
127
admin/inertia/components/maps/MapMarkerFormPopup.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
56
admin/inertia/components/maps/ScaleUnitControl.tsx
Normal file
56
admin/inertia/components/maps/ScaleUnitControl.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
48
admin/inertia/components/maps/ViewMapMarkerPopup.tsx
Normal file
48
admin/inertia/components/maps/ViewMapMarkerPopup.tsx
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
admin/package-lock.json
generated
10
admin/package-lock.json
generated
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user