Initial commit for being able to add notes on map markers and display them
This commit is contained in:
parent
08d14473d2
commit
5a55c8e632
|
|
@ -12,19 +12,23 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
|||
import { Protocol } from 'pmtiles'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
type ScaleUnit = 'imperial' | 'metric'
|
||||
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||
import type { PinColorId } from '~/hooks/useMapMarkers'
|
||||
import MarkerPin from './MarkerPin'
|
||||
import MarkerPanel from './MarkerPanel'
|
||||
|
||||
type ScaleUnit = 'imperial' | 'metric'
|
||||
|
||||
export default function MapComponent() {
|
||||
const mapRef = useRef<MapRef>(null)
|
||||
const { markers, addMarker, 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 [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
|
||||
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
|
||||
)
|
||||
|
|
@ -37,10 +41,10 @@ export default function MapComponent() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
// Add the PMTiles protocol to maplibre-gl
|
||||
useEffect(() => {
|
||||
let protocol = new Protocol()
|
||||
const protocol = new Protocol()
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||
|
||||
return () => {
|
||||
maplibregl.removeProtocol('pmtiles')
|
||||
}
|
||||
|
|
@ -49,18 +53,27 @@ 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 handleSaveMarker = useCallback(() => {
|
||||
if (placingMarker && markerName.trim()) {
|
||||
addMarker(markerName.trim(), placingMarker.lng, placingMarker.lat, markerColor)
|
||||
addMarker(
|
||||
markerName.trim(),
|
||||
placingMarker.lng,
|
||||
placingMarker.lat,
|
||||
markerColor,
|
||||
markerNotes.trim() || undefined
|
||||
)
|
||||
|
||||
setPlacingMarker(null)
|
||||
setMarkerName('')
|
||||
setMarkerNotes('')
|
||||
setMarkerColor('orange')
|
||||
}
|
||||
}, [placingMarker, markerName, markerColor, addMarker])
|
||||
}, [placingMarker, markerName, markerNotes, markerColor, addMarker])
|
||||
|
||||
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
|
||||
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||
|
|
@ -97,6 +110,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={{
|
||||
|
|
@ -110,7 +124,10 @@ export default function MapComponent() {
|
|||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => { if (scaleUnit !== 'metric') toggleScaleUnit() }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (scaleUnit !== 'metric') toggleScaleUnit()
|
||||
}}
|
||||
style={{
|
||||
background: scaleUnit === 'metric' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'metric' ? 'white' : '#666',
|
||||
|
|
@ -121,8 +138,12 @@ export default function MapComponent() {
|
|||
>
|
||||
Metric
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { if (scaleUnit !== 'imperial') toggleScaleUnit() }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (scaleUnit !== 'imperial') toggleScaleUnit()
|
||||
}}
|
||||
style={{
|
||||
background: scaleUnit === 'imperial' ? '#424420' : 'white',
|
||||
color: scaleUnit === 'imperial' ? 'white' : '#666',
|
||||
|
|
@ -136,7 +157,6 @@ export default function MapComponent() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing markers */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
|
|
@ -156,7 +176,6 @@ export default function MapComponent() {
|
|||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup for selected marker */}
|
||||
{selectedMarker && (
|
||||
<Popup
|
||||
longitude={selectedMarker.longitude}
|
||||
|
|
@ -167,10 +186,15 @@ export default function MapComponent() {
|
|||
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>
|
||||
)}
|
||||
|
||||
{/* Popup for placing a new marker */}
|
||||
{placingMarker && (
|
||||
<Popup
|
||||
longitude={placingMarker.lng}
|
||||
|
|
@ -192,33 +216,45 @@ export default function MapComponent() {
|
|||
}}
|
||||
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
|
||||
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"
|
||||
/>
|
||||
|
||||
<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',
|
||||
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 }}
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
|
|
@ -231,7 +267,6 @@ export default function MapComponent() {
|
|||
)}
|
||||
</Map>
|
||||
|
||||
{/* Marker panel overlay */}
|
||||
<MarkerPanel
|
||||
markers={markers}
|
||||
onDelete={handleDeleteMarker}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface MapMarker {
|
|||
longitude: number
|
||||
latitude: number
|
||||
color: PinColorId
|
||||
notes?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +26,6 @@ export function useMapMarkers() {
|
|||
const [markers, setMarkers] = useState<MapMarker[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Load markers from API on mount
|
||||
useEffect(() => {
|
||||
api.listMapMarkers().then((data) => {
|
||||
if (data) {
|
||||
|
|
@ -36,17 +36,32 @@ export function useMapMarkers() {
|
|||
longitude: m.longitude,
|
||||
latitude: m.latitude,
|
||||
color: m.color as PinColorId,
|
||||
notes: m.notes ?? null,
|
||||
createdAt: m.created_at,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
setLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addMarker = useCallback(
|
||||
async (name: string, longitude: number, latitude: number, color: PinColorId = 'orange') => {
|
||||
const result = await api.createMapMarker({ name, longitude, latitude, color })
|
||||
async (
|
||||
name: string,
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
color: PinColorId = 'orange',
|
||||
notes?: string
|
||||
) => {
|
||||
const result = await api.createMapMarker({
|
||||
name,
|
||||
longitude,
|
||||
latitude,
|
||||
color,
|
||||
notes,
|
||||
})
|
||||
|
||||
if (result) {
|
||||
const marker: MapMarker = {
|
||||
id: result.id,
|
||||
|
|
@ -54,28 +69,47 @@ export function useMapMarkers() {
|
|||
longitude: result.longitude,
|
||||
latitude: result.latitude,
|
||||
color: result.color as PinColorId,
|
||||
notes: result.notes ?? null,
|
||||
createdAt: result.created_at,
|
||||
}
|
||||
|
||||
setMarkers((prev) => [...prev, marker])
|
||||
return marker
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMarker = useCallback(async (id: number, updates: { name?: string; color?: string }) => {
|
||||
const result = await api.updateMapMarker(id, updates)
|
||||
if (result) {
|
||||
setMarkers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === id
|
||||
? { ...m, name: result.name, color: result.color as PinColorId }
|
||||
: m
|
||||
const updateMarker = useCallback(
|
||||
async (
|
||||
id: number,
|
||||
updates: {
|
||||
name?: string
|
||||
color?: string
|
||||
notes?: string | null
|
||||
}
|
||||
) => {
|
||||
const result = await api.updateMapMarker(id, updates)
|
||||
|
||||
if (result) {
|
||||
setMarkers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === id
|
||||
? {
|
||||
...m,
|
||||
name: result.name,
|
||||
color: result.color as PinColorId,
|
||||
notes: result.notes ?? null,
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const deleteMarker = useCallback(async (id: number) => {
|
||||
await api.deleteMapMarker(id)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
name: project-nomad
|
||||
services:
|
||||
admin:
|
||||
image: ghcr.io/crosstalk-solutions/project-nomad:latest
|
||||
pull_policy: always
|
||||
image: project-nomad:local
|
||||
pull_policy: never
|
||||
container_name: nomad_admin
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
|
|
@ -18,7 +18,7 @@ services:
|
|||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well!
|
||||
- /Users/kbb3/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well!
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
|
||||
- nomad-update-shared:/app/update-shared # Shared volume for update communication
|
||||
environment:
|
||||
|
|
@ -27,18 +27,18 @@ services:
|
|||
- PORT=8080
|
||||
- LOG_LEVEL=info
|
||||
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
|
||||
- APP_KEY=replaceme
|
||||
- APP_KEY=17cd5668db08606ef9befd97311bf83f787485507418bc41f47f4ef81070b7f7
|
||||
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
|
||||
- HOST=0.0.0.0
|
||||
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
|
||||
- URL=replaceme
|
||||
- URL=http://localhost:8080
|
||||
- DB_HOST=mysql
|
||||
# If you change the MySQL port, make sure to update this accordingly
|
||||
- DB_PORT=3306
|
||||
- DB_DATABASE=nomad
|
||||
- DB_USER=nomad_user
|
||||
# Needs to match the MYSQL_PASSWORD in the mysql service!
|
||||
- DB_PASSWORD=replaceme
|
||||
- DB_PASSWORD=28230a296b16650b7f241fb7044694245e4c039fb1dc4a79f496ca79d6c0d450
|
||||
- DB_NAME=nomad
|
||||
- DB_SSL=false
|
||||
- REDIS_HOST=redis
|
||||
|
|
@ -72,13 +72,13 @@ services:
|
|||
container_name: nomad_mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=replaceme
|
||||
- MYSQL_ROOT_PASSWORD=ee680548bc3c992dac137343413828adf20d8844e8fc69158a4c28b0abe05ca6
|
||||
- MYSQL_DATABASE=nomad
|
||||
- MYSQL_USER=nomad_user
|
||||
# Needs to match DB_PASSWORD in the admin service!
|
||||
- MYSQL_PASSWORD=replaceme
|
||||
- MYSQL_PASSWORD=28230a296b16650b7f241fb7044694245e4c039fb1dc4a79f496ca79d6c0d450
|
||||
volumes:
|
||||
- /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
|
||||
- /Users/kbb3/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 30s
|
||||
|
|
@ -89,7 +89,7 @@ services:
|
|||
container_name: nomad_redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
|
||||
- /Users/kbb3/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
|
|
@ -103,18 +103,18 @@ services:
|
|||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon
|
||||
- /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly
|
||||
- /Users/kbb3/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly
|
||||
- nomad-update-shared:/shared # Shared volume for communication with admin container
|
||||
disk-collector:
|
||||
#disk-collector:
|
||||
# Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI.
|
||||
# It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality.
|
||||
image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest
|
||||
pull_policy: always
|
||||
container_name: nomad_disk_collector
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
|
||||
- /opt/project-nomad/storage:/storage
|
||||
#image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest
|
||||
#pull_policy: always
|
||||
#container_name: nomad_disk_collector
|
||||
#restart: unless-stopped
|
||||
#volumes:
|
||||
# - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
|
||||
# - /Users/kbb3/project-nomad/storage:/storage
|
||||
|
||||
volumes:
|
||||
nomad-update-shared:
|
||||
|
|
|
|||
Loading…
Reference in New Issue