Browse Source

Rebuild

pull/21/head
Sacha Weatherstone 4 years ago
parent
commit
8d184578f0
  1. 4
      .gitignore
  2. 4
      .prettierrc
  3. 49
      package.json
  4. 1817
      pnpm-lock.yaml
  5. 6
      prettier.config.cjs
  6. 83
      src/App.tsx
  7. 84
      src/components/Connection.tsx
  8. 6
      src/components/FormFooter.tsx
  9. 2
      src/components/MapBox/MapboxProvider.tsx
  10. 2
      src/components/MapBox/mapboxContext.ts
  11. 27
      src/components/TabButton.tsx
  12. 56
      src/components/chat/Message.tsx
  13. 6
      src/components/connection/BLE.tsx
  14. 10
      src/components/connection/Serial.tsx
  15. 4
      src/components/generic/Blur.tsx
  16. 38
      src/components/generic/Drawer.tsx
  17. 46
      src/components/generic/ListItem.tsx
  18. 26
      src/components/generic/Modal.tsx
  19. 46
      src/components/generic/Sidebar.tsx
  20. 33
      src/components/generic/SidebarItem.tsx
  21. 14
      src/components/generic/Tooltip.tsx
  22. 89
      src/components/generic/form/BitwiseSelect.tsx
  23. 2
      src/components/generic/form/Label.tsx
  24. 79
      src/components/layout/Sidebar/ButtonNav.tsx
  25. 38
      src/components/layout/Sidebar/NavLinkButton.tsx
  26. 160
      src/components/layout/Sidebar/Settings/Channels.tsx
  27. 131
      src/components/layout/Sidebar/Settings/Index.tsx
  28. 28
      src/components/layout/Sidebar/Settings/Interface.tsx
  29. 140
      src/components/layout/Sidebar/Settings/Position.tsx
  30. 53
      src/components/layout/Sidebar/Settings/Power.tsx
  31. 44
      src/components/layout/Sidebar/Settings/Radio.tsx
  32. 114
      src/components/layout/Sidebar/Settings/User.tsx
  33. 79
      src/components/layout/Sidebar/Settings/WiFi.tsx
  34. 5
      src/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/DebugPanel.tsx
  35. 7
      src/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/SettingsPlanel.tsx
  36. 5
      src/components/layout/Sidebar/Settings/plugins/panels/RangeTest/DebugPanel.tsx
  37. 7
      src/components/layout/Sidebar/Settings/plugins/panels/RangeTest/SettingsPanel.tsx
  38. 5
      src/components/layout/Sidebar/Settings/plugins/panels/Serial/DebugPanel.tsx
  39. 7
      src/components/layout/Sidebar/Settings/plugins/panels/Serial/SettingsPanel.tsx
  40. 5
      src/components/layout/Sidebar/Settings/plugins/panels/StoreForward/DebugPanel.tsx
  41. 7
      src/components/layout/Sidebar/Settings/plugins/panels/StoreForward/SettingsPanel.tsx
  42. 59
      src/components/layout/Sidebar/Settings/radio/channels/panels/ChannelsGroup.tsx
  43. 5
      src/components/layout/Sidebar/Settings/radio/channels/panels/DebugPanel.tsx
  44. 15
      src/components/layout/Sidebar/Settings/radio/channels/panels/QRCodePanel.tsx
  45. 7
      src/components/layout/Sidebar/Settings/radio/channels/panels/SettingsPanel.tsx
  46. 42
      src/components/layout/Sidebar/SidebarItem.tsx
  47. 38
      src/components/layout/Sidebar/index.tsx
  48. 73
      src/components/layout/Sidebar/sections/CollapsibleSection.tsx
  49. 47
      src/components/layout/Sidebar/sections/ExternalSection.tsx
  50. 49
      src/components/layout/Sidebar/sections/SidebarOverlay.tsx
  51. 50
      src/components/layout/Sidebar/sections/SidebarPrimary.tsx
  52. 39
      src/components/layout/index.tsx
  53. 67
      src/components/menu/BottomNav.tsx
  54. 14
      src/components/menu/Logo.tsx
  55. 58
      src/components/menu/Navigation.tsx
  56. 8
      src/components/menu/buttons/DeviceStatus.tsx
  57. 22
      src/components/menu/buttons/MobileNavToggle.tsx
  58. 79
      src/components/menu/buttons/Notifications.tsx
  59. 27
      src/components/modals/VersionInfo.tsx
  60. 131
      src/components/pages/nodes/NodeCard.tsx
  61. 59
      src/components/pages/nodes/NodeSidebar.tsx
  62. 7
      src/components/pages/nodes/panels/InfoPanel.tsx
  63. 76
      src/components/pages/settings/plugins/PluginsSidebar.tsx
  64. 60
      src/components/pages/settings/radio/channels/ChannelsSidebar.tsx
  65. 89
      src/components/templates/PageLayout.tsx
  66. 46
      src/components/templates/PrimaryTemplate.tsx
  67. 25
      src/core/connection.ts
  68. 3
      src/core/router.ts
  69. 36
      src/core/slices/appSlice.ts
  70. 4
      src/core/slices/mapSlice.ts
  71. 48
      src/core/slices/meshtasticSlice.ts
  72. 21
      src/core/translation.ts
  73. 9
      src/core/utils/gqlFetcher.ts
  74. 12
      src/core/utils/notifications.ts
  75. 21
      src/hooks/useBreakpoint.ts
  76. 8
      src/index.tsx
  77. 69
      src/pages/Extensions/FileBrowser.tsx
  78. 55
      src/pages/Extensions/Index.tsx
  79. 42
      src/pages/Extensions/Info.tsx
  80. 10
      src/pages/Map/MapContainer.tsx
  81. 0
      src/pages/Map/Marker.tsx
  82. 85
      src/pages/Map/index.tsx
  83. 0
      src/pages/Map/styles.ts
  84. 63
      src/pages/Messages.tsx
  85. 81
      src/pages/Messages/Message.tsx
  86. 40
      src/pages/Messages/MessageBar.tsx
  87. 152
      src/pages/Messages/index.tsx
  88. 129
      src/pages/Nodes.tsx
  89. 141
      src/pages/Nodes/NodeCard.tsx
  90. 129
      src/pages/Nodes/index.tsx
  91. 5
      src/pages/Nodes/panels/DebugPanel.tsx
  92. 9
      src/pages/Nodes/panels/PositionPanel.tsx
  93. 17
      src/pages/NotFound.tsx
  94. 230
      src/pages/settings/Channels.tsx
  95. 98
      src/pages/settings/Index.tsx
  96. 73
      src/pages/settings/Interface.tsx
  97. 133
      src/pages/settings/Plugins.tsx
  98. 236
      src/pages/settings/Position.tsx
  99. 98
      src/pages/settings/Power.tsx
  100. 89
      src/pages/settings/Radio.tsx

