This commit is contained in:
Kenneth Brewer 2026-07-18 14:01:36 -04:00 committed by GitHub
commit 4312f90fb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1828 additions and 434 deletions

View File

@ -164,33 +164,49 @@ export default class MapsController {
longitude: vine.number().min(-180).max(180),
latitude: vine.number().min(-90).max(90),
color: vine.string().trim().maxLength(20).optional(),
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
icon: vine.string().trim().maxLength(50).nullable().optional(),
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
visible: vine.boolean().optional(),
notes: vine.string().trim().nullable().optional(),
marker_type: vine.string().trim().maxLength(20).optional(),
})
)
)
const marker = await MapMarker.create({
name: payload.name,
longitude: payload.longitude,
latitude: payload.latitude,
color: payload.color ?? 'orange',
custom_color: payload.custom_color ?? null,
icon: payload.icon ?? null,
icon_color: payload.icon_color ?? null,
visible: payload.visible ?? true,
notes: payload.notes ?? null,
marker_type: payload.marker_type ?? 'pin',
})
return marker
}
async updateMarker({ request, response }: HttpContext) {
const { id } = request.params()
const marker = await MapMarker.find(id)
if (!marker) {
return response.status(404).send({ message: 'Marker not found' })
}
const payload = await request.validateUsing(
vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(255).optional(),
color: vine.string().trim().maxLength(20).optional(),
custom_color: vine.string().trim().maxLength(7).nullable().optional(),
icon: vine.string().trim().maxLength(50).nullable().optional(),
icon_color: vine.string().trim().maxLength(7).nullable().optional(),
visible: vine.boolean().optional(),
longitude: vine.number().min(-180).max(180).optional(),
latitude: vine.number().min(-90).max(90).optional(),
notes: vine.string().trim().nullable().optional(),
@ -198,12 +214,18 @@ export default class MapsController {
})
)
)
if (payload.name !== undefined) marker.name = payload.name
if (payload.color !== undefined) marker.color = payload.color
if (payload.custom_color !== undefined) marker.custom_color = payload.custom_color
if (payload.icon !== undefined) marker.icon = payload.icon
if (payload.icon_color !== undefined) marker.icon_color = payload.icon_color
if (payload.visible !== undefined) marker.visible = payload.visible
if (payload.longitude !== undefined) marker.longitude = payload.longitude
if (payload.latitude !== undefined) marker.latitude = payload.latitude
if (payload.notes !== undefined) marker.notes = payload.notes
if (payload.marker_type !== undefined) marker.marker_type = payload.marker_type
await marker.save()
return marker
}

View File

