diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 981c678c..1fe91ef0 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -73,7 +73,8 @@ const adminLinks = [ { link: '/plugin_updates', label: t('Plugin Updates'), icon: IconPuzzle }, { link: '/device_profiles', label: t('Device Profiles'), icon: IconDeviceMobile }, { link: '/server_plugin_manager', label: t('Server Plugin Manager'), icon: IconPlugConnected }, - { link: '/link_account', 'label': t('Link TAK.gov Account'), icon: IconLink} + { link: '/link_account', 'label': t('Link TAK.gov Account'), icon: IconLink}, + { link: '/settings', label: t('Settings'), icon: IconSettings }, ]; interface ATAKQrCode { diff --git a/src/pages/Map/Map.tsx b/src/pages/Map/Map.tsx index 2ebced20..ca8524ef 100644 --- a/src/pages/Map/Map.tsx +++ b/src/pages/Map/Map.tsx @@ -33,6 +33,38 @@ export default function Map() { const [positionRows, setPositionRows] = useState([]); const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true }); + const [mapCenter, setMapCenter] = useState<[number, number]>([10, 0]); + const [mapZoom, setMapZoom] = useState(3); + const [defaultLayer, setDefaultLayer] = useState('OSM'); + const [mapReady, setMapReady] = useState(false); + + useEffect(() => { + const savedLat = localStorage.getItem('ots_map_lat'); + const savedLon = localStorage.getItem('ots_map_lon'); + const savedZoom = localStorage.getItem('ots_map_zoom'); + const savedLayer = localStorage.getItem('ots_map_layer'); + + if (savedLat && savedLon && savedZoom) { + setMapCenter([parseFloat(savedLat), parseFloat(savedLon)]); + setMapZoom(parseInt(savedZoom)); + if (savedLayer) setDefaultLayer(savedLayer); + setMapReady(true); + } else { + axios.get(apiRoutes.adminSettings) + .then(r => { + if (r.status === 200) { + setMapCenter([r.data.OTS_MAP_DEFAULT_LAT, r.data.OTS_MAP_DEFAULT_LON]); + setMapZoom(r.data.OTS_MAP_DEFAULT_ZOOM); + setDefaultLayer(r.data.OTS_MAP_DEFAULT_LAYER); + } + setMapReady(true); + }) + .catch(() => { + setMapReady(true); + }); + } + }, []); + const eudsLayer = new L.LayerGroup(); const rbLinesLayer = new L.LayerGroup(); const markersLayer = new L.LayerGroup(); @@ -435,6 +467,17 @@ export default function Map() { socket.on('eud', onEud); socket.on('casevac', onCaseEvac); + map.on('moveend', () => { + const center = map.getCenter(); + localStorage.setItem('ots_map_lat', String(center.lat)); + localStorage.setItem('ots_map_lon', String(center.lng)); + localStorage.setItem('ots_map_zoom', String(map.getZoom())); + }); + + map.on('baselayerchange', (e: any) => { + localStorage.setItem('ots_map_layer', e.name); + }); + return () => { socket.off('point', onPointEvent); socket.off('rb_line', onRBLine); @@ -475,16 +518,16 @@ export default function Map() { - - + - + - + - + - + - + @@ -534,7 +577,7 @@ export default function Map() { - + } ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 00000000..cfcf19e0 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + Grid, + NumberInput, + Paper, + Select, + Title, +} from '@mantine/core'; +import { IconCheck, IconX } from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import axios from '../axios_config'; +import { apiRoutes } from '../apiRoutes'; +import { t } from 'i18next'; + +const LAYER_OPTIONS = [ + 'OSM', + 'Google Streets', + 'Google Hybrid', + 'Google Terrain', + 'ESRI World Imagery (Clarity) Beta', + 'ESRI World Topo', +]; + +const TILE_URLS: Record = { + 'OSM': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + 'Google Streets': 'http://mt0.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', + 'Google Hybrid': 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga', + 'Google Terrain': 'http://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}', + 'ESRI World Imagery (Clarity) Beta': 'http://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + 'ESRI World Topo': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', +}; + +function MapEvents({ onClickMap, onZoomEnd }: { onClickMap: (lat: number, lon: number) => void, onZoomEnd: (zoom: number, lat: number, lon: number) => void }) { + useMapEvents({ + click(e) { + onClickMap(e.latlng.lat, e.latlng.lng); + }, + zoomend(e) { + const center = e.target.getCenter(); + onZoomEnd(e.target.getZoom(), center.lat, center.lng); + }, + }); + return null; +} + +function MapSync({ lat, lon, zoom }: { lat: number, lon: number, zoom: number }) { + const map = useMap(); + const isFirstRender = useRef(true); + const prevLat = useRef(lat); + const prevLon = useRef(lon); + const prevZoom = useRef(zoom); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + const latLonChanged = prevLat.current !== lat || prevLon.current !== lon; + const zoomChanged = prevZoom.current !== zoom; + prevLat.current = lat; + prevLon.current = lon; + prevZoom.current = zoom; + + if (latLonChanged) { + map.setView([lat, lon], zoom); + } else if (zoomChanged) { + map.setZoom(zoom); + } + }, [lat, lon, zoom]); + + return null; +} + +export default function Settings() { + const [lat, setLat] = useState(10); + const [lon, setLon] = useState(0); + const [zoom, setZoom] = useState(3); + const [layer, setLayer] = useState('OSM'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + axios.get(apiRoutes.adminSettings) + .then(r => { + if (r.status === 200) { + setLat(r.data.OTS_MAP_DEFAULT_LAT); + setLon(r.data.OTS_MAP_DEFAULT_LON); + setZoom(r.data.OTS_MAP_DEFAULT_ZOOM); + setLayer(r.data.OTS_MAP_DEFAULT_LAYER); + } + setLoading(false); + }) + .catch(err => { + setLoading(false); + if (err.response && err.response.status === 404) { + notifications.show({ + title: t('Settings unavailable'), + message: t('This feature requires a newer version of OpenTAKServer'), + icon: , + color: 'orange', + }); + } else { + notifications.show({ + title: t('Failed to load settings'), + message: err.response?.data?.error || String(err), + icon: , + color: 'red', + }); + } + }); + }, []); + + function saveSettings() { + axios.put(apiRoutes.adminSettings, { + OTS_MAP_DEFAULT_LAT: lat, + OTS_MAP_DEFAULT_LON: lon, + OTS_MAP_DEFAULT_ZOOM: zoom, + OTS_MAP_DEFAULT_LAYER: layer, + }).then(r => { + if (r.status === 200) { + notifications.show({ + message: t('Settings saved'), + icon: , + color: 'green', + }); + } + }).catch(err => { + notifications.show({ + title: t('Failed to save settings'), + message: err.response?.data?.error || String(err), + icon: , + color: 'red', + }); + }); + } + + return ( + <> + {t('Map Defaults')} + + + + setLat(Number(v))} + min={-90} + max={90} + decimalScale={6} + step={0.1} + mb="md" + disabled={loading} + /> + setLon(Number(v))} + min={-180} + max={180} + decimalScale={6} + step={0.1} + mb="md" + disabled={loading} + /> + setZoom(Number(v))} + min={1} + max={20} + mb="md" + disabled={loading} + /> +