4
.gitignore

@ -1,3 +1,5 @@
dist
node_modules
.env
.env
stats.html
.vercel

4
.prettierrc

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

49
package.json

@ -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"

1817
pnpm-lock.yaml

File diff suppressed because it is too large

6
prettier.config.cjs

@ -0,0 +1,6 @@
module.exports = {
plugins: [require('prettier-plugin-tailwindcss')],
singleQuote: true,
trailingComma: 'all',
tailwindConfig: './tailwind.config.cjs',
};

83
src/App.tsx

@ -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>

84
src/components/Connection.tsx

@ -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>
);
};

6
src/components/FormFooter.tsx

@ -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>
);

2
src/components/MapBox/MapboxProvider.tsx

@ -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 = {

2
src/components/MapBox/mapboxContext.ts

@ -8,5 +8,5 @@ export interface MapboxContextValue {
}
export const MapboxContext = React.createContext<MapboxContextValue>(
{} as MapboxContextValue
{} as MapboxContextValue,
);

27
src/components/TabButton.tsx

@ -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>
);
};

56
src/components/chat/Message.tsx

@ -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>
);
};

6
src/components/connection/BLE.tsx

@ -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>

10
src/components/connection/Serial.tsx

@ -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>
)}

4
src/components/generic/Blur.tsx

