Add node map + fixes
This commit is contained in:
+15
-12
@@ -13,39 +13,41 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.4.2",
|
"@headlessui/react": "^1.4.2",
|
||||||
"@meshtastic/meshtasticjs": "^0.6.31",
|
"@meshtastic/meshtasticjs": "^0.6.32",
|
||||||
"@reduxjs/toolkit": "^1.6.2",
|
"@reduxjs/toolkit": "^1.6.2",
|
||||||
"boring-avatars": "^1.5.8",
|
"boring-avatars": "^1.5.8",
|
||||||
"i18next": "^21.5.3",
|
"i18next": "^21.5.5",
|
||||||
"i18next-browser-languagedetector": "^6.1.2",
|
"i18next-browser-languagedetector": "^6.1.2",
|
||||||
|
"mapbox-gl": "^2.6.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-file-icon": "^1.1.0",
|
"react-file-icon": "^1.1.0",
|
||||||
"react-hook-form": "^7.20.4",
|
"react-hook-form": "^7.21.0",
|
||||||
"react-i18next": "^11.14.3",
|
"react-i18next": "^11.14.3",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"react-json-pretty": "^2.2.0",
|
"react-json-pretty": "^2.2.0",
|
||||||
"react-qr-code": "^2.0.3",
|
"react-qr-code": "^2.0.3",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
"rfc4648": "^1.5.0",
|
"rfc4648": "^1.5.0",
|
||||||
"swr": "^1.0.1",
|
"swr": "^1.1.0",
|
||||||
"timeago-react": "^3.0.4",
|
"timeago-react": "^3.0.4",
|
||||||
"type-route": "^0.6.0",
|
"type-route": "^0.6.0",
|
||||||
"use-breakpoint": "^3.0.0"
|
"use-breakpoint": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mapbox-gl": "^2.6.0",
|
||||||
"@types/react": "^17.0.37",
|
"@types/react": "^17.0.37",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
"@types/react-file-icon": "^1.0.1",
|
"@types/react-file-icon": "^1.0.1",
|
||||||
"@types/w3c-web-serial": "^1.0.2",
|
"@types/w3c-web-serial": "^1.0.2",
|
||||||
"@types/web-bluetooth": "^0.0.11",
|
"@types/web-bluetooth": "^0.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||||
"@typescript-eslint/parser": "^5.4.0",
|
"@typescript-eslint/parser": "^5.6.0",
|
||||||
"@verypossible/eslint-config": "^1.6.1",
|
"@verypossible/eslint-config": "^1.6.1",
|
||||||
"@vitejs/plugin-react": "^1.1.0",
|
"@vitejs/plugin-react": "^1.1.1",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"babel-plugin-module-resolver": "^4.1.0",
|
"babel-plugin-module-resolver": "^4.1.0",
|
||||||
"eslint": "8.3.0",
|
"eslint": "8.4.1",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"eslint-import-resolver-babel-module": "^5.3.1",
|
"eslint-import-resolver-babel-module": "^5.3.1",
|
||||||
@@ -55,12 +57,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"gzipper": "^6.0.0",
|
"gzipper": "^6.0.0",
|
||||||
"postcss": "^8.4.4",
|
"postcss": "^8.4.4",
|
||||||
"prettier": "^2.5.0",
|
"prettier": "^2.5.1",
|
||||||
"tailwindcss": "^3.0.0-alpha.2",
|
"tailwindcss": "^3.0.0-alpha.2",
|
||||||
"tar": "^6.1.11",
|
"tar": "^6.1.11",
|
||||||
"typescript": "^4.5.2",
|
"typescript": "^4.5.2",
|
||||||
"vite": "^2.6.14",
|
"vite": "^2.7.0",
|
||||||
"vite-plugin-pwa": "^0.11.7",
|
"vite-plugin-cdn-import": "^0.3.5",
|
||||||
"workbox-window": "^6.4.1"
|
"vite-plugin-pwa": "^0.11.10",
|
||||||
|
"workbox-window": "^6.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+443
-247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
|||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { renderToString } from 'react-dom/server';
|
||||||
|
import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa';
|
||||||
|
import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { useAppSelector } from '@app/hooks/redux';
|
||||||
|
import { IconButton } from '@components/generic/IconButton';
|
||||||
|
|
||||||
|
import { MapStyles } from './styles';
|
||||||
|
|
||||||
|
export const Map = (): JSX.Element => {
|
||||||
|
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||||
|
|
||||||
|
mapboxgl.accessToken =
|
||||||
|
'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ';
|
||||||
|
const mapDiv = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [map, setMap] = React.useState(null as mapboxgl.Map | null);
|
||||||
|
const [exaggeration, setExaggeration] = React.useState(false);
|
||||||
|
const [shading, setShading] = React.useState(false);
|
||||||
|
const [maptype, setMaptype] = React.useState<
|
||||||
|
'Streets' | 'Outdoors' | 'Satellite' | 'Default'
|
||||||
|
>('Default');
|
||||||
|
|
||||||
|
const PlaceNodes = (): void => {
|
||||||
|
nodes.map((node) => {
|
||||||
|
if (node.currentPosition && map) {
|
||||||
|
new mapboxgl.Marker({})
|
||||||
|
.setLngLat({
|
||||||
|
lat: node.currentPosition?.latitudeI / 1e7,
|
||||||
|
lng: node.currentPosition?.longitudeI / 1e7,
|
||||||
|
})
|
||||||
|
.setPopup(
|
||||||
|
new mapboxgl.Popup().setHTML(
|
||||||
|
renderToString(
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-medium">
|
||||||
|
{node.user?.longName}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>ID: {node.number}</li>
|
||||||
|
</ul>
|
||||||
|
</div>,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
PlaceNodes();
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (map?.loaded()) {
|
||||||
|
switch (maptype) {
|
||||||
|
case 'Outdoors':
|
||||||
|
map.setStyle(MapStyles.Outdoors.url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Satellite':
|
||||||
|
map.setStyle(MapStyles.Satellite.url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Streets':
|
||||||
|
map.setStyle(MapStyles.Streets.url);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
map.setStyle(darkMode ? MapStyles.Dark.url : MapStyles.Light.url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [maptype, darkMode, map]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (map?.loaded()) {
|
||||||
|
if (shading) {
|
||||||
|
map
|
||||||
|
.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14,
|
||||||
|
})
|
||||||
|
.addLayer(
|
||||||
|
{
|
||||||
|
id: 'hillshading',
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
type: 'hillshade',
|
||||||
|
// insert below waterway-river-canal-shadow;
|
||||||
|
// where hillshading sits in the Mapbox Outdoors style
|
||||||
|
},
|
||||||
|
'waterway-river-canal-shadow',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
map.removeLayer('hillshading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shading, map]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (map?.loaded()) {
|
||||||
|
if (exaggeration) {
|
||||||
|
map
|
||||||
|
.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14,
|
||||||
|
})
|
||||||
|
.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1.5,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map.setTerrain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [exaggeration, map]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!map && mapDiv.current) {
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: mapDiv.current,
|
||||||
|
style: darkMode ? MapStyles.Dark.url : MapStyles.Light.url,
|
||||||
|
// center: [lng, lat],
|
||||||
|
// zoom: zoom,
|
||||||
|
});
|
||||||
|
setMap(map);
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map.addLayer({
|
||||||
|
id: 'sky',
|
||||||
|
type: 'sky',
|
||||||
|
paint: {
|
||||||
|
'sky-type': 'atmosphere',
|
||||||
|
'sky-atmosphere-sun': [0.0, 0.0],
|
||||||
|
'sky-atmosphere-sun-intensity': 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
PlaceNodes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [map, darkMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full h-full">
|
||||||
|
<div className="fixed right-0 z-20 p-2 m-4 space-y-2 bg-white rounded-md shadow-md md:mx-10 dark:bg-primaryDark">
|
||||||
|
<IconButton
|
||||||
|
active={maptype === 'Satellite'}
|
||||||
|
onClick={(): void => {
|
||||||
|
if (maptype === 'Satellite') {
|
||||||
|
setMaptype('Default');
|
||||||
|
} else {
|
||||||
|
setMaptype('Satellite');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<FaGlobeAfrica />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-1 -m-1 space-y-2 border-gray-400 rounded-md dark:border-gray-200 ${
|
||||||
|
maptype === 'Outdoors' ? 'border' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
active={maptype === 'Outdoors'}
|
||||||
|
onClick={(): void => {
|
||||||
|
if (maptype === 'Outdoors') {
|
||||||
|
setMaptype('Default');
|
||||||
|
} else {
|
||||||
|
setMaptype('Outdoors');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<FaDirections />}
|
||||||
|
/>
|
||||||
|
{maptype === 'Outdoors' && (
|
||||||
|
<IconButton
|
||||||
|
active={shading}
|
||||||
|
onClick={(): void => {
|
||||||
|
setShading(!shading);
|
||||||
|
}}
|
||||||
|
icon={<MdWbShade />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="text-gray-400 dark:text-gray-200" />
|
||||||
|
<IconButton
|
||||||
|
active={exaggeration}
|
||||||
|
onClick={(): void => {
|
||||||
|
setExaggeration(!exaggeration);
|
||||||
|
}}
|
||||||
|
icon={<FaMountain />}
|
||||||
|
/>
|
||||||
|
<IconButton icon={<MdFullscreen />} />
|
||||||
|
<IconButton icon={<MdRadar />} />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full h-full">
|
||||||
|
<div className="flex-grow w-full h-full" ref={mapDiv} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export interface MapStyle {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MapStyles = {
|
||||||
|
Streets: {
|
||||||
|
title: 'Streets',
|
||||||
|
url: 'mapbox://styles/mapbox/streets-v11',
|
||||||
|
} as MapStyle,
|
||||||
|
Outdoors: {
|
||||||
|
title: 'Outdoors',
|
||||||
|
url: 'mapbox://styles/mapbox/outdoors-v11',
|
||||||
|
} as MapStyle,
|
||||||
|
|
||||||
|
Light: {
|
||||||
|
title: 'Light',
|
||||||
|
url: 'mapbox://styles/mapbox/light-v10',
|
||||||
|
} as MapStyle,
|
||||||
|
Dark: { title: 'Dark', url: 'mapbox://styles/mapbox/dark-v10' } as MapStyle,
|
||||||
|
Satellite: {
|
||||||
|
title: 'Satellite',
|
||||||
|
url: 'mapbox://styles/mapbox/satellite-v9',
|
||||||
|
} as MapStyle,
|
||||||
|
};
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
export interface StatCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatCard = ({ title, value }: StatCardProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="w-full bg-white border-gray-300 shadow-md select-none dark:text-white border-y md:border dark:bg-primaryDark dark:border-gray-600 md:rounded-md ">
|
|
||||||
<div className="m-4">
|
|
||||||
<div className="text-lg font-light">{title}</div>
|
|
||||||
<div className="text-3xl font-bold">{value}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -37,7 +37,7 @@ export const PageLayout = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab.List className="flex flex-col border-b border-gray-300 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600">
|
<Tab.List className="flex flex-col border-b border-gray-300 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600">
|
||||||
<div className="flex items-center justify-between m-8 mr-6 md:my-10">
|
<div className="flex items-center justify-between m-6 mr-6">
|
||||||
<div className="text-4xl font-extrabold leading-none tracking-tight">
|
<div className="text-4xl font-extrabold leading-none tracking-tight">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setMyNodeInfo,
|
setMyNodeInfo,
|
||||||
setPreferences,
|
setPreferences,
|
||||||
setReady,
|
setReady,
|
||||||
|
updateLastInteraction,
|
||||||
} from '@core/slices/meshtasticSlice';
|
} from '@core/slices/meshtasticSlice';
|
||||||
import { store } from '@core/store';
|
import { store } from '@core/store';
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +137,15 @@ const registerListeners = (): void => {
|
|||||||
store.dispatch(setLastMeshInterraction(date.getTime())),
|
store.dispatch(setLastMeshInterraction(date.getTime())),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
connection.onRoutingPacket.subscribe((routingPacket) => {
|
||||||
|
store.dispatch(
|
||||||
|
updateLastInteraction({
|
||||||
|
id: routingPacket.packet.from,
|
||||||
|
time: new Date(routingPacket.packet.rxTime * 1000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
connection.onTextPacket.subscribe((message) => {
|
connection.onTextPacket.subscribe((message) => {
|
||||||
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
|
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
// import { connection } from '../connection';
|
|
||||||
|
|
||||||
export interface MessageWithAck {
|
export interface MessageWithAck {
|
||||||
message: Types.TextPacket;
|
message: Types.TextPacket;
|
||||||
ack: boolean;
|
ack: boolean;
|
||||||
@@ -16,11 +14,19 @@ export interface ChannelData {
|
|||||||
messages: MessageWithAck[];
|
messages: MessageWithAck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CurrentPosition {
|
||||||
|
latitudeI: number;
|
||||||
|
longitudeI: number;
|
||||||
|
altitude: number;
|
||||||
|
posTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
number: number;
|
number: number;
|
||||||
lastHeard: Date;
|
lastHeard: Date;
|
||||||
snr: number[];
|
snr: number[];
|
||||||
positions: Protobuf.Position[];
|
positions: Protobuf.Position[];
|
||||||
|
currentPosition?: CurrentPosition;
|
||||||
user?: Protobuf.User;
|
user?: Protobuf.User;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +85,9 @@ export const meshtasticSlice = createSlice({
|
|||||||
);
|
);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.user = action.payload.data;
|
node.user = action.payload.data;
|
||||||
// todo: use rx time
|
if (action.payload.packet.rxTime) {
|
||||||
node.lastHeard = new Date();
|
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Node not in DB');
|
console.log('Node not in DB');
|
||||||
}
|
}
|
||||||
@@ -90,10 +97,29 @@ export const meshtasticSlice = createSlice({
|
|||||||
(node) => node.number === action.payload.packet.from,
|
(node) => node.number === action.payload.packet.from,
|
||||||
);
|
);
|
||||||
|
|
||||||
node?.positions.push(action.payload.data);
|
|
||||||
if (node) {
|
if (node) {
|
||||||
// todo: use rx time
|
node.positions.push(action.payload.data);
|
||||||
node.lastHeard = new Date();
|
|
||||||
|
if (
|
||||||
|
action.payload.data.latitudeI ||
|
||||||
|
action.payload.data.longitudeI ||
|
||||||
|
action.payload.data.altitude
|
||||||
|
) {
|
||||||
|
node.currentPosition = {
|
||||||
|
latitudeI:
|
||||||
|
action.payload.data.latitudeI ?? node.currentPosition?.latitudeI,
|
||||||
|
longitudeI:
|
||||||
|
action.payload.data.longitudeI ??
|
||||||
|
node.currentPosition?.longitudeI,
|
||||||
|
altitude:
|
||||||
|
action.payload.data.altitude ?? node.currentPosition?.altitude,
|
||||||
|
posTimestamp: action.payload.data.posTimestamp, //maybe new date?
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.payload.packet.rxTime) {
|
||||||
|
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
|
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
|
||||||
@@ -156,13 +182,23 @@ export const meshtasticSlice = createSlice({
|
|||||||
const channelIndex = state.radio.channels.findIndex(
|
const channelIndex = state.radio.channels.findIndex(
|
||||||
(channel) => channel.channel.index === action.payload.channel,
|
(channel) => channel.channel.index === action.payload.channel,
|
||||||
);
|
);
|
||||||
// todo: update last mesh/user interraction here
|
|
||||||
state.radio.channels[channelIndex].messages.map((message) => {
|
state.radio.channels[channelIndex].messages.map((message) => {
|
||||||
if (message.message.packet.id === action.payload.messageId) {
|
if (message.message.packet.id === action.payload.messageId) {
|
||||||
message.ack = true;
|
message.ack = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
updateLastInteraction: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ id: number; time: Date }>,
|
||||||
|
) => {
|
||||||
|
const node = state.nodes.find(
|
||||||
|
(node) => node.number === action.payload.id,
|
||||||
|
);
|
||||||
|
if (node) {
|
||||||
|
node.lastHeard = action.payload.time;
|
||||||
|
}
|
||||||
|
},
|
||||||
setHostOverrideEnabled: (state, action: PayloadAction<boolean>) => {
|
setHostOverrideEnabled: (state, action: PayloadAction<boolean>) => {
|
||||||
state.hostOverrideEnabled = action.payload;
|
state.hostOverrideEnabled = action.payload;
|
||||||
localStorage.setItem('hostOverrideEnabled', String(action.payload));
|
localStorage.setItem('hostOverrideEnabled', String(action.payload));
|
||||||
@@ -199,6 +235,7 @@ export const {
|
|||||||
setPreferences,
|
setPreferences,
|
||||||
addMessage,
|
addMessage,
|
||||||
ackMessage,
|
ackMessage,
|
||||||
|
updateLastInteraction,
|
||||||
setHostOverrideEnabled,
|
setHostOverrideEnabled,
|
||||||
setHostOverride,
|
setHostOverride,
|
||||||
resetState,
|
resetState,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const Messages = (): JSX.Element => {
|
|||||||
isSender={message.isSender}
|
isSender={message.isSender}
|
||||||
message={message.message.data}
|
message={message.message.data}
|
||||||
ack={message.ack}
|
ack={message.ack}
|
||||||
rxTime={new Date()}
|
rxTime={message.received}
|
||||||
senderName={
|
senderName={
|
||||||
nodes.find((node) => node.number === message.message.packet.from)
|
nodes.find((node) => node.number === message.message.packet.from)
|
||||||
?.user?.longName ?? 'UNK'
|
?.user?.longName ?? 'UNK'
|
||||||
|
|||||||
+88
-30
@@ -1,43 +1,101 @@
|
|||||||
import type React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Avatar from 'boring-avatars';
|
import { FiCode, FiXCircle } from 'react-icons/fi';
|
||||||
|
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md';
|
||||||
|
import TimeAgo from 'timeago-react';
|
||||||
|
|
||||||
|
import { useBreakpoint } from '@app/hooks/breakpoint';
|
||||||
import { useAppSelector } from '@app/hooks/redux';
|
import { useAppSelector } from '@app/hooks/redux';
|
||||||
import { PageLayout } from '@components/templates/PageLayout';
|
import { Drawer } from '@components/generic/Drawer';
|
||||||
|
import { IconButton } from '@components/generic/IconButton';
|
||||||
|
import { Map } from '@components/Map';
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
import { Node } from './Node';
|
|
||||||
|
|
||||||
export const Nodes = (): JSX.Element => {
|
export const Nodes = (): JSX.Element => {
|
||||||
const myNodeNum = useAppSelector(
|
const myNodeNum = useAppSelector(
|
||||||
(state) => state.meshtastic.radio.hardware,
|
(state) => state.meshtastic.radio.hardware,
|
||||||
).myNodeNum;
|
).myNodeNum;
|
||||||
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter(
|
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||||
(node) => node.number !== myNodeNum,
|
// .filter(
|
||||||
);
|
// (node) => node.number !== myNodeNum,
|
||||||
|
// );
|
||||||
|
const [navOpen, setNavOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const { breakpoint } = useBreakpoint();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<div className="relative flex w-full dark:text-white">
|
||||||
title="Nodes"
|
<Drawer
|
||||||
emptyMessage="No nodes discovered yet..."
|
open={breakpoint === 'sm' ? navOpen : true}
|
||||||
sidebarItems={nodes.map((node) => {
|
permenant={breakpoint !== 'sm'}
|
||||||
return {
|
onClose={(): void => {
|
||||||
title: node.user?.longName ?? node.number.toString(),
|
setNavOpen(!navOpen);
|
||||||
description: node.user
|
}}
|
||||||
? Protobuf.HardwareModel[node.user.hwModel]
|
>
|
||||||
: 'Unknown Hardware',
|
<div className="flex items-center justify-between m-6 mr-6">
|
||||||
icon: (
|
<div className="text-4xl font-extrabold leading-none tracking-tight">
|
||||||
<Avatar
|
Nodes
|
||||||
size={30}
|
</div>
|
||||||
name={node.user?.longName ?? node.number.toString()}
|
<div className="md:hidden">
|
||||||
variant="beam"
|
<IconButton
|
||||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
|
icon={<FiXCircle className="w-5 h-5" />}
|
||||||
|
onClick={(): void => {
|
||||||
|
setNavOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
</div>
|
||||||
};
|
</div>
|
||||||
})}
|
{!nodes.length && (
|
||||||
panels={nodes.map((node, index) => (
|
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
|
||||||
<Node key={index} node={node} />
|
No nodes found.
|
||||||
))}
|
</span>
|
||||||
/>
|
)}
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.number}
|
||||||
|
className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 p-2 bg-gray-100 shadow-md rounded-t-md dark:bg-primaryDark">
|
||||||
|
<div
|
||||||
|
className={`my-auto w-3 h-3 rounded-full ${
|
||||||
|
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15)
|
||||||
|
? 'bg-green-500'
|
||||||
|
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30)
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="my-auto">{node.user?.longName}</div>
|
||||||
|
<div className="my-auto ml-auto text-xs font-semibold">
|
||||||
|
{node.lastHeard.getTime() ? (
|
||||||
|
<TimeAgo datetime={node.lastHeard} />
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{node.currentPosition ? (
|
||||||
|
new Date(node.positions[0].posTimestamp * 1000) >
|
||||||
|
new Date(new Date().getTime() - 1000 * 60 * 30) ? (
|
||||||
|
<IconButton icon={<MdGpsFixed />} />
|
||||||
|
) : (
|
||||||
|
<IconButton icon={<MdGpsNotFixed />} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<IconButton disabled icon={<MdGpsOff />} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex p-2">
|
||||||
|
<div className="my-auto">
|
||||||
|
{Protobuf.HardwareModel[node.user?.hwModel ?? 0]}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<IconButton icon={<FiCode className="w-5 h-5" />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Drawer>
|
||||||
|
<Map />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'react-json-pretty/themes/acai.css';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FiCode, FiMenu } from 'react-icons/fi';
|
|
||||||
import JSONPretty from 'react-json-pretty';
|
|
||||||
import TimeAgo from 'timeago-react';
|
|
||||||
|
|
||||||
import { Card } from '@components/generic/Card';
|
|
||||||
import { Cover } from '@components/generic/Cover';
|
|
||||||
import { IconButton } from '@components/generic/IconButton';
|
|
||||||
import { StatCard } from '@components/generic/StatCard';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import type { Node as NodeType } from '@core/slices/meshtasticSlice';
|
|
||||||
|
|
||||||
export interface NodeProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
node: NodeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Node = ({ navOpen, setNavOpen, node }: NodeProps): JSX.Element => {
|
|
||||||
const [debug, setDebug] = React.useState(false);
|
|
||||||
return (
|
|
||||||
<PrimaryTemplate
|
|
||||||
title={node.user?.longName ?? node.number.toString()}
|
|
||||||
tagline="Node"
|
|
||||||
leftButton={
|
|
||||||
<IconButton
|
|
||||||
icon={<FiMenu className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen && setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
rightButton={
|
|
||||||
<IconButton
|
|
||||||
icon={<FiCode className="w-5 h-5" />}
|
|
||||||
active={debug}
|
|
||||||
onClick={(): void => {
|
|
||||||
setDebug(!debug);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="w-full space-y-4">
|
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row">
|
|
||||||
<StatCard
|
|
||||||
title="Last heard"
|
|
||||||
value={
|
|
||||||
node.lastHeard ? <TimeAgo datetime={node.lastHeard} /> : 'Never'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatCard title="SNR" value={node.snr.toString()} />
|
|
||||||
</div>
|
|
||||||
<Card title="Position" description={node.lastHeard.toLocaleString()}>
|
|
||||||
<Cover enabled={debug} content={<JSONPretty data={node} />} />
|
|
||||||
<div className="p-10">
|
|
||||||
<JSONPretty data={node.positions[node.positions.length - 1]} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import importToCDN from 'vite-plugin-cdn-import';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
@@ -8,6 +9,16 @@ import react from '@vitejs/plugin-react';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
importToCDN({
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
name: 'mapbox-gl',
|
||||||
|
var: 'mapboxgl',
|
||||||
|
path: `dist/mapbox-gl.js`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
VitePWA({
|
VitePWA({
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user