@ -35,6 +35,18 @@ export default class MapMarker extends BaseModel {
@column()
declare notes: string | null
@column()
declare custom_color: string | null
@column()
declare icon: string | null
@column()
declare icon_color: string | null
@column()
declare visible: boolean
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -878,7 +878,7 @@ export class DockerService {
await KVStore.setValue('chat.suggestionsEnabled', false)
logger.info('[DockerService] Ollama installation complete. Triggering Nomad docs discovery...')
// Need to use dynamic imports here to avoid circular dependency
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
const ragService = new (await import('./rag_service.js')).RagService(this, ollamaService)
@ -1752,7 +1752,7 @@ export class DockerService {
const newContainerConfig: any = {
Image: runtimeImage,
name: serviceName,
Env: finalEnv.length > 0 ? finalEnv : undefined,
Env: inspectData.Config?.Env || undefined,
Cmd: inspectData.Config?.Cmd || undefined,
ExposedPorts: inspectData.Config?.ExposedPorts || undefined,
WorkingDir: inspectData.Config?.WorkingDir || undefined,

View File

@ -724,7 +724,7 @@ export class RagService {
/**
* Main pipeline to process and embed an uploaded file into the RAG knowledge base.
* This includes text extraction, chunking, embedding, and storing in Qdrant.
*
*
* Orchestrates file type detection and delegates to specialized processors.
* For ZIM files, supports batch processing via batchOffset parameter.
*/

View File

@ -309,7 +309,7 @@ export class ZimService {
await this.onWikipediaDownloadComplete(url, true)
}
}
// Update the kiwix library XML after all downloaded ZIM files are in place.
// This covers all ZIM types including Wikipedia. Rebuilding once from disk
// avoids repeated XML parse/write cycles and reduces the chance of write races

View File

@ -9,7 +9,11 @@ export default class extends BaseSchema {
table.string('name').notNullable()
table.double('longitude').notNullable()
table.double('latitude').notNullable()
table.string('color', 20).notNullable().defaultTo('orange')
table.string('color', 20).notNullable().defaultTo('orange') // retaining this for backward compatibility
table.string('custom_color', 7).nullable() // "#aabbcc"
table.string('icon', 50).nullable() // "circle", "star", "flag", etc.
table.string('icon_color', 7).nullable()
table.boolean('visible').notNullable().defaultTo(true)
table.string('marker_type', 20).notNullable().defaultTo('pin')
table.string('route_id').nullable()
table.integer('route_order').nullable()

View File

@ -0,0 +1,28 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class AddVisibilityAndIconFieldsToMapMarkers extends BaseSchema {
protected tableName = 'map_markers'
async up() {
// create_map_markers_table now includes these columns on fresh installs.
if (await this.schema.hasColumn(this.tableName, 'custom_color')) {
return
}
this.schema.alterTable(this.tableName, (table) => {
table.string('custom_color', 7).nullable()
table.string('icon', 50).nullable()
table.string('icon_color', 7).nullable()
table.boolean('visible').notNullable().defaultTo(true)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('custom_color')
table.dropColumn('icon')
table.dropColumn('icon_color')
table.dropColumn('visible')
})
}
}

View File

@ -0,0 +1,167 @@
import { useMemo, useState } from 'react'
import * as TablerIcons from '@tabler/icons-react'
import type { IconProps } from '@tabler/icons-react'
import type { ComponentType } from 'react'
import * as FontAwesomeIcons from 'react-icons/fa'
import type { IconType } from 'react-icons'
const PAGE_SIZE = 48
type IconSelectorPopoverProps = {
selectedIcon?: string | null
onSelect: (iconName: string) => void
onClose: () => void
}
const normalizeIconNameForSort = (name: string) => {
return name
.replace(/^Icon/, '') // Tabler: IconHome -> Home
.replace(/^Fa/, '') // FontAwesome: FaHome -> Home
.toLowerCase()
}
type IconEntry = {
name: string
label: string
library: 'tabler' | 'fa'
Icon: ComponentType<IconProps> | IconType
}
const tablerIconEntries: IconEntry[] = Object.entries(TablerIcons)
.filter(([name, value]) => {
return (
name.startsWith('Icon') &&
name !== 'Icon' &&
value !== null &&
(typeof value === 'function' || typeof value === 'object')
)
})
.map(([name, Icon]) => ({
name: `tabler:${name}`,
label: name,
library: 'tabler' as const,
Icon: Icon as ComponentType<IconProps>,
}))
const fontAwesomeIconEntries: IconEntry[] = Object.entries(FontAwesomeIcons)
.filter(([name, value]) => {
return (
name.startsWith('Fa') &&
value !== null &&
(typeof value === 'function' || typeof value === 'object')
)
})
.map(([name, Icon]) => ({
name: `fa:${name}`,
label: name,
library: 'fa' as const,
Icon: Icon as IconType,
}))
const iconEntries: IconEntry[] = [
...tablerIconEntries,
...fontAwesomeIconEntries,
].sort((a, b) => a.label.localeCompare(b.label))
export default function IconSelectorPopover({
selectedIcon,
onSelect,
onClose,
}: IconSelectorPopoverProps) {
const [query, setQuery] = useState('')
const [page, setPage] = useState(0)
const filteredIcons = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return iconEntries.filter((entry) => {
const normalizedLabel = normalizeIconNameForSort(entry.label)
return (
entry.label.toLowerCase().includes(normalizedQuery) ||
normalizedLabel.includes(normalizedQuery) ||
entry.library.toLowerCase().includes(normalizedQuery)
)
})
}, [query])
const pageCount = Math.max(1, Math.ceil(filteredIcons.length / PAGE_SIZE))
const pagedIcons = filteredIcons.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
return (
<div
className="absolute left-0 top-7 z-50 w-72 rounded-md border border-border-subtle bg-surface-primary p-2 shadow-lg"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<input
autoFocus
type="search"
placeholder="Search icons..."
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(0)
}}
className="mb-2 block w-full rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
/>
<div className="max-h-56 overflow-y-auto themed-scrollbar">
<div className="grid grid-cols-6 gap-1">
{pagedIcons.map(({ name, label, library, Icon }) => (
<button
key={name}
type="button"
title={`${label} (${library})`}
aria-label={label}
onClick={() => {
onSelect(name)
onClose()
}}
className={`flex h-8 w-8 items-center justify-center rounded transition-colors hover:bg-surface-secondary ${
selectedIcon === name ? 'bg-desert-green text-white' : 'text-text-secondary'
}`}
>
<Icon size={18} />
</button>
))}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setPage((prev) => Math.max(0, prev - 1))}
disabled={page === 0}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
>
Prev
</button>
<span className="text-xs text-text-muted">
{filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`}
</span>
<button
type="button"
onClick={() => setPage((prev) => Math.min(pageCount - 1, prev + 1))}
disabled={page >= pageCount - 1}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
>
Next
</button>
</div>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
>
Close
</button>
</div>
</div>
)
}

View File

@ -1,31 +1,45 @@
import Map, {
FullscreenControl,
Marker,
MapProvider,
NavigationControl,
ScaleControl,
Marker,
Popup,
MapProvider,
} from 'react-map-gl/maplibre'
import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre'
import type { MapLayerMouseEvent, MapRef } from 'react-map-gl/maplibre'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Protocol } from 'pmtiles'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers'
import { useMapMarkers } from '~/hooks/useMapMarkers'
import MarkerPin from './MarkerPin'
import MarkerPanel from './MarkerPanel'
import CoordinateOverlay from './CoordinateOverlay'
import ScaleUnitToggle from './ScaleUnitToggle'
import ViewMapMarkerPopup from './ViewMapMarkerPopup'
import MapMarkerFormPopup from './MapMarkerFormPopup'
import ScaleUnitToggle from './ScaleUnitSelector'
type ScaleUnit = 'imperial' | 'metric'
type ScaleUnit = 'imperial' | 'metric' | 'nautical'
type MapCommand = {
id: number
lat: number
lng: number
action: 'fly' | 'marker'
}
type MapComponentProps = {
isHoveringUI: boolean
showCoordinatesEnabled: boolean
mapCommand?: MapCommand | null
isHoveringUI?: boolean
showCoordinatesEnabled?: boolean
}
type MapLocationParams = {
lat: number
lng: number
zoom: number
}
const SAVED_MAP_VIEW_KEY = 'nomad:map-view'
@ -33,6 +47,45 @@ const DEFAULT_MAP_VIEW = { longitude: -101, latitude: 40, zoom: 3.5 }
type SavedMapView = { longitude: number; latitude: number; zoom: number }
const getMapLocationParams = (): MapLocationParams | null => {
const params = new URLSearchParams(window.location.search)
const lat = Number(params.get('lat'))
const lngParam = params.get('lng')
const longParam = params.get('long')
const lng = Number(lngParam ?? longParam)
const zoom = Number(params.get('zoom') ?? 12)
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
lat < -90 ||
lat > 90 ||
lng < -180 ||
lng > 180
) {
return null
}
if (!lngParam && longParam) {
params.set('lng', longParam)
params.delete('long')
const query = params.toString()
window.history.replaceState(
null,
'',
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
)
}
return {
lat,
lng,
zoom: Number.isFinite(zoom) ? zoom : 12,
}
}
// Restore the last map position/zoom from localStorage so a refresh of /maps doesn't snap back
// to the default US-wide view. Bounds-checked so a corrupt or out-of-range value falls through
// to the default instead of throwing.
@ -60,25 +113,41 @@ const getSavedMapView = (): SavedMapView | null => {
return null
}
const isValidMarkerCoordinate = (marker: { longitude: number; latitude: number }) =>
Number.isFinite(marker.longitude) &&
Number.isFinite(marker.latitude) &&
marker.longitude >= -180 &&
marker.longitude <= 180 &&
marker.latitude >= -90 &&
marker.latitude <= 90
export default function MapComponent({
isHoveringUI,
showCoordinatesEnabled,
}: MapComponentProps) {
mapCommand,
isHoveringUI = false,
showCoordinatesEnabled = true,
}: MapComponentProps) {
const mapRef = useRef<MapRef>(null)
const animationFrameRef = useRef<number | null>(null)
const handledMapCommandIdRef = useRef<number | null>(null)
const { markers, addMarker, deleteMarker } = useMapMarkers()
const { markers, addMarker, updateMarker, deleteMarker } = useMapMarkers()
const [targetIndicator, setTargetIndicator] = useState<{ lng: number; lat: number } | null>(null)
const [isDraggingMap, setIsDraggingMap] = useState(false)
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 [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false)
const [showCoordinates, setShowCoordinates] = useState(false)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
const getInitialScaleUnit = (): ScaleUnit => {
const stored = localStorage.getItem('nomad:map-scale-unit')
return stored === 'metric' || stored === 'imperial' || stored === 'nautical'
? stored
: 'metric'
}
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(getInitialScaleUnit)
// Resolve the initial view once at mount: saved view → default. Lazy so it isn't recomputed
// on every render.
@ -91,7 +160,26 @@ export default function MapComponent({
y: number
} | null>(null)
const [showCoordinates, setShowCoordinates] = useState(false)
const hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
const flyToLocationParams = useCallback(() => {
const location = getMapLocationParams()
if (!location) return
mapRef.current?.flyTo({
center: [location.lng, location.lat],
zoom: location.zoom,
duration: 1500,
})
}, [])
const confirmDiscardMarkerChanges = useCallback(() => {
if (!hasUnsavedMarkerChanges) return true
return window.confirm('Discard unsaved marker changes?')
}, [hasUnsavedMarkerChanges])
useEffect(() => {
const protocol = new Protocol()
@ -110,16 +198,88 @@ export default function MapComponent({
}
}, [])
const hideCoordinates = useCallback(() => {
setShowCoordinates(false)
setCursorLngLat(null)
}, [])
useEffect(() => {
if (!mapCommand) return
if (handledMapCommandIdRef.current === mapCommand.id) return
handledMapCommandIdRef.current = mapCommand.id
if (mapCommand.action === 'fly') {
const currentZoom = mapRef.current?.getZoom() ?? 12
setTargetIndicator({
lng: mapCommand.lng,
lat: mapCommand.lat,
})
mapRef.current?.flyTo({
center: [mapCommand.lng, mapCommand.lat],
zoom: currentZoom,
duration: 1500,
})
return
}
if (mapCommand.action === 'marker') {
if (!confirmDiscardMarkerChanges()) return
setTargetIndicator(null)
const currentZoom = mapRef.current?.getZoom() ?? 12
mapRef.current?.flyTo({
center: [mapCommand.lng, mapCommand.lat],
zoom: currentZoom,
duration: 750,
})
window.setTimeout(() => {
setPlacingMarker({
lng: mapCommand.lng,
lat: mapCommand.lat,
})
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}, 750)
}
}, [mapCommand, confirmDiscardMarkerChanges])
useEffect(() => {
if (!selectedMarkerId) return
const marker = markers.find((existingMarker) => existingMarker.id === selectedMarkerId)
if (!marker || marker.visible === false) {
setSelectedMarkerId(null)
setEditingMarkerId(null)
}
}, [markers, selectedMarkerId])
const handleScaleUnitChange = useCallback((unit: ScaleUnit) => {
setScaleUnit(unit)
localStorage.setItem('nomad:map-scale-unit', unit)
}, [])
const handleMapLoad = useCallback(() => {
flyToLocationParams()
}, [flyToLocationParams])
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
if (!confirmDiscardMarkerChanges()) return
setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat })
setSelectedMarkerId(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
},
[confirmDiscardMarkerChanges]
)
const handleMouseMove = useCallback(
(e: MapLayerMouseEvent) => {
const target = e.originalEvent.target as HTMLElement | null
@ -151,44 +311,25 @@ export default function MapComponent({
[hideCoordinates, isHoveringUI, isDraggingMap, showCoordinatesEnabled]
)
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()) {
const trimmedNotes = markerNotes.trim()
addMarker(
markerName.trim(),
placingMarker.lng,
placingMarker.lat,
markerColor,
trimmedNotes ? trimmedNotes : null
)
setPlacingMarker(null)
setMarkerName('')
setMarkerNotes('')
setMarkerColor('orange')
}
}, [placingMarker, markerName, markerNotes, markerColor, addMarker])
const handleFlyTo = useCallback((longitude: number, latitude: number) => {
setTargetIndicator(null)
mapRef.current?.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
}, [])
const handleDeleteMarker = useCallback(
(id: number) => {
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 && isValidMarkerCoordinate(marker))
: null
return (
<MapProvider>
@ -230,6 +371,7 @@ export default function MapComponent({
// ignore persistence failures (private mode, quota)
}
}}
onLoad={handleMapLoad}
onMouseDown={() => {
setIsDraggingMap(true)
hideCoordinates()
@ -262,121 +404,126 @@ export default function MapComponent({
/>
)}
{targetIndicator && (
<Marker longitude={targetIndicator.lng} latitude={targetIndicator.lat} anchor="center">
<div
className="pointer-events-none flex h-9 w-9 items-center justify-center rounded-full border-2 border-desert-orange bg-surface-primary/70 shadow-lg"
aria-hidden="true"
>
<div className="relative h-5 w-5">
<div className="absolute left-1/2 top-0 h-full w-[2px] -translate-x-1/2 bg-desert-orange" />
<div className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 bg-desert-orange" />
</div>
</div>
</Marker>
)}
<ScaleUnitToggle
scaleUnit={scaleUnit}
onChange={handleScaleUnitChange}
onMouseEnter={hideCoordinates}
/>
{markers.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
}}
>
<MarkerPin
color={PIN_COLORS.find((c) => c.id === marker.color)?.hex}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{markers
.filter((marker) => marker.visible)
.map((marker) => (
<Marker
key={marker.id}
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation()
{selectedMarker && (
<Popup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
anchor="bottom"
offset={[0, -36]}
onClose={() => setSelectedMarkerId(null)}
closeOnClick={false}
>
<div className="text-sm font-medium">{selectedMarker.name}</div>
{selectedMarker.notes && selectedMarker.notes.trim() && (
<div className="mt-1 text-xs text-desert-stone-dark whitespace-pre-wrap break-words max-w-[240px]">
{selectedMarker.notes}
</div>
)}
</Popup>
)}
if (!confirmDiscardMarkerChanges()) return
setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id)
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
>
<MarkerPin
color={marker.color}
customColor={marker.customColor}
icon={marker.icon}
iconColor={marker.iconColor}
visible={marker.visible}
active={marker.id === selectedMarkerId}
/>
</Marker>
))}
{placingMarker && (
<Popup
<MapMarkerFormPopup
longitude={placingMarker.lng}
latitude={placingMarker.lat}
anchor="bottom"
onClose={() => setPlacingMarker(null)}
closeOnClick={false}
>
<div onMouseEnter={hideCoordinates} 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"
/>
onDirtyChange={setHasUnsavedMarkerChanges}
onMouseEnter={hideCoordinates}
onSave={async ({ name, notes, color, customColor, icon }) => {
await addMarker({
name,
longitude: placingMarker.lng,
latitude: placingMarker.lat,
color,
customColor,
icon,
notes: notes || null,
})
<textarea
placeholder="Notes (optional)"
value={markerNotes}
onChange={(e) => setMarkerNotes(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setPlacingMarker(null)
}}
rows={2}
className="mt-1.5 block w-full resize-y rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500"
/>
setPlacingMarker(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
<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>
setPlacingMarker(null)
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
setTargetIndicator(null)
}}
/>
)}
<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 && (
<ViewMapMarkerPopup
marker={selectedMarker}
onClose={() => setSelectedMarkerId(null)}
onEdit={() => setEditingMarkerId(selectedMarker.id)}
onMouseEnter={hideCoordinates}
/>
)}
<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>
{selectedMarker && editingMarkerId === selectedMarker.id && (
<MapMarkerFormPopup
longitude={selectedMarker.longitude}
latitude={selectedMarker.latitude}
initialMarker={selectedMarker}
onDirtyChange={setHasUnsavedMarkerChanges}
onMouseEnter={hideCoordinates}
onSave={async ({ id, name, notes, color, customColor, icon }) => {
if (!id) return
await updateMarker(id, {
name,
notes: notes || null,
color,
customColor,
icon,
})
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
onCancel={() => {
if (!confirmDiscardMarkerChanges()) return
setEditingMarkerId(null)
setHasUnsavedMarkerChanges(false)
}}
/>
)}
</Map>
</div>
@ -388,6 +535,7 @@ export default function MapComponent({
onFlyTo={handleFlyTo}
onSelect={setSelectedMarkerId}
selectedMarkerId={selectedMarkerId}
onToggleVisibility={(id, visible) => updateMarker(id, { visible })}
/>
</div>
</MapProvider>

View File

@ -0,0 +1,251 @@
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'
import {Popup} from 'react-map-gl/maplibre'
import {IconPalette} from '@tabler/icons-react'
import {IconIcons} from '@tabler/icons-react'
import IconSelectorPopover from './IconSelectorPopover'
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
customColor: string | null
icon: string | null
}) => Promise<void> | void
onCancel: () => void
onDirtyChange?: (dirty: boolean) => void
onMouseEnter?: () => void
}
export default function MapMarkerFormPopup({
longitude,
latitude,
initialMarker,
onSave,
onCancel,
onDirtyChange,
onMouseEnter,
}: MapMarkerFormPopupProps) {
const [name, setName] = useState(initialMarker?.name ?? '')
const [notes, setNotes] = useState(initialMarker?.notes ?? '')
const [color, setColor] = useState<PinColorId>(initialMarker?.color ?? 'orange')
const [customColor, setCustomColor] = useState<string | null>(initialMarker?.customColor ?? null)
const [isSaving, setIsSaving] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const nameInputRef = useRef<HTMLInputElement | null>(null)
const colorInputRef = useRef<HTMLInputElement | null>(null)
const resizeTextarea = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}, [])
const [icon, setIcon] = useState<string | null>(initialMarker?.icon ?? null)
const [showIconSelector, setShowIconSelector] = useState(false)
useLayoutEffect(() => {
resizeTextarea()
}, [resizeTextarea])
useLayoutEffect(() => {
nameInputRef.current?.focus()
nameInputRef.current?.select()
}, [])
const isDirty =
name !== (initialMarker?.name ?? '') ||
notes !== (initialMarker?.notes ?? '') ||
color !== (initialMarker?.color ?? 'orange') ||
customColor !== (initialMarker?.customColor ?? null) ||
icon !== (initialMarker?.icon ?? null)
useEffect(() => {
onDirtyChange?.(isDirty)
}, [isDirty, onDirtyChange])
const handleSave = async () => {
if (!name.trim() || isSaving) return
try {
setIsSaving(true)
await onSave({
id: initialMarker?.id,
name: name.trim(),
notes: notes.trim(),
color,
customColor,
icon,
})
} finally {
setIsSaving(false)
}
}
return (
<Popup
longitude={longitude}
latitude={latitude}
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={onCancel}
closeOnClick={false}
closeButton={false}
>
<div
className="p-1"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseEnter={onMouseEnter}
>
<input
ref={nameInputRef}
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
ref={textareaRef}
placeholder="Add notes (optional)"
value={notes}
rows={2}
maxLength={MAX_MARKER_NOTES_LENGTH}
onChange={(e) => {
setNotes(e.target.value)
requestAnimationFrame(resizeTextarea)
}}
className={`mt-1 min-h-[64px] max-h-[240px] resize-none overflow-y-auto themed-scrollbar ${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)
setCustomColor(null)
}}
title={pinColor.label}
className="rounded-full p-0.5 transition-transform"
style={{
outline:
!customColor && 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>
))}
<button
type="button"
title="Choose custom marker color"
aria-label="Choose custom marker color"
onClick={() => colorInputRef.current?.click()}
className="rounded-full p-0.5 transition-transform"
style={{
outline: customColor ? `2px solid ${customColor}` : '2px solid transparent',
outlineOffset: '1px',
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-border-default"
style={{backgroundColor: customColor ?? '#424420'}}
>
<IconPalette size={16} fill="currentColor" stroke={1.5} className="text-white"/>
</span>
</button>
<div className="relative">
<button
type="button"
title="Choose custom marker icon"
aria-label="Choose custom marker icon"
onClick={() => setShowIconSelector((prev) => !prev)}
className="rounded-full p-0.5 transition-transform"
style={{
outline: icon ? '2px solid #424420' : '2px solid transparent',
outlineOffset: '1px',
}}
>
<span
className="flex h-5 w-5 items-center justify-center rounded-full border border-border-default bg-[#424420] text-white">
<IconIcons size={16} fill="currentColor" stroke={1.5}/>
</span>
</button>
{showIconSelector && (
<IconSelectorPopover
selectedIcon={icon}
onSelect={setIcon}
onClose={() => setShowIconSelector(false)}
/>
)}
</div>
<input
ref={colorInputRef}
type="color"
value={customColor ?? '#a84a12'}
onChange={(e) => setCustomColor(e.target.value)}
className="sr-only"
aria-label="Choose custom marker color"
/>
</div>
<div className="mt-1.5 flex gap-1.5 justify-end">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!name.trim() || isSaving}
className="text-xs bg-[#424420] text-white rounded px-2.5 py-1 hover:bg-[#525530] disabled:opacity-40 transition-colors"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Popup>
)
}