@ -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}

38
src/components/generic/Drawer.tsx

@ -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>
</>
);
};

46
src/components/generic/ListItem.tsx

@ -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>
);
};

26
src/components/generic/Modal.tsx

@ -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>
);
};

46
src/components/generic/Sidebar.tsx

@ -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>
);
};

33
src/components/generic/SidebarItem.tsx

@ -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>
);
};

14
src/components/generic/Tooltip.tsx

@ -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>;
};

89
src/components/generic/form/BitwiseSelect.tsx

@ -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>
);
};

2
src/components/generic/form/Label.tsx

@ -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>
);

79
src/components/layout/Sidebar/ButtonNav.tsx

@ -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>
);
};

38
src/components/layout/Sidebar/NavLinkButton.tsx

@ -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>
);
};

160
src/components/layout/Sidebar/Settings/Channels.tsx

@ -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>
))}
</>
);
};

131
src/components/layout/Sidebar/Settings/Index.tsx

@ -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 */}
</>
);
};

28
src/components/layout/Sidebar/Settings/Interface.tsx

@ -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');
}}
/>
);
};

140
src/components/layout/Sidebar/Settings/Position.tsx

@ -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>
);
};

53
src/components/layout/Sidebar/Settings/Power.tsx

@ -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>
);
};

44
src/components/layout/Sidebar/Settings/Radio.tsx

@ -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>
);
};

114
src/components/layout/Sidebar/Settings/User.tsx

@ -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>
);
};

79
src/components/layout/Sidebar/Settings/WiFi.tsx

@ -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>
);
};

5
src/components/pages/settings/plugins/panels/ExternalNotifications/DebugPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/DebugPanel.tsx

@ -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>
</>
);
};

7
src/components/pages/settings/plugins/panels/ExternalNotifications/SettingsPlanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/SettingsPlanel.tsx

@ -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>
</>
);
};

5
src/components/pages/settings/plugins/panels/RangeTest/DebugPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/RangeTest/DebugPanel.tsx

@ -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>
</>
);
};

7
src/components/pages/settings/plugins/panels/RangeTest/SettingsPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/RangeTest/SettingsPanel.tsx

@ -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>
</>
);
};

5
src/components/pages/settings/plugins/panels/Serial/DebugPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/Serial/DebugPanel.tsx

@ -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>
</>
);
};

7
src/components/pages/settings/plugins/panels/Serial/SettingsPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/Serial/SettingsPanel.tsx

@ -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>
</>
);
};

5
src/components/pages/settings/plugins/panels/StoreForward/DebugPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/StoreForward/DebugPanel.tsx

@ -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>
</>
);
};

7
src/components/pages/settings/plugins/panels/StoreForward/SettingsPanel.tsx → src/components/layout/Sidebar/Settings/plugins/panels/StoreForward/SettingsPanel.tsx

@ -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>
</>
);
};

59
src/components/layout/Sidebar/Settings/radio/channels/panels/ChannelsGroup.tsx

@ -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>
);
})}
</>
);
};

5
src/components/pages/settings/radio/channels/panels/DebugPanel.tsx → src/components/layout/Sidebar/Settings/radio/channels/panels/DebugPanel.tsx

@ -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>
</>
);
};

15
src/components/pages/settings/radio/channels/panels/QRCodePanel.tsx → src/components/layout/Sidebar/Settings/radio/channels/panels/QRCodePanel.tsx

@ -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>
);
};

7
src/components/pages/settings/radio/channels/panels/SettingsPanel.tsx → src/components/layout/Sidebar/Settings/radio/channels/panels/SettingsPanel.tsx

@ -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>
);
};

42
src/components/layout/Sidebar/SidebarItem.tsx

@ -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>
);
};

38
src/components/layout/Sidebar/index.tsx

@ -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>
);
};

73
src/components/layout/Sidebar/sections/CollapsibleSection.tsx

@ -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>
);
};

47
src/components/layout/Sidebar/sections/ExternalSection.tsx

@ -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>
);
};

49
src/components/layout/Sidebar/sections/SidebarOverlay.tsx

