Rebuild
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
|
stats.html
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
+22
-27
@@ -12,52 +12,46 @@
|
|||||||
"lint": "eslint 'src/**/*.{ts,tsx}'"
|
"lint": "eslint 'src/**/*.{ts,tsx}'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emeraldpay/hashicon-react": "^0.5.1",
|
||||||
"@floating-ui/react-dom": "^0.4.3",
|
"@floating-ui/react-dom": "^0.4.3",
|
||||||
"@headlessui/react": "^1.4.3",
|
"@meshtastic/components": "^1.0.23",
|
||||||
"@meshtastic/components": "^1.0.19",
|
"@meshtastic/meshtasticjs": "^0.6.39",
|
||||||
"@meshtastic/meshtasticjs": "^0.6.38",
|
"@reduxjs/toolkit": "^1.7.2",
|
||||||
"@reduxjs/toolkit": "^1.7.1",
|
|
||||||
"@tippyjs/react": "^4.2.6",
|
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"boring-avatars": "^1.6.1",
|
"framer-motion": "^6.2.4",
|
||||||
"graphql-request": "^3.7.0",
|
"graphql-request": "^4.0.0",
|
||||||
"i18next": "^21.6.6",
|
"mapbox-gl": "^2.7.0",
|
||||||
"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-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-file-icon": "^1.1.0",
|
"react-flow-renderer": "^10.0.0-next.38",
|
||||||
"react-hook-form": "^7.24.0",
|
"react-hook-form": "^7.26.0",
|
||||||
"react-i18next": "^11.15.3",
|
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"react-json-pretty": "^2.2.0",
|
"react-json-pretty": "^2.2.0",
|
||||||
|
"react-multi-select-component": "^4.2.1",
|
||||||
"react-qr-code": "^2.0.3",
|
"react-qr-code": "^2.0.3",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
"react-select": "^5.2.2",
|
|
||||||
"react-use-clipboard": "^1.0.7",
|
"react-use-clipboard": "^1.0.7",
|
||||||
"rfc4648": "^1.5.1",
|
"rfc4648": "^1.5.1",
|
||||||
"swr": "^1.1.2",
|
"rollup-plugin-visualizer": "^5.5.4",
|
||||||
|
"swr": "^1.2.1",
|
||||||
"timeago-react": "^3.0.4",
|
"timeago-react": "^3.0.4",
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"type-route": "^0.6.0",
|
"type-route": "^0.6.0",
|
||||||
"use-breakpoint": "^3.0.1",
|
|
||||||
"vite-plugin-environment": "^1.1.0"
|
"vite-plugin-environment": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mapbox-gl": "^2.6.0",
|
"@types/mapbox-gl": "^2.6.1",
|
||||||
"@types/react": "^17.0.38",
|
"@types/react": "^17.0.39",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
"@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.12",
|
"@types/web-bluetooth": "^0.0.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.9.1",
|
"@typescript-eslint/eslint-plugin": "^5.10.2",
|
||||||
"@typescript-eslint/parser": "^5.9.1",
|
"@typescript-eslint/parser": "^5.10.2",
|
||||||
"@verypossible/eslint-config": "^1.6.1",
|
"@verypossible/eslint-config": "^1.6.1",
|
||||||
"@vitejs/plugin-react": "^1.1.4",
|
"@vitejs/plugin-react": "^1.1.4",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"babel-plugin-module-resolver": "^4.1.0",
|
"babel-plugin-module-resolver": "^4.1.0",
|
||||||
"eslint": "8.7.0",
|
"eslint": "8.8.0",
|
||||||
"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",
|
||||||
@@ -66,12 +60,13 @@
|
|||||||
"eslint-plugin-react": "^7.28.0",
|
"eslint-plugin-react": "^7.28.0",
|
||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"gzipper": "^7.0.0",
|
"gzipper": "^7.0.0",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "^8.4.6",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"tailwindcss": "^3.0.15",
|
"prettier-plugin-tailwindcss": "^0.1.5",
|
||||||
|
"tailwindcss": "^3.0.18",
|
||||||
"tar": "^6.1.11",
|
"tar": "^6.1.11",
|
||||||
"typescript": "^4.5.4",
|
"typescript": "^4.5.5",
|
||||||
"vite": "^2.7.12",
|
"vite": "^2.7.13",
|
||||||
"vite-plugin-cdn-import": "^0.3.5",
|
"vite-plugin-cdn-import": "^0.3.5",
|
||||||
"vite-plugin-pwa": "^0.11.13",
|
"vite-plugin-pwa": "^0.11.13",
|
||||||
"workbox-window": "^6.4.2"
|
"workbox-window": "^6.4.2"
|
||||||
|
|||||||
Generated
+1052
-765
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [require('prettier-plugin-tailwindcss')],
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
tailwindConfig: './tailwind.config.cjs',
|
||||||
|
};
|
||||||
+12
-71
@@ -1,89 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { Map } from '@app/pages/Map';
|
||||||
import { FiBell } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { Connection } from '@components/Connection';
|
import { Connection } from '@components/Connection';
|
||||||
import { Logo } from '@components/menu/Logo';
|
|
||||||
import { Navigation } from '@components/menu/Navigation';
|
|
||||||
import { useRoute } from '@core/router';
|
import { useRoute } from '@core/router';
|
||||||
import { requestNotificationPermission } from '@core/utils/notifications';
|
|
||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
import { Messages } from '@pages/Messages';
|
|
||||||
import { Nodes } from '@pages/Nodes';
|
|
||||||
import { NotFound } from '@pages/NotFound';
|
|
||||||
import { Settings } from '@pages/settings/Index';
|
|
||||||
|
|
||||||
import { ErrorFallback } from './components/ErrorFallback';
|
|
||||||
import { MapboxProvider } from './components/MapBox/MapboxProvider';
|
|
||||||
import { BottomNav } from './components/menu/BottomNav';
|
import { BottomNav } from './components/menu/BottomNav';
|
||||||
import { addNotification, removeNotification } from './core/slices/appSlice';
|
import { Extensions } from './pages/Extensions/Index';
|
||||||
|
import { Messages } from './pages/Messages';
|
||||||
|
import { Nodes } from './pages/Nodes';
|
||||||
|
import { NotFound } from './pages/NotFound';
|
||||||
|
|
||||||
export const App = (): JSX.Element => {
|
export const App = (): JSX.Element => {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const notifications = useAppSelector((state) => state.app.notifications);
|
|
||||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (
|
|
||||||
Notification.permission !== 'granted' &&
|
|
||||||
notifications.findIndex((n) => n.id === 'notification-permission') === -1
|
|
||||||
) {
|
|
||||||
dispatch(
|
|
||||||
addNotification({
|
|
||||||
id: 'notification-permission',
|
|
||||||
icon: <FiBell className="w-4 h-4" />,
|
|
||||||
read: Notification.permission === 'denied',
|
|
||||||
title: 'Enable Push Notifications',
|
|
||||||
action: {
|
|
||||||
message: 'Enable',
|
|
||||||
action: async () => await requestNotificationPermission(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
requestNotificationPermission().catch((e) => {
|
|
||||||
console.log(e);
|
|
||||||
});
|
|
||||||
}, [dispatch, notifications]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (Notification.permission === 'granted') {
|
|
||||||
dispatch(removeNotification('notification-permission'));
|
|
||||||
}
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-screen w-screen ${darkMode ? 'dark' : ''}`}>
|
<div className={`h-screen w-screen ${darkMode ? 'dark' : ''}`}>
|
||||||
<Connection />
|
<Connection />
|
||||||
<div className="flex flex-col h-full bg-gray-200 dark:bg-secondaryDark">
|
<div className="flex h-full flex-col bg-gray-200 dark:bg-secondaryDark">
|
||||||
<div className="flex flex-shrink-0 overflow-hidden bg-primary dark:bg-primary">
|
<div className="flex min-h-0 w-full flex-grow">
|
||||||
<div className="w-full overflow-hidden bg-white border-b border-gray-300 dark:border-gray-600 md:shadow-md dark:bg-secondaryDark">
|
{route.name === 'messages' && <Messages />}
|
||||||
<div className="flex items-center justify-between h-12 px-4">
|
{route.name === 'nodes' && <Nodes />}
|
||||||
<div className="flex">
|
{route.name === 'map' && <Map />}
|
||||||
<Logo />
|
{route.name === 'extensions' && <Extensions />}
|
||||||
</div>
|
{route.name === false && <NotFound />}
|
||||||
<Navigation />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-grow w-full min-h-0">
|
|
||||||
<div className="flex w-full bg-gray-100 md:shadow-xl md:overflow-hidden dark:bg-secondaryDark">
|
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
||||||
{route.name === 'messages' && <Messages />}
|
|
||||||
{route.name === 'nodes' && (
|
|
||||||
<MapboxProvider>
|
|
||||||
<Nodes />
|
|
||||||
</MapboxProvider>
|
|
||||||
)}
|
|
||||||
{route.name === 'settings' && <Settings />}
|
|
||||||
{route.name === false && <NotFound />}
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
import { BLE } from '@components/connection/BLE';
|
import { BLE } from '@components/connection/BLE';
|
||||||
import { HTTP } from '@components/connection/HTTP';
|
import { HTTP } from '@components/connection/HTTP';
|
||||||
import { Serial } from '@components/connection/Serial';
|
import { Serial } from '@components/connection/Serial';
|
||||||
@@ -46,47 +48,51 @@ export const Connection = (): JSX.Element => {
|
|||||||
}, [state.ready, dispatch]);
|
}, [state.ready, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<AnimatePresence>
|
||||||
className="w-full max-w-3xl"
|
{appState.connectionModalOpen && (
|
||||||
open={appState.connectionModalOpen}
|
<Modal
|
||||||
onClose={(): void => {
|
className="w-full max-w-3xl"
|
||||||
dispatch(closeConnectionModal());
|
onClose={(): void => {
|
||||||
}}
|
dispatch(closeConnectionModal());
|
||||||
>
|
}}
|
||||||
<Card>
|
>
|
||||||
<div className="w-full max-w-3xl p-10">
|
<Card>
|
||||||
{state.deviceStatus === Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
|
<div className="w-full max-w-3xl p-10">
|
||||||
<div className="space-y-2">
|
|
||||||
<Select
|
|
||||||
label="Connection Method"
|
|
||||||
optionsEnum={connType}
|
|
||||||
value={appState.connType}
|
|
||||||
onChange={(e): void => {
|
|
||||||
dispatch(setConnType(parseInt(e.target.value)));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{appState.connType === connType.HTTP && <HTTP />}
|
|
||||||
{appState.connType === connType.BLE && <BLE />}
|
|
||||||
{appState.connType === connType.SERIAL && <Serial />}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<span>Connecting...</span>
|
|
||||||
{state.deviceStatus ===
|
{state.deviceStatus ===
|
||||||
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
|
Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
border
|
<Select
|
||||||
onClick={async (): Promise<void> => {
|
label="Connection Method"
|
||||||
await connection.disconnect();
|
optionsEnum={connType}
|
||||||
}}
|
value={appState.connType}
|
||||||
>
|
onChange={(e): void => {
|
||||||
Disconnect
|
dispatch(setConnType(parseInt(e.target.value)));
|
||||||
</Button>
|
}}
|
||||||
|
/>
|
||||||
|
{appState.connType === connType.HTTP && <HTTP />}
|
||||||
|
{appState.connType === connType.BLE && <BLE />}
|
||||||
|
{appState.connType === connType.SERIAL && <Serial />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span>Connecting...</span>
|
||||||
|
{state.deviceStatus ===
|
||||||
|
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
|
||||||
|
<Button
|
||||||
|
border
|
||||||
|
onClick={async (): Promise<void> => {
|
||||||
|
await connection.disconnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
</Modal>
|
||||||
</Card>
|
)}
|
||||||
</Modal>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export const FormFooter = ({
|
|||||||
saveAction,
|
saveAction,
|
||||||
}: FormFooterProps): JSX.Element => {
|
}: FormFooterProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className="flex float-right gap-2">
|
<div className="float-right flex gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<FiXCircle className="w-5 h-5" />}
|
icon={<FiXCircle className="h-5 w-5" />}
|
||||||
disabled={!dirty}
|
disabled={!dirty}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
clearAction && clearAction();
|
clearAction && clearAction();
|
||||||
@@ -29,7 +29,7 @@ export const FormFooter = ({
|
|||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
saveAction && saveAction();
|
saveAction && saveAction();
|
||||||
}}
|
}}
|
||||||
icon={<FiSave className="w-5 h-5" />}
|
icon={<FiSave className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useAppDispatch } from '@hooks/useAppDispatch';
|
|||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
import { useCreateMapbox } from '@hooks/useCreateMapbox';
|
import { useCreateMapbox } from '@hooks/useCreateMapbox';
|
||||||
|
|
||||||
import { MapStyles } from '../Map/styles';
|
import { MapStyles } from '../../pages/Map/styles';
|
||||||
import { MapboxContext } from './mapboxContext';
|
import { MapboxContext } from './mapboxContext';
|
||||||
|
|
||||||
export type MapboxProviderProps = {
|
export type MapboxProviderProps = {
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export interface MapboxContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MapboxContext = React.createContext<MapboxContextValue>(
|
export const MapboxContext = React.createContext<MapboxContextValue>(
|
||||||
{} as MapboxContextValue
|
{} as MapboxContextValue,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export interface TabButtonProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TabButton = ({ children }: TabButtonProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Tab
|
|
||||||
className={({ selected }): string =>
|
|
||||||
`border-gray-300 hover:border-b-2 dark:border-gray-600 w-full ${
|
|
||||||
selected ? 'border-b-2' : 'border-b-0'
|
|
||||||
} `
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="my-auto text-gray-500 group dark:text-gray-400">
|
|
||||||
<div className="flex p-2 rounded-t-md hover:bg-gray-200 dark:hover:bg-gray-600">
|
|
||||||
<div className="m-auto transition duration-200 ease-in-out group-active:scale-90">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import Avatar from 'boring-avatars';
|
|
||||||
|
|
||||||
export interface MessageProps {
|
|
||||||
message: string;
|
|
||||||
ack: boolean;
|
|
||||||
isSender: boolean;
|
|
||||||
rxTime: Date;
|
|
||||||
senderName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Message = ({
|
|
||||||
message,
|
|
||||||
ack,
|
|
||||||
isSender,
|
|
||||||
rxTime,
|
|
||||||
senderName,
|
|
||||||
}: MessageProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex space-x-2 ${
|
|
||||||
!isSender ? 'ml-auto flex-row-reverse' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`shadow-md rounded-full mt-auto ${!isSender ? 'ml-2' : ''}`}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
size={30}
|
|
||||||
name={senderName ?? 'UNK'}
|
|
||||||
variant="beam"
|
|
||||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={`relative max-w-3/4 px-3 py-2 rounded-t-lg ${
|
|
||||||
isSender
|
|
||||||
? 'bg-gray-500 text-gray-50 rounded-br-lg'
|
|
||||||
: 'bg-primary text-blue-50 rounded-bl-lg'
|
|
||||||
} ${ack ? 'animate-none' : 'animate-pulse'}`}
|
|
||||||
>
|
|
||||||
<div className="leading-5 min-w-4">{message}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">{senderName}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-auto mb-4 mr-3 text-xs font-medium text-secondary dark:text-gray-200">
|
|
||||||
{rxTime.toLocaleTimeString(undefined, {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,8 +4,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { FiCheck } from 'react-icons/fi';
|
import { FiCheck } from 'react-icons/fi';
|
||||||
|
|
||||||
import { connType } from '@app/core/slices/appSlice';
|
import { connType } from '@app/core/slices/appSlice';
|
||||||
import { ble, setConnection } from '@core/connection';
|
import { setConnection } from '@core/connection';
|
||||||
import { Button, IconButton } from '@meshtastic/components';
|
import { Button, IconButton } from '@meshtastic/components';
|
||||||
|
import { IBLEConnection } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
export const BLE = (): JSX.Element => {
|
export const BLE = (): JSX.Element => {
|
||||||
const [bleDevices, setBleDevices] = React.useState<BluetoothDevice[]>([]);
|
const [bleDevices, setBleDevices] = React.useState<BluetoothDevice[]>([]);
|
||||||
@@ -15,6 +16,7 @@ export const BLE = (): JSX.Element => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const updateBleDeviceList = React.useCallback(async (): Promise<void> => {
|
const updateBleDeviceList = React.useCallback(async (): Promise<void> => {
|
||||||
|
const ble = new IBLEConnection();
|
||||||
const devices = await ble.getDevices();
|
const devices = await ble.getDevices();
|
||||||
setBleDevices(devices);
|
setBleDevices(devices);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,7 +36,7 @@ export const BLE = (): JSX.Element => {
|
|||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
await setConnection(connType.BLE);
|
await setConnection(connType.BLE);
|
||||||
}}
|
}}
|
||||||
className="flex justify-between p-2 bg-gray-700 rounded-md"
|
className="flex justify-between rounded-md bg-gray-700 p-2"
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
<div className="my-auto">{device.name}</div>
|
<div className="my-auto">{device.name}</div>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import React from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FiCheck } from 'react-icons/fi';
|
import { FiCheck } from 'react-icons/fi';
|
||||||
|
|
||||||
import { serial, setConnection } from '@core/connection';
|
import { setConnection } from '@core/connection';
|
||||||
import { connType, setConnectionParams } from '@core/slices/appSlice';
|
import { connType, setConnectionParams } from '@core/slices/appSlice';
|
||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||||
import { Button, IconButton } from '@meshtastic/components';
|
import { Button, IconButton } from '@meshtastic/components';
|
||||||
|
import { ISerialConnection } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
export const Serial = (): JSX.Element => {
|
export const Serial = (): JSX.Element => {
|
||||||
const [serialDevices, setSerialDevices] = React.useState<SerialPort[]>([]);
|
const [serialDevices, setSerialDevices] = React.useState<SerialPort[]>([]);
|
||||||
@@ -17,6 +18,7 @@ export const Serial = (): JSX.Element => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const updateSerialDeviceList = React.useCallback(async (): Promise<void> => {
|
const updateSerialDeviceList = React.useCallback(async (): Promise<void> => {
|
||||||
|
const serial = new ISerialConnection();
|
||||||
const devices = await serial.getPorts();
|
const devices = await serial.getPorts();
|
||||||
setSerialDevices(devices);
|
setSerialDevices(devices);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,10 +36,10 @@ export const Serial = (): JSX.Element => {
|
|||||||
{serialDevices.length > 0 ? (
|
{serialDevices.length > 0 ? (
|
||||||
serialDevices.map((device, index) => (
|
serialDevices.map((device, index) => (
|
||||||
<div
|
<div
|
||||||
className="flex justify-between p-2 bg-gray-700 rounded-md"
|
className="flex justify-between rounded-md bg-gray-700 p-2"
|
||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
<div className="flex gap-4 my-auto">
|
<div className="my-auto flex gap-4">
|
||||||
<p>
|
<p>
|
||||||
Vendor: <small>{device.getInfo().usbVendorId}</small>
|
Vendor: <small>{device.getInfo().usbVendorId}</small>
|
||||||
</p>
|
</p>
|
||||||
@@ -62,7 +64,7 @@ export const Serial = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="h-40 border border-gray-300 rounded-md dark:border-gray-600">
|
<div className="h-40 rounded-md border border-gray-300 dark:border-gray-600">
|
||||||
<p>No previously connected devices found</p>
|
<p>No previously connected devices found</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ export const Blur = ({
|
|||||||
}: BlurProps): JSX.Element => {
|
}: BlurProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 z-20 w-full h-full transition-opacity ${
|
className={`absolute inset-0 z-20 h-full w-full transition-opacity ${
|
||||||
disableOnMd ? 'md:hidden' : ''
|
disableOnMd ? 'md:hidden' : ''
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`absolute inset-0 w-full h-full backdrop-filter backdrop-blur-sm ${
|
className={`absolute inset-0 h-full w-full backdrop-blur-sm backdrop-filter ${
|
||||||
disableOnMd ? 'md:hidden' : ''
|
disableOnMd ? 'md:hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { Blur } from '@components/generic/Blur';
|
|
||||||
|
|
||||||
type DefaultAsideProps = JSX.IntrinsicElements['aside'];
|
|
||||||
|
|
||||||
interface DrawerProps extends DefaultAsideProps {
|
|
||||||
open: boolean;
|
|
||||||
permenant?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Drawer = ({
|
|
||||||
open,
|
|
||||||
permenant,
|
|
||||||
onClose,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
|
|
||||||
...props
|
|
||||||
}: DrawerProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{open && (
|
|
||||||
<Blur className={className} disableOnMd={true} onClick={onClose} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={`transform top-0 left-0 bg-white dark:bg-secondaryDark shadow-md max-w-xs w-full border-r dark:border-gray-600 border-gray-300 h-full overflow-auto ease-in-out transition-all duration-300 z-30 ${
|
|
||||||
permenant ? '' : 'absolute'
|
|
||||||
} ${open ? 'translate-x-0' : '-translate-x-full'} ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
|
||||||
|
export interface ListItemProps {
|
||||||
|
selected: boolean;
|
||||||
|
selectedIcon: JSX.Element;
|
||||||
|
actions?: JSX.Element;
|
||||||
|
status: JSX.Element;
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem = ({
|
||||||
|
selected,
|
||||||
|
selectedIcon,
|
||||||
|
actions,
|
||||||
|
status,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: ListItemProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
onClick && onClick();
|
||||||
|
}}
|
||||||
|
className={`flex select-none rounded-md border bg-gray-100 shadow-md dark:bg-primaryDark ${
|
||||||
|
selected
|
||||||
|
? 'border-primary dark:border-primary'
|
||||||
|
: 'border-gray-100 dark:border-primaryDark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-3 rounded-l-md bg-green-500" />
|
||||||
|
<div className="flex justify-between p-2">
|
||||||
|
<div className="my-auto flex space-x-2">
|
||||||
|
{status}
|
||||||
|
<div className="flex gap-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{actions}
|
||||||
|
<IconButton active={selected} icon={selectedIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import { Dialog } from '@headlessui/react';
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
|
||||||
|
// import { Backdrop } from './Backdrop';
|
||||||
|
|
||||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||||
|
|
||||||
export interface ModalProps extends DefaultDivProps {
|
export interface ModalProps extends DefaultDivProps {
|
||||||
children: React.ReactNode;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = ({
|
export const Modal = ({
|
||||||
children,
|
|
||||||
open,
|
|
||||||
onClose,
|
onClose,
|
||||||
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModalProps): JSX.Element => {
|
}: ModalProps): JSX.Element => {
|
||||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<m.div
|
||||||
as="div"
|
|
||||||
className={`fixed inset-0 z-30 ${darkMode ? 'dark' : ''}`}
|
className={`fixed inset-0 z-30 ${darkMode ? 'dark' : ''}`}
|
||||||
open={open}
|
onClick={onClose}
|
||||||
onClose={onClose}
|
|
||||||
>
|
>
|
||||||
<Dialog.Overlay className="fixed w-full h-full backdrop-filter backdrop-blur-sm" />
|
<m.div className="fixed h-full w-full backdrop-blur-sm backdrop-filter" />
|
||||||
<div className="text-center ">
|
<m.div className="text-center ">
|
||||||
<span
|
<span
|
||||||
className="inline-block h-screen align-middle "
|
className="inline-block h-screen align-middle "
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -37,7 +37,7 @@ export const Modal = ({
|
|||||||
<div className={`inline-block align-middle ${className}`} {...props}>
|
<div className={`inline-block align-middle ${className}`} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</m.div>
|
||||||
</Dialog>
|
</m.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { FiX } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
|
||||||
title: string;
|
|
||||||
tagline: string;
|
|
||||||
footer?: JSX.Element;
|
|
||||||
closeSidebar: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar = ({
|
|
||||||
title,
|
|
||||||
tagline,
|
|
||||||
closeSidebar,
|
|
||||||
children,
|
|
||||||
}: SidebarProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="absolute z-50 flex flex-col w-full h-full bg-white border-l border-gray-300 md:z-10 md:max-w-sm md:static min-w-max dark:border-gray-600 dark:bg-secondaryDark">
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-medium text-gray-400">{title}</h3>
|
|
||||||
<h1 className="text-lg font-medium truncate">{tagline}</h1>
|
|
||||||
</div>
|
|
||||||
<div className="mb-auto">
|
|
||||||
<IconButton
|
|
||||||
onClick={(): void => {
|
|
||||||
closeSidebar();
|
|
||||||
}}
|
|
||||||
icon={<FiX />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children ?? (
|
|
||||||
<div className="flex flex-grow bg-gray-50 dark:bg-primaryDark">
|
|
||||||
<div className="m-auto text-lg font-medium">Please select item</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
|
||||||
|
|
||||||
export interface SidebarItemProps extends DefaultDivProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
selected?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SidebarItem = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
selected,
|
|
||||||
icon,
|
|
||||||
...props
|
|
||||||
}: SidebarItemProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex p-3 cursor-pointer select-none dark:hover:bg-primaryDark ${
|
|
||||||
selected ? 'bg-gray-200 dark:bg-primaryDark' : 'dark:bg-secondaryDark'
|
|
||||||
}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="my-auto text-gray-500 dark:text-gray-400">{icon}</div>
|
|
||||||
<div className="ml-3 text-left">
|
|
||||||
<div className="font-medium text-left">{title}</div>
|
|
||||||
<div className="mt-0.5 text-gray-400 text-sm">{description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import 'tippy.js/dist/tippy.css';
|
|
||||||
|
|
||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import Tippy from '@tippyjs/react';
|
|
||||||
|
|
||||||
export interface TooltipProps {
|
|
||||||
children: JSX.Element;
|
|
||||||
contents: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Tooltip = ({ children, contents }: TooltipProps): JSX.Element => {
|
|
||||||
return <Tippy content={contents}>{children}</Tippy>;
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import type { Noop, RefCallBack } from 'react-hook-form';
|
|
||||||
import type { Theme } from 'react-select';
|
|
||||||
import ReactSelect from 'react-select';
|
|
||||||
|
|
||||||
import { bitwiseDecode, bitwiseEncode } from '@app/core/utils/bitwise';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
|
|
||||||
import { Label } from './Label';
|
|
||||||
|
|
||||||
export interface BiwiseSelectProps {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
value: number;
|
|
||||||
optionsEnum: { [s: string]: string | number };
|
|
||||||
onChange: (...event: unknown[]) => void;
|
|
||||||
onBlur: Noop;
|
|
||||||
name: string;
|
|
||||||
ref: RefCallBack;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BitwiseSelect = ({
|
|
||||||
label,
|
|
||||||
error,
|
|
||||||
value,
|
|
||||||
optionsEnum,
|
|
||||||
onChange,
|
|
||||||
ref,
|
|
||||||
}: BiwiseSelectProps): JSX.Element => {
|
|
||||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
{label && <Label label={label} error={error} />}
|
|
||||||
<ReactSelect
|
|
||||||
ref={ref}
|
|
||||||
isMulti
|
|
||||||
// styles={{
|
|
||||||
// control: (provided, state) => ({
|
|
||||||
// ...provided,
|
|
||||||
// // color: state.isFocused ? 'blue' : 'red',
|
|
||||||
// // borderColor: state.isFocused ? 'blue' : 'red',
|
|
||||||
// }),
|
|
||||||
// }}
|
|
||||||
theme={(theme): Theme => ({
|
|
||||||
...theme,
|
|
||||||
borderRadius: 7,
|
|
||||||
colors: {
|
|
||||||
...theme.colors,
|
|
||||||
primary: '#67ea94', //focus border color
|
|
||||||
// primary75: 'red',
|
|
||||||
// primary50: 'red',
|
|
||||||
// primary25: 'red',
|
|
||||||
// danger: 'red',
|
|
||||||
// dangerLight: 'red',
|
|
||||||
neutral0: darkMode ? 'rgb(30 41 59)' : 'white', //bg color
|
|
||||||
// neutral5: 'red',
|
|
||||||
neutral10: darkMode ? 'rgb(75 85 99)' : 'rgb(229 231 235)', //tag bg color
|
|
||||||
neutral20: darkMode ? 'rgb(229 231 235)' : 'rgb(156 163 175)', //border color
|
|
||||||
neutral30: '#67ea94', //border hover
|
|
||||||
// neutral40: 'red',
|
|
||||||
// neutral50: 'red',
|
|
||||||
// neutral60: 'red',
|
|
||||||
// neutral70: 'red',
|
|
||||||
neutral80: darkMode ? 'white' : 'black', //tag text color
|
|
||||||
// neutral90: 'red',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
value={bitwiseDecode(value, optionsEnum).map((flag) => {
|
|
||||||
return {
|
|
||||||
value: flag,
|
|
||||||
label: (optionsEnum[flag] as string).replace('POS_', ''),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
options={Object.entries(optionsEnum)
|
|
||||||
.filter((value) => typeof value[1] !== 'number')
|
|
||||||
.filter((value) => parseInt(value[0]) !== optionsEnum.POS_UNDEFINED)
|
|
||||||
.map((value) => {
|
|
||||||
return {
|
|
||||||
value: parseInt(value[0]),
|
|
||||||
label: value[1].toString().replace('POS_', ''),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(e): void => onChange(bitwiseEncode(e.map((v) => v.value)))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -9,6 +9,6 @@ export const Label = ({ label, error }: LabelProps): JSX.Element => (
|
|||||||
<label className="flex py-1 text-xs font-semibold text-gray-500 dark:text-gray-400">
|
<label className="flex py-1 text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||||
{label}
|
{label}
|
||||||
{error && <span className="ml-2 text-red-500">{error}</span>}
|
{error && <span className="ml-2 text-red-500">{error}</span>}
|
||||||
<div className="flex-grow h-0.5 my-auto ml-2 dark:bg-gray-700 bg-gray-300 rounded-full" />
|
<div className="my-auto ml-2 h-0.5 flex-grow rounded-full bg-gray-300 dark:bg-gray-700" />
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FiMessageCircle, FiSettings } from 'react-icons/fi';
|
||||||
|
import { RiMindMap, RiRoadMapLine } from 'react-icons/ri';
|
||||||
|
import { VscExtensions } from 'react-icons/vsc';
|
||||||
|
|
||||||
|
import { toggleMobileNav } from '@app/core/slices/appSlice.js';
|
||||||
|
import { useAppDispatch } from '@app/hooks/useAppDispatch.js';
|
||||||
|
import { routes, useRoute } from '@core/router';
|
||||||
|
|
||||||
|
import { NavLinkButton } from './NavLinkButton';
|
||||||
|
|
||||||
|
export interface ButtonNavProps {
|
||||||
|
toggleSettingsOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonNav = ({
|
||||||
|
toggleSettingsOpen,
|
||||||
|
}: ButtonNavProps): JSX.Element => {
|
||||||
|
const route = useRoute();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 flex justify-between border-t border-gray-300 px-6 py-2 dark:border-gray-600 dark:bg-primaryDark">
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch(toggleMobileNav());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLinkButton
|
||||||
|
active={route.name === 'messages'}
|
||||||
|
link={routes.messages().link}
|
||||||
|
>
|
||||||
|
<FiMessageCircle className="h-5 w-5" />
|
||||||
|
</NavLinkButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch(toggleMobileNav());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLinkButton
|
||||||
|
active={route.name === 'nodes'}
|
||||||
|
link={routes.nodes().link}
|
||||||
|
>
|
||||||
|
<RiMindMap className="h-5 w-5" />
|
||||||
|
</NavLinkButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch(toggleMobileNav());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLinkButton active={route.name === 'map'} link={routes.map().link}>
|
||||||
|
<RiRoadMapLine className="h-5 w-5" />
|
||||||
|
</NavLinkButton>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch(toggleMobileNav());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavLinkButton
|
||||||
|
active={route.name === 'extensions'}
|
||||||
|
link={routes.extensions().link}
|
||||||
|
>
|
||||||
|
<VscExtensions className="h-5 w-5" />
|
||||||
|
</NavLinkButton>
|
||||||
|
</div>
|
||||||
|
<NavLinkButton
|
||||||
|
action={(): void => {
|
||||||
|
toggleSettingsOpen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiSettings className="h-5 w-5" />
|
||||||
|
</NavLinkButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { m } from 'framer-motion';
|
||||||
|
import type { Link } from 'type-route';
|
||||||
|
|
||||||
|
export interface NavLinkButtonProps {
|
||||||
|
link?: Link;
|
||||||
|
active?: boolean;
|
||||||
|
action?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavLinkButton = ({
|
||||||
|
link,
|
||||||
|
active,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: NavLinkButtonProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
animate={active ? 'selected' : 'deselected'}
|
||||||
|
initial={{ borderColor: '#1C1D23' }}
|
||||||
|
variants={{
|
||||||
|
selected: { borderColor: '#67ea94' },
|
||||||
|
deselected: { borderColor: '#1C1D23' },
|
||||||
|
}}
|
||||||
|
className="cursor-pointer rounded-full border-2 p-3 hover:bg-opacity-80 hover:shadow-md dark:bg-secondaryDark dark:text-white"
|
||||||
|
onClick={(): void => {
|
||||||
|
action && action();
|
||||||
|
}}
|
||||||
|
{...(link && link)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FiExternalLink, FiX } from 'react-icons/fi';
|
||||||
|
import {
|
||||||
|
RiArrowDownLine,
|
||||||
|
RiArrowUpDownLine,
|
||||||
|
RiArrowUpLine,
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
|
||||||
|
import { ListItem } from '@app/components/generic/ListItem';
|
||||||
|
import type { ChannelData } from '@app/core/slices/meshtasticSlice';
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Input, Select, Tooltip } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const Channels = (): JSX.Element => {
|
||||||
|
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
|
||||||
|
const adminChannel =
|
||||||
|
channels.find(
|
||||||
|
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY,
|
||||||
|
) ?? channels[0];
|
||||||
|
const [usePreset, setUsePreset] = React.useState(true);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [selectedChannel, setSelectedChannel] = React.useState<
|
||||||
|
ChannelData | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState } = useForm<
|
||||||
|
DeepOmit<Protobuf.Channel, 'psk'>
|
||||||
|
>({
|
||||||
|
defaultValues: {
|
||||||
|
...adminChannel.channel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const channelData = Protobuf.Channel.create({
|
||||||
|
...data,
|
||||||
|
settings: {
|
||||||
|
...data.settings,
|
||||||
|
psk: adminChannel.channel.settings?.psk,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.setChannel(channelData, (): Promise<void> => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{adminChannel && (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
checked={usePreset}
|
||||||
|
label="Use Presets"
|
||||||
|
onChange={(e): void => setUsePreset(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{usePreset ? (
|
||||||
|
<Select
|
||||||
|
label="Preset"
|
||||||
|
optionsEnum={Protobuf.ChannelSettings_ModemConfig}
|
||||||
|
{...register('settings.modemConfig', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Bandwidth"
|
||||||
|
type="number"
|
||||||
|
suffix="MHz"
|
||||||
|
{...register('settings.bandwidth', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Spread Factor"
|
||||||
|
type="number"
|
||||||
|
suffix="CPS"
|
||||||
|
min={7}
|
||||||
|
max={12}
|
||||||
|
{...register('settings.spreadFactor', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Coding Rate"
|
||||||
|
type="number"
|
||||||
|
{...register('settings.codingRate', {
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Transmit Power"
|
||||||
|
type="number"
|
||||||
|
suffix="dBm"
|
||||||
|
{...register('settings.txPower', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<ListItem
|
||||||
|
key={channel.channel.index}
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelectedChannel(channel);
|
||||||
|
}}
|
||||||
|
status={
|
||||||
|
<div
|
||||||
|
className={`my-auto h-3 w-3 rounded-full ${
|
||||||
|
[
|
||||||
|
Protobuf.Channel_Role.SECONDARY,
|
||||||
|
Protobuf.Channel_Role.PRIMARY,
|
||||||
|
].find((role) => role === channel.channel.role)
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
selected={selectedChannel?.channel.index === channel.channel.index}
|
||||||
|
selectedIcon={<FiExternalLink />}
|
||||||
|
actions={
|
||||||
|
<Tooltip content={`MQTT Status`}>
|
||||||
|
<div className="rounded-md p-2">
|
||||||
|
{channel.channel.settings?.uplinkEnabled &&
|
||||||
|
channel.channel.settings?.downlinkEnabled ? (
|
||||||
|
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" />
|
||||||
|
) : channel.channel.settings?.uplinkEnabled ? (
|
||||||
|
<RiArrowUpLine className="p-0.5 group-active:scale-90" />
|
||||||
|
) : channel.channel.settings?.downlinkEnabled ? (
|
||||||
|
<RiArrowDownLine className="p-0.5 group-active:scale-90" />
|
||||||
|
) : (
|
||||||
|
<FiX className="p-0.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{channel.channel.settings?.name.length
|
||||||
|
? channel.channel.settings.name
|
||||||
|
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
|
||||||
|
? 'Primary'
|
||||||
|
: `Channel: ${channel.channel.index}`}
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FiAlignLeft,
|
||||||
|
FiBell,
|
||||||
|
FiFastForward,
|
||||||
|
FiLayers,
|
||||||
|
FiLayout,
|
||||||
|
FiMapPin,
|
||||||
|
FiPackage,
|
||||||
|
FiRadio,
|
||||||
|
FiRss,
|
||||||
|
FiUser,
|
||||||
|
FiWifi,
|
||||||
|
FiZap,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection';
|
||||||
|
import { ExternalSection } from '@app/components/layout/Sidebar/sections/ExternalSection';
|
||||||
|
import { SidebarOverlay } from '@app/components/layout/Sidebar/sections/SidebarOverlay';
|
||||||
|
import { SidebarPrimary } from '@app/components/layout/Sidebar/sections/SidebarPrimary';
|
||||||
|
import { Channels } from '@app/components/layout/Sidebar/Settings/Channels';
|
||||||
|
import { ExternalNotificationsSettingsPlanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/SettingsPlanel';
|
||||||
|
import { RangeTestSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/RangeTest/SettingsPanel';
|
||||||
|
import { SerialSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/Serial/SettingsPanel';
|
||||||
|
import { StoreForwardSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/StoreForward/SettingsPanel';
|
||||||
|
import { Position } from '@app/components/layout/Sidebar/Settings/Position';
|
||||||
|
import { Power } from '@app/components/layout/Sidebar/Settings/Power';
|
||||||
|
import { Radio } from '@app/components/layout/Sidebar/Settings/Radio';
|
||||||
|
import { User } from '@app/components/layout/Sidebar/Settings/User';
|
||||||
|
import { WiFi } from '@app/components/layout/Sidebar/Settings/WiFi';
|
||||||
|
|
||||||
|
import { Interface } from './Interface';
|
||||||
|
import { ChannelsGroup } from './radio/channels/panels/ChannelsGroup';
|
||||||
|
|
||||||
|
export interface SettingsProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Settings = ({ open, setOpen }: SettingsProps): JSX.Element => {
|
||||||
|
const [pluginsOpen, setPluginsOpen] = React.useState(false);
|
||||||
|
const [channelsOpen, setChannelsOpen] = React.useState(false);
|
||||||
|
// const { hasGps, hasWifi } = useAppSelector((state) => state.meshtastic.radio.hardware);
|
||||||
|
|
||||||
|
const hasGps = true;
|
||||||
|
const hasWifi = true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarPrimary
|
||||||
|
title="Settings"
|
||||||
|
open={open}
|
||||||
|
close={(): void => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollapsibleSection icon={<FiWifi />} title="WiFi & MQTT">
|
||||||
|
<WiFi />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection icon={<FiMapPin />} title="Position">
|
||||||
|
<Position />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection icon={<FiUser />} title="User">
|
||||||
|
<User />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection icon={<FiZap />} title="Power">
|
||||||
|
<Power />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection icon={<FiRadio />} title="Radio">
|
||||||
|
<Radio />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection icon={<FiLayers />} title="Primary Channel">
|
||||||
|
<Channels />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<ExternalSection
|
||||||
|
onClick={(): void => {
|
||||||
|
setChannelsOpen(true);
|
||||||
|
}}
|
||||||
|
icon={<FiLayers />}
|
||||||
|
title="Channels"
|
||||||
|
/>
|
||||||
|
<ExternalSection
|
||||||
|
onClick={(): void => {
|
||||||
|
setPluginsOpen(true);
|
||||||
|
}}
|
||||||
|
icon={<FiPackage />}
|
||||||
|
title="Plugins"
|
||||||
|
/>
|
||||||
|
<CollapsibleSection icon={<FiLayout />} title="Interface">
|
||||||
|
<Interface />
|
||||||
|
</CollapsibleSection>
|
||||||
|
</SidebarPrimary>
|
||||||
|
|
||||||
|
{/* Plugins */}
|
||||||
|
<SidebarOverlay
|
||||||
|
title="Plugins"
|
||||||
|
open={pluginsOpen}
|
||||||
|
close={(): void => {
|
||||||
|
setPluginsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollapsibleSection title="Range Test" icon={<FiRss />}>
|
||||||
|
<RangeTestSettingsPanel />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="External Notifications" icon={<FiBell />}>
|
||||||
|
<ExternalNotificationsSettingsPlanel />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Serial" icon={<FiAlignLeft />}>
|
||||||
|
<SerialSettingsPanel />
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Store & Forward" icon={<FiFastForward />}>
|
||||||
|
<StoreForwardSettingsPanel />
|
||||||
|
</CollapsibleSection>
|
||||||
|
</SidebarOverlay>
|
||||||
|
{/* End Plugins */}
|
||||||
|
|
||||||
|
{/* Channels */}
|
||||||
|
<SidebarOverlay
|
||||||
|
title="Channels"
|
||||||
|
open={channelsOpen}
|
||||||
|
close={(): void => {
|
||||||
|
setChannelsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChannelsGroup />
|
||||||
|
</SidebarOverlay>
|
||||||
|
{/* End Channels */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { Select } from '@meshtastic/components';
|
||||||
|
|
||||||
|
export const Interface = (): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
label="Language"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
name: 'English',
|
||||||
|
value: 'en',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '日本',
|
||||||
|
value: 'jp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Português',
|
||||||
|
value: 'pt',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(e): void => {
|
||||||
|
console.log('changed language');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { MultiSelect } from 'react-multi-select-component';
|
||||||
|
|
||||||
|
import { bitwiseEncode } from '@app/core/utils/bitwise';
|
||||||
|
import { Label } from '@components/generic/form/Label';
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Input, Select } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const Position = (): JSX.Element => {
|
||||||
|
const preferences = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.preferences,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const { register, handleSubmit, formState, reset, control } =
|
||||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||||
|
defaultValues: {
|
||||||
|
...preferences,
|
||||||
|
positionBroadcastSecs:
|
||||||
|
preferences.positionBroadcastSecs === 0
|
||||||
|
? preferences.isRouter
|
||||||
|
? 43200
|
||||||
|
: 900
|
||||||
|
: preferences.positionBroadcastSecs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reset(preferences);
|
||||||
|
}, [reset, preferences]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
setLoading(true);
|
||||||
|
void connection.setPreferences(data, async () => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const encode = (enums: Protobuf.PositionFlags[]): number => {
|
||||||
|
return enums.reduce((acc, curr) => acc | curr, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decode = (value: number): Protobuf.PositionFlags[] => {
|
||||||
|
const enumValues = Object.keys(Protobuf.PositionFlags)
|
||||||
|
.map(Number)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return enumValues.map((b) => value & b).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-2" onSubmit={onSubmit}>
|
||||||
|
<Input
|
||||||
|
label="Broadcast Interval"
|
||||||
|
type="number"
|
||||||
|
suffix="Seconds"
|
||||||
|
{...register('positionBroadcastSecs', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="positionFlags"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }): JSX.Element => {
|
||||||
|
const { value, onChange, ...rest } = field;
|
||||||
|
const { error } = fieldState;
|
||||||
|
const label = 'Position Flags';
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && <Label label={label} error={error?.message} />}
|
||||||
|
<MultiSelect
|
||||||
|
options={Object.entries(Protobuf.PositionFlags)
|
||||||
|
.filter((value) => typeof value[1] !== 'number')
|
||||||
|
.filter(
|
||||||
|
(value) =>
|
||||||
|
parseInt(value[0]) !==
|
||||||
|
Protobuf.PositionFlags.POS_UNDEFINED,
|
||||||
|
)
|
||||||
|
.map((value) => {
|
||||||
|
return {
|
||||||
|
value: parseInt(value[0]),
|
||||||
|
label: value[1].toString().replace('POS_', ''),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
value={decode(value).map((flag) => {
|
||||||
|
return {
|
||||||
|
value: flag,
|
||||||
|
label: Protobuf.PositionFlags[flag].replace('POS_', ''),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onChange={(e: { value: number; label: string }[]): void =>
|
||||||
|
onChange(bitwiseEncode(e.map((v) => v.value)))
|
||||||
|
}
|
||||||
|
labelledBy="Select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Position Type (DEBUG)"
|
||||||
|
type="number"
|
||||||
|
disabled
|
||||||
|
{...register('positionFlags', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Use Fixed Position" {...register('fixedPosition')} />
|
||||||
|
<Select
|
||||||
|
label="Location Sharing"
|
||||||
|
optionsEnum={Protobuf.LocationSharing}
|
||||||
|
{...register('locationShare', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="GPS Mode"
|
||||||
|
optionsEnum={Protobuf.GpsOperation}
|
||||||
|
{...register('gpsOperation', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Display Format"
|
||||||
|
optionsEnum={Protobuf.GpsCoordinateFormat}
|
||||||
|
{...register('gpsFormat', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} />
|
||||||
|
<Input
|
||||||
|
label="Max DOP"
|
||||||
|
type="number"
|
||||||
|
{...register('gpsMaxDop', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last GPS Attempt"
|
||||||
|
disabled
|
||||||
|
{...register('gpsAttemptTime', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Select } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const Power = (): JSX.Element => {
|
||||||
|
const preferences = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.preferences,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const { register, handleSubmit, formState, reset } =
|
||||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||||
|
defaultValues: {
|
||||||
|
...preferences,
|
||||||
|
isLowPower: preferences.isRouter ? true : preferences.isLowPower,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reset(preferences);
|
||||||
|
}, [reset, preferences]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
setLoading(true);
|
||||||
|
void connection.setPreferences(data, async () => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form className="space-y-2" onSubmit={onSubmit}>
|
||||||
|
<Select
|
||||||
|
label="Charge current"
|
||||||
|
optionsEnum={Protobuf.ChargeCurrent}
|
||||||
|
{...register('chargeCurrent', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Always powered" {...register('isAlwaysPowered')} />
|
||||||
|
<Checkbox
|
||||||
|
label="Powered by low power source (solar)"
|
||||||
|
disabled={preferences.isRouter}
|
||||||
|
validationMessage={
|
||||||
|
preferences.isRouter ? 'Enabled by default in router mode' : ''
|
||||||
|
}
|
||||||
|
{...register('isLowPower')}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Select } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const Radio = (): JSX.Element => {
|
||||||
|
const preferences = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.preferences,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const { register, handleSubmit, formState, reset } =
|
||||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||||
|
defaultValues: preferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reset(preferences);
|
||||||
|
}, [reset, preferences]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
setLoading(true);
|
||||||
|
void connection.setPreferences(data, async () => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form className="space-y-2" onSubmit={onSubmit}>
|
||||||
|
<Checkbox label="Is Router" {...register('isRouter')} />
|
||||||
|
<Select
|
||||||
|
label="Region"
|
||||||
|
optionsEnum={Protobuf.RegionCode}
|
||||||
|
{...register('region', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Debug Log" {...register('debugLogEnabled')} />
|
||||||
|
<Checkbox label="Serial Disabled" {...register('serialDisabled')} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { base16 } from 'rfc4648';
|
||||||
|
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Input, Select } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const User = (): JSX.Element => {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const myNodeNum = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.hardware,
|
||||||
|
).myNodeNum;
|
||||||
|
const node = useAppSelector((state) => state.meshtastic.nodes).find(
|
||||||
|
(node) => node.number === myNodeNum,
|
||||||
|
);
|
||||||
|
const { register, handleSubmit, formState, reset } = useForm<{
|
||||||
|
longName: string;
|
||||||
|
shortName: string;
|
||||||
|
isLicensed: boolean;
|
||||||
|
team: Protobuf.Team;
|
||||||
|
antAzimuth: number;
|
||||||
|
antGainDbi: number;
|
||||||
|
txPowerDbm: number;
|
||||||
|
}>({
|
||||||
|
defaultValues: {
|
||||||
|
longName: node?.user?.longName,
|
||||||
|
shortName: node?.user?.shortName,
|
||||||
|
isLicensed: node?.user?.isLicensed,
|
||||||
|
team: node?.user?.team,
|
||||||
|
antAzimuth: node?.user?.antAzimuth,
|
||||||
|
antGainDbi: node?.user?.antGainDbi,
|
||||||
|
txPowerDbm: node?.user?.txPowerDbm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reset({
|
||||||
|
longName: node?.user?.longName,
|
||||||
|
shortName: node?.user?.shortName,
|
||||||
|
isLicensed: node?.user?.isLicensed,
|
||||||
|
team: node?.user?.team,
|
||||||
|
});
|
||||||
|
}, [reset, node]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (node?.user) {
|
||||||
|
void connection.setOwner({ ...node.user, ...data }, async () => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
// TODO: can be removed once getUser is implemented
|
||||||
|
// dispatch(
|
||||||
|
// addUser({ ...node.user, ...{ data: { ...node.user.data, ...data } } }),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-2" onSubmit={onSubmit}>
|
||||||
|
<Input label="Device ID" value={node?.user?.id} disabled />
|
||||||
|
<Input
|
||||||
|
label="Hardware"
|
||||||
|
value={
|
||||||
|
Protobuf.HardwareModel[
|
||||||
|
node?.user?.hwModel ?? Protobuf.HardwareModel.UNSET
|
||||||
|
]
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Mac Address"
|
||||||
|
defaultValue={
|
||||||
|
base16
|
||||||
|
.stringify(node?.user?.macaddr ?? [])
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
?.join(':') ?? ''
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input label="Device Name" {...register('longName')} />
|
||||||
|
<Input label="Short Name" maxLength={3} {...register('shortName')} />
|
||||||
|
<Checkbox label="Licenced Operator?" {...register('isLicensed')} />
|
||||||
|
<Select
|
||||||
|
label="Team"
|
||||||
|
optionsEnum={Protobuf.Team}
|
||||||
|
{...register('team', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Antenna Azimuth"
|
||||||
|
suffix="°"
|
||||||
|
type="number"
|
||||||
|
{...register('antAzimuth', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Antenna Gain"
|
||||||
|
suffix="dBi"
|
||||||
|
type="number"
|
||||||
|
{...register('antGainDbi', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Transmit Power"
|
||||||
|
suffix="dBm"
|
||||||
|
type="number"
|
||||||
|
{...register('txPowerDbm', { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { connection } from '@core/connection';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Checkbox, Input } from '@meshtastic/components';
|
||||||
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
export const WiFi = (): JSX.Element => {
|
||||||
|
const preferences = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.preferences,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const { register, handleSubmit, formState, reset, control } =
|
||||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({
|
||||||
|
defaultValues: preferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchWifiApMode = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'wifiApMode',
|
||||||
|
defaultValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchMQTTDisabled = useWatch({
|
||||||
|
control,
|
||||||
|
name: 'mqttDisabled',
|
||||||
|
defaultValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
reset(preferences);
|
||||||
|
}, [reset, preferences]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
|
setLoading(true);
|
||||||
|
void connection.setPreferences(data, async () => {
|
||||||
|
reset({ ...data });
|
||||||
|
setLoading(false);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form className="space-y-2" onSubmit={onSubmit}>
|
||||||
|
<Checkbox label="Enable WiFi AP" {...register('wifiApMode')} />
|
||||||
|
<Input
|
||||||
|
label="WiFi SSID"
|
||||||
|
disabled={watchWifiApMode}
|
||||||
|
{...register('wifiSsid')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
label="WiFi PSK"
|
||||||
|
disabled={watchWifiApMode}
|
||||||
|
{...register('wifiPassword')}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Disable MQTT" {...register('mqttDisabled')} />
|
||||||
|
<Input
|
||||||
|
label="MQTT Server Address"
|
||||||
|
disabled={watchMQTTDisabled}
|
||||||
|
{...register('mqttServer')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="MQTT Username"
|
||||||
|
disabled={watchMQTTDisabled}
|
||||||
|
{...register('mqttUsername')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="MQTT Password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={watchMQTTDisabled}
|
||||||
|
{...register('mqttPassword')}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
|||||||
|
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export const ExternalNotificationsDebugPanel = (): JSX.Element => {
|
export const ExternalNotificationsDebugPanel = (): JSX.Element => {
|
||||||
const preferences = useAppSelector(
|
const preferences = useAppSelector(
|
||||||
@@ -22,11 +21,11 @@ export const ExternalNotificationsDebugPanel = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<>
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(debugData)} />
|
<CopyButton data={JSON.stringify(debugData)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={debugData} />
|
<JSONPretty className="max-w-sm" data={debugData} />
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+3
-4
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
|
|||||||
import { Form } from '@app/components/generic/form/Form';
|
import { Form } from '@app/components/generic/form/Form';
|
||||||
import { connection } from '@app/core/connection';
|
import { connection } from '@app/core/connection';
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-col w-full">
|
<>
|
||||||
<Form loading={loading}>
|
<Form loading={loading}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Plugin Enabled"
|
label="Plugin Enabled"
|
||||||
@@ -82,7 +81,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||||
<div className="p-2 ml-auto">
|
<div className="ml-auto p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!formState.isDirty}
|
disabled={!formState.isDirty}
|
||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
@@ -92,6 +91,6 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
|||||||
|
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export const RangeTestDebugPanel = (): JSX.Element => {
|
export const RangeTestDebugPanel = (): JSX.Element => {
|
||||||
const preferences = useAppSelector(
|
const preferences = useAppSelector(
|
||||||
@@ -18,11 +17,11 @@ export const RangeTestDebugPanel = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<>
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(debugData)} />
|
<CopyButton data={JSON.stringify(debugData)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={debugData} />
|
<JSONPretty className="max-w-sm" data={debugData} />
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+3
-4
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
|
|||||||
import { Form } from '@app/components/generic/form/Form';
|
import { Form } from '@app/components/generic/form/Form';
|
||||||
import { connection } from '@app/core/connection';
|
import { connection } from '@app/core/connection';
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-col w-full">
|
<>
|
||||||
<Form loading={loading}>
|
<Form loading={loading}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Range Test Plugin Enabled?"
|
label="Range Test Plugin Enabled?"
|
||||||
@@ -64,7 +63,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||||
<div className="p-2 ml-auto">
|
<div className="ml-auto p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!formState.isDirty}
|
disabled={!formState.isDirty}
|
||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
@@ -74,6 +73,6 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
|||||||
|
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export const SerialDebugPanel = (): JSX.Element => {
|
export const SerialDebugPanel = (): JSX.Element => {
|
||||||
const preferences = useAppSelector(
|
const preferences = useAppSelector(
|
||||||
@@ -21,11 +20,11 @@ export const SerialDebugPanel = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<>
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(debugData)} />
|
<CopyButton data={JSON.stringify(debugData)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={debugData} />
|
<JSONPretty className="max-w-sm" data={debugData} />
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+3
-4
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
|
|||||||
import { Form } from '@app/components/generic/form/Form';
|
import { Form } from '@app/components/generic/form/Form';
|
||||||
import { connection } from '@app/core/connection';
|
import { connection } from '@app/core/connection';
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-col w-full">
|
<>
|
||||||
<Form loading={loading}>
|
<Form loading={loading}>
|
||||||
<Checkbox label="Plugin Enabled" {...register('serialpluginEnabled')} />
|
<Checkbox label="Plugin Enabled" {...register('serialpluginEnabled')} />
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -85,7 +84,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||||
<div className="p-2 ml-auto">
|
<div className="ml-auto p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!formState.isDirty}
|
disabled={!formState.isDirty}
|
||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
@@ -95,6 +94,6 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
|||||||
|
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export const StoreForwardDebugPanel = (): JSX.Element => {
|
export const StoreForwardDebugPanel = (): JSX.Element => {
|
||||||
const preferences = useAppSelector(
|
const preferences = useAppSelector(
|
||||||
@@ -22,11 +21,11 @@ export const StoreForwardDebugPanel = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<>
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(debugData)} />
|
<CopyButton data={JSON.stringify(debugData)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={debugData} />
|
<JSONPretty className="max-w-sm" data={debugData} />
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+3
-4
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
|
|||||||
import { Form } from '@app/components/generic/form/Form';
|
import { Form } from '@app/components/generic/form/Form';
|
||||||
import { connection } from '@app/core/connection';
|
import { connection } from '@app/core/connection';
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-col w-full">
|
<>
|
||||||
<Form loading={loading}>
|
<Form loading={loading}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Plugin Enabled"
|
label="Plugin Enabled"
|
||||||
@@ -80,7 +79,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||||
<div className="p-2 ml-auto">
|
<div className="ml-auto p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!formState.isDirty}
|
disabled={!formState.isDirty}
|
||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
@@ -90,6 +89,6 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { FaQrcode } from 'react-icons/fa';
|
||||||
|
import { FiCode, FiSave } from 'react-icons/fi';
|
||||||
|
|
||||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection';
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
|
import { SettingsPanel } from './SettingsPanel';
|
||||||
|
|
||||||
|
export const ChannelsGroup = (): JSX.Element => {
|
||||||
|
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{channels.map((channel) => {
|
||||||
|
return (
|
||||||
|
<div key={channel.channel.index}>
|
||||||
|
<CollapsibleSection
|
||||||
|
title={
|
||||||
|
channel.channel.settings?.name.length
|
||||||
|
? channel.channel.settings.name
|
||||||
|
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
|
||||||
|
? 'Primary'
|
||||||
|
: `Channel: ${channel.channel.index}`
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<div
|
||||||
|
className={`h-3 w-3 rounded-full ${
|
||||||
|
channel.channel.role === Protobuf.Channel_Role.PRIMARY
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: channel.channel.role === Protobuf.Channel_Role.SECONDARY
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-gray-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<IconButton icon={<FiCode />} />
|
||||||
|
<IconButton icon={<FaQrcode />} />
|
||||||
|
<IconButton icon={<FiSave />} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{/* <DebugPanel channel={channel.channel} /> */}
|
||||||
|
{/* <QRCodePanel channel={channel.channel} /> */}
|
||||||
|
<SettingsPanel channel={channel.channel} />
|
||||||
|
</>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-3
@@ -3,7 +3,6 @@ import type React from 'react';
|
|||||||
import JSONPretty from 'react-json-pretty';
|
import JSONPretty from 'react-json-pretty';
|
||||||
|
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
export interface DebugPanelProps {
|
export interface DebugPanelProps {
|
||||||
@@ -12,11 +11,11 @@ export interface DebugPanelProps {
|
|||||||
|
|
||||||
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
|
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<>
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(channel)} />
|
<CopyButton data={JSON.stringify(channel)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={channel} />
|
<JSONPretty className="max-w-sm" data={channel} />
|
||||||
</Tab.Panel>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+6
-9
@@ -2,7 +2,6 @@ import type React from 'react';
|
|||||||
|
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
export interface QRCodePanelProps {
|
export interface QRCodePanelProps {
|
||||||
@@ -11,13 +10,11 @@ export interface QRCodePanelProps {
|
|||||||
|
|
||||||
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
|
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-grow p-2">
|
<div className="m-auto">
|
||||||
<div className="m-auto">
|
<QRCode
|
||||||
<QRCode
|
className="rounded-md"
|
||||||
className="rounded-md"
|
value={`https://www.meshtastic.org/d/#${channel.index}`}
|
||||||
value={`https://www.meshtastic.org/d/#${channel.index}`}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</Tab.Panel>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+3
-4
@@ -7,7 +7,6 @@ import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
|
|||||||
|
|
||||||
import { Form } from '@app/components/generic/form/Form';
|
import { Form } from '@app/components/generic/form/Form';
|
||||||
import { connection } from '@app/core/connection';
|
import { connection } from '@app/core/connection';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
|
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="flex flex-col w-full">
|
<div className="flex w-full flex-col">
|
||||||
<Form loading={loading}>
|
<Form loading={loading}>
|
||||||
{channel?.index !== 0 && (
|
{channel?.index !== 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -123,7 +122,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
|
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
|
||||||
</Form>
|
</Form>
|
||||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||||
<div className="p-2 ml-auto">
|
<div className="ml-auto p-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!formState.isDirty}
|
disabled={!formState.isDirty}
|
||||||
onClick={async (): Promise<void> => {
|
onClick={async (): Promise<void> => {
|
||||||
@@ -133,6 +132,6 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { m } from 'framer-motion';
|
||||||
|
|
||||||
|
export interface SidebarItemProps {
|
||||||
|
selected: boolean;
|
||||||
|
setSelected: () => void;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarItem = ({
|
||||||
|
selected,
|
||||||
|
setSelected,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: SidebarItemProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelected();
|
||||||
|
}}
|
||||||
|
animate={selected ? 'selected' : 'deselected'}
|
||||||
|
initial={{ borderColor: '#1C1D23' }}
|
||||||
|
variants={{
|
||||||
|
selected: { borderColor: '#67ea94' },
|
||||||
|
deselected: { borderColor: '#1C1D23' },
|
||||||
|
}}
|
||||||
|
className="mx-2 flex cursor-pointer select-none rounded-md border-2 p-2 first:mt-2 last:mb-2 dark:bg-secondaryDark"
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
className="flex w-full justify-between"
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">{children}</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">{actions}</div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector.js';
|
||||||
|
|
||||||
|
import { ButtonNav } from './ButtonNav';
|
||||||
|
import { Settings } from './Settings/Index';
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||||
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const appState = useAppSelector((state) => state.app);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-grow">
|
||||||
|
<div
|
||||||
|
className={`absolute h-full w-full flex-grow flex-col pb-6 md:relative md:flex md:w-96 md:pb-0 ${
|
||||||
|
appState.mobileNavOpen ? 'flex' : 'hidden'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full flex-col shadow-xl dark:bg-primaryDark">
|
||||||
|
<div className="relative flex-grow gap-1">
|
||||||
|
<div className="absolute h-full w-full">{children}</div>
|
||||||
|
<Settings open={settingsOpen} setOpen={setSettingsOpen} />
|
||||||
|
</div>
|
||||||
|
<ButtonNav
|
||||||
|
toggleSettingsOpen={(): void => {
|
||||||
|
setSettingsOpen(!settingsOpen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, m } from 'framer-motion';
|
||||||
|
import { FiArrowUp } from 'react-icons/fi';
|
||||||
|
|
||||||
|
export interface CollapsibleSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
actions?: JSX.Element;
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollapsibleSection = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: CollapsibleSectionProps): JSX.Element => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const toggleOpen = (): void => setOpen(!open);
|
||||||
|
return (
|
||||||
|
<m.div>
|
||||||
|
<m.div
|
||||||
|
layout
|
||||||
|
className="w-full cursor-pointer select-none overflow-hidden shadow-md dark:bg-secondaryDark dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
layout
|
||||||
|
onClick={toggleOpen}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
className="flex justify-between gap-2 border-b border-primaryDark p-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<m.div className="flex gap-2 ">
|
||||||
|
<m.div className="my-auto">{icon}</m.div>
|
||||||
|
{title}
|
||||||
|
</m.div>
|
||||||
|
<m.div
|
||||||
|
animate={open ? 'open' : 'closed'}
|
||||||
|
initial={{ rotate: 180 }}
|
||||||
|
variants={{
|
||||||
|
open: { rotate: 0 },
|
||||||
|
closed: { rotate: 180 },
|
||||||
|
}}
|
||||||
|
className="my-auto"
|
||||||
|
>
|
||||||
|
<FiArrowUp />
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{actions && (
|
||||||
|
<m.div className="flex justify-end gap-1 rounded-b-md border-x border-b p-1 shadow-inner dark:border-gray-600">
|
||||||
|
{actions}
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
<m.div
|
||||||
|
className="p-2"
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</m.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { m } from 'framer-motion';
|
||||||
|
import { FiExternalLink } from 'react-icons/fi';
|
||||||
|
|
||||||
|
export interface ExternalSectionProps {
|
||||||
|
title: string;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExternalSection = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: ExternalSectionProps): JSX.Element => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const toggleOpen = (): void => setOpen(!open);
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
onClick={(): void => {
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
layout
|
||||||
|
className="w-full cursor-pointer select-none overflow-hidden shadow-md dark:bg-secondaryDark dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<m.div
|
||||||
|
layout
|
||||||
|
onClick={toggleOpen}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
className="flex justify-between gap-2 border-b border-primaryDark p-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<m.div className="flex gap-2 ">
|
||||||
|
<m.div className="my-auto">{icon}</m.div>
|
||||||
|
{title}
|
||||||
|
</m.div>
|
||||||
|
<m.div className="my-auto">
|
||||||
|
<FiExternalLink />
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion';
|
||||||
|
import { FiArrowLeft } from 'react-icons/fi';
|
||||||
|
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
|
||||||
|
export interface SidebarOverlayProps {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
close: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarOverlay = ({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
children,
|
||||||
|
}: SidebarOverlayProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<m.div
|
||||||
|
className="absolute z-20 flex h-full w-full flex-col bg-primaryDark"
|
||||||
|
animate={{ translateX: 0 }}
|
||||||
|
initial={{ translateX: '-100%' }}
|
||||||
|
exit={{ translateX: '-100%' }}
|
||||||
|
transition={{ type: 'just' }}
|
||||||
|
>
|
||||||
|
<AnimateSharedLayout>
|
||||||
|
<div className="flex gap-2 p-2">
|
||||||
|
<IconButton
|
||||||
|
onClick={(): void => {
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
icon={<FiArrowLeft />}
|
||||||
|
/>
|
||||||
|
<div className="my-auto text-lg font-medium dark:text-white">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow overflow-y-auto">{children}</div>
|
||||||
|
</AnimateSharedLayout>
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion';
|
||||||
|
import { FiArrowDown } from 'react-icons/fi';
|
||||||
|
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
|
||||||
|
export interface SidebarPrimaryProps {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
close: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarPrimary = ({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
children,
|
||||||
|
}: SidebarPrimaryProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<m.div
|
||||||
|
className="absolute flex h-full w-full flex-col bg-gray-100 dark:bg-primaryDark"
|
||||||
|
animate={{ translateY: 0 }}
|
||||||
|
initial={{ translateY: '100%' }}
|
||||||
|
exit={{ translateY: '100%' }}
|
||||||
|
transition={{ type: 'just' }}
|
||||||
|
>
|
||||||
|
<AnimateSharedLayout>
|
||||||
|
<div className="flex gap-2 p-2">
|
||||||
|
<IconButton
|
||||||
|
onClick={(): void => {
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
icon={<FiArrowDown />}
|
||||||
|
/>
|
||||||
|
<div className="my-auto text-lg font-medium dark:text-white">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow overflow-y-auto">{children}</div>
|
||||||
|
</AnimateSharedLayout>
|
||||||
|
</m.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
|
||||||
|
import { ErrorFallback } from '../ErrorFallback';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
export interface LayoutProps {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
sidebarContents: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
sidebarContents,
|
||||||
|
children,
|
||||||
|
}: LayoutProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full bg-gray-100 dark:bg-secondaryDark md:overflow-hidden md:shadow-xl">
|
||||||
|
<Sidebar>
|
||||||
|
<div className="flex gap-2 border-b border-gray-300 p-2 dark:border-gray-600">
|
||||||
|
<IconButton icon={icon} />
|
||||||
|
<div className="my-auto text-lg font-medium dark:text-white">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">{sidebarContents}</div>
|
||||||
|
</Sidebar>
|
||||||
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FiBell,
|
|
||||||
FiBluetooth,
|
FiBluetooth,
|
||||||
FiCpu,
|
FiCpu,
|
||||||
FiGitBranch,
|
FiGitBranch,
|
||||||
|
FiMenu,
|
||||||
FiMoon,
|
FiMoon,
|
||||||
FiSun,
|
FiSun,
|
||||||
FiWifi,
|
FiWifi,
|
||||||
@@ -20,18 +20,15 @@ import {
|
|||||||
connType,
|
connType,
|
||||||
openConnectionModal,
|
openConnectionModal,
|
||||||
setDarkModeEnabled,
|
setDarkModeEnabled,
|
||||||
|
toggleMobileNav,
|
||||||
} from '@app/core/slices/appSlice';
|
} from '@app/core/slices/appSlice';
|
||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { Tooltip } from '@meshtastic/components';
|
||||||
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
import { Tooltip } from '../generic/Tooltip';
|
|
||||||
import { VersionInfo } from '../modals/VersionInfo';
|
import { VersionInfo } from '../modals/VersionInfo';
|
||||||
|
|
||||||
// export interface BottomNavProps {
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
export const BottomNav = (): JSX.Element => {
|
export const BottomNav = (): JSX.Element => {
|
||||||
const [showVersionInfo, setShowVersionInfo] = React.useState(false);
|
const [showVersionInfo, setShowVersionInfo] = React.useState(false);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -43,11 +40,26 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
?.channel.settings;
|
?.channel.settings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between bg-white border-t border-gray-300 dark:bg-secondaryDark dark:border-gray-600">
|
<div className="z-20 flex justify-between border-t border-gray-300 bg-white dark:border-gray-600 dark:bg-secondaryDark">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Tooltip contents={`Connection Status`}>
|
<Tooltip content="Meshtastic WebUI">
|
||||||
|
<div className="group flex cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark">
|
||||||
|
<img
|
||||||
|
title="Logo"
|
||||||
|
className="w-5 dark:hidden"
|
||||||
|
src="/Logo_Black.svg"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
title="Logo"
|
||||||
|
className="hidden w-5 dark:flex"
|
||||||
|
src="/Logo_White.svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={`Connection Status`}>
|
||||||
<div
|
<div
|
||||||
className={`flex p-1 cursor-pointer group w-min hover:bg-opacity-80 ${
|
className={`group flex w-min cursor-pointer p-1 hover:bg-opacity-80 ${
|
||||||
[
|
[
|
||||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||||
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
||||||
@@ -72,7 +84,7 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
) : (
|
) : (
|
||||||
<FiWifi className="mr-1 p-0.5 group-active:scale-90" />
|
<FiWifi className="mr-1 p-0.5 group-active:scale-90" />
|
||||||
)}
|
)}
|
||||||
<div className="text-xs font-medium truncate group-active:scale-90">
|
<div className="truncate text-xs font-medium group-active:scale-90">
|
||||||
{meshtasticState.nodes.find(
|
{meshtasticState.nodes.find(
|
||||||
(node) =>
|
(node) =>
|
||||||
node.number === meshtasticState.radio.hardware.myNodeNum,
|
node.number === meshtasticState.radio.hardware.myNodeNum,
|
||||||
@@ -80,8 +92,8 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip contents={`MQTT Status`}>
|
<Tooltip content="MQTT Status">
|
||||||
<div className="flex p-1 border-r border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark">
|
<div className="group flex cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark">
|
||||||
{primaryChannelSettings?.uplinkEnabled &&
|
{primaryChannelSettings?.uplinkEnabled &&
|
||||||
primaryChannelSettings?.downlinkEnabled &&
|
primaryChannelSettings?.downlinkEnabled &&
|
||||||
!meshtasticState.radio.preferences.mqttDisabled ? (
|
!meshtasticState.radio.preferences.mqttDisabled ? (
|
||||||
@@ -98,7 +110,18 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
dispatch(toggleMobileNav());
|
||||||
|
}}
|
||||||
|
className="group flex w-full cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark md:hidden"
|
||||||
|
>
|
||||||
|
{appState.mobileNavOpen ? (
|
||||||
|
<FiX className="m-auto p-0.5 group-active:scale-90" />
|
||||||
|
) : (
|
||||||
|
<FiMenu className="m-auto p-0.5 group-active:scale-90" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<VersionInfo
|
<VersionInfo
|
||||||
visible={showVersionInfo}
|
visible={showVersionInfo}
|
||||||
@@ -106,26 +129,22 @@ export const BottomNav = (): JSX.Element => {
|
|||||||
setShowVersionInfo(false);
|
setShowVersionInfo(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip contents={`Current Commit`}>
|
|
||||||
|
<Tooltip content={`Current Commit`}>
|
||||||
<div
|
<div
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
setShowVersionInfo(true);
|
setShowVersionInfo(true);
|
||||||
}}
|
}}
|
||||||
className="flex p-1 border-l border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark"
|
className="group flex cursor-pointer select-none border-l border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark"
|
||||||
>
|
>
|
||||||
<FiGitBranch className="p-0.5 mr-1 group-active:scale-90" />
|
<FiGitBranch className="mr-1 p-0.5 group-active:scale-90" />
|
||||||
<p className="text-xs opacity-60">{process.env.COMMIT_HASH}</p>
|
<p className="text-xs opacity-60">{process.env.COMMIT_HASH}</p>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip contents={`Notifications`}>
|
|
||||||
<div className="flex p-1 border-l border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark">
|
<Tooltip content={`Toggle Theme`}>
|
||||||
<FiBell className="p-0.5 mr-1 group-active:scale-90" />
|
|
||||||
<p className="text-xs opacity-60">Example Notification</p>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip contents={`Toggle Theme`}>
|
|
||||||
<div
|
<div
|
||||||
className="p-1 border-l border-gray-300 cursor-pointer group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark"
|
className="group cursor-pointer border-l border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark"
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
dispatch(setDarkModeEnabled(!appState.darkMode));
|
dispatch(setDarkModeEnabled(!appState.darkMode));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
export const Logo = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<img title="Logo" className="w-16 dark:hidden" src="/Logo_Black.svg" />
|
|
||||||
<img
|
|
||||||
title="Logo"
|
|
||||||
className="hidden w-16 dark:flex"
|
|
||||||
src="/Logo_White.svg"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { FiGrid, FiMessageSquare, FiSettings } from 'react-icons/fi';
|
|
||||||
import type { Link } from 'type-route';
|
|
||||||
|
|
||||||
import { routes, useRoute } from '@core/router';
|
|
||||||
|
|
||||||
export const Navigation = (): JSX.Element => {
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full mt-2 border-t border-l border-r border-gray-300 rounded-t dark:text-white dark:border-gray-600">
|
|
||||||
<NavLink
|
|
||||||
name="Messages"
|
|
||||||
icon={
|
|
||||||
<FiMessageSquare className="w-5 h-5 my-auto group-active:scale-90" />
|
|
||||||
}
|
|
||||||
active={route.name === 'messages'}
|
|
||||||
link={routes.messages().link}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NavLink
|
|
||||||
name="Nodes"
|
|
||||||
icon={<FiGrid className="w-5 h-5 my-auto group-active:scale-90" />}
|
|
||||||
active={route.name === 'nodes'}
|
|
||||||
link={routes.nodes().link}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NavLink
|
|
||||||
name="Settings"
|
|
||||||
icon={<FiSettings className="w-5 h-5 my-auto group-active:scale-90" />}
|
|
||||||
active={route.name === 'settings'}
|
|
||||||
link={routes.settings().link}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface NavLinkProps {
|
|
||||||
name: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
active: boolean;
|
|
||||||
link: Link;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavLink = ({ name, icon, active, link }: NavLinkProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={`flex h-full gap-1 p-2 cursor-pointer group hover:bg-gray-200 dark:hover:bg-primaryDark ${
|
|
||||||
active ? 'dark:bg-primaryDark bg-gray-200' : 'bg-transparent'
|
|
||||||
}`}
|
|
||||||
{...link}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<div className="hidden my-auto md:flex">{name}</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -23,7 +23,7 @@ export const DeviceStatus = (): JSX.Element => {
|
|||||||
<div className="flex gap-2 px-2">
|
<div className="flex gap-2 px-2">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
my-auto w-2 h-2 rounded-full min-w-[2] ${
|
my-auto h-2 w-2 min-w-[2] rounded-full ${
|
||||||
[
|
[
|
||||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||||
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
||||||
@@ -44,11 +44,11 @@ export const DeviceStatus = (): JSX.Element => {
|
|||||||
)?.user?.longName ?? 'Disconnected'}
|
)?.user?.longName ?? 'Disconnected'}
|
||||||
</div>
|
</div>
|
||||||
{appState.connType === connType.BLE ? (
|
{appState.connType === connType.BLE ? (
|
||||||
<FiBluetooth className="w-5 h-5" />
|
<FiBluetooth className="h-5 w-5" />
|
||||||
) : appState.connType === connType.SERIAL ? (
|
) : appState.connType === connType.SERIAL ? (
|
||||||
<FiCpu className="w-5 h-5" />
|
<FiCpu className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<FiWifi className="w-5 h-5" />
|
<FiWifi className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { FiMenu } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { openMobileNav } from '@core/slices/appSlice';
|
|
||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
|
||||||
import { IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export const MobileNavToggle = (): JSX.Element => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="md:hidden">
|
|
||||||
<IconButton
|
|
||||||
icon={<FiMenu className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
dispatch(openMobileNav());
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FiBell, FiX } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { shift, useFloating } from '@floating-ui/react-dom';
|
|
||||||
import { Popover } from '@headlessui/react';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { Button, IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export const Notifications = (): JSX.Element => {
|
|
||||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
|
||||||
const notifications = useAppSelector((state) => state.app.notifications);
|
|
||||||
|
|
||||||
const { x, y, reference, floating, strategy } = useFloating({
|
|
||||||
placement: 'bottom',
|
|
||||||
middleware: [shift()],
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setUnreadCount(
|
|
||||||
notifications.filter((notification) => !notification.read).length,
|
|
||||||
);
|
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<Popover.Button as="div" className="relative" ref={reference}>
|
|
||||||
<IconButton icon={<FiBell className="w-5 h-5" />} />
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<div className="absolute pointer-events-none top-1 right-1">
|
|
||||||
<div className="w-3 h-3 text-xs font-semibold leading-3 text-center text-white bg-orange-500 rounded-full">
|
|
||||||
{unreadCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Popover.Button>
|
|
||||||
|
|
||||||
<Popover.Panel
|
|
||||||
ref={floating}
|
|
||||||
style={{
|
|
||||||
position: strategy,
|
|
||||||
top: y ?? '',
|
|
||||||
left: x ?? '',
|
|
||||||
}}
|
|
||||||
className="fixed z-50 border border-gray-300 rounded-md shadow-md w-72 bg-primaryDark dark:border-gray-600"
|
|
||||||
>
|
|
||||||
<div className="divide-y divide-gray-600">
|
|
||||||
{notifications.map((notification, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`p-1 flex text-sm justify-between ${
|
|
||||||
notification.read
|
|
||||||
? 'text-gray-600 dark:text-gray-300'
|
|
||||||
: 'text-gray-900 dark:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="my-auto">{notification.icon}</div>
|
|
||||||
|
|
||||||
<div className="my-auto font-light">{notification.title}</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
{notification.action ? (
|
|
||||||
<div className="my-auto w-18">
|
|
||||||
<Button border onClick={notification.action.action}>
|
|
||||||
{notification.action.message}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-16" />
|
|
||||||
)}
|
|
||||||
<IconButton icon={<FiX />} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
import { Modal } from '@components/generic/Modal';
|
import { Modal } from '@components/generic/Modal';
|
||||||
import { Card } from '@meshtastic/components';
|
import { Card } from '@meshtastic/components';
|
||||||
|
|
||||||
@@ -41,16 +43,19 @@ export const VersionInfo = ({
|
|||||||
// console.log(data);
|
// console.log(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<AnimatePresence>
|
||||||
open={visible}
|
{visible && (
|
||||||
onClose={(): void => {
|
<Modal
|
||||||
onclose();
|
onClose={(): void => {
|
||||||
}}
|
onclose();
|
||||||
>
|
}}
|
||||||
<Card>
|
>
|
||||||
<div className="w-full max-w-3xl p-10">Version Info</div>
|
<Card>
|
||||||
{/* {data?.sha} */}
|
<div className="w-full max-w-3xl p-10">Version Info</div>
|
||||||
</Card>
|
{/* {data?.sha} */}
|
||||||
</Modal>
|
</Card>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import mapbox from 'mapbox-gl';
|
|
||||||
import { FiAlignLeft } from 'react-icons/fi';
|
|
||||||
import {
|
|
||||||
MdAccountCircle,
|
|
||||||
MdGpsFixed,
|
|
||||||
MdGpsNotFixed,
|
|
||||||
MdGpsOff,
|
|
||||||
} from 'react-icons/md';
|
|
||||||
import TimeAgo from 'timeago-react';
|
|
||||||
|
|
||||||
import type { Node } from '@core/slices/meshtasticSlice';
|
|
||||||
import { useMapbox } from '@hooks/useMapbox';
|
|
||||||
import { IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
type PositionConfidence = 'high' | 'low' | 'none';
|
|
||||||
type NodeAge = 'young' | 'aging' | 'old' | 'dead';
|
|
||||||
|
|
||||||
export interface NodeCardProps {
|
|
||||||
node: Node;
|
|
||||||
isMyNode?: boolean;
|
|
||||||
setSelected: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeCard = ({
|
|
||||||
node,
|
|
||||||
isMyNode,
|
|
||||||
setSelected,
|
|
||||||
}: NodeCardProps): JSX.Element => {
|
|
||||||
const { map } = useMapbox();
|
|
||||||
|
|
||||||
// React.useEffect(() => {
|
|
||||||
// setSnrAverage(
|
|
||||||
// node.snr
|
|
||||||
// .slice(node.snr.length - 3, node.snr.length)
|
|
||||||
// .reduce((a, b) => a + b) / (node.snr.length > 3 ? 3 : node.snr.length),
|
|
||||||
// );
|
|
||||||
// }, [node.snr]);
|
|
||||||
const [PositionConfidence, setPositionConfidence] =
|
|
||||||
React.useState<PositionConfidence>('none');
|
|
||||||
const [age, setAge] = React.useState<NodeAge>('young');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setAge(
|
|
||||||
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15)
|
|
||||||
? 'young'
|
|
||||||
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30)
|
|
||||||
? 'aging'
|
|
||||||
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 60)
|
|
||||||
? 'old'
|
|
||||||
: 'dead',
|
|
||||||
);
|
|
||||||
}, [node.lastHeard]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setPositionConfidence(
|
|
||||||
node.currentPosition
|
|
||||||
? new Date(node.currentPosition.posTimestamp * 1000) >
|
|
||||||
new Date(new Date().getTime() - 1000 * 60 * 30)
|
|
||||||
? 'high'
|
|
||||||
: 'low'
|
|
||||||
: 'none',
|
|
||||||
);
|
|
||||||
}, [node.currentPosition]);
|
|
||||||
return (
|
|
||||||
<div className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700">
|
|
||||||
<div className="flex w-full gap-1 p-2 bg-gray-100 rounded-md shadow-md dark:bg-primaryDark">
|
|
||||||
{isMyNode ? (
|
|
||||||
<MdAccountCircle className="my-auto" />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`my-auto w-3 h-3 rounded-full ${
|
|
||||||
age === 'young'
|
|
||||||
? 'bg-green-500'
|
|
||||||
: age === 'aging'
|
|
||||||
? 'bg-yellow-500'
|
|
||||||
: age === 'old'
|
|
||||||
? 'bg-red-500'
|
|
||||||
: 'bg-gray-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="my-auto">{node.user?.longName}</div>
|
|
||||||
|
|
||||||
<div className="my-auto ml-auto text-xs font-semibold">
|
|
||||||
{!isMyNode && (
|
|
||||||
<span>
|
|
||||||
{node.lastHeard.getTime() ? (
|
|
||||||
<TimeAgo datetime={node.lastHeard} />
|
|
||||||
) : (
|
|
||||||
'Never'
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
disabled={PositionConfidence === 'none'}
|
|
||||||
onClick={(e): void => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (PositionConfidence !== 'none' && node.currentPosition) {
|
|
||||||
map?.flyTo({
|
|
||||||
center: new mapbox.LngLat(
|
|
||||||
node.currentPosition.longitudeI / 1e7,
|
|
||||||
node.currentPosition.latitudeI / 1e7,
|
|
||||||
),
|
|
||||||
zoom: 16,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
PositionConfidence === 'high' ? (
|
|
||||||
<MdGpsFixed />
|
|
||||||
) : PositionConfidence === 'low' ? (
|
|
||||||
<MdGpsNotFixed />
|
|
||||||
) : (
|
|
||||||
<MdGpsOff />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
onClick={(): void => {
|
|
||||||
setSelected();
|
|
||||||
}}
|
|
||||||
icon={<FiAlignLeft />}
|
|
||||||
/>
|
|
||||||
{/* <FiBatteryCharging /> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FiCode, FiMapPin, FiSliders, FiUser } from 'react-icons/fi';
|
|
||||||
import { IoTelescope } from 'react-icons/io5';
|
|
||||||
|
|
||||||
import { DebugPanel } from '@app/components/pages/nodes/panels/DebugPanel';
|
|
||||||
import { InfoPanel } from '@app/components/pages/nodes/panels/InfoPanel';
|
|
||||||
import { PositionPanel } from '@app/components/pages/nodes/panels/PositionPanel';
|
|
||||||
import { Sidebar } from '@components/generic/Sidebar';
|
|
||||||
import { TabButton } from '@components/TabButton';
|
|
||||||
import type { Node } from '@core/slices/meshtasticSlice';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export interface NodeSidebarProps {
|
|
||||||
node: Node;
|
|
||||||
closeSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeSidebar = ({
|
|
||||||
node,
|
|
||||||
closeSidebar,
|
|
||||||
}: NodeSidebarProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Sidebar
|
|
||||||
title={node.number.toString()}
|
|
||||||
tagline={`${node.user?.longName}(${node.user?.shortName})`}
|
|
||||||
closeSidebar={closeSidebar}
|
|
||||||
>
|
|
||||||
<Tab.Group>
|
|
||||||
<div className="shadow-md">
|
|
||||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
|
|
||||||
<TabButton>
|
|
||||||
<FiUser />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FiMapPin />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<IoTelescope />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FiSliders />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FiCode />
|
|
||||||
</TabButton>
|
|
||||||
</Tab.List>
|
|
||||||
</div>
|
|
||||||
<Tab.Panels className="flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
|
|
||||||
<InfoPanel />
|
|
||||||
<PositionPanel node={node} />
|
|
||||||
<Tab.Panel className="p-2">Content 3</Tab.Panel>
|
|
||||||
<Tab.Panel className="p-2">Remote Administration</Tab.Panel>
|
|
||||||
<DebugPanel node={node} />
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export const InfoPanel = (): JSX.Element => {
|
|
||||||
return <Tab.Panel className="p-2">Info</Tab.Panel>;
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { FiCode, FiSliders } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { TabButton } from '@app/components/TabButton';
|
|
||||||
import type { Plugin } from '@app/pages/settings/Plugins';
|
|
||||||
import { Sidebar } from '@components/generic/Sidebar';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
import { ExternalNotificationsDebugPanel } from './panels/ExternalNotifications/DebugPanel';
|
|
||||||
import { ExternalNotificationsSettingsPlanel } from './panels/ExternalNotifications/SettingsPlanel';
|
|
||||||
import { RangeTestDebugPanel } from './panels/RangeTest/DebugPanel';
|
|
||||||
import { RangeTestSettingsPanel } from './panels/RangeTest/SettingsPanel';
|
|
||||||
import { SerialDebugPanel } from './panels/Serial/DebugPanel';
|
|
||||||
import { SerialSettingsPanel } from './panels/Serial/SettingsPanel';
|
|
||||||
import { StoreForwardDebugPanel } from './panels/StoreForward/DebugPanel';
|
|
||||||
import { StoreForwardSettingsPanel } from './panels/StoreForward/SettingsPanel';
|
|
||||||
|
|
||||||
export interface PluginsSidebarProps {
|
|
||||||
plugin?: Plugin;
|
|
||||||
closeSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PluginsSidebar = ({
|
|
||||||
plugin,
|
|
||||||
closeSidebar,
|
|
||||||
}: PluginsSidebarProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Sidebar
|
|
||||||
title={plugin ?? 'Please select plugin'}
|
|
||||||
tagline={plugin ? 'settings' : '...'}
|
|
||||||
closeSidebar={closeSidebar}
|
|
||||||
>
|
|
||||||
{plugin && (
|
|
||||||
<Tab.Group>
|
|
||||||
<div className="shadow-md">
|
|
||||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
|
|
||||||
<TabButton>
|
|
||||||
<FiSliders />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FiCode />
|
|
||||||
</TabButton>
|
|
||||||
</Tab.List>
|
|
||||||
</div>
|
|
||||||
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
|
|
||||||
{plugin === 'Range Test' && (
|
|
||||||
<>
|
|
||||||
<RangeTestSettingsPanel />
|
|
||||||
<RangeTestDebugPanel />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{plugin === 'External Notifications' && (
|
|
||||||
<>
|
|
||||||
<ExternalNotificationsSettingsPlanel />
|
|
||||||
<ExternalNotificationsDebugPanel />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{plugin === 'Serial' && (
|
|
||||||
<>
|
|
||||||
<SerialSettingsPanel />
|
|
||||||
<SerialDebugPanel />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{plugin === 'Store & Forward' && (
|
|
||||||
<>
|
|
||||||
<StoreForwardSettingsPanel />
|
|
||||||
<StoreForwardDebugPanel />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
)}
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { FaQrcode } from 'react-icons/fa';
|
|
||||||
import { FiCode, FiSliders } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { TabButton } from '@app/components/TabButton';
|
|
||||||
import { Sidebar } from '@components/generic/Sidebar';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
import { DebugPanel } from './panels/DebugPanel';
|
|
||||||
import { QRCodePanel } from './panels/QRCodePanel';
|
|
||||||
import { SettingsPanel } from './panels/SettingsPanel';
|
|
||||||
|
|
||||||
export interface ChannelsSidebarProps {
|
|
||||||
channel?: Protobuf.Channel;
|
|
||||||
closeSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChannelsSidebar = ({
|
|
||||||
channel,
|
|
||||||
closeSidebar,
|
|
||||||
}: ChannelsSidebarProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<Sidebar
|
|
||||||
title={
|
|
||||||
channel
|
|
||||||
? channel.settings?.name.length
|
|
||||||
? channel.settings.name
|
|
||||||
: `Channel: ${channel.index}`
|
|
||||||
: 'Please select channel'
|
|
||||||
}
|
|
||||||
tagline={channel ? Protobuf.Channel_Role[channel.role] : '...'}
|
|
||||||
closeSidebar={closeSidebar}
|
|
||||||
>
|
|
||||||
{channel && (
|
|
||||||
<Tab.Group>
|
|
||||||
<div className="shadow-md">
|
|
||||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
|
|
||||||
<TabButton>
|
|
||||||
<FiSliders />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FaQrcode />
|
|
||||||
</TabButton>
|
|
||||||
<TabButton>
|
|
||||||
<FiCode />
|
|
||||||
</TabButton>
|
|
||||||
</Tab.List>
|
|
||||||
</div>
|
|
||||||
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
|
|
||||||
<SettingsPanel channel={channel} />
|
|
||||||
<QRCodePanel channel={channel} />
|
|
||||||
<DebugPanel channel={channel} />
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
)}
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FiMenu, FiXCircle } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { Drawer } from '@components/generic/Drawer';
|
|
||||||
import type { SidebarItemProps } from '@components/generic/SidebarItem';
|
|
||||||
import { SidebarItem } from '@components/generic/SidebarItem';
|
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
import { useBreakpoint } from '@hooks/useBreakpoint';
|
|
||||||
import { IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export interface PageLayoutProps {
|
|
||||||
title: string;
|
|
||||||
sidebarItems: SidebarItemProps[];
|
|
||||||
panels: JSX.Element[];
|
|
||||||
emptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageLayout = ({
|
|
||||||
title,
|
|
||||||
sidebarItems,
|
|
||||||
panels,
|
|
||||||
emptyMessage,
|
|
||||||
}: PageLayoutProps): JSX.Element => {
|
|
||||||
const [navOpen, setNavOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const { breakpoint } = useBreakpoint();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tab.Group>
|
|
||||||
<div className="relative flex w-full dark:text-white">
|
|
||||||
<Drawer
|
|
||||||
open={breakpoint === 'sm' ? navOpen : true}
|
|
||||||
permenant={breakpoint !== 'sm'}
|
|
||||||
onClose={(): void => {
|
|
||||||
setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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-4">
|
|
||||||
<div className="text-2xl font-extrabold leading-none tracking-tight">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<IconButton icon={<FiMenu />} />
|
|
||||||
<div className="md:hidden">
|
|
||||||
<IconButton
|
|
||||||
icon={<FiXCircle className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!sidebarItems.length && (
|
|
||||||
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
|
|
||||||
{emptyMessage}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{sidebarItems.map((props, index) => (
|
|
||||||
<Tab
|
|
||||||
key={index}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ selected }): JSX.Element => (
|
|
||||||
<SidebarItem {...props} selected={selected} />
|
|
||||||
)}
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</Tab.List>
|
|
||||||
</Drawer>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Tab.Panels className="flex w-full">
|
|
||||||
{panels.map((Panel, index) => (
|
|
||||||
<Tab.Panel key={index} className="flex w-full">
|
|
||||||
{React.cloneElement(Panel, {
|
|
||||||
key: index,
|
|
||||||
navOpen: navOpen,
|
|
||||||
setNavOpen: setNavOpen,
|
|
||||||
})}
|
|
||||||
</Tab.Panel>
|
|
||||||
))}
|
|
||||||
</Tab.Panels>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tab.Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
export interface PrimaryTemplateProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
tagline: string;
|
|
||||||
leftButton?: JSX.Element;
|
|
||||||
rightButton?: JSX.Element;
|
|
||||||
footer?: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PrimaryTemplate = ({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
tagline,
|
|
||||||
leftButton,
|
|
||||||
rightButton,
|
|
||||||
footer,
|
|
||||||
}: PrimaryTemplateProps): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-auto h-full min-w-0">
|
|
||||||
<div className="flex p-2 bg-white border-b border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<a className="font-medium whitespace-nowrap text-primary">
|
|
||||||
{tagline}
|
|
||||||
</a>
|
|
||||||
<h2 className="text-3xl font-extrabold leading-7 tracking-tight truncate md:text-4xl md:leading-10 dark:text-white">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{rightButton}
|
|
||||||
</div>
|
|
||||||
<div className="flex-auto flex-grow py-6 overflow-y-auto bg-white md:p-5 dark:bg-secondaryDark">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{footer && (
|
|
||||||
<div className="flex px-4 py-2 bg-white border-t border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
|
||||||
{leftButton && (
|
|
||||||
<div className="pr-2 m-auto md:hidden">{leftButton}</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">{footer}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+18
-7
@@ -4,6 +4,7 @@ import {
|
|||||||
addMessage,
|
addMessage,
|
||||||
addNode,
|
addNode,
|
||||||
addPosition,
|
addPosition,
|
||||||
|
addRoute,
|
||||||
addUser,
|
addUser,
|
||||||
resetState,
|
resetState,
|
||||||
setDeviceStatus,
|
setDeviceStatus,
|
||||||
@@ -23,8 +24,6 @@ import {
|
|||||||
Types,
|
Types,
|
||||||
} from '@meshtastic/meshtasticjs';
|
} from '@meshtastic/meshtasticjs';
|
||||||
|
|
||||||
import { showNotification } from './utils/notifications';
|
|
||||||
|
|
||||||
type connectionType = IBLEConnection | IHTTPConnection | ISerialConnection;
|
type connectionType = IBLEConnection | IHTTPConnection | ISerialConnection;
|
||||||
|
|
||||||
export let connection: connectionType = new IHTTPConnection();
|
export let connection: connectionType = new IHTTPConnection();
|
||||||
@@ -36,9 +35,6 @@ export const connectionUrl = state.hostOverrideEnabled
|
|||||||
? window.location.hostname
|
? window.location.hostname
|
||||||
: (import.meta.env.VITE_PUBLIC_DEVICE_IP as string) ?? 'meshtastic.local';
|
: (import.meta.env.VITE_PUBLIC_DEVICE_IP as string) ?? 'meshtastic.local';
|
||||||
|
|
||||||
export const ble = new IBLEConnection();
|
|
||||||
export const serial = new ISerialConnection();
|
|
||||||
|
|
||||||
export const setConnection = async (conn: connType): Promise<void> => {
|
export const setConnection = async (conn: connType): Promise<void> => {
|
||||||
await connection.disconnect();
|
await connection.disconnect();
|
||||||
cleanupListeners();
|
cleanupListeners();
|
||||||
@@ -75,6 +71,7 @@ export const setConnection = async (conn: connType): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cleanupListeners = (): void => {
|
export const cleanupListeners = (): void => {
|
||||||
|
connection.onMeshPacket.cancelAll();
|
||||||
connection.onDeviceStatus.cancelAll();
|
connection.onDeviceStatus.cancelAll();
|
||||||
connection.onMyNodeInfo.cancelAll();
|
connection.onMyNodeInfo.cancelAll();
|
||||||
connection.onUserPacket.cancelAll();
|
connection.onUserPacket.cancelAll();
|
||||||
@@ -88,6 +85,20 @@ export const cleanupListeners = (): void => {
|
|||||||
const registerListeners = (): void => {
|
const registerListeners = (): void => {
|
||||||
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE;
|
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE;
|
||||||
|
|
||||||
|
connection.onMeshPacket.subscribe((packet) => {
|
||||||
|
console.log(packet);
|
||||||
|
store.dispatch(
|
||||||
|
addRoute({
|
||||||
|
from: packet.from,
|
||||||
|
to:
|
||||||
|
packet.to === 0xffffffff
|
||||||
|
? store.getState().meshtastic.radio.hardware.myNodeNum
|
||||||
|
: packet.to,
|
||||||
|
hops: packet.hopLimit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
connection.onDeviceStatus.subscribe((status) => {
|
connection.onDeviceStatus.subscribe((status) => {
|
||||||
store.dispatch(setDeviceStatus(status));
|
store.dispatch(setDeviceStatus(status));
|
||||||
|
|
||||||
@@ -142,6 +153,8 @@ const registerListeners = (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
connection.onRoutingPacket.subscribe((routingPacket) => {
|
connection.onRoutingPacket.subscribe((routingPacket) => {
|
||||||
|
console.log(routingPacket);
|
||||||
|
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
updateLastInteraction({
|
updateLastInteraction({
|
||||||
id: routingPacket.packet.from,
|
id: routingPacket.packet.from,
|
||||||
@@ -152,13 +165,11 @@ const registerListeners = (): void => {
|
|||||||
|
|
||||||
connection.onTextPacket.subscribe((message) => {
|
connection.onTextPacket.subscribe((message) => {
|
||||||
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
|
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
|
||||||
showNotification('New message', message.data);
|
|
||||||
|
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
addMessage({
|
addMessage({
|
||||||
message: message,
|
message: message,
|
||||||
ack: message.packet.from !== myNodeNum,
|
ack: message.packet.from !== myNodeNum,
|
||||||
isSender: message.packet.from === myNodeNum,
|
|
||||||
received: message.packet.rxTime
|
received: message.packet.rxTime
|
||||||
? new Date(message.packet.rxTime * 1000)
|
? new Date(message.packet.rxTime * 1000)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ import { createRouter, defineRoute } from 'type-route';
|
|||||||
|
|
||||||
export const { RouteProvider, useRoute, routes } = createRouter({
|
export const { RouteProvider, useRoute, routes } = createRouter({
|
||||||
messages: defineRoute('/'),
|
messages: defineRoute('/'),
|
||||||
|
map: defineRoute('/map'),
|
||||||
nodes: defineRoute('/nodes'),
|
nodes: defineRoute('/nodes'),
|
||||||
settings: defineRoute('/settings'),
|
extensions: defineRoute('/extensions'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,17 +10,6 @@ export enum connType {
|
|||||||
SERIAL,
|
SERIAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
action?: {
|
|
||||||
message: string;
|
|
||||||
action: () => void;
|
|
||||||
};
|
|
||||||
read: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
mobileNavOpen: boolean;
|
mobileNavOpen: boolean;
|
||||||
navCollapsed: boolean;
|
navCollapsed: boolean;
|
||||||
@@ -33,7 +22,6 @@ interface AppState {
|
|||||||
HTTP: Types.HTTPConnectionParameters;
|
HTTP: Types.HTTPConnectionParameters;
|
||||||
SERIAL: Types.SerialConnectionParameters;
|
SERIAL: Types.SerialConnectionParameters;
|
||||||
};
|
};
|
||||||
notifications: Notification[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AppState = {
|
const initialState: AppState = {
|
||||||
@@ -53,18 +41,14 @@ const initialState: AppState = {
|
|||||||
},
|
},
|
||||||
SERIAL: {},
|
SERIAL: {},
|
||||||
},
|
},
|
||||||
notifications: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const appSlice = createSlice({
|
export const appSlice = createSlice({
|
||||||
name: 'app',
|
name: 'app',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
openMobileNav(state) {
|
toggleMobileNav(state) {
|
||||||
state.mobileNavOpen = true;
|
state.mobileNavOpen = !state.mobileNavOpen;
|
||||||
},
|
|
||||||
closeMobileNav(state) {
|
|
||||||
state.mobileNavOpen = false;
|
|
||||||
},
|
},
|
||||||
openConnectionModal(state) {
|
openConnectionModal(state) {
|
||||||
state.connectionModalOpen = true;
|
state.connectionModalOpen = true;
|
||||||
@@ -93,31 +77,17 @@ export const appSlice = createSlice({
|
|||||||
state.connectionParams[connType[action.payload.type]] =
|
state.connectionParams[connType[action.payload.type]] =
|
||||||
action.payload.params;
|
action.payload.params;
|
||||||
},
|
},
|
||||||
addNotification(state, action: PayloadAction<Notification>) {
|
|
||||||
state.notifications.push(action.payload);
|
|
||||||
},
|
|
||||||
removeNotification(state, action: PayloadAction<string>) {
|
|
||||||
state.notifications.splice(
|
|
||||||
state.notifications.findIndex(
|
|
||||||
(notification) => notification.id === action.payload,
|
|
||||||
),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
openMobileNav,
|
toggleMobileNav,
|
||||||
closeMobileNav,
|
|
||||||
openConnectionModal,
|
openConnectionModal,
|
||||||
closeConnectionModal,
|
closeConnectionModal,
|
||||||
setDarkModeEnabled,
|
setDarkModeEnabled,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setConnType,
|
setConnType,
|
||||||
setConnectionParams,
|
setConnectionParams,
|
||||||
addNotification,
|
|
||||||
removeNotification,
|
|
||||||
} = appSlice.actions;
|
} = appSlice.actions;
|
||||||
|
|
||||||
export default appSlice.reducer;
|
export default appSlice.reducer;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
|
||||||
import type { MapStyle } from '@app/components/Map/styles';
|
import type { MapStyle } from '@app/pages/Map/styles';
|
||||||
import { MapStyles } from '@app/components/Map/styles';
|
import { MapStyles } from '@app/pages/Map/styles';
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { createSlice } from '@reduxjs/toolkit';
|
|||||||
export interface MessageWithAck {
|
export interface MessageWithAck {
|
||||||
message: Types.TextPacket;
|
message: Types.TextPacket;
|
||||||
ack: boolean;
|
ack: boolean;
|
||||||
isSender: boolean;
|
|
||||||
received: Date;
|
received: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelData {
|
export interface ChannelData {
|
||||||
channel: Protobuf.Channel;
|
channel: Protobuf.Channel;
|
||||||
|
lastChatInterraction: Date;
|
||||||
messages: MessageWithAck[];
|
messages: MessageWithAck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,14 @@ interface CurrentPosition {
|
|||||||
satsInView: number;
|
satsInView: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
hops: number;
|
||||||
|
|
||||||
|
//speed stats?
|
||||||
|
}
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
number: number;
|
number: number;
|
||||||
lastHeard: Date;
|
lastHeard: Date;
|
||||||
@@ -29,6 +37,7 @@ export interface Node {
|
|||||||
positions: Protobuf.Position[];
|
positions: Protobuf.Position[];
|
||||||
currentPosition?: CurrentPosition;
|
currentPosition?: CurrentPosition;
|
||||||
user?: Protobuf.User;
|
user?: Protobuf.User;
|
||||||
|
routes: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Radio {
|
export interface Radio {
|
||||||
@@ -90,7 +99,9 @@ export const meshtasticSlice = createSlice({
|
|||||||
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
|
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// todo: add node
|
||||||
console.log('Node not in DB');
|
console.log('Node not in DB');
|
||||||
|
console.log(action.payload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addPosition: (state, action: PayloadAction<Types.PositionPacket>) => {
|
addPosition: (state, action: PayloadAction<Types.PositionPacket>) => {
|
||||||
@@ -142,6 +153,7 @@ export const meshtasticSlice = createSlice({
|
|||||||
lastHeard: new Date(action.payload.lastHeard * 1000),
|
lastHeard: new Date(action.payload.lastHeard * 1000),
|
||||||
snr: [action.payload.snr],
|
snr: [action.payload.snr],
|
||||||
positions: [],
|
positions: [],
|
||||||
|
routes: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,6 +167,7 @@ export const meshtasticSlice = createSlice({
|
|||||||
return channel.channel.index === action.payload.index
|
return channel.channel.index === action.payload.index
|
||||||
? {
|
? {
|
||||||
channel: action.payload,
|
channel: action.payload,
|
||||||
|
lastChatInterraction: new Date(),
|
||||||
messages: channel.messages,
|
messages: channel.messages,
|
||||||
}
|
}
|
||||||
: channel;
|
: channel;
|
||||||
@@ -162,10 +175,41 @@ export const meshtasticSlice = createSlice({
|
|||||||
} else {
|
} else {
|
||||||
state.radio.channels.push({
|
state.radio.channels.push({
|
||||||
channel: action.payload,
|
channel: action.payload,
|
||||||
|
lastChatInterraction: new Date(),
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
addRoute: (state, action: PayloadAction<Route>) => {
|
||||||
|
const node = state.nodes.find(
|
||||||
|
(node) => node.number === action.payload.from,
|
||||||
|
);
|
||||||
|
const exists = node?.routes.findIndex(
|
||||||
|
(route) =>
|
||||||
|
route.from === action.payload.from && route.to === action.payload.to,
|
||||||
|
);
|
||||||
|
console.log(exists);
|
||||||
|
|
||||||
|
if (exists === -1) {
|
||||||
|
node?.routes.push(action.payload);
|
||||||
|
}
|
||||||
|
// node?.routes.map((route) => {
|
||||||
|
// if (
|
||||||
|
|
||||||
|
// ) {
|
||||||
|
// node?.routes.push(action.payload);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (node) {
|
||||||
|
// node.routes = node.routes.map((route) => {
|
||||||
|
// return route.from === action.payload.from &&
|
||||||
|
// route.to === action.payload.to
|
||||||
|
// ? action.payload
|
||||||
|
// : route;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
},
|
||||||
setPreferences: (
|
setPreferences: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<Protobuf.RadioConfig_UserPreferences>,
|
action: PayloadAction<Protobuf.RadioConfig_UserPreferences>,
|
||||||
@@ -178,6 +222,7 @@ export const meshtasticSlice = createSlice({
|
|||||||
channel.channel.index === action.payload.message.packet.channel,
|
channel.channel.index === action.payload.message.packet.channel,
|
||||||
);
|
);
|
||||||
state.radio.channels[channelIndex].messages.push(action.payload);
|
state.radio.channels[channelIndex].messages.push(action.payload);
|
||||||
|
state.radio.channels[channelIndex].lastChatInterraction = new Date();
|
||||||
},
|
},
|
||||||
ackMessage: (
|
ackMessage: (
|
||||||
state,
|
state,
|
||||||
@@ -237,6 +282,7 @@ export const {
|
|||||||
addNode,
|
addNode,
|
||||||
addChannel,
|
addChannel,
|
||||||
setPreferences,
|
setPreferences,
|
||||||
|
addRoute,
|
||||||
addMessage,
|
addMessage,
|
||||||
ackMessage,
|
ackMessage,
|
||||||
updateLastInteraction,
|
updateLastInteraction,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import i18n from 'i18next';
|
|
||||||
import detector from 'i18next-browser-languagedetector';
|
|
||||||
import { initReactI18next } from 'react-i18next';
|
|
||||||
|
|
||||||
import { en } from '../translations/en';
|
|
||||||
import { jp } from '../translations/jp';
|
|
||||||
import { pt } from '../translations/pt';
|
|
||||||
|
|
||||||
void i18n
|
|
||||||
.use(detector)
|
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
|
||||||
fallbackLng: 'en',
|
|
||||||
resources: {
|
|
||||||
en: { translation: en },
|
|
||||||
jp: { translation: jp },
|
|
||||||
pt: { translation: pt },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default i18n;
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { request } from 'graphql-request';
|
||||||
|
|
||||||
|
export default async function gqlFetcher<JSON>(
|
||||||
|
url: string,
|
||||||
|
query?: string,
|
||||||
|
): Promise<JSON> {
|
||||||
|
// const res = await fetch(input, init);
|
||||||
|
return await request<JSON>(url, query);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const requestNotificationPermission = async (): Promise<void> => {
|
|
||||||
if (window.Notification && Notification.permission !== 'denied') {
|
|
||||||
await Notification.requestPermission();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const showNotification = (title: string, body: string): void => {
|
|
||||||
new Notification(title, {
|
|
||||||
body,
|
|
||||||
icon: 'android-512.png',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
||||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
|
||||||
import useBreakpointHook from 'use-breakpoint';
|
|
||||||
|
|
||||||
const BREAKPOINTS = {
|
|
||||||
sm: 0,
|
|
||||||
// => @media (min-width: 640px) { ... }
|
|
||||||
|
|
||||||
md: 768,
|
|
||||||
// => @media (min-width: 768px) { ... }
|
|
||||||
|
|
||||||
lg: 1024,
|
|
||||||
// => @media (min-width: 1024px) { ... }
|
|
||||||
|
|
||||||
xl: 1280,
|
|
||||||
// => @media (min-width: 1280px) { ... }
|
|
||||||
|
|
||||||
'2xl': 1536,
|
|
||||||
// => @media (min-width: 1536px) { ... }
|
|
||||||
};
|
|
||||||
export const useBreakpoint = () => useBreakpointHook(BREAKPOINTS);
|
|
||||||
+5
-3
@@ -1,26 +1,28 @@
|
|||||||
import '@meshtastic/components/dist/style.css';
|
import '@meshtastic/components/dist/style.css';
|
||||||
import '@app/index.css';
|
import '@app/index.css';
|
||||||
import '@core/translation';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
import { domAnimation, LazyMotion } from 'framer-motion';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { App } from '@app/App';
|
import { App } from '@app/App';
|
||||||
import { ReloadPrompt } from '@components/pwa/ReloadPrompt';
|
import { ReloadPrompt } from '@components/pwa/ReloadPrompt';
|
||||||
import { RouteProvider } from '@core/router';
|
|
||||||
import { store } from '@core/store';
|
import { store } from '@core/store';
|
||||||
|
|
||||||
import { ErrorFallback } from './components/ErrorFallback';
|
import { ErrorFallback } from './components/ErrorFallback';
|
||||||
|
import { RouteProvider } from './core/router';
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
<RouteProvider>
|
<RouteProvider>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App />
|
<LazyMotion features={domAnimation}>
|
||||||
|
<App />
|
||||||
|
</LazyMotion>
|
||||||
<ReloadPrompt />
|
<ReloadPrompt />
|
||||||
</Provider>
|
</Provider>
|
||||||
</RouteProvider>
|
</RouteProvider>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import fetcher from '@app/core/utils/fetcher';
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
nameModified: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Files {
|
||||||
|
data: {
|
||||||
|
files: File[];
|
||||||
|
fileSystem: {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileBrowser = (): JSX.Element => {
|
||||||
|
const connectionParams = useAppSelector(
|
||||||
|
(state) => state.app.connectionParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = useSWR<Files>(
|
||||||
|
`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
|
||||||
|
connectionParams.HTTP.address
|
||||||
|
}/json/spiffs/browse/static`,
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full select-none flex-col gap-4 p-4">
|
||||||
|
<div className="w-full flex-grow rounded-md bg-gray-200 dark:bg-primaryDark">
|
||||||
|
<div className="flex h-10 w-full rounded-t-md bg-gray-300 px-4 text-lg font-semibold dark:bg-zinc-700 dark:text-white">
|
||||||
|
<div className="my-auto w-1/3">FileName</div>
|
||||||
|
<div className="my-auto w-1/3">Actions</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
{data?.data.files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.name}
|
||||||
|
className="flex h-10 w-full border-b border-gray-500 px-4 font-medium dark:text-white"
|
||||||
|
>
|
||||||
|
<div className="my-auto w-1/3">
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href={`${connectionParams.HTTP.tls ? 'https' : 'http'}://${
|
||||||
|
connectionParams.HTTP.address
|
||||||
|
}/${file.name.replace('static/', '')}`}
|
||||||
|
>
|
||||||
|
{file.name.replace('static/', '').replace('.gz', '')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="my-auto w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FiFile, FiInfo } from 'react-icons/fi';
|
||||||
|
import { RiPinDistanceFill } from 'react-icons/ri';
|
||||||
|
import { VscExtensions } from 'react-icons/vsc';
|
||||||
|
|
||||||
|
import { Layout } from '@app/components/layout';
|
||||||
|
import { ExternalSection } from '@app/components/layout/Sidebar/sections/ExternalSection';
|
||||||
|
|
||||||
|
import { FileBrowser } from './FileBrowser';
|
||||||
|
import { Info } from './Info';
|
||||||
|
|
||||||
|
export const Extensions = (): JSX.Element => {
|
||||||
|
const [selectedExtension, setSelectedExtension] = React.useState<
|
||||||
|
'info' | 'fileBrowser' | 'rangeTest'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Extensions"
|
||||||
|
icon={<VscExtensions />}
|
||||||
|
sidebarContents={
|
||||||
|
<div className="absolute flex h-full w-full flex-col dark:bg-primaryDark">
|
||||||
|
<ExternalSection
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelectedExtension('info');
|
||||||
|
}}
|
||||||
|
icon={<FiInfo />}
|
||||||
|
title="Node Info"
|
||||||
|
/>
|
||||||
|
<ExternalSection
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelectedExtension('fileBrowser');
|
||||||
|
}}
|
||||||
|
icon={<FiFile />}
|
||||||
|
title="File Browser"
|
||||||
|
/>
|
||||||
|
<ExternalSection
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelectedExtension('rangeTest');
|
||||||
|
}}
|
||||||
|
icon={<RiPinDistanceFill />}
|
||||||
|
title="Range Test"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
{selectedExtension === 'info' && <Info />}
|
||||||
|
|
||||||
|
{selectedExtension === 'fileBrowser' && <FileBrowser />}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { m } from 'framer-motion';
|
||||||
|
import JSONPretty from 'react-json-pretty';
|
||||||
|
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector.js';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
const Hashicon = skypack_hashicon.Hashicon;
|
||||||
|
// import { Hashicon } from '@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
export const Info = (): JSX.Element => {
|
||||||
|
const hardwareInfo = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.hardware,
|
||||||
|
);
|
||||||
|
const node = useAppSelector((state) =>
|
||||||
|
state.meshtastic.nodes.find(
|
||||||
|
(node) => node.number === hardwareInfo.myNodeNum,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full select-none flex-col gap-4 p-4">
|
||||||
|
<m.div
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
className="flex w-full flex-col gap-4 rounded-md p-8 shadow-md dark:bg-primaryDark"
|
||||||
|
>
|
||||||
|
<div className="m-auto">
|
||||||
|
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} />
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-lg font-medium dark:text-white">
|
||||||
|
{node?.user?.longName || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</m.div>
|
||||||
|
|
||||||
|
<div className="rounded-md p-8 shadow-md dark:bg-primaryDark">
|
||||||
|
<JSONPretty data={hardwareInfo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ import { IconButton } from '@meshtastic/components';
|
|||||||
import type { MapStyle } from './styles';
|
import type { MapStyle } from './styles';
|
||||||
import { MapStyles } from './styles';
|
import { MapStyles } from './styles';
|
||||||
|
|
||||||
export const Map = (): JSX.Element => {
|
export const MapContainer = (): JSX.Element => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ export const Map = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full h-full">
|
<div className="relative flex h-full w-full">
|
||||||
<div className="absolute right-0 z-20 p-2 m-4 space-y-2 bg-white border border-gray-300 rounded-md shadow-md dark:bg-primaryDark dark:border-gray-600">
|
<div className="absolute right-0 z-20 m-4 space-y-2 rounded-md border border-gray-300 bg-white p-2 shadow-md dark:border-gray-600 dark:bg-primaryDark">
|
||||||
<IconButton
|
<IconButton
|
||||||
active={mapState.style.title === 'Satellite'}
|
active={mapState.style.title === 'Satellite'}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
@@ -51,7 +51,7 @@ export const Map = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`p-1 -m-1 space-y-2 rounded-md border-gray-300 dark:border-gray-600 ${
|
className={`-m-1 space-y-2 rounded-md border-gray-300 p-1 dark:border-gray-600 ${
|
||||||
mapState.style.title === 'Outdoors' ? 'border' : ''
|
mapState.style.title === 'Outdoors' ? 'border' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -84,7 +84,7 @@ export const Map = (): JSX.Element => {
|
|||||||
<IconButton icon={<MdFullscreen />} />
|
<IconButton icon={<MdFullscreen />} />
|
||||||
<IconButton icon={<MdRadar />} />
|
<IconButton icon={<MdRadar />} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow w-full h-full" ref={ref} />
|
<div className="h-full w-full flex-grow" ref={ref} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { FiMapPin } from 'react-icons/fi';
|
||||||
|
import { RiRoadMapLine } from 'react-icons/ri';
|
||||||
|
|
||||||
|
import { Layout } from '@app/components/layout';
|
||||||
|
import { MapboxProvider } from '@app/components/MapBox/MapboxProvider';
|
||||||
|
import type { Node } from '@app/core/slices/meshtasticSlice';
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
|
|
||||||
|
import { NodeCard } from '../Nodes/NodeCard';
|
||||||
|
import { MapContainer } from './MapContainer';
|
||||||
|
import { Marker } from './Marker';
|
||||||
|
|
||||||
|
export const Map = (): JSX.Element => {
|
||||||
|
const [selectedNode, setSelectedNode] = React.useState<Node>();
|
||||||
|
|
||||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||||
|
const myNodeNum = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.hardware.myNodeNum,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapboxProvider>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
return (
|
||||||
|
node.currentPosition && (
|
||||||
|
<Marker
|
||||||
|
key={node.number}
|
||||||
|
center={
|
||||||
|
new mapboxgl.LngLat(
|
||||||
|
node.currentPosition.longitudeI / 1e7,
|
||||||
|
node.currentPosition.latitudeI / 1e7,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
setSelectedNode(node);
|
||||||
|
}}
|
||||||
|
className={`z-50 rounded-full border-2 bg-opacity-30 ${
|
||||||
|
node.number === selectedNode?.number
|
||||||
|
? 'border-green-500 bg-green-500'
|
||||||
|
: 'border-blue-500 bg-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="m-4 ">
|
||||||
|
<FiMapPin className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Layout
|
||||||
|
title="Nodes"
|
||||||
|
icon={<RiRoadMapLine />}
|
||||||
|
sidebarContents={
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{!nodes.length && (
|
||||||
|
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
|
||||||
|
No nodes found.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<NodeCard
|
||||||
|
key={node.number}
|
||||||
|
node={node}
|
||||||
|
isMyNode={node.number === myNodeNum}
|
||||||
|
selected={selectedNode?.number === node.number}
|
||||||
|
setSelected={(): void => {
|
||||||
|
setSelectedNode(node);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MapContainer />
|
||||||
|
</Layout>
|
||||||
|
</MapboxProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { FiHash } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { Message } from '@components/chat/Message';
|
|
||||||
import { MessageBar } from '@components/chat/MessageBar';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { Select } from '@meshtastic/components';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
export const Messages = (): JSX.Element => {
|
|
||||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
|
||||||
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
|
|
||||||
const [channelIndex, setChannelIndex] = React.useState(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<div className="flex justify-between w-full px-2 border-b border-gray-300 dark:border-gray-600 dark:text-gray-300">
|
|
||||||
<div className="flex py-2 my-auto text-sm">
|
|
||||||
<FiHash className="w-4 h-4 my-auto mr-1" />
|
|
||||||
<Select
|
|
||||||
options={channels
|
|
||||||
.filter(
|
|
||||||
(channel) =>
|
|
||||||
channel.channel.role !== Protobuf.Channel_Role.DISABLED &&
|
|
||||||
channel.channel.settings?.name !== 'admin',
|
|
||||||
)
|
|
||||||
.map((channel) => {
|
|
||||||
return {
|
|
||||||
name: channel.channel.settings?.name.length
|
|
||||||
? channel.channel.settings.name
|
|
||||||
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
|
|
||||||
? 'Primary'
|
|
||||||
: `CH: ${channel.channel.index}`,
|
|
||||||
value: channel.channel.index,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(e): void => {
|
|
||||||
setChannelIndex(parseInt(e.target.value));
|
|
||||||
}}
|
|
||||||
small
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col flex-grow p-6 space-y-2 overflow-y-auto bg-white border-b border-gray-300 md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
|
|
||||||
{channels[channelIndex]?.messages.map((message, index) => (
|
|
||||||
<Message
|
|
||||||
key={index}
|
|
||||||
isSender={message.isSender}
|
|
||||||
message={message.message.data}
|
|
||||||
ack={message.ack}
|
|
||||||
rxTime={message.received}
|
|
||||||
senderName={
|
|
||||||
nodes.find((node) => node.number === message.message.packet.from)
|
|
||||||
?.user?.longName ?? 'UNK'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<MessageBar channelIndex={channelIndex} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
import type { Node } from '@app/core/slices/meshtasticSlice';
|
||||||
|
import { Tooltip } from '@meshtastic/components';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
const Hashicon = skypack_hashicon.Hashicon;
|
||||||
|
|
||||||
|
export interface MessageProps {
|
||||||
|
lastMsgSameUser: boolean;
|
||||||
|
message: string;
|
||||||
|
ack: boolean;
|
||||||
|
rxTime: Date;
|
||||||
|
sender?: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Message = ({
|
||||||
|
lastMsgSameUser,
|
||||||
|
message,
|
||||||
|
ack,
|
||||||
|
rxTime,
|
||||||
|
sender,
|
||||||
|
}: MessageProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="group mb-3 hover:bg-gray-200 dark:hover:bg-primaryDark">
|
||||||
|
{lastMsgSameUser ? (
|
||||||
|
<div
|
||||||
|
className={`mx-6 -mt-3 flex gap-2 ${lastMsgSameUser ? '' : 'py-1'}`}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<Tooltip content={rxTime.toString()}>
|
||||||
|
<div className="my-auto ml-auto w-8 pt-1 text-xs text-transparent dark:group-hover:text-gray-400">
|
||||||
|
{rxTime
|
||||||
|
.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
.replace('AM', '')
|
||||||
|
.replace('PM', '')}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`my-auto dark:text-gray-300 ${
|
||||||
|
ack ? '' : 'animate-pulse dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mx-6 flex gap-2">
|
||||||
|
<div className="my-auto w-8">
|
||||||
|
<Hashicon value={(sender?.number ?? 0).toString()} size={32} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="cursor-default text-sm font-semibold hover:underline dark:text-white">
|
||||||
|
{sender?.user?.longName ?? 'UNK'}
|
||||||
|
</div>
|
||||||
|
<div className="my-auto text-xs dark:text-gray-400">
|
||||||
|
{rxTime.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`dark:text-gray-300 ${
|
||||||
|
ack ? '' : 'animate-pulse dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { FiSend } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { connection } from '@core/connection';
|
import { connection } from '@core/connection';
|
||||||
import { ackMessage } from '@core/slices/meshtasticSlice';
|
import { ackMessage } from '@core/slices/meshtasticSlice';
|
||||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
import { IconButton, Input, Select } from '@meshtastic/components';
|
import { Input } from '@meshtastic/components';
|
||||||
|
|
||||||
export interface MessageBarProps {
|
export interface MessageBarProps {
|
||||||
channelIndex: number;
|
channelIndex: number;
|
||||||
@@ -16,19 +13,13 @@ export interface MessageBarProps {
|
|||||||
export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||||
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
|
||||||
const myNodeNum = useAppSelector(
|
|
||||||
(state) => state.meshtastic.radio.hardware,
|
|
||||||
).myNodeNum;
|
|
||||||
|
|
||||||
const [currentMessage, setCurrentMessage] = React.useState('');
|
const [currentMessage, setCurrentMessage] = React.useState('');
|
||||||
const [destinationNode, setDestinationNode] =
|
|
||||||
React.useState<number>(0xffffffff);
|
|
||||||
const sendMessage = (): void => {
|
const sendMessage = (): void => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
void connection.sendText(
|
void connection.sendText(
|
||||||
currentMessage,
|
currentMessage,
|
||||||
destinationNode,
|
undefined,
|
||||||
true,
|
true,
|
||||||
channelIndex--,
|
channelIndex--,
|
||||||
(id) => {
|
(id) => {
|
||||||
@@ -40,10 +31,9 @@ export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
|||||||
setCurrentMessage('');
|
setCurrentMessage('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full p-4 mx-auto space-x-2 text-gray-500 bg-gray-50 dark:bg-transparent dark:text-gray-400">
|
<div className="mx-auto flex w-full space-x-2 bg-gray-50 p-4 text-gray-500 dark:bg-transparent dark:text-gray-400">
|
||||||
<div className="flex w-full max-w-4xl mx-auto">
|
<div className="mx-auto flex w-full max-w-4xl">
|
||||||
<form
|
<form
|
||||||
className="flex w-full space-x-2"
|
className="flex w-full space-x-2"
|
||||||
onSubmit={(e): void => {
|
onSubmit={(e): void => {
|
||||||
@@ -51,36 +41,16 @@ export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
|||||||
sendMessage();
|
sendMessage();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select
|
|
||||||
onChange={(e): void => {
|
|
||||||
setDestinationNode(parseInt(e.target.value));
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
name: 'All',
|
|
||||||
value: 0xffffffff,
|
|
||||||
},
|
|
||||||
...nodes
|
|
||||||
.filter((node) => node.number !== myNodeNum)
|
|
||||||
.map((node) => {
|
|
||||||
return {
|
|
||||||
name: node.user ? node.user.shortName : node.number,
|
|
||||||
value: node.number,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
minLength={2}
|
minLength={2}
|
||||||
placeholder={`${t('placeholder.message')}...`}
|
placeholder="Enter Message"
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
value={currentMessage}
|
value={currentMessage}
|
||||||
onChange={(e): void => {
|
onChange={(e): void => {
|
||||||
setCurrentMessage(e.target.value);
|
setCurrentMessage(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton icon={<FiSend className="w-5 h-5" />} type="submit" />
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { m } from 'framer-motion';
|
||||||
|
import { FiHash, FiMessageCircle, FiSettings } from 'react-icons/fi';
|
||||||
|
import { MdPublic } from 'react-icons/md';
|
||||||
|
import TimeAgo from 'timeago-react';
|
||||||
|
|
||||||
|
import { Layout } from '@app/components/layout';
|
||||||
|
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem';
|
||||||
|
import { useAppSelector } from '@hooks/useAppSelector';
|
||||||
|
import { IconButton, Tooltip } from '@meshtastic/components';
|
||||||
|
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
import { Message } from './Message';
|
||||||
|
import { MessageBar } from './MessageBar';
|
||||||
|
|
||||||
|
const Hashicon = skypack_hashicon.Hashicon;
|
||||||
|
|
||||||
|
export const Messages = (): JSX.Element => {
|
||||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||||
|
const channels = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.channels,
|
||||||
|
).filter((ch) => ch.channel.role !== Protobuf.Channel_Role.DISABLED);
|
||||||
|
const [channelIndex, setChannelIndex] = React.useState(0);
|
||||||
|
const chatRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (chatRef.current) {
|
||||||
|
chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [channels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Message Groups"
|
||||||
|
icon={<FiMessageCircle />}
|
||||||
|
sidebarContents={
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={channel.channel.index}
|
||||||
|
selected={channelIndex === channel.channel.index}
|
||||||
|
setSelected={(): void => {
|
||||||
|
setChannelIndex(channel.channel.index);
|
||||||
|
}}
|
||||||
|
actions={<IconButton icon={<FiSettings />} />}
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 rounded-full bg-gray-200 dark:bg-primaryDark dark:text-white">
|
||||||
|
<div className="m-auto">
|
||||||
|
{channel.channel.role === Protobuf.Channel_Role.PRIMARY ? (
|
||||||
|
<MdPublic />
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
{channel.channel.settings?.name.length
|
||||||
|
? channel.channel.settings.name
|
||||||
|
.substring(0, 3)
|
||||||
|
.toUpperCase()
|
||||||
|
: `CH: ${channel.channel.index}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{channel.messages.length ? (
|
||||||
|
<>
|
||||||
|
<div className="mx-2 flex h-8">
|
||||||
|
{[
|
||||||
|
...new Set(
|
||||||
|
channel.messages.flatMap(({ message }) => [
|
||||||
|
message.packet.from,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.sort()
|
||||||
|
.map((nodeId) => {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={nodeId}
|
||||||
|
content={
|
||||||
|
nodes.find((node) => node.number === nodeId)?.user
|
||||||
|
?.longName ?? 'UNK'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<m.div
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
className="my-auto -ml-2"
|
||||||
|
>
|
||||||
|
<Hashicon value={nodeId.toString()} size={20} />
|
||||||
|
</m.div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<TimeAgo
|
||||||
|
className="my-auto ml-auto text-xs font-semibold dark:text-gray-400"
|
||||||
|
datetime={channel.lastChatInterraction}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="my-auto dark:text-white">No messages</div>
|
||||||
|
)}
|
||||||
|
</SidebarItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="flex w-full justify-between border-b border-gray-300 px-2 dark:border-gray-600 dark:text-gray-300">
|
||||||
|
<div className="my-auto flex py-2 text-sm">
|
||||||
|
<FiHash className="my-auto mr-1 h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
{channels[channelIndex]?.channel.settings?.name.length
|
||||||
|
? channels[channelIndex]?.channel.settings?.name
|
||||||
|
: channels[channelIndex]?.channel.role ===
|
||||||
|
Protobuf.Channel_Role.PRIMARY
|
||||||
|
? 'Primary'
|
||||||
|
: `Channel: ${channels[channelIndex]?.channel.index}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={chatRef}
|
||||||
|
className="flex flex-grow flex-col space-y-2 overflow-y-auto border-b border-gray-300 bg-white pb-6 dark:border-gray-600 dark:bg-secondaryDark"
|
||||||
|
>
|
||||||
|
<div className="mt-auto">
|
||||||
|
{channels[channelIndex]?.messages.map((message, index) => (
|
||||||
|
<Message
|
||||||
|
key={index}
|
||||||
|
message={message.message.data}
|
||||||
|
ack={message.ack}
|
||||||
|
rxTime={message.received}
|
||||||
|
lastMsgSameUser={
|
||||||
|
index === 0
|
||||||
|
? false
|
||||||
|
: channels[channelIndex]?.messages[index - 1].message.packet
|
||||||
|
.from === message.message.packet.from
|
||||||
|
}
|
||||||
|
sender={nodes.find(
|
||||||
|
(node) => node.number === message.message.packet.from,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MessageBar channelIndex={channelIndex} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import mapbox from 'mapbox-gl';
|
|
||||||
import { FiMapPin, FiXCircle } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { Marker } from '@app/components/Map/Marker';
|
|
||||||
import type { Node } from '@app/core/slices/meshtasticSlice';
|
|
||||||
import { Drawer } from '@components/generic/Drawer';
|
|
||||||
import { Map } from '@components/Map';
|
|
||||||
import { NodeSidebar } from '@components/pages/nodes/NodeSidebar';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { useBreakpoint } from '@hooks/useBreakpoint';
|
|
||||||
import { IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
import { NodeCard } from '../components/pages/nodes/NodeCard';
|
|
||||||
|
|
||||||
export const Nodes = (): JSX.Element => {
|
|
||||||
const myNodeInfo = useAppSelector((state) => state.meshtastic.radio.hardware);
|
|
||||||
|
|
||||||
const nodes = useAppSelector((state) => state.meshtastic.nodes)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) =>
|
|
||||||
a.number === myNodeInfo.myNodeNum
|
|
||||||
? 1
|
|
||||||
: b?.lastHeard.getTime() - a?.lastHeard.getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const myNode = nodes.find((node) => node.number === myNodeInfo.myNodeNum);
|
|
||||||
|
|
||||||
const { breakpoint } = useBreakpoint();
|
|
||||||
const [navOpen, setNavOpen] = React.useState(false);
|
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
|
||||||
const [selectedNode, setSelectedNode] = React.useState<Node | undefined>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex w-full dark:text-white">
|
|
||||||
<Drawer
|
|
||||||
open={breakpoint === 'sm' ? navOpen : true}
|
|
||||||
permenant={breakpoint !== 'sm'}
|
|
||||||
onClose={(): void => {
|
|
||||||
setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between m-6 mr-6">
|
|
||||||
<div className="text-3xl font-extrabold leading-none tracking-tight">
|
|
||||||
Nodes
|
|
||||||
</div>
|
|
||||||
<div className="md:hidden">
|
|
||||||
<IconButton
|
|
||||||
icon={<FiXCircle className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!nodes.length && (
|
|
||||||
<span className="p-4 text-sm text-gray-400 dark:text-gray-600">
|
|
||||||
No nodes found.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{myNode && (
|
|
||||||
<NodeCard
|
|
||||||
node={myNode}
|
|
||||||
isMyNode={true}
|
|
||||||
setSelected={(): void => {
|
|
||||||
setSelectedNode(myNode);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{nodes
|
|
||||||
.filter((node) => node.number !== myNodeInfo.myNodeNum)
|
|
||||||
.map((node) => (
|
|
||||||
<NodeCard
|
|
||||||
key={node.number}
|
|
||||||
node={node}
|
|
||||||
setSelected={(): void => {
|
|
||||||
setSelectedNode(node);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{nodes.map((node) => {
|
|
||||||
return (
|
|
||||||
node.currentPosition && (
|
|
||||||
<Marker
|
|
||||||
center={
|
|
||||||
new mapbox.LngLat(
|
|
||||||
node.currentPosition.longitudeI / 1e7,
|
|
||||||
node.currentPosition.latitudeI / 1e7,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={(): void => {
|
|
||||||
setSelectedNode(node);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
className={`z-50 border-2 rounded-full bg-opacity-30 ${
|
|
||||||
node.number === selectedNode?.number
|
|
||||||
? 'bg-green-500 border-green-500'
|
|
||||||
: 'bg-blue-500 border-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="m-4 ">
|
|
||||||
<FiMapPin className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Marker>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Map />
|
|
||||||
|
|
||||||
{sidebarOpen && selectedNode && (
|
|
||||||
<NodeSidebar
|
|
||||||
closeSidebar={(): void => {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
node={selectedNode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import mapbox from 'mapbox-gl';
|
||||||
|
import {
|
||||||
|
FiAlignLeft,
|
||||||
|
FiCode,
|
||||||
|
FiMapPin,
|
||||||
|
FiSliders,
|
||||||
|
FiUser,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import { IoTelescope } from 'react-icons/io5';
|
||||||
|
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md';
|
||||||
|
import JSONPretty from 'react-json-pretty';
|
||||||
|
|
||||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection';
|
||||||
|
import { SidebarOverlay } from '@app/components/layout/Sidebar/sections/SidebarOverlay';
|
||||||
|
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem';
|
||||||
|
import { CopyButton } from '@app/components/menu/buttons/CopyButton';
|
||||||
|
import type { Node } from '@core/slices/meshtasticSlice';
|
||||||
|
import { useMapbox } from '@hooks/useMapbox';
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
const Hashicon = skypack_hashicon.Hashicon;
|
||||||
|
|
||||||
|
type PositionConfidence = 'high' | 'low' | 'none';
|
||||||
|
|
||||||
|
export interface NodeCardProps {
|
||||||
|
node: Node;
|
||||||
|
isMyNode: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
setSelected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeCard = ({
|
||||||
|
node,
|
||||||
|
isMyNode,
|
||||||
|
selected,
|
||||||
|
setSelected,
|
||||||
|
}: NodeCardProps): JSX.Element => {
|
||||||
|
const { map } = useMapbox();
|
||||||
|
const [infoOpen, setInfoOpen] = React.useState(false);
|
||||||
|
const [PositionConfidence, setPositionConfidence] =
|
||||||
|
React.useState<PositionConfidence>('none');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPositionConfidence(
|
||||||
|
node.currentPosition
|
||||||
|
? new Date(node.currentPosition.posTimestamp * 1000) >
|
||||||
|
new Date(new Date().getTime() - 1000 * 60 * 30)
|
||||||
|
? 'high'
|
||||||
|
: 'low'
|
||||||
|
: 'none',
|
||||||
|
);
|
||||||
|
}, [node.currentPosition]);
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
selected={selected}
|
||||||
|
setSelected={setSelected}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
disabled={PositionConfidence === 'none'}
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelected();
|
||||||
|
if (PositionConfidence !== 'none' && node.currentPosition) {
|
||||||
|
map?.flyTo({
|
||||||
|
center: new mapbox.LngLat(
|
||||||
|
node.currentPosition.longitudeI / 1e7,
|
||||||
|
node.currentPosition.latitudeI / 1e7,
|
||||||
|
),
|
||||||
|
zoom: 16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
PositionConfidence === 'high' ? (
|
||||||
|
<MdGpsFixed />
|
||||||
|
) : PositionConfidence === 'low' ? (
|
||||||
|
<MdGpsNotFixed />
|
||||||
|
) : (
|
||||||
|
<MdGpsOff />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setInfoOpen(true);
|
||||||
|
}}
|
||||||
|
icon={<FiAlignLeft />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex dark:text-white">
|
||||||
|
<div className="m-auto">
|
||||||
|
<Hashicon value={node.number.toString()} size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
|
||||||
|
{node.lastHeard.getTime()
|
||||||
|
? node.lastHeard.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: 'Never'}
|
||||||
|
</div>
|
||||||
|
<SidebarOverlay
|
||||||
|
title={`Node ${node.user?.longName ?? 'UNK'} `}
|
||||||
|
open={infoOpen}
|
||||||
|
close={(): void => {
|
||||||
|
setInfoOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollapsibleSection title="User" icon={<FiUser />}>
|
||||||
|
<div>Info</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Location" icon={<FiMapPin />}>
|
||||||
|
<div>Info</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Line of Sight" icon={<IoTelescope />}>
|
||||||
|
<div>Info</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Administration" icon={<FiSliders />}>
|
||||||
|
<div>Info</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
<CollapsibleSection title="Debug" icon={<FiCode />}>
|
||||||
|
<>
|
||||||
|
<div className="fixed right-0 mr-6">
|
||||||
|
<CopyButton data={JSON.stringify(node)} />
|
||||||
|
</div>
|
||||||
|
<JSONPretty className="max-w-sm" data={node} />
|
||||||
|
</>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</SidebarOverlay>
|
||||||
|
</SidebarItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { Edge, Node } from 'react-flow-renderer';
|
||||||
|
import ReactFlow, { Background, Controls, MiniMap } from 'react-flow-renderer';
|
||||||
|
import { FiSettings } from 'react-icons/fi';
|
||||||
|
import { RiMindMap } from 'react-icons/ri';
|
||||||
|
|
||||||
|
import { Layout } from '@app/components/layout';
|
||||||
|
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem';
|
||||||
|
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||||
|
import { IconButton } from '@meshtastic/components';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react';
|
||||||
|
|
||||||
|
const Hashicon = skypack_hashicon.Hashicon;
|
||||||
|
|
||||||
|
export const Nodes = (): JSX.Element => {
|
||||||
|
const [graphNodes, setGraphNodes] = React.useState<Node[]>([]);
|
||||||
|
const [graphEdges, setGraphEdges] = React.useState<Edge[]>([]);
|
||||||
|
const [selected, setSelected] = React.useState<number>(0);
|
||||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes);
|
||||||
|
const myNodeNum = useAppSelector(
|
||||||
|
(state) => state.meshtastic.radio.hardware.myNodeNum,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const tmpNodes: Node[] = [];
|
||||||
|
// User Terminal
|
||||||
|
tmpNodes.push({
|
||||||
|
id: '1',
|
||||||
|
type: 'input',
|
||||||
|
|
||||||
|
data: { label: 'User Terminal' },
|
||||||
|
position: { x: 160 + 500, y: 0 + 500 },
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.map((node, index) => {
|
||||||
|
tmpNodes.push({
|
||||||
|
id: node.number.toString(),
|
||||||
|
data: { label: node.user?.longName ?? `Unknown ${node.number}` },
|
||||||
|
position: { x: index * 160 + 500, y: 100 + 500 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setGraphNodes(tmpNodes);
|
||||||
|
}, [nodes, myNodeNum]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const tmpEdges: Edge[] = [];
|
||||||
|
|
||||||
|
nodes.map((node, index) => {
|
||||||
|
if (node.number === myNodeNum) {
|
||||||
|
tmpEdges.push({
|
||||||
|
id: `e${1}-${myNodeNum}`,
|
||||||
|
source: '1',
|
||||||
|
target: myNodeNum.toString(),
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: {
|
||||||
|
stroke: 'yellow',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
node.routes.map((route) => {
|
||||||
|
tmpEdges.push({
|
||||||
|
id: `e${route.from}-${route.to}`,
|
||||||
|
source: node.number.toString(),
|
||||||
|
target: route.to.toString(),
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setGraphEdges(tmpEdges);
|
||||||
|
}, [nodes, myNodeNum]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Nodes"
|
||||||
|
icon={<RiMindMap />}
|
||||||
|
sidebarContents={
|
||||||
|
<>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={node.number}
|
||||||
|
selected={node.number === selected}
|
||||||
|
setSelected={(): void => {
|
||||||
|
setSelected(node.number);
|
||||||
|
}}
|
||||||
|
actions={
|
||||||
|
<IconButton
|
||||||
|
onClick={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelected(node.number);
|
||||||
|
}}
|
||||||
|
icon={<FiSettings />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex dark:text-white">
|
||||||
|
<div className="m-auto">
|
||||||
|
<Hashicon value={node.number.toString()} size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400">
|
||||||
|
{node.lastHeard.getTime()
|
||||||
|
? node.lastHeard.toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: 'Never'}
|
||||||
|
</div>
|
||||||
|
</SidebarItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full w-full">
|
||||||
|
<ReactFlow nodes={graphNodes} edges={graphEdges}>
|
||||||
|
<MiniMap />
|
||||||
|
<Controls />
|
||||||
|
<Background />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
|||||||
|
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import type { Node } from '@core/slices/meshtasticSlice';
|
import type { Node } from '@core/slices/meshtasticSlice';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export interface DebugPanelProps {
|
export interface DebugPanelProps {
|
||||||
node: Node;
|
node: Node;
|
||||||
@@ -12,11 +11,11 @@ export interface DebugPanelProps {
|
|||||||
|
|
||||||
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
|
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="relative">
|
<div className="relative">
|
||||||
<div className="fixed right-0 m-2">
|
<div className="fixed right-0 m-2">
|
||||||
<CopyButton data={JSON.stringify(node)} />
|
<CopyButton data={JSON.stringify(node)} />
|
||||||
</div>
|
</div>
|
||||||
<JSONPretty className="max-w-sm" data={node} />
|
<JSONPretty className="max-w-sm" data={node} />
|
||||||
</Tab.Panel>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+4
-5
@@ -2,7 +2,6 @@ import type React from 'react';
|
|||||||
|
|
||||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||||
import type { Node } from '@core/slices/meshtasticSlice';
|
import type { Node } from '@core/slices/meshtasticSlice';
|
||||||
import { Tab } from '@headlessui/react';
|
|
||||||
|
|
||||||
export interface PositionPanelProps {
|
export interface PositionPanelProps {
|
||||||
node: Node;
|
node: Node;
|
||||||
@@ -10,10 +9,10 @@ export interface PositionPanelProps {
|
|||||||
|
|
||||||
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="p-2">
|
<div className="p-2">
|
||||||
{node.currentPosition && (
|
{node.currentPosition && (
|
||||||
<div className="flex justify-between h-10 px-1 text-gray-500 bg-transparent bg-gray-200 border border-gray-300 rounded-md select-none dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
|
<div className="flex h-10 select-none justify-between rounded-md border border-gray-300 bg-transparent bg-gray-200 px-1 text-gray-500 dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
|
||||||
<div className="px-1 my-auto">
|
<div className="my-auto px-1">
|
||||||
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},
|
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},
|
||||||
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
|
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
|
||||||
</div>
|
</div>
|
||||||
@@ -28,6 +27,6 @@ export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+7
-10
@@ -1,18 +1,15 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { Card } from '@meshtastic/components';
|
import { Card } from '@meshtastic/components';
|
||||||
|
|
||||||
export const NotFound = (): JSX.Element => {
|
export const NotFound = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<PrimaryTemplate title="Page not found" tagline="404">
|
<Card
|
||||||
<Card
|
title="The requested file or directory could not be found"
|
||||||
title="The requested file or directory could not be found"
|
description="Better luck next time"
|
||||||
description="Better luck next time"
|
>
|
||||||
>
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
</Card>
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { FiExternalLink, FiMenu, FiX } from 'react-icons/fi';
|
|
||||||
import {
|
|
||||||
RiArrowDownLine,
|
|
||||||
RiArrowUpDownLine,
|
|
||||||
RiArrowUpLine,
|
|
||||||
} from 'react-icons/ri';
|
|
||||||
|
|
||||||
import { Tooltip } from '@app/components/generic/Tooltip';
|
|
||||||
import type { ChannelData } from '@app/core/slices/meshtasticSlice';
|
|
||||||
import { FormFooter } from '@components/FormFooter';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { connection } from '@core/connection';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { useBreakpoint } from '@hooks/useBreakpoint';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Loading,
|
|
||||||
Select,
|
|
||||||
} from '@meshtastic/components';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
import { ChannelsSidebar } from '../../components/pages/settings/radio/channels/ChannelsSidebar';
|
|
||||||
|
|
||||||
export interface ChannelsProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Channels = ({
|
|
||||||
navOpen,
|
|
||||||
setNavOpen,
|
|
||||||
}: ChannelsProps): JSX.Element => {
|
|
||||||
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
|
|
||||||
const adminChannel =
|
|
||||||
channels.find(
|
|
||||||
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY,
|
|
||||||
) ?? channels[0];
|
|
||||||
const { breakpoint } = useBreakpoint();
|
|
||||||
const [usePreset, setUsePreset] = React.useState(true);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(breakpoint !== 'sm');
|
|
||||||
const [selectedChannel, setSelectedChannel] = React.useState<
|
|
||||||
ChannelData | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const { register, handleSubmit, reset, formState } = useForm<
|
|
||||||
DeepOmit<Protobuf.Channel, 'psk'>
|
|
||||||
>({
|
|
||||||
defaultValues: {
|
|
||||||
...adminChannel.channel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const channelData = Protobuf.Channel.create({
|
|
||||||
...data,
|
|
||||||
settings: {
|
|
||||||
...data.settings,
|
|
||||||
psk: adminChannel.channel.settings?.psk,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await connection.setChannel(channelData, (): Promise<void> => {
|
|
||||||
reset({ ...data });
|
|
||||||
setLoading(false);
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Channels"
|
|
||||||
tagline="Settings"
|
|
||||||
leftButton={
|
|
||||||
<IconButton
|
|
||||||
icon={<FiMenu className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen && setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<FormFooter
|
|
||||||
dirty={formState.isDirty}
|
|
||||||
saveAction={onSubmit}
|
|
||||||
clearAction={reset}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{adminChannel && (
|
|
||||||
<Card>
|
|
||||||
{loading && <Loading />}
|
|
||||||
<div className="w-full max-w-3xl p-10 md:max-w-xl">
|
|
||||||
{/* TODO: get gap working */}
|
|
||||||
<Checkbox
|
|
||||||
checked={usePreset}
|
|
||||||
label="Use Presets"
|
|
||||||
onChange={(e): void => setUsePreset(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
{usePreset ? (
|
|
||||||
<Select
|
|
||||||
label="Preset"
|
|
||||||
optionsEnum={Protobuf.ChannelSettings_ModemConfig}
|
|
||||||
{...register('settings.modemConfig', {
|
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
label="Bandwidth"
|
|
||||||
type="number"
|
|
||||||
suffix="MHz"
|
|
||||||
{...register('settings.bandwidth', {
|
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Spread Factor"
|
|
||||||
type="number"
|
|
||||||
suffix="CPS"
|
|
||||||
min={7}
|
|
||||||
max={12}
|
|
||||||
{...register('settings.spreadFactor', {
|
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Coding Rate"
|
|
||||||
type="number"
|
|
||||||
{...register('settings.codingRate', {
|
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
label="Transmit Power"
|
|
||||||
type="number"
|
|
||||||
suffix="dBm"
|
|
||||||
{...register('settings.txPower', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Card>
|
|
||||||
<div className="w-full p-4 space-y-2 md:p-10">
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<div
|
|
||||||
key={channel.channel.index}
|
|
||||||
onClick={(): void => {
|
|
||||||
setSelectedChannel(channel);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${
|
|
||||||
selectedChannel?.channel.index === channel.channel.index
|
|
||||||
? 'border-primary dark:border-primary'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex my-auto space-x-2">
|
|
||||||
<div
|
|
||||||
className={`h-3 my-auto w-3 rounded-full ${
|
|
||||||
[
|
|
||||||
Protobuf.Channel_Role.SECONDARY,
|
|
||||||
Protobuf.Channel_Role.PRIMARY,
|
|
||||||
].find((role) => role === channel.channel.role)
|
|
||||||
? 'bg-green-500'
|
|
||||||
: 'bg-gray-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{channel.channel.settings?.name.length
|
|
||||||
? channel.channel.settings.name
|
|
||||||
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
|
|
||||||
? 'Primary'
|
|
||||||
: `Channel: ${channel.channel.index}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Tooltip contents={`MQTT Status`}>
|
|
||||||
<div className="p-2 rounded-md">
|
|
||||||
{channel.channel.settings?.uplinkEnabled &&
|
|
||||||
channel.channel.settings?.downlinkEnabled ? (
|
|
||||||
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" />
|
|
||||||
) : channel.channel.settings?.uplinkEnabled ? (
|
|
||||||
<RiArrowUpLine className="p-0.5 group-active:scale-90" />
|
|
||||||
) : channel.channel.settings?.downlinkEnabled ? (
|
|
||||||
<RiArrowDownLine className="p-0.5 group-active:scale-90" />
|
|
||||||
) : (
|
|
||||||
<FiX className="p-0.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<IconButton
|
|
||||||
active={
|
|
||||||
selectedChannel?.channel.index === channel.channel.index
|
|
||||||
}
|
|
||||||
icon={<FiExternalLink />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
{sidebarOpen && (
|
|
||||||
<ChannelsSidebar
|
|
||||||
closeSidebar={(): void => {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
channel={selectedChannel?.channel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FiLayers,
|
|
||||||
FiLayout,
|
|
||||||
FiMapPin,
|
|
||||||
FiPackage,
|
|
||||||
FiRadio,
|
|
||||||
FiUser,
|
|
||||||
FiWifi,
|
|
||||||
FiZap,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
|
|
||||||
import type { SidebarItemProps } from '@app/components/generic/SidebarItem';
|
|
||||||
import { PageLayout } from '@components/templates/PageLayout';
|
|
||||||
|
|
||||||
import { Channels } from './Channels';
|
|
||||||
import { Interface } from './Interface';
|
|
||||||
import { Plugins } from './Plugins';
|
|
||||||
import { Position } from './Position';
|
|
||||||
import { Power } from './Power';
|
|
||||||
import { Radio } from './Radio';
|
|
||||||
import { User } from './User';
|
|
||||||
import { WiFi } from './WiFi';
|
|
||||||
|
|
||||||
export const Settings = (): JSX.Element => {
|
|
||||||
// const { hasGps, hasWifi } = useAppSelector((state) => state.meshtastic.radio.hardware);
|
|
||||||
|
|
||||||
const hasGps = true;
|
|
||||||
const hasWifi = true;
|
|
||||||
|
|
||||||
const panels: JSX.Element[] = [
|
|
||||||
<User key={4} />,
|
|
||||||
<Power key={5} />,
|
|
||||||
<Radio key={6} />,
|
|
||||||
<Channels key={7} />,
|
|
||||||
<Plugins key={8} />,
|
|
||||||
<Interface key={9} />,
|
|
||||||
];
|
|
||||||
|
|
||||||
const sidebarItems: SidebarItemProps[] = [
|
|
||||||
{
|
|
||||||
title: 'User',
|
|
||||||
description: 'Device name and details',
|
|
||||||
icon: <FiUser className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Power',
|
|
||||||
description: 'Power and sleep settings',
|
|
||||||
icon: <FiZap className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Radio',
|
|
||||||
description: 'LoRa settings',
|
|
||||||
icon: <FiRadio className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Channels',
|
|
||||||
description: 'Manage channels',
|
|
||||||
icon: <FiLayers className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Plugins',
|
|
||||||
description: 'Plugins',
|
|
||||||
icon: <FiPackage className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Interface',
|
|
||||||
description: 'Language and UI settings',
|
|
||||||
icon: <FiLayout className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (hasGps) {
|
|
||||||
panels.unshift(<Position key={3} />);
|
|
||||||
sidebarItems.unshift({
|
|
||||||
title: 'Position',
|
|
||||||
description: 'Position settings and flags',
|
|
||||||
icon: <FiMapPin className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (hasWifi) {
|
|
||||||
panels.unshift(<WiFi key={2} />);
|
|
||||||
sidebarItems.unshift({
|
|
||||||
title: 'WiFi & MQTT',
|
|
||||||
description: 'WiFi & MQTT settings',
|
|
||||||
icon: <FiWifi className="flex-shrink-0 w-6 h-6" />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(panels);
|
|
||||||
}, [hasGps, hasWifi]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout title="Settings" sidebarItems={sidebarItems} panels={panels} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type React from 'react';
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { FiMenu, FiSave } from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import i18n from '@core/translation';
|
|
||||||
import { Button, Card, Select } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export interface InterfaceProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Interface = ({
|
|
||||||
navOpen,
|
|
||||||
setNavOpen,
|
|
||||||
}: InterfaceProps): JSX.Element => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Interface"
|
|
||||||
tagline="Settings"
|
|
||||||
leftButton={
|
|
||||||
<Button
|
|
||||||
icon={<FiMenu className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen && setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<Button
|
|
||||||
className="px-10 ml-auto"
|
|
||||||
icon={<FiSave className="w-5 h-5" />}
|
|
||||||
active
|
|
||||||
border
|
|
||||||
>
|
|
||||||
{t('strings.save_changes')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
title="Basic settings"
|
|
||||||
description="Device name and user parameters"
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl">
|
|
||||||
<Select
|
|
||||||
label="Language"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
name: 'English',
|
|
||||||
value: 'en',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '日本',
|
|
||||||
value: 'jp',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Português',
|
|
||||||
value: 'pt',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(e): void => {
|
|
||||||
void i18n.changeLanguage(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FiAlignLeft,
|
|
||||||
FiBell,
|
|
||||||
FiExternalLink,
|
|
||||||
FiFastForward,
|
|
||||||
FiMenu,
|
|
||||||
FiRss,
|
|
||||||
} from 'react-icons/fi';
|
|
||||||
|
|
||||||
import { PluginsSidebar } from '@app/components/pages/settings/plugins/PluginsSidebar';
|
|
||||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { Button, Card, IconButton } from '@meshtastic/components';
|
|
||||||
|
|
||||||
export type Plugin =
|
|
||||||
| 'Range Test'
|
|
||||||
| 'External Notifications'
|
|
||||||
| 'Serial'
|
|
||||||
| 'Store & Forward';
|
|
||||||
|
|
||||||
export interface PluginsProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Plugins = ({ navOpen, setNavOpen }: PluginsProps): JSX.Element => {
|
|
||||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
|
||||||
const [selectedPlugin, setSelectedPlugin] = React.useState<
|
|
||||||
Plugin | undefined
|
|
||||||
>();
|
|
||||||
const preferences = useAppSelector(
|
|
||||||
(state) => state.meshtastic.radio.preferences,
|
|
||||||
);
|
|
||||||
|
|
||||||
const plugins: {
|
|
||||||
name: Plugin;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
icon: JSX.Element;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
name: 'Range Test',
|
|
||||||
description: 'Test the range of your Meshtastic node',
|
|
||||||
enabled: preferences.rangeTestPluginEnabled,
|
|
||||||
icon: <FiRss />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'External Notifications',
|
|
||||||
description: 'External hardware alerts',
|
|
||||||
enabled: preferences.extNotificationPluginEnabled,
|
|
||||||
icon: <FiBell />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Serial',
|
|
||||||
description: 'Send serial data over the mesh',
|
|
||||||
enabled: preferences.serialpluginEnabled,
|
|
||||||
icon: <FiAlignLeft />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Store & Forward',
|
|
||||||
description: 'Retrive message history',
|
|
||||||
enabled: preferences.storeForwardPluginEnabled,
|
|
||||||
icon: <FiFastForward />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Plugins"
|
|
||||||
tagline="Settings"
|
|
||||||
leftButton={
|
|
||||||
<Button
|
|
||||||
icon={<FiMenu className="w-5 h-5" />}
|
|
||||||
onClick={(): void => {
|
|
||||||
setNavOpen && setNavOpen(!navOpen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
title="Basic settings"
|
|
||||||
description="Device name and user parameters"
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl">
|
|
||||||
{plugins.map((plugin, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
onClick={(): void => {
|
|
||||||
setSelectedPlugin(plugin.name);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${
|
|
||||||
selectedPlugin === plugin.name
|
|
||||||
? 'border-primary dark:border-primary'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex my-auto space-x-2">
|
|
||||||
<div
|
|
||||||
className={`h-3 my-auto w-3 rounded-full ${
|
|
||||||
plugin.enabled ? 'bg-green-500' : 'bg-gray-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="my-auto">{plugin.icon}</div>
|
|
||||||
{plugin.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<IconButton
|
|
||||||
active={plugin.name === selectedPlugin}
|
|
||||||
icon={<FiExternalLink />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
{sidebarOpen && (
|
|
||||||
<PluginsSidebar
|
|
||||||
closeSidebar={(): void => {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
plugin={selectedPlugin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import { FiCode, FiMenu } from 'react-icons/fi';
|
|
||||||
import JSONPretty from 'react-json-pretty';
|
|
||||||
import type { Theme } from 'react-select';
|
|
||||||
import ReactSelect from 'react-select';
|
|
||||||
|
|
||||||
import { FormFooter } from '@components/FormFooter';
|
|
||||||
import { Cover } from '@components/generic/Cover';
|
|
||||||
import { Label } from '@components/generic/form/Label';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { connection } from '@core/connection';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
} from '@meshtastic/components';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
export interface PositionProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Position = ({
|
|
||||||
navOpen,
|
|
||||||
setNavOpen,
|
|
||||||
}: PositionProps): JSX.Element => {
|
|
||||||
const preferences = useAppSelector(
|
|
||||||
(state) => state.meshtastic.radio.preferences,
|
|
||||||
);
|
|
||||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
|
||||||
const [debug, setDebug] = React.useState(false);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const { register, handleSubmit, formState, reset, control } =
|
|
||||||
useForm<Protobuf.RadioConfig_UserPreferences>({
|
|
||||||
defaultValues: {
|
|
||||||
...preferences,
|
|
||||||
positionBroadcastSecs:
|
|
||||||
preferences.positionBroadcastSecs === 0
|
|
||||||
? preferences.isRouter
|
|
||||||
? 43200
|
|
||||||
: 900
|
|
||||||
: preferences.positionBroadcastSecs,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// const watchPsk = useWatch({
|
|
||||||
// control,
|
|
||||||
// name: 'positionFlags',
|
|
||||||
// defaultValue: 0,
|
|
||||||
// });
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
reset(preferences);
|
|
||||||
}, [reset, preferences]);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
|
||||||
setLoading(true);
|
|
||||||
void connection.setPreferences(data, async () => {
|
|
||||||
reset({ ...data });
|
|
||||||
setLoading(false);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const encode = (enums: Protobuf.PositionFlags[]): number => {
|
|
||||||
return enums.reduce((acc, curr) => acc | curr, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decode = (value: number): Protobuf.PositionFlags[] => {
|
|
||||||
const enumValues = Object.keys(Protobuf.PositionFlags)
|
|
||||||
.map(Number)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return enumValues.map((b) => value & b).filter(Boolean);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Position"
|
|
||||||
tagline="Settings"
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<FormFooter
|
|
||||||
dirty={formState.isDirty}
|
|
||||||
saveAction={onSubmit}
|
|
||||||
clearAction={reset}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card loading={loading}>
|
|
||||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
|
|
||||||
<div className="w-full max-w-3xl p-10 md:max-w-xl">
|
|
||||||
<form className="space-y-2" onSubmit={onSubmit}>
|
|
||||||
<Input
|
|
||||||
label="Broadcast Interval"
|
|
||||||
type="number"
|
|
||||||
suffix="Seconds"
|
|
||||||
{...register('positionBroadcastSecs', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="positionFlags"
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }): JSX.Element => {
|
|
||||||
const { value, onChange, ...rest } = field;
|
|
||||||
const { error } = fieldState;
|
|
||||||
const label = 'Position Flags';
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
{label && <Label label={label} error={error?.message} />}
|
|
||||||
<ReactSelect
|
|
||||||
{...rest}
|
|
||||||
isMulti
|
|
||||||
theme={(theme): Theme => ({
|
|
||||||
...theme,
|
|
||||||
borderRadius: 7,
|
|
||||||
colors: {
|
|
||||||
...theme.colors,
|
|
||||||
primary: '#67ea94', //focus border color
|
|
||||||
// primary75: 'red',
|
|
||||||
// primary50: 'red',
|
|
||||||
// primary25: 'red',
|
|
||||||
// danger: 'red',
|
|
||||||
// dangerLight: 'red',
|
|
||||||
neutral0: darkMode ? 'rgb(30 41 59)' : 'white', //bg color
|
|
||||||
// neutral5: 'red',
|
|
||||||
neutral10: darkMode
|
|
||||||
? 'rgb(75 85 99)'
|
|
||||||
: 'rgb(229 231 235)', //tag bg color
|
|
||||||
neutral20: darkMode
|
|
||||||
? 'rgb(229 231 235)'
|
|
||||||
: 'rgb(156 163 175)', //border color
|
|
||||||
neutral30: '#67ea94', //border hover
|
|
||||||
// neutral40: 'red',
|
|
||||||
// neutral50: 'red',
|
|
||||||
// neutral60: 'red',
|
|
||||||
// neutral70: 'red',
|
|
||||||
neutral80: darkMode ? 'white' : 'black', //tag text color
|
|
||||||
// neutral90: 'red',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
value={decode(value).map((flag) => {
|
|
||||||
return {
|
|
||||||
value: flag,
|
|
||||||
label: Protobuf.PositionFlags[flag].replace(
|
|
||||||
'POS_',
|
|
||||||
'',
|
|
||||||
),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
options={Object.entries(Protobuf.PositionFlags)
|
|
||||||
.filter((value) => typeof value[1] !== 'number')
|
|
||||||
.filter(
|
|
||||||
(value) =>
|
|
||||||
parseInt(value[0]) !==
|
|
||||||
Protobuf.PositionFlags.POS_UNDEFINED,
|
|
||||||
)
|
|
||||||
.map((value) => {
|
|
||||||
return {
|
|
||||||
value: parseInt(value[0]),
|
|
||||||
label: value[1].toString().replace('POS_', ''),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(e): void =>
|
|
||||||
onChange(encode(e.map((v) => v.value)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Position Type (DEBUG)"
|
|
||||||
type="number"
|
|
||||||
disabled
|
|
||||||
{...register('positionFlags', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Use Fixed Position"
|
|
||||||
{...register('fixedPosition')}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Location Sharing"
|
|
||||||
optionsEnum={Protobuf.LocationSharing}
|
|
||||||
{...register('locationShare', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="GPS Mode"
|
|
||||||
optionsEnum={Protobuf.GpsOperation}
|
|
||||||
{...register('gpsOperation', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Display Format"
|
|
||||||
optionsEnum={Protobuf.GpsCoordinateFormat}
|
|
||||||
{...register('gpsFormat', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} />
|
|
||||||
<Input
|
|
||||||
label="Max DOP"
|
|
||||||
type="number"
|
|
||||||
{...register('gpsMaxDop', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Last GPS Attempt"
|
|
||||||
disabled
|
|
||||||
{...register('gpsAttemptTime', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { FiCode, FiMenu } from 'react-icons/fi';
|
|
||||||
import JSONPretty from 'react-json-pretty';
|
|
||||||
|
|
||||||
import { FormFooter } from '@components/FormFooter';
|
|
||||||
import { Cover } from '@components/generic/Cover';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { connection } from '@core/connection';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
export interface PowerProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Power = ({ navOpen, setNavOpen }: PowerProps): JSX.Element => {
|
|
||||||
const preferences = useAppSelector(
|
|
||||||
(state) => state.meshtastic.radio.preferences,
|
|
||||||
);
|
|
||||||
const [debug, setDebug] = React.useState(false);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const { register, handleSubmit, formState, reset } =
|
|
||||||
useForm<Protobuf.RadioConfig_UserPreferences>({
|
|
||||||
defaultValues: {
|
|
||||||
...preferences,
|
|
||||||
isLowPower: preferences.isRouter ? true : preferences.isLowPower,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
reset(preferences);
|
|
||||||
}, [reset, preferences]);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
|
||||||
setLoading(true);
|
|
||||||
void connection.setPreferences(data, async () => {
|
|
||||||
reset({ ...data });
|
|
||||||
setLoading(false);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Power"
|
|
||||||
tagline="Settings"
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<FormFooter
|
|
||||||
dirty={formState.isDirty}
|
|
||||||
saveAction={onSubmit}
|
|
||||||
clearAction={reset}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card loading={loading}>
|
|
||||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
|
|
||||||
<div className="w-full max-w-3xl p-10 md:max-w-xl">
|
|
||||||
<form className="space-y-2" onSubmit={onSubmit}>
|
|
||||||
<Select
|
|
||||||
label="Charge current"
|
|
||||||
optionsEnum={Protobuf.ChargeCurrent}
|
|
||||||
{...register('chargeCurrent', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Checkbox label="Always powered" {...register('isAlwaysPowered')} />
|
|
||||||
<Checkbox
|
|
||||||
label="Powered by low power source (solar)"
|
|
||||||
disabled={preferences.isRouter}
|
|
||||||
validationMessage={
|
|
||||||
preferences.isRouter ? 'Enabled by default in router mode' : ''
|
|
||||||
}
|
|
||||||
{...register('isLowPower')}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { FiCode, FiMenu } from 'react-icons/fi';
|
|
||||||
import JSONPretty from 'react-json-pretty';
|
|
||||||
|
|
||||||
import { FormFooter } from '@components/FormFooter';
|
|
||||||
import { Cover } from '@components/generic/Cover';
|
|
||||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
|
||||||
import { connection } from '@core/connection';
|
|
||||||
import { useAppSelector } from '@hooks/useAppSelector';
|
|
||||||
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components';
|
|
||||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
|
||||||
|
|
||||||
export interface RadioProps {
|
|
||||||
navOpen?: boolean;
|
|
||||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Radio = ({ navOpen, setNavOpen }: RadioProps): JSX.Element => {
|
|
||||||
const preferences = useAppSelector(
|
|
||||||
(state) => state.meshtastic.radio.preferences,
|
|
||||||
);
|
|
||||||
const [debug, setDebug] = React.useState(false);
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const { register, handleSubmit, formState, reset } =
|
|
||||||
useForm<Protobuf.RadioConfig_UserPreferences>({
|
|
||||||
defaultValues: preferences,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
reset(preferences);
|
|
||||||
}, [reset, preferences]);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
|
||||||
setLoading(true);
|
|
||||||
void connection.setPreferences(data, async () => {
|
|
||||||
reset({ ...data });
|
|
||||||
setLoading(false);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<PrimaryTemplate
|
|
||||||
title="Radio"
|
|
||||||
tagline="Settings"
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<FormFooter
|
|
||||||
dirty={formState.isDirty}
|
|
||||||
saveAction={onSubmit}
|
|
||||||
clearAction={reset}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Card loading={loading}>
|
|
||||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} />
|
|
||||||
<div className="w-full max-w-3xl p-10 md:max-w-xl">
|
|
||||||
<form className="space-y-2" onSubmit={onSubmit}>
|
|
||||||
<Checkbox label="Is Router" {...register('isRouter')} />
|
|
||||||
<Select
|
|
||||||
label="Region"
|
|
||||||
optionsEnum={Protobuf.RegionCode}
|
|
||||||
{...register('region', { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
<Checkbox label="Debug Log" {...register('debugLogEnabled')} />
|
|
||||||
<Checkbox label="Serial Disabled" {...register('serialDisabled')} />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PrimaryTemplate>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user