View File

@ -1,5 +1,20 @@
import { useState } from 'react'
import { IconMapPinFilled, IconTrash, IconMapPin, IconX } from '@tabler/icons-react'
import { useMemo, useState } from 'react'
import {
IconEye,
IconEyeOff,
IconList,
IconMapPin,
IconMapPinFilled,
IconSitemap,
IconTrash,
IconX,
} from '@tabler/icons-react'
import * as FontAwesomeIcons from 'react-icons/fa'
import type { IconType } from 'react-icons'
import * as TablerIcons from '@tabler/icons-react'
import type { IconProps } from '@tabler/icons-react'
import type { ComponentType } from 'react'
import { PIN_COLORS } from '~/hooks/useMapMarkers'
import type { MapMarker } from '~/hooks/useMapMarkers'
@ -8,29 +23,351 @@ interface MarkerPanelProps {
onDelete: (id: number) => void
onFlyTo: (longitude: number, latitude: number) => void
onSelect: (id: number | null) => void
onToggleVisibility: (id: number, visible: boolean) => void
selectedMarkerId: number | null
}
type SortField = 'name' | 'color' | 'visibility' | 'icon'
type SortDirection = 'asc' | 'desc'
type ViewMode = 'list' | 'tree'
type ColorSortValue = {
bucket: number
hue: number
lightness: number
}
type MarkerGroup = {
key: string
label: string
markers: MapMarker[]
}
const normalizeColorHex = (color: string, customColor?: string | null) => {
if (customColor) return customColor
const preset = PIN_COLORS.find((pinColor) => pinColor.id === color)
return preset?.hex ?? color
}
const getColorSortValue = (color: string, customColor?: string | null): ColorSortValue => {
const hex = normalizeColorHex(color, customColor).replace('#', '')
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
return { bucket: 2, hue: 0, lightness: 0 }
}
const r = parseInt(hex.slice(0, 2), 16) / 255
const g = parseInt(hex.slice(2, 4), 16) / 255
const b = parseInt(hex.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const lightness = (max + min) / 2
if (delta === 0) {
return { bucket: 0, hue: 0, lightness }
}
let hue = 0
if (max === r) {
hue = ((g - b) / delta) % 6
} else if (max === g) {
hue = (b - r) / delta + 2
} else {
hue = (r - g) / delta + 4
}
return {
bucket: 1,
hue: Math.round(hue * 60 + 360) % 360,
lightness,
}
}
const getHueGroupLabel = ({ bucket, hue, lightness }: ColorSortValue) => {
if (bucket === 0) {
return lightness < 0.34
? 'Grayscale — Dark'
: lightness < 0.67
? 'Grayscale — Mid'
: 'Grayscale — Light'
}
if (bucket === 2) return 'Other colors'
if (hue < 30 || hue >= 330) return 'Red'
if (hue < 60) return 'Orange'
if (hue < 90) return 'Yellow'
if (hue < 150) return 'Green'
if (hue < 210) return 'Cyan'
if (hue < 270) return 'Blue'
return 'Purple'
}
const getReadableIconName = (icon?: string | null) => {
if (!icon) return 'Default pin'
return icon
.replace(/^fa:/, '')
.replace(/^tabler:/, '')
.replace(/^Fa/, '')
.replace(/^Icon/, '')
}
const resolveMarkerIcon = (
icon?: string | null
): ComponentType<IconProps> | IconType => {
if (!icon) return IconMapPinFilled
// Font Awesome
if (icon.startsWith('fa:')) {
const iconName = icon.replace('fa:', '')
const Icon = (FontAwesomeIcons as Record<string, unknown>)[iconName]
return Icon ? (Icon as IconType) : IconMapPinFilled
}
// Tabler
if (icon.startsWith('tabler:')) {
const iconName = icon.replace('tabler:', '')
const Icon = (TablerIcons as Record<string, unknown>)[iconName]
return Icon ? (Icon as ComponentType<IconProps>) : IconMapPinFilled
}
// Backward compatibility
const Icon =
(TablerIcons as Record<string, unknown>)[icon] ??
(FontAwesomeIcons as Record<string, unknown>)[icon]
return Icon ? (Icon as ComponentType<IconProps> | IconType) : IconMapPinFilled
}
const getMarkerGroup = (marker: MapMarker, sortField: SortField) => {
if (sortField === 'name') {
const firstLetter = marker.name.trim().charAt(0).toUpperCase()
if (!firstLetter || !/[A-Z]/.test(firstLetter)) {
return { key: '#', label: '#' }
}
return { key: firstLetter, label: firstLetter }
}
if (sortField === 'color') {
const label = getHueGroupLabel(getColorSortValue(marker.color, marker.customColor))
return { key: label, label }
}
if (sortField === 'icon') {
const label = getReadableIconName(marker.icon)
return { key: label, label }
}
return marker.visible
? { key: 'visible', label: 'Visible' }
: { key: 'hidden', label: 'Hidden' }
}
export default function MarkerPanel({
markers,
onDelete,
onFlyTo,
onSelect,
selectedMarkerId,
}: MarkerPanelProps) {
markers,
onDelete,
onFlyTo,
onSelect,
onToggleVisibility,
selectedMarkerId,
}: MarkerPanelProps) {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [sortField, setSortField] = useState<SortField>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const sortDirectionLabel =
sortField === 'name'
? sortDirection === 'asc'
? 'A → Z'
: 'Z → A'
: sortField === 'color'
? sortDirection === 'asc'
? 'Hue ↑'
: 'Hue ↓'
: sortField === 'icon'
? sortDirection === 'asc'
? 'A → Z'
: 'Z → A'
: sortDirection === 'asc'
? 'Hidden first'
: 'Visible first'
const filteredAndSortedMarkers = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
return [...markers]
.filter((marker) => {
if (!query) return true
return marker.name.toLowerCase().includes(query)
})
.sort((a, b) => {
const result =
sortField === 'name'
? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
: sortField === 'visibility'
? Number(a.visible) - Number(b.visible)
: sortField === 'icon'
? (a.icon ? 1 : 0) - (b.icon ? 1 : 0) ||
(a.icon ?? '').localeCompare(b.icon ?? '', undefined, { sensitivity: 'base' })
: (() => {
const aColor = getColorSortValue(a.color, a.customColor)
const bColor = getColorSortValue(b.color, b.customColor)
return (
aColor.bucket - bColor.bucket ||
aColor.hue - bColor.hue ||
aColor.lightness - bColor.lightness
)
})()
return sortDirection === 'asc' ? result : -result
})
}, [markers, searchQuery, sortField, sortDirection])
const markerGroups = useMemo(() => {
const groups = new Map<string, MarkerGroup>()
filteredAndSortedMarkers.forEach((marker) => {
const group = getMarkerGroup(marker, sortField)
if (!groups.has(group.key)) {
groups.set(group.key, {
key: group.key,
label: group.label,
markers: [],
})
}
groups.get(group.key)?.markers.push(marker)
})
return Array.from(groups.values()).sort((a, b) => {
const result = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
return sortDirection === 'asc' ? result : -result
})
}, [filteredAndSortedMarkers, sortField, sortDirection])
const allGroupsCollapsed =
markerGroups.length > 0 && markerGroups.every((group) => collapsedGroups.has(group.key))
const allFilteredMarkersVisible =
filteredAndSortedMarkers.length > 0 &&
filteredAndSortedMarkers.every((marker) => marker.visible)
const toggleGroupCollapsed = (groupKey: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(groupKey)) {
next.delete(groupKey)
} else {
next.add(groupKey)
}
return next
})
}
const collapseAllGroups = () => {
setCollapsedGroups(new Set(markerGroups.map((group) => group.key)))
}
const expandAllGroups = () => {
setCollapsedGroups(new Set())
}
const setGroupVisibility = (group: MarkerGroup, visible: boolean) => {
group.markers.forEach((marker) => {
if (marker.visible !== visible) {
onToggleVisibility(marker.id, visible)
}
})
}
const setAllMarkerVisibility = (visible: boolean) => {
filteredAndSortedMarkers.forEach((marker) => {
if (marker.visible !== visible) {
onToggleVisibility(marker.id, visible)
}
})
}
const renderMarkerRow = (marker: MapMarker) => {
const MarkerIcon = resolveMarkerIcon(marker.icon)
return (
<li
key={marker.id}
className={`group flex items-center gap-2 border-b border-border-subtle px-3 py-2 transition-colors last:border-b-0 ${
marker.id === selectedMarkerId ? 'bg-desert-green/10' : 'hover:bg-surface-secondary'
} ${marker.visible ? '' : 'opacity-60'}`}
>
<MarkerIcon
size={16}
className="shrink-0"
style={{
color: normalizeColorHex(marker.color, marker.customColor),
}}
/>
<button
type="button"
onClick={() => {
onSelect(marker.id)
onFlyTo(marker.longitude, marker.latitude)
}}
className="min-w-0 flex-1 text-left"
title={marker.name}
>
<p className="truncate text-sm font-medium text-text-primary">{marker.name}</p>
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onToggleVisibility(marker.id, !marker.visible)
}}
className="shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-surface-secondary hover:text-text-primary"
title={marker.visible ? 'Hide pin' : 'Show pin'}
aria-label={marker.visible ? 'Hide pin' : 'Show pin'}
>
{marker.visible ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
<button
type="button"
onClick={() => onDelete(marker.id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 transition-all hover:bg-surface-secondary hover:text-desert-red group-hover:opacity-100"
title="Delete pin"
>
<IconTrash size={14} />
</button>
</li>
)
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg bg-surface-primary/95 px-3 py-2 shadow-lg border border-border-subtle backdrop-blur-sm hover:bg-surface-secondary transition-colors"
className="absolute left-4 top-[72px] z-40 flex items-center gap-1.5 rounded-lg border border-border-subtle bg-surface-primary/95 px-3 py-2 shadow-lg backdrop-blur-sm transition-colors hover:bg-surface-secondary"
title="Show saved locations"
>
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-medium text-text-primary">Pins</span>
{markers.length > 0 && (
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
<span className="ml-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange px-1 text-[11px] font-bold text-white">
{markers.length}
</span>
)}
@ -39,76 +376,163 @@ export default function MarkerPanel({
}
return (
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg bg-surface-primary/95 shadow-lg border border-border-subtle backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle">
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg border border-border-subtle bg-surface-primary/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2.5">
<div className="flex items-center gap-2">
<IconMapPin size={18} className="text-desert-orange" />
<span className="text-sm font-semibold text-text-primary">
Saved Locations
</span>
<span className="text-sm font-semibold text-text-primary">Saved Locations</span>
{markers.length > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange text-[11px] font-bold text-white px-1">
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-desert-orange px-1 text-[11px] font-bold text-white">
{markers.length}
</span>
)}
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded p-0.5 text-text-muted hover:text-text-primary hover:bg-surface-secondary transition-colors"
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-secondary hover:text-text-primary"
title="Close panel"
>
<IconX size={16} />
</button>
</div>
{/* Marker list */}
<div className="max-h-[calc(100vh-180px)] overflow-y-auto">
<div className="space-y-2 border-b border-border-subtle px-3 py-2">
<input
type="search"
placeholder="Search pins..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="block w-full rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setViewMode('list')}
title="List view"
className={`flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
viewMode === 'list'
? 'bg-[#424420] text-white'
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
}`}
>
<IconList size={14} />
List
</button>
<button
type="button"
onClick={() => setViewMode('tree')}
title="Tree view"
className={`flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
viewMode === 'tree'
? 'bg-[#424420] text-white'
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
}`}
>
<IconSitemap size={14} />
Tree
</button>
</div>
{viewMode === 'tree' && (
<div className="flex gap-2">
<button
type="button"
onClick={allGroupsCollapsed ? expandAllGroups : collapseAllGroups}
className="flex-1 rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
>
{allGroupsCollapsed ? 'Expand all' : 'Collapse all'}
</button>
<button
type="button"
onClick={() => setAllMarkerVisibility(!allFilteredMarkersVisible)}
className="flex-1 rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
>
{allFilteredMarkersVisible ? 'Hide all' : 'Show all'}
</button>
</div>
)}
<div className="flex gap-2">
<select
value={sortField}
onChange={(e) => {
setSortField(e.target.value as SortField)
setCollapsedGroups(new Set())
}}
className="flex-1 rounded border border-border-default bg-surface-primary px-2 py-1 text-xs text-text-primary focus:border-desert-green focus:outline-none"
>
<option value="name">Sort by name</option>
<option value="color">Sort by hue</option>
<option value="icon">Sort by icon</option>
<option value="visibility">Sort by visibility</option>
</select>
<button
type="button"
onClick={() => setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
className="rounded border border-border-default px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
>
{sortDirectionLabel}
</button>
</div>
</div>
<div className="max-h-[calc(100vh-280px)] overflow-y-auto">
{markers.length === 0 ? (
<div className="px-3 py-6 text-center">
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
<p className="text-sm text-text-muted">
Click anywhere on the map to drop a pin
</p>
<p className="text-sm text-text-muted">Click anywhere on the map to drop a pin</p>
</div>
) : filteredAndSortedMarkers.length === 0 ? (
<div className="px-3 py-6 text-center">
<p className="text-sm text-text-muted">No pins match your search.</p>
</div>
) : viewMode === 'list' ? (
<ul>{filteredAndSortedMarkers.map(renderMarkerRow)}</ul>
) : (
<ul>
{markers.map((marker) => (
<li
key={marker.id}
className={`flex items-center gap-2 px-3 py-2 border-b border-border-subtle last:border-b-0 group transition-colors ${
marker.id === selectedMarkerId
? 'bg-desert-green/10'
: 'hover:bg-surface-secondary'
}`}
>
<IconMapPinFilled
size={16}
className="shrink-0"
style={{ color: PIN_COLORS.find((c) => c.id === marker.color)?.hex ?? '#a84a12' }}
/>
<button
onClick={() => {
onSelect(marker.id)
onFlyTo(marker.longitude, marker.latitude)
}}
className="flex-1 min-w-0 text-left"
title={marker.name}
>
<p className="text-sm font-medium text-text-primary truncate">
{marker.name}
</p>
</button>
<button
onClick={() => onDelete(marker.id)}
className="shrink-0 rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-desert-red hover:bg-surface-secondary transition-all"
title="Delete pin"
>
<IconTrash size={14} />
</button>
</li>
))}
</ul>
<div>
{markerGroups.map((group) => {
const isCollapsed = collapsedGroups.has(group.key)
const allVisible = group.markers.every((marker) => marker.visible)
const someVisible = group.markers.some((marker) => marker.visible)
return (
<div key={group.key} className="border-b border-border-subtle last:border-b-0">
<div className="flex items-center gap-2 bg-surface-secondary/70 px-3 py-1.5">
<button
type="button"
onClick={() => toggleGroupCollapsed(group.key)}
className="min-w-0 flex-1 text-left text-xs font-semibold text-text-primary"
title={group.label}
>
<span className="truncate">
{isCollapsed ? '▸' : '▾'} {group.label} ({group.markers.length})
</span>
</button>
<button
type="button"
onClick={() => setGroupVisibility(group, !allVisible)}
className="shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-surface-primary hover:text-text-primary"
title={allVisible ? 'Hide all pins in group' : 'Show all pins in group'}
aria-label={allVisible ? 'Hide all pins in group' : 'Show all pins in group'}
>
{allVisible || someVisible ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
</div>
{!isCollapsed && <ul>{group.markers.map(renderMarkerRow)}</ul>}
</div>
)
})}
</div>
)}
</div>
</div>

View File

@ -1,17 +1,132 @@
import { IconMapPinFilled } from '@tabler/icons-react'
import { IconCircleFilled } from '@tabler/icons-react'
import * as TablerIcons from '@tabler/icons-react'
import type { IconProps } from '@tabler/icons-react'
import type { IconType } from 'react-icons'
import * as FontAwesomeIcons from 'react-icons/fa'
import type { ComponentType } from 'react'
import { PIN_COLORS } from '~/hooks/useMapMarkers'
import type { PinColorId } from '~/hooks/useMapMarkers'
interface MarkerPinProps {
color?: string
color?: PinColorId | string | null
customColor?: string | null
icon?: string | null
iconColor?: string | null
visible?: boolean
active?: boolean
}
export default function MarkerPin({ color = '#a84a12', active = false }: MarkerPinProps) {
const resolvePinColor = (color?: PinColorId | string | null, customColor?: string | null) => {
if (customColor) return customColor
if (!color) return '#a84a12'
const preset = PIN_COLORS.find((pinColor) => pinColor.id === color)
return preset?.hex ?? color
}
const getContrastingIconColor = (backgroundColor: string) => {
const hex = backgroundColor.replace('#', '')
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
return '#ffffff'
}
const r = parseInt(hex.slice(0, 2), 16) / 255
const g = parseInt(hex.slice(2, 4), 16) / 255
const b = parseInt(hex.slice(4, 6), 16) / 255
const luminance =
0.2126 * r +
0.7152 * g +
0.0722 * b
return luminance > 0.55 ? '#111827' : '#ffffff'
}
type MarkerIconComponent =
| ComponentType<IconProps>
| IconType
const resolveIcon = (icon?: string | null): MarkerIconComponent => {
if (!icon) return IconCircleFilled
if (icon.startsWith('fa:')) {
const iconName = icon.replace('fa:', '')
const Icon = (FontAwesomeIcons as Record<string, unknown>)[iconName]
return Icon ? (Icon as IconType) : IconCircleFilled
}
if (icon.startsWith('tabler:')) {
const iconName = icon.replace('tabler:', '')
const Icon = (TablerIcons as Record<string, unknown>)[iconName]
return Icon ? (Icon as ComponentType<IconProps>) : IconCircleFilled
}
const Icon =
(TablerIcons as Record<string, unknown>)[icon] ??
(FontAwesomeIcons as Record<string, unknown>)[icon]
return Icon ? (Icon as MarkerIconComponent) : IconCircleFilled
}
export default function MarkerPin({
color = 'orange',
customColor,
icon,
iconColor,
visible = true,
active = false,
}: MarkerPinProps) {
if (!visible) return null
const resolvedColor = resolvePinColor(color, customColor)
const resolvedIconColor = iconColor ?? getContrastingIconColor(resolvedColor)
const Icon = resolveIcon(icon)
const width = active ? 42 : 36
const height = active ? 52 : 46
const iconSize = active ? 18 : 16
return (
<div className="cursor-pointer" style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))' }}>
<IconMapPinFilled
size={active ? 36 : 32}
style={{ color }}
/>
<div
className="relative cursor-pointer"
style={{
width,
height,
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))',
}}
>
<svg
width={width}
height={height}
viewBox="0 0 36 46"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M18 45 C18 45 4 27.5 4 16.5 C4 7.4 10.3 1 18 1 C25.7 1 32 7.4 32 16.5 C32 27.5 18 45 18 45 Z"
fill={resolvedColor}
stroke="rgba(0,0,0,0.25)"
strokeWidth="1.5"
/>
<circle cx="18" cy="16.5" r="10.5" fill="rgba(255,255,255,0.18)" />
</svg>
<div
className="pointer-events-none absolute flex items-center justify-center"
style={{
left: '50%',
top: active ? 17 : 15,
transform: 'translate(-50%, -50%)',
width: iconSize,
height: iconSize,
}}
>
<Icon size={iconSize} color={resolvedIconColor} />
</div>
</div>
)
}

