Rebuild
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
||||
dist
|
||||
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}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emeraldpay/hashicon-react": "^0.5.1",
|
||||
"@floating-ui/react-dom": "^0.4.3",
|
||||
"@headlessui/react": "^1.4.3",
|
||||
"@meshtastic/components": "^1.0.19",
|
||||
"@meshtastic/meshtasticjs": "^0.6.38",
|
||||
"@reduxjs/toolkit": "^1.7.1",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@meshtastic/components": "^1.0.23",
|
||||
"@meshtastic/meshtasticjs": "^0.6.39",
|
||||
"@reduxjs/toolkit": "^1.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"boring-avatars": "^1.6.1",
|
||||
"graphql-request": "^3.7.0",
|
||||
"i18next": "^21.6.6",
|
||||
"i18next-browser-languagedetector": "^6.1.2",
|
||||
"mapbox-gl": "^2.6.1",
|
||||
"framer-motion": "^6.2.4",
|
||||
"graphql-request": "^4.0.0",
|
||||
"mapbox-gl": "^2.7.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-file-icon": "^1.1.0",
|
||||
"react-hook-form": "^7.24.0",
|
||||
"react-i18next": "^11.15.3",
|
||||
"react-flow-renderer": "^10.0.0-next.38",
|
||||
"react-hook-form": "^7.26.0",
|
||||
"react-icons": "^4.3.1",
|
||||
"react-json-pretty": "^2.2.0",
|
||||
"react-multi-select-component": "^4.2.1",
|
||||
"react-qr-code": "^2.0.3",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-select": "^5.2.2",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"rfc4648": "^1.5.1",
|
||||
"swr": "^1.1.2",
|
||||
"rollup-plugin-visualizer": "^5.5.4",
|
||||
"swr": "^1.2.1",
|
||||
"timeago-react": "^3.0.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"type-route": "^0.6.0",
|
||||
"use-breakpoint": "^3.0.1",
|
||||
"vite-plugin-environment": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mapbox-gl": "^2.6.0",
|
||||
"@types/react": "^17.0.38",
|
||||
"@types/mapbox-gl": "^2.6.1",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-file-icon": "^1.0.1",
|
||||
"@types/w3c-web-serial": "^1.0.2",
|
||||
"@types/web-bluetooth": "^0.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.9.1",
|
||||
"@typescript-eslint/parser": "^5.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.2",
|
||||
"@typescript-eslint/parser": "^5.10.2",
|
||||
"@verypossible/eslint-config": "^1.6.1",
|
||||
"@vitejs/plugin-react": "^1.1.4",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
"eslint": "8.7.0",
|
||||
"eslint": "8.8.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-babel-module": "^5.3.1",
|
||||
@@ -66,12 +60,13 @@
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"gzipper": "^7.0.0",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.5.1",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"prettier-plugin-tailwindcss": "^0.1.5",
|
||||
"tailwindcss": "^3.0.18",
|
||||
"tar": "^6.1.11",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.12",
|
||||
"typescript": "^4.5.5",
|
||||
"vite": "^2.7.13",
|
||||
"vite-plugin-cdn-import": "^0.3.5",
|
||||
"vite-plugin-pwa": "^0.11.13",
|
||||
"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 { ErrorBoundary } from 'react-error-boundary';
|
||||
import { FiBell } from 'react-icons/fi';
|
||||
|
||||
import { Map } from '@app/pages/Map';
|
||||
import { Connection } from '@components/Connection';
|
||||
import { Logo } from '@components/menu/Logo';
|
||||
import { Navigation } from '@components/menu/Navigation';
|
||||
import { useRoute } from '@core/router';
|
||||
import { requestNotificationPermission } from '@core/utils/notifications';
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
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 { 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 => {
|
||||
const route = useRoute();
|
||||
const dispatch = useAppDispatch();
|
||||
const notifications = useAppSelector((state) => state.app.notifications);
|
||||
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 (
|
||||
<div className={`h-screen w-screen ${darkMode ? 'dark' : ''}`}>
|
||||
<Connection />
|
||||
<div className="flex flex-col h-full bg-gray-200 dark:bg-secondaryDark">
|
||||
<div className="flex flex-shrink-0 overflow-hidden bg-primary dark:bg-primary">
|
||||
<div className="w-full overflow-hidden bg-white border-b border-gray-300 dark:border-gray-600 md:shadow-md dark:bg-secondaryDark">
|
||||
<div className="flex items-center justify-between h-12 px-4">
|
||||
<div className="flex">
|
||||
<Logo />
|
||||
</div>
|
||||
<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 className="flex h-full flex-col bg-gray-200 dark:bg-secondaryDark">
|
||||
<div className="flex min-h-0 w-full flex-grow">
|
||||
{route.name === 'messages' && <Messages />}
|
||||
{route.name === 'nodes' && <Nodes />}
|
||||
{route.name === 'map' && <Map />}
|
||||
{route.name === 'extensions' && <Extensions />}
|
||||
{route.name === false && <NotFound />}
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { BLE } from '@components/connection/BLE';
|
||||
import { HTTP } from '@components/connection/HTTP';
|
||||
import { Serial } from '@components/connection/Serial';
|
||||
@@ -46,47 +48,51 @@ export const Connection = (): JSX.Element => {
|
||||
}, [state.ready, dispatch]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="w-full max-w-3xl"
|
||||
open={appState.connectionModalOpen}
|
||||
onClose={(): void => {
|
||||
dispatch(closeConnectionModal());
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<div className="w-full max-w-3xl p-10">
|
||||
{state.deviceStatus === Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
|
||||
<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>
|
||||
<AnimatePresence>
|
||||
{appState.connectionModalOpen && (
|
||||
<Modal
|
||||
className="w-full max-w-3xl"
|
||||
onClose={(): void => {
|
||||
dispatch(closeConnectionModal());
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<div className="w-full max-w-3xl p-10">
|
||||
{state.deviceStatus ===
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
|
||||
<Button
|
||||
border
|
||||
onClick={async (): Promise<void> => {
|
||||
await connection.disconnect();
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
|
||||
<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 ===
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
|
||||
<Button
|
||||
border
|
||||
onClick={async (): Promise<void> => {
|
||||
await connection.disconnect();
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Modal>
|
||||
</Card>
|
||||
</Modal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,9 @@ export const FormFooter = ({
|
||||
saveAction,
|
||||
}: FormFooterProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex float-right gap-2">
|
||||
<div className="float-right flex gap-2">
|
||||
<IconButton
|
||||
icon={<FiXCircle className="w-5 h-5" />}
|
||||
icon={<FiXCircle className="h-5 w-5" />}
|
||||
disabled={!dirty}
|
||||
onClick={(): void => {
|
||||
clearAction && clearAction();
|
||||
@@ -29,7 +29,7 @@ export const FormFooter = ({
|
||||
onClick={(): void => {
|
||||
saveAction && saveAction();
|
||||
}}
|
||||
icon={<FiSave className="w-5 h-5" />}
|
||||
icon={<FiSave className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
import { useCreateMapbox } from '@hooks/useCreateMapbox';
|
||||
|
||||
import { MapStyles } from '../Map/styles';
|
||||
import { MapStyles } from '../../pages/Map/styles';
|
||||
import { MapboxContext } from './mapboxContext';
|
||||
|
||||
export type MapboxProviderProps = {
|
||||
|
||||
@@ -8,5 +8,5 @@ export interface 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 { connType } from '@app/core/slices/appSlice';
|
||||
import { ble, setConnection } from '@core/connection';
|
||||
import { setConnection } from '@core/connection';
|
||||
import { Button, IconButton } from '@meshtastic/components';
|
||||
import { IBLEConnection } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export const BLE = (): JSX.Element => {
|
||||
const [bleDevices, setBleDevices] = React.useState<BluetoothDevice[]>([]);
|
||||
@@ -15,6 +16,7 @@ export const BLE = (): JSX.Element => {
|
||||
}>();
|
||||
|
||||
const updateBleDeviceList = React.useCallback(async (): Promise<void> => {
|
||||
const ble = new IBLEConnection();
|
||||
const devices = await ble.getDevices();
|
||||
setBleDevices(devices);
|
||||
}, []);
|
||||
@@ -34,7 +36,7 @@ export const BLE = (): JSX.Element => {
|
||||
onClick={async (): Promise<void> => {
|
||||
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}
|
||||
>
|
||||
<div className="my-auto">{device.name}</div>
|
||||
|
||||
@@ -3,10 +3,11 @@ import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { Button, IconButton } from '@meshtastic/components';
|
||||
import { ISerialConnection } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export const Serial = (): JSX.Element => {
|
||||
const [serialDevices, setSerialDevices] = React.useState<SerialPort[]>([]);
|
||||
@@ -17,6 +18,7 @@ export const Serial = (): JSX.Element => {
|
||||
}>();
|
||||
|
||||
const updateSerialDeviceList = React.useCallback(async (): Promise<void> => {
|
||||
const serial = new ISerialConnection();
|
||||
const devices = await serial.getPorts();
|
||||
setSerialDevices(devices);
|
||||
}, []);
|
||||
@@ -34,10 +36,10 @@ export const Serial = (): JSX.Element => {
|
||||
{serialDevices.length > 0 ? (
|
||||
serialDevices.map((device, index) => (
|
||||
<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}
|
||||
>
|
||||
<div className="flex gap-4 my-auto">
|
||||
<div className="my-auto flex gap-4">
|
||||
<p>
|
||||
Vendor: <small>{device.getInfo().usbVendorId}</small>
|
||||
</p>
|
||||
@@ -62,7 +64,7 @@ export const Serial = (): JSX.Element => {
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,14 +14,14 @@ export const Blur = ({
|
||||
}: BlurProps): JSX.Element => {
|
||||
return (
|
||||
<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' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
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' : ''
|
||||
}`}
|
||||
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 { Dialog } from '@headlessui/react';
|
||||
import { m } from 'framer-motion';
|
||||
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
|
||||
// import { Backdrop } from './Backdrop';
|
||||
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div'];
|
||||
|
||||
export interface ModalProps extends DefaultDivProps {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ModalProps): JSX.Element => {
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
as="div"
|
||||
<m.div
|
||||
className={`fixed inset-0 z-30 ${darkMode ? 'dark' : ''}`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Dialog.Overlay className="fixed w-full h-full backdrop-filter backdrop-blur-sm" />
|
||||
<div className="text-center ">
|
||||
<m.div className="fixed h-full w-full backdrop-blur-sm backdrop-filter" />
|
||||
<m.div className="text-center ">
|
||||
<span
|
||||
className="inline-block h-screen align-middle "
|
||||
aria-hidden="true"
|
||||
@@ -37,7 +37,7 @@ export const Modal = ({
|
||||
<div className={`inline-block align-middle ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</m.div>
|
||||
</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}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -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 { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export const ExternalNotificationsDebugPanel = (): JSX.Element => {
|
||||
const preferences = useAppSelector(
|
||||
@@ -22,11 +21,11 @@ export const ExternalNotificationsDebugPanel = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<>
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(debugData)} />
|
||||
</div>
|
||||
<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 { connection } from '@app/core/connection';
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
@@ -42,7 +41,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Tab.Panel className="flex flex-col w-full">
|
||||
<>
|
||||
<Form loading={loading}>
|
||||
<Checkbox
|
||||
label="Plugin Enabled"
|
||||
@@ -82,7 +81,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
||||
/>
|
||||
</Form>
|
||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||
<div className="p-2 ml-auto">
|
||||
<div className="ml-auto p-2">
|
||||
<IconButton
|
||||
disabled={!formState.isDirty}
|
||||
onClick={async (): Promise<void> => {
|
||||
@@ -92,6 +91,6 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
||||
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export const RangeTestDebugPanel = (): JSX.Element => {
|
||||
const preferences = useAppSelector(
|
||||
@@ -18,11 +17,11 @@ export const RangeTestDebugPanel = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<>
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(debugData)} />
|
||||
</div>
|
||||
<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 { connection } from '@app/core/connection';
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
@@ -42,7 +41,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Tab.Panel className="flex flex-col w-full">
|
||||
<>
|
||||
<Form loading={loading}>
|
||||
<Checkbox
|
||||
label="Range Test Plugin Enabled?"
|
||||
@@ -64,7 +63,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</Form>
|
||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||
<div className="p-2 ml-auto">
|
||||
<div className="ml-auto p-2">
|
||||
<IconButton
|
||||
disabled={!formState.isDirty}
|
||||
onClick={async (): Promise<void> => {
|
||||
@@ -74,6 +73,6 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
||||
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export const SerialDebugPanel = (): JSX.Element => {
|
||||
const preferences = useAppSelector(
|
||||
@@ -21,11 +20,11 @@ export const SerialDebugPanel = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<>
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(debugData)} />
|
||||
</div>
|
||||
<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 { connection } from '@app/core/connection';
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
@@ -42,7 +41,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Tab.Panel className="flex flex-col w-full">
|
||||
<>
|
||||
<Form loading={loading}>
|
||||
<Checkbox label="Plugin Enabled" {...register('serialpluginEnabled')} />
|
||||
<Checkbox
|
||||
@@ -85,7 +84,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</Form>
|
||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||
<div className="p-2 ml-auto">
|
||||
<div className="ml-auto p-2">
|
||||
<IconButton
|
||||
disabled={!formState.isDirty}
|
||||
onClick={async (): Promise<void> => {
|
||||
@@ -95,6 +94,6 @@ export const SerialSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
-3
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
|
||||
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export const StoreForwardDebugPanel = (): JSX.Element => {
|
||||
const preferences = useAppSelector(
|
||||
@@ -22,11 +21,11 @@ export const StoreForwardDebugPanel = (): JSX.Element => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<>
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(debugData)} />
|
||||
</div>
|
||||
<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 { connection } from '@app/core/connection';
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Checkbox, IconButton, Input } from '@meshtastic/components';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
@@ -42,7 +41,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Tab.Panel className="flex flex-col w-full">
|
||||
<>
|
||||
<Form loading={loading}>
|
||||
<Checkbox
|
||||
label="Plugin Enabled"
|
||||
@@ -80,7 +79,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</Form>
|
||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||
<div className="p-2 ml-auto">
|
||||
<div className="ml-auto p-2">
|
||||
<IconButton
|
||||
disabled={!formState.isDirty}
|
||||
onClick={async (): Promise<void> => {
|
||||
@@ -90,6 +89,6 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
|
||||
/>
|
||||
</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 { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface DebugPanelProps {
|
||||
@@ -12,11 +11,11 @@ export interface DebugPanelProps {
|
||||
|
||||
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<>
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(channel)} />
|
||||
</div>
|
||||
<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 { Tab } from '@headlessui/react';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface QRCodePanelProps {
|
||||
@@ -11,13 +10,11 @@ export interface QRCodePanelProps {
|
||||
|
||||
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
|
||||
return (
|
||||
<Tab.Panel className="flex flex-grow p-2">
|
||||
<div className="m-auto">
|
||||
<QRCode
|
||||
className="rounded-md"
|
||||
value={`https://www.meshtastic.org/d/#${channel.index}`}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
<div className="m-auto">
|
||||
<QRCode
|
||||
className="rounded-md"
|
||||
value={`https://www.meshtastic.org/d/#${channel.index}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-4
@@ -7,7 +7,6 @@ import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
|
||||
|
||||
import { Form } from '@app/components/generic/form/Form';
|
||||
import { connection } from '@app/core/connection';
|
||||
import { Tab } from '@headlessui/react';
|
||||
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
@@ -72,7 +71,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Tab.Panel className="flex flex-col w-full">
|
||||
<div className="flex w-full flex-col">
|
||||
<Form loading={loading}>
|
||||
{channel?.index !== 0 && (
|
||||
<>
|
||||
@@ -123,7 +122,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
|
||||
</Form>
|
||||
<div className="flex w-full bg-white dark:bg-secondaryDark">
|
||||
<div className="p-2 ml-auto">
|
||||
<div className="ml-auto p-2">
|
||||
<IconButton
|
||||
disabled={!formState.isDirty}
|
||||
onClick={async (): Promise<void> => {
|
||||
@@ -133,6 +132,6 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
/>
|
||||
</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 {
|
||||
FiBell,
|
||||
FiBluetooth,
|
||||
FiCpu,
|
||||
FiGitBranch,
|
||||
FiMenu,
|
||||
FiMoon,
|
||||
FiSun,
|
||||
FiWifi,
|
||||
@@ -20,18 +20,15 @@ import {
|
||||
connType,
|
||||
openConnectionModal,
|
||||
setDarkModeEnabled,
|
||||
toggleMobileNav,
|
||||
} from '@app/core/slices/appSlice';
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
import { Tooltip } from '@meshtastic/components';
|
||||
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { Tooltip } from '../generic/Tooltip';
|
||||
import { VersionInfo } from '../modals/VersionInfo';
|
||||
|
||||
// export interface BottomNavProps {
|
||||
|
||||
// }
|
||||
|
||||
export const BottomNav = (): JSX.Element => {
|
||||
const [showVersionInfo, setShowVersionInfo] = React.useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -43,11 +40,26 @@ export const BottomNav = (): JSX.Element => {
|
||||
?.channel.settings;
|
||||
|
||||
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">
|
||||
<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
|
||||
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_CONFIGURED,
|
||||
@@ -72,7 +84,7 @@ export const BottomNav = (): JSX.Element => {
|
||||
) : (
|
||||
<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(
|
||||
(node) =>
|
||||
node.number === meshtasticState.radio.hardware.myNodeNum,
|
||||
@@ -80,8 +92,8 @@ export const BottomNav = (): JSX.Element => {
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip contents={`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">
|
||||
<Tooltip content="MQTT Status">
|
||||
<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?.downlinkEnabled &&
|
||||
!meshtasticState.radio.preferences.mqttDisabled ? (
|
||||
@@ -98,7 +110,18 @@ export const BottomNav = (): JSX.Element => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
</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">
|
||||
<VersionInfo
|
||||
visible={showVersionInfo}
|
||||
@@ -106,26 +129,22 @@ export const BottomNav = (): JSX.Element => {
|
||||
setShowVersionInfo(false);
|
||||
}}
|
||||
/>
|
||||
<Tooltip contents={`Current Commit`}>
|
||||
|
||||
<Tooltip content={`Current Commit`}>
|
||||
<div
|
||||
onClick={(): void => {
|
||||
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>
|
||||
</div>
|
||||
</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">
|
||||
<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`}>
|
||||
|
||||
<Tooltip content={`Toggle Theme`}>
|
||||
<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 => {
|
||||
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={`
|
||||
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_CONFIGURED,
|
||||
@@ -44,11 +44,11 @@ export const DeviceStatus = (): JSX.Element => {
|
||||
)?.user?.longName ?? 'Disconnected'}
|
||||
</div>
|
||||
{appState.connType === connType.BLE ? (
|
||||
<FiBluetooth className="w-5 h-5" />
|
||||
<FiBluetooth className="h-5 w-5" />
|
||||
) : 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>
|
||||
</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 { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { Modal } from '@components/generic/Modal';
|
||||
import { Card } from '@meshtastic/components';
|
||||
|
||||
@@ -41,16 +43,19 @@ export const VersionInfo = ({
|
||||
// console.log(data);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onClose={(): void => {
|
||||
onclose();
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<div className="w-full max-w-3xl p-10">Version Info</div>
|
||||
{/* {data?.sha} */}
|
||||
</Card>
|
||||
</Modal>
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<Modal
|
||||
onClose={(): void => {
|
||||
onclose();
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<div className="w-full max-w-3xl p-10">Version Info</div>
|
||||
{/* {data?.sha} */}
|
||||
</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,
|
||||
addNode,
|
||||
addPosition,
|
||||
addRoute,
|
||||
addUser,
|
||||
resetState,
|
||||
setDeviceStatus,
|
||||
@@ -23,8 +24,6 @@ import {
|
||||
Types,
|
||||
} from '@meshtastic/meshtasticjs';
|
||||
|
||||
import { showNotification } from './utils/notifications';
|
||||
|
||||
type connectionType = IBLEConnection | IHTTPConnection | ISerialConnection;
|
||||
|
||||
export let connection: connectionType = new IHTTPConnection();
|
||||
@@ -36,9 +35,6 @@ export const connectionUrl = state.hostOverrideEnabled
|
||||
? window.location.hostname
|
||||
: (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> => {
|
||||
await connection.disconnect();
|
||||
cleanupListeners();
|
||||
@@ -75,6 +71,7 @@ export const setConnection = async (conn: connType): Promise<void> => {
|
||||
};
|
||||
|
||||
export const cleanupListeners = (): void => {
|
||||
connection.onMeshPacket.cancelAll();
|
||||
connection.onDeviceStatus.cancelAll();
|
||||
connection.onMyNodeInfo.cancelAll();
|
||||
connection.onUserPacket.cancelAll();
|
||||
@@ -88,6 +85,20 @@ export const cleanupListeners = (): void => {
|
||||
const registerListeners = (): void => {
|
||||
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) => {
|
||||
store.dispatch(setDeviceStatus(status));
|
||||
|
||||
@@ -142,6 +153,8 @@ const registerListeners = (): void => {
|
||||
);
|
||||
|
||||
connection.onRoutingPacket.subscribe((routingPacket) => {
|
||||
console.log(routingPacket);
|
||||
|
||||
store.dispatch(
|
||||
updateLastInteraction({
|
||||
id: routingPacket.packet.from,
|
||||
@@ -152,13 +165,11 @@ const registerListeners = (): void => {
|
||||
|
||||
connection.onTextPacket.subscribe((message) => {
|
||||
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
|
||||
showNotification('New message', message.data);
|
||||
|
||||
store.dispatch(
|
||||
addMessage({
|
||||
message: message,
|
||||
ack: message.packet.from !== myNodeNum,
|
||||
isSender: message.packet.from === myNodeNum,
|
||||
received: message.packet.rxTime
|
||||
? new Date(message.packet.rxTime * 1000)
|
||||
: new Date(),
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import { createRouter, defineRoute } from 'type-route';
|
||||
|
||||
export const { RouteProvider, useRoute, routes } = createRouter({
|
||||
messages: defineRoute('/'),
|
||||
map: defineRoute('/map'),
|
||||
nodes: defineRoute('/nodes'),
|
||||
settings: defineRoute('/settings'),
|
||||
extensions: defineRoute('/extensions'),
|
||||
});
|
||||
|
||||
@@ -10,17 +10,6 @@ export enum connType {
|
||||
SERIAL,
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
action?: {
|
||||
message: string;
|
||||
action: () => void;
|
||||
};
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
mobileNavOpen: boolean;
|
||||
navCollapsed: boolean;
|
||||
@@ -33,7 +22,6 @@ interface AppState {
|
||||
HTTP: Types.HTTPConnectionParameters;
|
||||
SERIAL: Types.SerialConnectionParameters;
|
||||
};
|
||||
notifications: Notification[];
|
||||
}
|
||||
|
||||
const initialState: AppState = {
|
||||
@@ -53,18 +41,14 @@ const initialState: AppState = {
|
||||
},
|
||||
SERIAL: {},
|
||||
},
|
||||
notifications: [],
|
||||
};
|
||||
|
||||
export const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState,
|
||||
reducers: {
|
||||
openMobileNav(state) {
|
||||
state.mobileNavOpen = true;
|
||||
},
|
||||
closeMobileNav(state) {
|
||||
state.mobileNavOpen = false;
|
||||
toggleMobileNav(state) {
|
||||
state.mobileNavOpen = !state.mobileNavOpen;
|
||||
},
|
||||
openConnectionModal(state) {
|
||||
state.connectionModalOpen = true;
|
||||
@@ -93,31 +77,17 @@ export const appSlice = createSlice({
|
||||
state.connectionParams[connType[action.payload.type]] =
|
||||
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 {
|
||||
openMobileNav,
|
||||
closeMobileNav,
|
||||
toggleMobileNav,
|
||||
openConnectionModal,
|
||||
closeConnectionModal,
|
||||
setDarkModeEnabled,
|
||||
setCurrentPage,
|
||||
setConnType,
|
||||
setConnectionParams,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
} = appSlice.actions;
|
||||
|
||||
export default appSlice.reducer;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
|
||||
import type { MapStyle } from '@app/components/Map/styles';
|
||||
import { MapStyles } from '@app/components/Map/styles';
|
||||
import type { MapStyle } from '@app/pages/Map/styles';
|
||||
import { MapStyles } from '@app/pages/Map/styles';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
export interface MessageWithAck {
|
||||
message: Types.TextPacket;
|
||||
ack: boolean;
|
||||
isSender: boolean;
|
||||
received: Date;
|
||||
}
|
||||
|
||||
export interface ChannelData {
|
||||
channel: Protobuf.Channel;
|
||||
lastChatInterraction: Date;
|
||||
messages: MessageWithAck[];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ interface CurrentPosition {
|
||||
satsInView: number;
|
||||
}
|
||||
|
||||
interface Route {
|
||||
from: number;
|
||||
to: number;
|
||||
hops: number;
|
||||
|
||||
//speed stats?
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
number: number;
|
||||
lastHeard: Date;
|
||||
@@ -29,6 +37,7 @@ export interface Node {
|
||||
positions: Protobuf.Position[];
|
||||
currentPosition?: CurrentPosition;
|
||||
user?: Protobuf.User;
|
||||
routes: Route[];
|
||||
}
|
||||
|
||||
export interface Radio {
|
||||
@@ -90,7 +99,9 @@ export const meshtasticSlice = createSlice({
|
||||
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
|
||||
}
|
||||
} else {
|
||||
// todo: add node
|
||||
console.log('Node not in DB');
|
||||
console.log(action.payload);
|
||||
}
|
||||
},
|
||||
addPosition: (state, action: PayloadAction<Types.PositionPacket>) => {
|
||||
@@ -142,6 +153,7 @@ export const meshtasticSlice = createSlice({
|
||||
lastHeard: new Date(action.payload.lastHeard * 1000),
|
||||
snr: [action.payload.snr],
|
||||
positions: [],
|
||||
routes: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -155,6 +167,7 @@ export const meshtasticSlice = createSlice({
|
||||
return channel.channel.index === action.payload.index
|
||||
? {
|
||||
channel: action.payload,
|
||||
lastChatInterraction: new Date(),
|
||||
messages: channel.messages,
|
||||
}
|
||||
: channel;
|
||||
@@ -162,10 +175,41 @@ export const meshtasticSlice = createSlice({
|
||||
} else {
|
||||
state.radio.channels.push({
|
||||
channel: action.payload,
|
||||
lastChatInterraction: new Date(),
|
||||
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: (
|
||||
state,
|
||||
action: PayloadAction<Protobuf.RadioConfig_UserPreferences>,
|
||||
@@ -178,6 +222,7 @@ export const meshtasticSlice = createSlice({
|
||||
channel.channel.index === action.payload.message.packet.channel,
|
||||
);
|
||||
state.radio.channels[channelIndex].messages.push(action.payload);
|
||||
state.radio.channels[channelIndex].lastChatInterraction = new Date();
|
||||
},
|
||||
ackMessage: (
|
||||
state,
|
||||
@@ -237,6 +282,7 @@ export const {
|
||||
addNode,
|
||||
addChannel,
|
||||
setPreferences,
|
||||
addRoute,
|
||||
addMessage,
|
||||
ackMessage,
|
||||
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 '@app/index.css';
|
||||
import '@core/translation';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { domAnimation, LazyMotion } from 'framer-motion';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { App } from '@app/App';
|
||||
import { ReloadPrompt } from '@components/pwa/ReloadPrompt';
|
||||
import { RouteProvider } from '@core/router';
|
||||
import { store } from '@core/store';
|
||||
|
||||
import { ErrorFallback } from './components/ErrorFallback';
|
||||
import { RouteProvider } from './core/router';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<RouteProvider>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<LazyMotion features={domAnimation}>
|
||||
<App />
|
||||
</LazyMotion>
|
||||
<ReloadPrompt />
|
||||
</Provider>
|
||||
</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 { MapStyles } from './styles';
|
||||
|
||||
export const Map = (): JSX.Element => {
|
||||
export const MapContainer = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||
|
||||
@@ -40,8 +40,8 @@ export const Map = (): JSX.Element => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full h-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="relative flex h-full w-full">
|
||||
<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
|
||||
active={mapState.style.title === 'Satellite'}
|
||||
onClick={(): void => {
|
||||
@@ -51,7 +51,7 @@ export const Map = (): JSX.Element => {
|
||||
/>
|
||||
|
||||
<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' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -84,7 +84,7 @@ export const Map = (): JSX.Element => {
|
||||
<IconButton icon={<MdFullscreen />} />
|
||||
<IconButton icon={<MdRadar />} />
|
||||
</div>
|
||||
<div className="flex-grow w-full h-full" ref={ref} />
|
||||
<div className="h-full w-full flex-grow" ref={ref} />
|
||||
</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 { useTranslation } from 'react-i18next';
|
||||
import { FiSend } from 'react-icons/fi';
|
||||
|
||||
import { connection } from '@core/connection';
|
||||
import { ackMessage } from '@core/slices/meshtasticSlice';
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
import { IconButton, Input, Select } from '@meshtastic/components';
|
||||
import { Input } from '@meshtastic/components';
|
||||
|
||||
export interface MessageBarProps {
|
||||
channelIndex: number;
|
||||
@@ -16,19 +13,13 @@ export interface MessageBarProps {
|
||||
export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
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 [destinationNode, setDestinationNode] =
|
||||
React.useState<number>(0xffffffff);
|
||||
const sendMessage = (): void => {
|
||||
if (ready) {
|
||||
void connection.sendText(
|
||||
currentMessage,
|
||||
destinationNode,
|
||||
undefined,
|
||||
true,
|
||||
channelIndex--,
|
||||
(id) => {
|
||||
@@ -40,10 +31,9 @@ export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
||||
setCurrentMessage('');
|
||||
}
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
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="flex w-full max-w-4xl mx-auto">
|
||||
<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="mx-auto flex w-full max-w-4xl">
|
||||
<form
|
||||
className="flex w-full space-x-2"
|
||||
onSubmit={(e): void => {
|
||||
@@ -51,36 +41,16 @@ export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
||||
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
|
||||
type="text"
|
||||
minLength={2}
|
||||
placeholder={`${t('placeholder.message')}...`}
|
||||
placeholder="Enter Message"
|
||||
disabled={!ready}
|
||||
value={currentMessage}
|
||||
onChange={(e): void => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon={<FiSend className="w-5 h-5" />} type="submit" />
|
||||
</form>
|
||||
</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 type { Node } from '@core/slices/meshtasticSlice';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export interface DebugPanelProps {
|
||||
node: Node;
|
||||
@@ -12,11 +11,11 @@ export interface DebugPanelProps {
|
||||
|
||||
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
|
||||
return (
|
||||
<Tab.Panel className="relative">
|
||||
<div className="relative">
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(node)} />
|
||||
</div>
|
||||
<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 type { Node } from '@core/slices/meshtasticSlice';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
export interface PositionPanelProps {
|
||||
node: Node;
|
||||
@@ -10,10 +9,10 @@ export interface PositionPanelProps {
|
||||
|
||||
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
||||
return (
|
||||
<Tab.Panel className="p-2">
|
||||
<div className="p-2">
|
||||
{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="px-1 my-auto">
|
||||
<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="my-auto px-1">
|
||||
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},
|
||||
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
|
||||
</div>
|
||||
@@ -28,6 +27,6 @@ export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-10
@@ -1,18 +1,15 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { Card } from '@meshtastic/components';
|
||||
|
||||
export const NotFound = (): JSX.Element => {
|
||||
return (
|
||||
<PrimaryTemplate title="Page not found" tagline="404">
|
||||
<Card
|
||||
title="The requested file or directory could not be found"
|
||||
description="Better luck next time"
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
</Card>
|
||||
</PrimaryTemplate>
|
||||
<Card
|
||||
title="The requested file or directory could not be found"
|
||||
description="Better luck next time"
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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