diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index 097d9fb..aaefbe1 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -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 } diff --git a/admin/app/models/map_marker.ts b/admin/app/models/map_marker.ts index 7d588fd..7a53ee9 100644 --- a/admin/app/models/map_marker.ts +++ b/admin/app/models/map_marker.ts @@ -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 diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 8a9aabf..2bded0e 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -887,7 +887,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) @@ -1761,7 +1761,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, diff --git a/admin/app/services/rag_service.ts b/admin/app/services/rag_service.ts index 29b65cf..ec5eac0 100644 --- a/admin/app/services/rag_service.ts +++ b/admin/app/services/rag_service.ts @@ -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. */ diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index c305939..22dfc6c 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -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 diff --git a/admin/database/migrations/1771200000001_create_map_markers_table.ts b/admin/database/migrations/1771200000001_create_map_markers_table.ts index 3268de4..ce228d3 100644 --- a/admin/database/migrations/1771200000001_create_map_markers_table.ts +++ b/admin/database/migrations/1771200000001_create_map_markers_table.ts @@ -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() diff --git a/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts b/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts new file mode 100644 index 0000000..d2698e8 --- /dev/null +++ b/admin/database/migrations/1771200000002_add_visibility_and_icon_fields_to_map_markers.ts @@ -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') + }) + } +} diff --git a/admin/inertia/components/maps/IconSelectorPopover.tsx b/admin/inertia/components/maps/IconSelectorPopover.tsx new file mode 100644 index 0000000..f031091 --- /dev/null +++ b/admin/inertia/components/maps/IconSelectorPopover.tsx @@ -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 | 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, + })) + +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 ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + { + 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" + /> + +
+
+ {pagedIcons.map(({ name, label, library, Icon }) => ( + + ))} +
+
+ +
+ + + + {filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`} + + + +
+ +
+ +
+
+ ) +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index cd40cbc..ca4e013 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -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(null) const animationFrameRef = useRef(null) + const handledMapCommandIdRef = useRef(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('orange') const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [editingMarkerId, setEditingMarkerId] = useState(null) + const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false) + const [showCoordinates, setShowCoordinates] = useState(false) - const [scaleUnit, setScaleUnit] = useState( - () => (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(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 ( @@ -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 && ( + +