View File

@ -0,0 +1,69 @@
type ScaleUnit = 'imperial' | 'metric' | 'nautical'
type ScaleUnitSelectorProps = {
scaleUnit: ScaleUnit
onChange: (unit: ScaleUnit) => void
onMouseEnter?: () => void
}
export default function ScaleUnitSelector({
scaleUnit,
onChange,
onMouseEnter,
}: ScaleUnitSelectorProps) {
return (
<div className="absolute bottom-[30px] left-[10px] z-[2]" onMouseEnter={onMouseEnter}>
<div className="inline-flex overflow-hidden rounded text-[11px] font-semibold leading-none shadow-[0_0_0_2px_rgba(0,0,0,0.1)]">
{/* Metric */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'metric') onChange('metric')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Metric
</button>
{/* Imperial */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'imperial') onChange('imperial')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Imperial
</button>
{/* Nautical */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'nautical') onChange('nautical')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'nautical' ? '#424420' : 'white',
color: scaleUnit === 'nautical' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Nautical
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,83 @@
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
onMouseEnter?: () => void
}
export default function ViewMapMarkerPopup({
marker,
onClose,
onEdit,
onMouseEnter,
}: ViewMapMarkerPopupProps) {
return (
<Popup
longitude={marker.longitude}
latitude={marker.latitude}
anchor="bottom"
offset={[0, -36] as [number, number]}
onClose={onClose}
closeOnClick={false}
closeButton={false}
>
<div
className="max-w-[260px]"
onMouseEnter={onMouseEnter}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="text-sm font-medium break-words">{marker.name}</div>
{marker.notes && (
<div className="mt-1 max-w-[240px] break-all whitespace-pre-wrap text-xs text-gray-500">
{/* react-markdown is intentionally used without rehypeRaw.
Do not enable raw HTML rendering unless notes are sanitized first. */}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 underline hover:text-blue-700"
>
{children}
</a>
),
}}
>
{marker.notes}
</ReactMarkdown>
</div>
)}
<div className="mt-2 flex justify-end gap-1.5">
<button
type="button"
onClick={onClose}
className="w-16 rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530] border-none outline-none"
>
Close
</button>
<button
type="button"
onClick={onEdit}
className="w-16 rounded bg-[#424420] px-2.5 py-1 text-xs text-white hover:bg-[#525530] border-none outline-none"
>
Edit
</button>
</div>
</div>
</Popup>
)
}