@ -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>
);
};

50
src/components/layout/Sidebar/sections/SidebarPrimary.tsx

@ -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>
);
};

39
src/components/layout/index.tsx

@ -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>
);
};

67
src/components/menu/BottomNav.tsx

@ -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));
}}

14
src/components/menu/Logo.tsx

@ -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"
/>
</>
);
};

58
src/components/menu/Navigation.tsx

@ -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>
);
};

8
src/components/menu/buttons/DeviceStatus.tsx

@ -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>

22
src/components/menu/buttons/MobileNavToggle.tsx

@ -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>
);
};

79
src/components/menu/buttons/Notifications.tsx

@ -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>
);
};

27
src/components/modals/VersionInfo.tsx

@ -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>
);
};

131
src/components/pages/nodes/NodeCard.tsx

@ -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>
);
};

59
src/components/pages/nodes/NodeSidebar.tsx

@ -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>
);
};

7
src/components/pages/nodes/panels/InfoPanel.tsx

@ -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>;
};

76
src/components/pages/settings/plugins/PluginsSidebar.tsx

@ -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>
);
};

60
src/components/pages/settings/radio/channels/ChannelsSidebar.tsx

@ -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>
);
};

89
src/components/templates/PageLayout.tsx

@ -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>
);
};

46
src/components/templates/PrimaryTemplate.tsx

@ -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>
);
};

25
src/core/connection.ts

@ -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(),

3
src/core/router.ts

@ -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'),
});

36
src/core/slices/appSlice.ts

@ -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;

4
src/core/slices/mapSlice.ts

@ -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';

48
src/core/slices/meshtasticSlice.ts

@ -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,

21
src/core/translation.ts

@ -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;

9
src/core/utils/gqlFetcher.ts

@ -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);
}

12
src/core/utils/notifications.ts

@ -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',
});
};

21
src/hooks/useBreakpoint.ts

@ -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);

8
src/index.tsx

@ -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>

69
src/pages/Extensions/FileBrowser.tsx

@ -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>
);
};

55
src/pages/Extensions/Index.tsx

@ -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>
);
};

42
src/pages/Extensions/Info.tsx

@ -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>
);
};

10
src/components/Map/index.tsx → src/pages/Map/MapContainer.tsx

@ -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
src/components/Map/Marker.tsx → src/pages/Map/Marker.tsx

85
src/pages/Map/index.tsx

@ -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>
);
};

0
src/components/Map/styles.ts → src/pages/Map/styles.ts

63
src/pages/Messages.tsx

@ -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>
);
};

81
src/pages/Messages/Message.tsx

@ -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>
);
};

40
src/components/chat/MessageBar.tsx → src/pages/Messages/MessageBar.tsx

@ -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>

152
src/pages/Messages/index.tsx

@ -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>
);
};

129
src/pages/Nodes.tsx

@ -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>
);
};

141
src/pages/Nodes/NodeCard.tsx

@ -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>
);
};

129
src/pages/Nodes/index.tsx

@ -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>
);
};

5
src/components/pages/nodes/panels/DebugPanel.tsx → src/pages/Nodes/panels/DebugPanel.tsx

@ -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>
);
};

9
src/components/pages/nodes/panels/PositionPanel.tsx → src/pages/Nodes/panels/PositionPanel.tsx

@ -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)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
@ -28,6 +27,6 @@ export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
/>
</div>
)}
</Tab.Panel>
</div>
);
};

17
src/pages/NotFound.tsx

@ -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>
);
};

230
src/pages/settings/Channels.tsx

@ -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}
/>
)}
</>
);
};

98
src/pages/settings/Index.tsx

@ -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} />
);
};

73
src/pages/settings/Interface.tsx

@ -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>
);
};

133
src/pages/settings/Plugins.tsx

@ -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}
/>
)}
</>
);
};

236
src/pages/settings/Position.tsx

@ -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>
);
};

98
src/pages/settings/Power.tsx

@ -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>
);
};

89
src/pages/settings/Radio.tsx

@ -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 changed in this diff

Loading…
Cancel
Save