View File

@ -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,51 @@ body {
[data-theme="dark"] .maplibregl-popup-close-button:hover {
color: #f7eedc;
background: #353420;
}
}
/* Base (hidden scrollbar + fade hint) */
.themed-scrollbar {
scrollbar-width: none; /* Firefox */
mask-image: linear-gradient(
to bottom,
transparent,
black 8px,
black calc(100% - 8px),
transparent
);
}
/* Chrome / Safari */
.themed-scrollbar::-webkit-scrollbar {
width: 0px;
}
/* Show scrollbar on hover OR focus */
.themed-scrollbar:hover,
.themed-scrollbar:focus {
scrollbar-width: thin;
scrollbar-color: var(--color-border-default) transparent;
}
.themed-scrollbar:hover::-webkit-scrollbar,
.themed-scrollbar:focus::-webkit-scrollbar {
width: 8px;
}
.themed-scrollbar:hover::-webkit-scrollbar-track,
.themed-scrollbar:focus::-webkit-scrollbar-track {
background: transparent;
}
.themed-scrollbar:hover::-webkit-scrollbar-thumb,
.themed-scrollbar:focus::-webkit-scrollbar-thumb {
background-color: var(--color-border-default);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
.themed-scrollbar:hover::-webkit-scrollbar-thumb:hover,
.themed-scrollbar:focus::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-muted);
}

View File

@ -1,5 +1,8 @@
import { useState, useCallback, useEffect } from 'react'
// eslint-disable-next-line @unicorn/filename-case
import { useCallback, useEffect, useState } from 'react'
import api from '~/lib/api'
import type { MapMarkerResponse } from '../../types/maps'
export const PIN_COLORS = [
{ id: 'orange', label: 'Orange', hex: '#a84a12' },
@ -10,7 +13,9 @@ export const PIN_COLORS = [
{ id: 'yellow', label: 'Yellow', hex: '#ca8a04' },
] as const
export type PinColorId = typeof PIN_COLORS[number]['id']
export type PinColorId = (typeof PIN_COLORS)[number]['id']
export type MarkerIcon = 'pin' | 'circle' | 'star'
export interface MapMarker {
id: number
@ -18,10 +23,52 @@ export interface MapMarker {
longitude: number
latitude: number
color: PinColorId
notes: string | null
customColor?: string | null
icon?: MarkerIcon | string | null
iconColor?: string | null
visible: boolean
notes?: string | null
createdAt: string
updatedAt?: string
}
type CreateMapMarkerValues = {
name: string
longitude: number
latitude: number
color?: PinColorId
customColor?: string | null
icon?: MarkerIcon | string | null
iconColor?: string | null
visible?: boolean
notes?: string | null
}
type UpdateMapMarkerValues = {
name?: string
color?: PinColorId
customColor?: string | null
icon?: MarkerIcon | string | null
iconColor?: string | null
visible?: boolean
notes?: string | null
}
const mapMarkerResponse = (marker: MapMarkerResponse): MapMarker => ({
id: marker.id,
name: marker.name,
longitude: marker.longitude,
latitude: marker.latitude,
color: marker.color as PinColorId,
customColor: marker.custom_color ?? null,
icon: marker.icon ?? null,
iconColor: marker.icon_color ?? null,
visible: marker.visible ?? true,
notes: marker.notes ?? null,
createdAt: marker.created_at,
updatedAt: marker.updated_at,
})
export function useMapMarkers() {
const [markers, setMarkers] = useState<MapMarker[]>([])
const [loaded, setLoaded] = useState(false)
@ -30,65 +77,58 @@ export function useMapMarkers() {
useEffect(() => {
api.listMapMarkers().then((data) => {
if (data) {
setMarkers(
data.map((m) => ({
id: m.id,
name: m.name,
longitude: m.longitude,
latitude: m.latitude,
color: m.color as PinColorId,
notes: m.notes ?? null,
createdAt: m.created_at,
}))
)
setMarkers(data.map(mapMarkerResponse))
}
setLoaded(true)
})
}, [])
const addMarker = useCallback(
async (
name: string,
longitude: number,
latitude: number,
color: PinColorId = 'orange',
notes: string | null = null
) => {
const result = await api.createMapMarker({ name, longitude, latitude, color, notes })
if (result) {
const marker: MapMarker = {
id: result.id,
name: result.name,
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 addMarker = useCallback(async (values: CreateMapMarkerValues) => {
const result = await api.createMapMarker({
name: values.name,
longitude: values.longitude,
latitude: values.latitude,
color: values.color ?? 'orange',
custom_color: values.customColor ?? null,
icon: values.icon ?? null,
icon_color: values.iconColor ?? null,
visible: values.visible ?? true,
notes: values.notes ?? null,
})
const updateMarker = useCallback(async (id: number, updates: { name?: string; color?: string }) => {
const result = await api.updateMapMarker(id, updates)
if (result) {
const marker = mapMarkerResponse(result)
setMarkers((prev) => [...prev, marker])
return marker
}
return null
}, [])
const updateMarker = useCallback(async (id: number, updates: UpdateMapMarkerValues) => {
const result = await api.updateMapMarker(id, {
name: updates.name,
color: updates.color,
custom_color: updates.customColor,
icon: updates.icon,
icon_color: updates.iconColor,
visible: updates.visible,
notes: updates.notes,
})
if (result) {
const marker = mapMarkerResponse(result)
setMarkers((prev) =>
prev.map((m) =>
m.id === id
? { ...m, name: result.name, color: result.color as PinColorId }
: m
)
prev.map((existingMarker) => (existingMarker.id === id ? marker : existingMarker))
)
}
}, [])
const deleteMarker = useCallback(async (id: number) => {
await api.deleteMapMarker(id)
setMarkers((prev) => prev.filter((m) => m.id !== id))
setMarkers((prev) => prev.filter((marker) => marker.id !== id))
}, [])
return { markers, loaded, addMarker, updateMarker, deleteMarker }

View File

@ -11,6 +11,7 @@ import { catchInternal } from './util'
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import BenchmarkResult from '#models/benchmark_result'
import { BenchmarkType, RunBenchmarkResponse, SubmitBenchmarkResponse, UpdateBuilderTagResponse } from '../../types/benchmark'
import type {CreateMapMarkerPayload, MapMarkerResponse, UpdateMapMarkerPayload} from '../../types/maps'
class API {
private client: AxiosInstance
@ -713,27 +714,21 @@ class API {
async listMapMarkers() {
return catchInternal(async () => {
const response = await this.client.get<
Array<{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }>
>('/maps/markers')
const response = await this.client.get<MapMarkerResponse[]>('/maps/markers')
return response.data
})()
}
async createMapMarker(data: { name: string; longitude: number; latitude: number; color?: string; notes?: string | null }) {
async createMapMarker(data: CreateMapMarkerPayload) {
return catchInternal(async () => {
const response = await this.client.post<
{ id: number; name: string; longitude: number; latitude: number; color: string; notes: string | null; created_at: string }
>('/maps/markers', data)
const response = await this.client.post<MapMarkerResponse>('/maps/markers', data)
return response.data
})()
}
async updateMapMarker(id: number, data: { name?: string; color?: string }) {
async updateMapMarker(id: number, data: UpdateMapMarkerPayload) {
return catchInternal(async () => {
const response = await this.client.patch<
{ id: number; name: string; longitude: number; latitude: number; color: string }
>(`/maps/markers/${id}`, data)
const response = await this.client.patch<MapMarkerResponse>(`/maps/markers/${id}`, data)
return response.data
})()
}

View File

@ -1,19 +1,58 @@
import { useState } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react'
import MapsLayout from '~/layouts/MapsLayout'
import MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton'
import { IconArrowLeft, IconCrosshair, IconMapPin, IconPlaneTilt } from '@tabler/icons-react'
import { FileEntry } from '../../types/files'
import Alert from '~/components/Alert'
import { FileEntry } from '../../types/files'
type MapCommand = {
id: number
lat: number
lng: number
action: 'fly' | 'marker'
}
export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
}) {
const [isHoveringUI, setIsHoveringUI] = useState(false)
const [showMapCoordinates, setShowMapCoordinates] = useState(true)
const [coordinateSearch, setCoordinateSearch] = useState('')
const [mapCommand, setMapCommand] = useState<MapCommand | null>(null)
const [showCoordinatesEnabled, setShowCoordinatesEnabled] = useState(true)
const parseCoordinates = () => {
const [latRaw, lngRaw] = coordinateSearch.split(',').map((value) => value.trim())
const lat = Number(latRaw)
const lng = Number(lngRaw)
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
lat < -90 ||
lat > 90 ||
lng < -180 ||
lng > 180
) {
return null
}
return { lat, lng }
}
const handleCoordinateAction = (action: 'fly' | 'marker') => {
const coordinates = parseCoordinates()
if (!coordinates) return
setMapCommand({
id: Date.now(),
...coordinates,
action,
})
}
const alertMessage = !props.maps.baseAssetsExist
? 'The base map assets have not been installed. Please download them first to enable map functionality.'
@ -37,16 +76,50 @@ export default function Maps(props: {
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
<div className="flex items-center gap-3 mr-4">
<div className="flex items-center gap-2">
<input
type="text"
placeholder="lat,lng"
value={coordinateSearch}
onChange={(event) => setCoordinateSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') handleCoordinateAction('fly')
}}
className="w-52 rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
/>
<button
type="button"
onClick={() => setShowMapCoordinates((prev) => !prev)}
className="rounded px-3 py-2 text-sm bg-surface-primary text-text-secondary hover:opacity-80 transition"
onClick={() => handleCoordinateAction('fly')}
className="rounded border border-border-default bg-surface-primary p-2 text-text-secondary hover:bg-surface-secondary"
title="Fly to coordinates"
>
{showMapCoordinates ? 'Hide Coordinates' : 'Show Coordinates'}
<IconPlaneTilt size={18}/>
</button>
<Link href="/settings/maps">
<button
type="button"
onClick={() => handleCoordinateAction('marker')}
className="rounded border border-border-default bg-surface-primary p-2 text-text-secondary hover:bg-surface-secondary"
title="Add marker at coordinates"
>
<IconMapPin size={18}/>
</button>
<button
type="button"
onClick={() => setShowCoordinatesEnabled((prev) => !prev)}
className={`rounded border border-border-default p-2 transition-colors ${
showCoordinatesEnabled
? 'bg-desert-green text-white'
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
}`}
title={showCoordinatesEnabled ? 'Hide coordinates' : 'Show coordinates'}
>
<IconCrosshair size={18}/>
</button>
<Link href="/settings/maps" className="mr-4">
<StyledButton variant="primary" icon="IconSettings">
Manage Map Regions
</StyledButton>
@ -77,10 +150,12 @@ export default function Maps(props: {
)}
{/* Map */}
<div className="absolute inset-0">
<MapComponent
mapCommand={mapCommand}
isHoveringUI={isHoveringUI}
showCoordinatesEnabled={showMapCoordinates}
showCoordinatesEnabled={showCoordinatesEnabled}
/>
</div>
</div>

158
admin/package-lock.json generated
View File

@ -64,6 +64,7 @@
"react": "19.2.7",
"react-adonis-transmit": "1.0.1",
"react-dom": "19.2.7",
"react-icons": "5.6.0",
"react-map-gl": "8.1.0",
"react-markdown": "10.1.0",
"reflect-metadata": "0.2.2",
@ -2230,9 +2231,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2249,9 +2247,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2268,9 +2263,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2287,9 +2279,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2306,9 +2295,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2325,9 +2311,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2344,9 +2327,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2363,9 +2343,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2382,9 +2359,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2407,9 +2381,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2432,9 +2403,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2457,9 +2425,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2482,9 +2447,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2507,9 +2469,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2532,9 +2491,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2557,9 +2513,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -3435,9 +3388,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3454,9 +3404,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3473,9 +3420,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3492,9 +3436,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3511,9 +3452,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4199,9 +4137,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4215,9 +4150,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4231,9 +4163,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4247,9 +4176,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4263,9 +4189,6 @@
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4279,9 +4202,6 @@
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4295,9 +4215,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4311,9 +4228,6 @@
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4327,9 +4241,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4343,9 +4254,6 @@
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4359,9 +4267,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4375,9 +4280,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4391,9 +4293,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4590,6 +4489,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4606,6 +4506,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4622,6 +4523,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -4638,9 +4540,7 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4657,9 +4557,7 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4676,9 +4574,7 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4695,9 +4591,7 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4714,6 +4608,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4730,6 +4625,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4746,6 +4642,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4938,9 +4835,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4957,9 +4851,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4976,9 +4867,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4995,9 +4883,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -11240,9 +11125,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -11263,9 +11145,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -11286,9 +11165,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -11309,9 +11185,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -14491,6 +14364,15 @@
"react": "^19.2.7"
}
},
"node_modules/react-icons": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",

View File

@ -117,6 +117,7 @@
"react": "19.2.7",
"react-adonis-transmit": "1.0.1",
"react-dom": "19.2.7",
"react-icons": "5.6.0",
"react-map-gl": "8.1.0",
"react-markdown": "10.1.0",
"reflect-metadata": "0.2.2",

View File

@ -55,3 +55,32 @@ export type MapExtractPreflight = {
key: string
}
}
export type CreateMapMarkerPayload = {
name: string
notes?: string | null
longitude: number
latitude: number
color?: string
custom_color?: string | null
icon?: string | null
icon_color?: string | null
visible?: boolean
}
export type UpdateMapMarkerPayload = Partial<CreateMapMarkerPayload>
export type MapMarkerResponse = {
id: number
name: string
longitude: number
latitude: number
color: string
custom_color?: string | null
icon?: string | null
icon_color?: string | null
visible?: boolean
notes?: string | null
created_at: string
updated_at?: string
}