Browse Source

Initial 1.3.x rework

pull/31/head
Sacha Weatherstone 4 years ago
parent
commit
a2280ab96e
  1. 2
      .env.example
  2. 1
      .gitignore
  3. 2
      .prettierrc
  4. 15
      README.md
  5. 10
      index.html
  6. 67
      package.json
  7. 3713
      pnpm-lock.yaml
  8. 6
      postcss.config.js
  9. 57
      public/Files.svg
  10. 57
      public/Files_Dark.svg
  11. 12
      public/Logo_White.svg
  12. 77
      public/View_Code.svg
  13. 28
      public/View_Code_Dark.svg
  14. 17
      public/safari-tab.svg
  15. 35
      src/App.tsx
  16. 84
      src/DeviceWrapper.tsx
  17. 22
      src/PageRouter.tsx
  18. 150
      src/components/Connection.tsx
  19. 141
      src/components/Dialog/PeersDialog.tsx
  20. 112
      src/components/Dialog/QRDialog.tsx
  21. 16
      src/components/ErrorFallback.tsx
  22. 137
      src/components/MapBox/MapboxProvider.tsx
  23. 13
      src/components/MapBox/mapboxContext.ts
  24. 117
      src/components/PageComponents/Config/Device.tsx
  25. 72
      src/components/PageComponents/Config/Display.tsx
  26. 163
      src/components/PageComponents/Config/LoRa.tsx
  27. 224
      src/components/PageComponents/Config/Position.tsx
  28. 137
      src/components/PageComponents/Config/Power.tsx
  29. 141
      src/components/PageComponents/Config/User.tsx
  30. 94
      src/components/PageComponents/Config/WiFi copy.tsx
  31. 96
      src/components/PageComponents/Config/WiFi.tsx
  32. 171
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  33. 134
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  34. 105
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  35. 96
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  36. 131
      src/components/PageComponents/ModuleConfig/Serial.tsx
  37. 114
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  38. 135
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  39. 88
      src/components/Progress.tsx
  40. 113
      src/components/SlideSheets/NewDevice.tsx
  41. 60
      src/components/Tab.tsx
  42. 35
      src/components/Tabs.tsx
  43. 85
      src/components/connect/BLE.tsx
  44. 60
      src/components/connect/HTTP.tsx
  45. 102
      src/components/connect/Serial.tsx
  46. 79
      src/components/connection/BLE.tsx
  47. 93
      src/components/connection/HTTP.tsx
  48. 95
      src/components/connection/Serial.tsx
  49. 49
      src/components/form/Form.tsx
  50. 48
      src/components/generic/Card.tsx
  51. 23
      src/components/generic/ContextItem.tsx
  52. 57
      src/components/generic/ContextMenu.tsx
  53. 9
      src/components/generic/Loading.tsx
  54. 72
      src/components/generic/Modal.tsx
  55. 85
      src/components/generic/Sidebar/CollapsibleSection.tsx
  56. 54
      src/components/generic/Sidebar/ExternalSection.tsx
  57. 61
      src/components/generic/Sidebar/SidebarOverlay.tsx
  58. 17
      src/components/generic/Tooltip.tsx
  59. 79
      src/components/generic/button/Button.tsx
  60. 50
      src/components/generic/button/IconButton.tsx
  61. 49
      src/components/generic/form/Checkbox.tsx
  62. 36
      src/components/generic/form/Form.tsx
  63. 41
      src/components/generic/form/Input.tsx
  64. 29
      src/components/generic/form/InputWrapper.tsx
  65. 14
      src/components/generic/form/Label.tsx
  66. 69
      src/components/generic/form/Select.tsx
  67. 74
      src/components/layout/AppLayout.tsx
  68. 147
      src/components/layout/Header.tsx
  69. 103
      src/components/layout/Sidebar/DeviceCard.tsx
  70. 62
      src/components/layout/Sidebar/Settings/Device.tsx
  71. 64
      src/components/layout/Sidebar/Settings/Display.tsx
  72. 182
      src/components/layout/Sidebar/Settings/Index.tsx
  73. 28
      src/components/layout/Sidebar/Settings/Interface.tsx
  74. 141
      src/components/layout/Sidebar/Settings/LoRa.tsx
  75. 140
      src/components/layout/Sidebar/Settings/Position.tsx
  76. 124
      src/components/layout/Sidebar/Settings/Power.tsx
  77. 106
      src/components/layout/Sidebar/Settings/User.tsx
  78. 62
      src/components/layout/Sidebar/Settings/WiFi.tsx
  79. 127
      src/components/layout/Sidebar/Settings/channels/Channels.tsx
  80. 43
      src/components/layout/Sidebar/Settings/channels/ChannelsGroup.tsx
  81. 102
      src/components/layout/Sidebar/Settings/modules/CannedMessage.tsx
  82. 89
      src/components/layout/Sidebar/Settings/modules/ExternalNotifications.tsx
  83. 76
      src/components/layout/Sidebar/Settings/modules/MQTT.tsx
  84. 71
      src/components/layout/Sidebar/Settings/modules/RangeTest.tsx
  85. 99
      src/components/layout/Sidebar/Settings/modules/Serial.tsx
  86. 87
      src/components/layout/Sidebar/Settings/modules/StoreForward.tsx
  87. 97
      src/components/layout/Sidebar/Settings/modules/Telemetry.tsx
  88. 38
      src/components/layout/Sidebar/SidebarItem.tsx
  89. 135
      src/components/layout/Sidebar/index.tsx
  90. 91
      src/components/layout/index.tsx
  91. 74
      src/components/layout/page/TabbedContent.tsx
  92. 203
      src/components/menu/BottomNav.tsx
  93. 32
      src/components/menu/BottomNavItem.tsx
  94. 31
      src/components/menu/buttons/CopyButton.tsx
  95. 18
      src/components/misc/NoDevice.tsx
  96. 110
      src/components/modals/VersionInfo.tsx
  97. 29
      src/components/pwa/ReloadPrompt.css
  98. 60
      src/components/pwa/ReloadPrompt.tsx
  99. 205
      src/core/connection.ts
  100. 60
      src/core/mapStyles.ts

2
.env.example

@ -1,2 +0,0 @@
VITE_PUBLIC_DEVICE_IP=
VITE_PUBLIC_HOSTED=

1
.gitignore

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

2
.prettierrc

@ -1 +1 @@
"@meshtastic/eslint-config/prettier"
{}

15
README.md

@ -1,6 +1,7 @@
# Meshtastic Web
<!--Project specific badges here-->
[![CI](https://img.shields.io/github/workflow/status/meshtastic/meshtastic-web/CI?label=actions&logo=github&color=yellow)](https://github.com/meshtastic/meshtastic-web/actions/workflows/main.yml)
[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/meshtastic-web)](https://cla-assistant.io/meshtastic/meshtastic-web)
[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/)
@ -14,7 +15,7 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
## Stats
![Alt](https://repobeats.axiom.co/api/embed/1299935eef158e8156c956ba399ecda43585d86e.svg "Repobeats analytics image")
![Alt](https://repobeats.axiom.co/api/embed/1299935eef158e8156c956ba399ecda43585d86e.svg 'Repobeats analytics image')
## Development & Building
@ -34,18 +35,6 @@ pnpm package
### Development
Create a `.env` file:
```bash
cp ./.env.example ./.env
```
And define the device IP address in the `.env` file.
```
VITE_PUBLIC_DEVICE_IP=xxx.xxx.xxx.xxx
```
Install the dependencies.
```bash

10
index.html

@ -2,16 +2,10 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link
rel="alternate icon"
href="/safari-tab.svg"
type="image/png"
sizes="16x16"
/>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-tab.svg" color="#67ea94" />
<link rel="mask-icon" href="/Logo_Black.svg" color="#67ea94" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link
rel="stylesheet"
@ -19,7 +13,7 @@
/>
<link
rel="stylesheet"
href="https://js.arcgis.com/4.23/esri/themes/dark/main.css"
href="https://js.arcgis.com/4.23/esri/themes/light/main.css"
/>
<meta name="theme-color" content="#67ea94" />
<meta

67
package.json

@ -20,50 +20,47 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@arcgis/core": "^4.23.7",
"@arcgis/core": "^4.24.7",
"@emeraldpay/hashicon-react": "^0.5.2",
"@hookform/resolvers": "^2.9.6",
"@meshtastic/eslint-config": "^1.0.8",
"@meshtastic/meshtasticjs": "^0.6.63",
"@reduxjs/toolkit": "^1.8.1",
"@tippyjs/react": "^4.2.6",
"@meshtastic/meshtasticjs": "^0.6.81",
"base64-js": "^1.5.1",
"framer-motion": "^6.3.3",
"prettier": "^2.6.2",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "^7.31.1",
"react-icons": "^4.3.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"evergreen-ui": "^6.10.3",
"geodesy": "^2.4.0",
"immer": "^9.0.15",
"modern-css-reset": "^1.4.0",
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.33.1",
"react-icons": "^4.4.0",
"react-json-pretty": "^2.2.0",
"react-multi-select-component": "^4.2.5",
"react-redux": "^8.0.1",
"react-use-clipboard": "^1.0.8",
"rfc4648": "^1.5.1",
"react-qrcode-logo": "^2.7.0",
"rfc4648": "^1.5.2",
"snarkdown": "^2.0.0",
"swr": "^1.3.0",
"timeago-react": "^3.0.4",
"tippy.js": "^6.3.7",
"type-route": "^0.7.1",
"vite-plugin-environment": "^1.1.1"
"vite-plugin-environment": "^1.1.2",
"zustand": "4.0.0-rc.4"
},
"devDependencies": {
"@types/chrome": "^0.0.184",
"@types/node": "^17.0.32",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.3",
"@types/chrome": "^0.0.193",
"@types/geodesy": "^2.2.3",
"@types/node": "^18.0.6",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/w3c-web-serial": "^1.0.2",
"@types/web-bluetooth": "^0.0.14",
"@vitejs/plugin-react": "^1.3.2",
"autoprefixer": "^10.4.7",
"@types/web-bluetooth": "^0.0.15",
"@vitejs/plugin-react": "^2.0.0",
"gzipper": "^7.1.0",
"postcss": "^8.4.13",
"rollup-plugin-visualizer": "^5.6.0",
"tailwindcss": "^3.0.24",
"rollup-plugin-visualizer": "^5.7.1",
"tar": "^6.1.11",
"typescript": "^4.6.4",
"unimported": "^1.20.0",
"vite": "^2.9.8",
"vite-plugin-cdn-import": "^0.3.5",
"vite-plugin-pwa": "^0.12.0",
"workbox-window": "^6.5.3"
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"unimported": "^1.21.0",
"vite": "^3.0.2",
"vite-plugin-cdn-import": "^0.3.5"
}
}

3713
pnpm-lock.yaml

File diff suppressed because it is too large

6
postcss.config.js

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

57
public/Files.svg

@ -1,57 +0,0 @@
<svg width="183" height="122" viewBox="0 0 183 122" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M91.119 120.982C123.441 120.982 149.644 94.7789 149.644 62.3418C149.644 29.9047 123.325 3.70111 91.119 3.70111C58.7974 3.70111 32.5938 29.9047 32.5938 62.3418C32.5938 94.7789 58.7974 120.982 91.119 120.982Z" fill="#F1F3F9" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10"/>
<path d="M157.074 43.7622C159.687 43.7622 161.806 41.6432 161.806 39.0294C161.806 36.4155 159.687 34.2966 157.074 34.2966C154.46 34.2966 152.341 36.4155 152.341 39.0294C152.341 41.6432 154.46 43.7622 157.074 43.7622Z" fill="#F1F3F9"/>
<path d="M164 25.2929C165.785 25.2929 167.232 23.8458 167.232 22.0607C167.232 20.2756 165.785 18.8286 164 18.8286C162.215 18.8286 160.768 20.2756 160.768 22.0607C160.768 23.8458 162.215 25.2929 164 25.2929Z" fill="#F1F3F9"/>
<path d="M35.2488 23.902C37.0338 23.902 38.4809 22.4549 38.4809 20.6698C38.4809 18.8848 37.0338 17.4377 35.2488 17.4377C33.4637 17.4377 32.0166 18.8848 32.0166 20.6698C32.0166 22.4549 33.4637 23.902 35.2488 23.902Z" fill="#F1F3F9"/>
<path d="M15.5094 86.2369C18.8246 86.2369 21.512 83.5494 21.512 80.2343C21.512 76.9191 18.8246 74.2317 15.5094 74.2317C12.1943 74.2317 9.50684 76.9191 9.50684 80.2343C9.50684 83.5494 12.1943 86.2369 15.5094 86.2369Z" fill="#F1F3F9"/>
<path d="M75.0655 93.6397L28.6757 103.156C27.9976 103.262 27.3755 102.909 27.2703 102.231L13.1237 32.9822C13.0185 32.304 13.3709 31.682 14.049 31.5768L60.4388 22.0601C61.1169 21.9549 61.739 22.3073 61.8442 22.9854L76.0665 92.2311C76.1684 92.8335 75.7436 93.5345 75.0655 93.6397Z" fill="white" stroke="#C5CCDA" stroke-width="2" stroke-miterlimit="10"/>
<path d="M25.8894 66.3678L44.0407 62.638" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M49.4513 61.5422L53.4374 60.7234" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M58.2779 59.724L62.2641 58.9052" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.8918 71.2795L45.0431 67.5497" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M50.4511 66.3822L54.4404 65.635" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M59.2086 64.6389L63.1948 63.8201" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M27.8913 76.1199L46.0427 72.3901" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M51.4532 71.2942L55.4394 70.4754" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M60.2076 69.479L64.1938 68.6602" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28.8907 80.9599L47.0451 77.3018" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.7086 94.6263L36.8324 93.5429" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30.9795 91.0672L40.0926 89.2366" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M58.9795 87.5576L68.0209 85.7301" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M52.4527 76.1343L56.4389 75.3155" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M61.2105 74.391L65.1967 73.5722" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.0229 40.0265C30.4601 40.3551 29.3699 41.6895 29.1897 43.316C29.1342 43.673 29.3913 43.9643 29.7317 44.0308C30.0721 44.0974 30.3429 43.8332 30.3984 43.4762C30.5023 42.4183 31.287 41.4935 32.2872 41.2832C33.2874 41.0729 34.3641 41.5371 34.8992 42.5297C35.0938 42.8342 35.4481 42.9669 35.7328 42.7689C36.0176 42.5708 36.1356 42.2007 35.941 41.8962C35.1071 40.4139 33.5857 39.6978 32.0229 40.0265Z" fill="#AAB2C5"/>
<path d="M46.3652 37.0103C44.8024 37.3389 43.7122 38.6733 43.532 40.2998C43.4765 40.6568 43.7336 40.9481 44.074 41.0146C44.4144 41.0812 44.6852 40.817 44.7407 40.46C44.8446 39.4021 45.6293 38.4774 46.6295 38.267C47.6296 38.0567 48.7064 38.521 49.2415 39.5136C49.4361 39.818 49.7904 39.9507 50.0751 39.7527C50.3599 39.5547 50.4779 39.1845 50.2833 38.8801C49.4494 37.3978 47.928 36.6817 46.3652 37.0103Z" fill="#AAB2C5"/>
<path d="M36.8384 45.4507L37.7418 45.2536C38.87 46.7547 41.0217 46.9406 42.4382 45.7581C43.003 45.3437 43.4377 44.6665 43.5792 43.9804L44.4827 43.7834C44.1881 46.1044 41.9289 47.7619 39.6622 47.3827C38.5983 47.178 37.5131 46.5411 36.8384 45.4507Z" fill="#AAB2C5"/>
<circle opacity="0.4" cx="51.3774" cy="45.5842" r="2.44265" transform="rotate(-11.8756 51.3774 45.5842)" fill="#D6DCE8"/>
<circle opacity="0.4" cx="30.8881" cy="49.8927" r="2.44265" transform="rotate(-11.8756 30.8881 49.8927)" fill="#D6DCE8"/>
<path d="M119.239 113.297L68.0595 116.294C67.3211 116.371 66.7513 115.793 66.6746 115.054L62.217 38.777C62.1403 38.0385 62.7183 37.4688 63.4567 37.392L114.636 34.3953C115.375 34.3186 115.945 34.8966 116.021 35.635L120.479 111.913C120.474 112.65 119.978 113.221 119.239 113.297Z" fill="white" stroke="#C5CCDA" stroke-width="2" stroke-miterlimit="10"/>
<path d="M84.6531 78.409L109.232 76.9742" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M84.405 73.9273L108.914 72.492" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M84.9011 82.8905L109.48 81.4557" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M73.6652 87.9902L109.729 85.8672" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M73.9134 92.4717L94.5308 91.219" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.8002 107.447L79.9122 107.134" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.6156 103.946L83.6492 103.381" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M101.716 102.319L110.679 101.823" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M81.192 83.843C81.1219 83.8424 81.0519 83.8418 81.0523 83.7719L79.8767 81.2449L75.4608 81.4883L74.6035 84.1386C74.6031 84.2085 74.5326 84.2778 74.4625 84.2773L73.5512 84.3397C73.4812 84.3391 73.4111 84.3386 73.4115 84.2686C73.3419 84.1981 73.3419 84.1981 73.4124 84.1288L76.5546 74.5745C76.555 74.5046 76.6255 74.4352 76.6956 74.4358L77.9572 74.3762C78.0273 74.3768 78.0974 74.3774 78.0969 74.4473L82.3849 83.5731C82.3844 83.643 82.384 83.7129 82.384 83.7129C82.3836 83.7828 82.3135 83.7823 82.2434 83.7817L81.192 83.843ZM75.7484 80.3019L79.3231 80.1215L77.2492 75.5592L75.7484 80.3019Z" fill="#D6DCE8"/>
<path d="M98.4157 51.3895C99.4897 51.3209 100.351 50.3416 100.283 49.2676C100.214 48.1937 99.235 47.3319 98.161 47.4005C97.087 47.4691 96.2253 48.4484 96.2939 49.5223C96.3673 50.673 97.265 51.463 98.4157 51.3895Z" fill="#AAB2C5"/>
<path d="M82.903 52.404C83.977 52.3354 84.8388 51.3561 84.7702 50.2821C84.7016 49.2081 83.7223 48.3464 82.6483 48.4149C81.5744 48.4835 80.7126 49.4628 80.7812 50.5368C80.9313 51.6826 81.8291 52.4725 82.903 52.404Z" fill="#AAB2C5"/>
<path d="M87.4785 55.9879C87.6662 57.7617 89.1418 59.0969 90.851 59.0006C92.5603 58.9043 93.8765 57.4118 93.8638 55.6281L87.4785 55.9879Z" fill="#AAB2C5"/>
<circle opacity="0.4" cx="102.544" cy="58.0975" r="2.64249" transform="rotate(-3.22458 102.544 58.0975)" fill="#D6DCE8"/>
<circle opacity="0.4" cx="79.93" cy="59.3714" r="2.64249" transform="rotate(-3.22458 79.93 59.3714)" fill="#D6DCE8"/>
<path d="M170.543 79.7581L124.63 91.2494C123.968 91.4311 123.321 91.0358 123.14 90.374L105.993 21.8355C105.812 21.1737 106.207 20.5271 106.869 20.3455L152.782 8.85411C153.444 8.67246 154.09 9.06777 154.272 9.72953L171.418 78.2681C171.6 78.9299 171.204 79.5764 170.543 79.7581Z" fill="white" stroke="#C5CCDA" stroke-width="2" stroke-miterlimit="10"/>
<path d="M118.233 49.6166L123.924 72.3253L161.077 63.0143L155.386 40.3057L118.233 49.6166Z" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M126.54 82.5658L131.77 81.2829" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M125.584 78.9595L134.861 76.6253" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M154.332 74.0635L163.609 71.7293" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M127.311 47.4183L132.989 70.1146" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M136.795 45.0021L142.542 67.671" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M146.374 42.6272L152.052 65.3234" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M119.715 55.3824L156.822 46.0457" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M121.094 61.053L158.228 51.7851" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M122.568 66.765L159.675 57.4282" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M125.142 25.481C123.279 25.9313 122.02 27.571 121.862 29.5284C121.808 29.9586 122.127 30.2988 122.538 30.3664C122.949 30.434 123.264 30.1074 123.318 29.6772C123.404 28.4046 124.312 27.2671 125.504 26.9789C126.696 26.6906 128.005 27.2088 128.682 28.3801C128.927 28.7383 129.356 28.8847 129.691 28.637C130.025 28.3892 130.153 27.941 129.909 27.5828C128.855 25.8349 127.005 25.0306 125.142 25.481Z" fill="#AAB2C5"/>
<path d="M137.692 22.4467C135.985 22.8595 134.855 24.4677 134.747 26.4131C134.706 26.8403 135.006 27.185 135.386 27.2601C135.766 27.3352 136.05 27.0161 136.091 26.5889C136.147 25.3237 136.962 24.2088 138.054 23.9446C139.147 23.6804 140.362 24.2211 141.009 25.3999C141.241 25.7611 141.639 25.915 141.943 25.6748C142.246 25.4345 142.356 24.9908 142.124 24.6296C141.12 22.8696 139.4 22.0339 137.692 22.4467Z" fill="#AAB2C5"/>
<path d="M135.042 36.7295C136.166 36.4578 136.857 35.3263 136.585 34.2024C136.313 33.0785 135.182 32.3876 134.058 32.6594C132.934 32.9311 132.243 34.0626 132.515 35.1865C132.787 36.3104 133.918 37.0013 135.042 36.7295Z" fill="#AAB2C5"/>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

57
public/Files_Dark.svg

@ -1,57 +0,0 @@
<svg width="183" height="122" viewBox="0 0 183 122" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M91.119 120.983C123.441 120.983 149.644 94.7789 149.644 62.3419C149.644 29.9048 123.325 3.70117 91.119 3.70117C58.7974 3.70117 32.5938 29.9048 32.5938 62.3419C32.5938 94.7789 58.7974 120.983 91.119 120.983Z" fill="#1F2229" stroke="#3B414E" stroke-width="2" stroke-miterlimit="10"/>
<path d="M157.074 43.7623C159.687 43.7623 161.806 41.6433 161.806 39.0294C161.806 36.4156 159.687 34.2966 157.074 34.2966C154.46 34.2966 152.341 36.4156 152.341 39.0294C152.341 41.6433 154.46 43.7623 157.074 43.7623Z" fill="#F1F3F9"/>
<path d="M164 25.2929C165.785 25.2929 167.232 23.8459 167.232 22.0608C167.232 20.2757 165.785 18.8286 164 18.8286C162.215 18.8286 160.768 20.2757 160.768 22.0608C160.768 23.8459 162.215 25.2929 164 25.2929Z" fill="#1F2229"/>
<path d="M35.2488 23.9021C37.0338 23.9021 38.4809 22.455 38.4809 20.6699C38.4809 18.8848 37.0338 17.4377 35.2488 17.4377C33.4637 17.4377 32.0166 18.8848 32.0166 20.6699C32.0166 22.455 33.4637 23.9021 35.2488 23.9021Z" fill="#3B414E" stroke="#3B414E" stroke-width="2" stroke-miterlimit="10"/>
<path d="M15.5094 86.2369C18.8246 86.2369 21.512 83.5494 21.512 80.2343C21.512 76.9191 18.8246 74.2317 15.5094 74.2317C12.1943 74.2317 9.50684 76.9191 9.50684 80.2343C9.50684 83.5494 12.1943 86.2369 15.5094 86.2369Z" fill="#3B414E" stroke="#3B414E" stroke-width="2" stroke-miterlimit="10"/>
<path d="M75.0655 93.6397L28.6757 103.156C27.9976 103.262 27.3755 102.909 27.2703 102.231L13.1237 32.9822C13.0185 32.304 13.3709 31.682 14.049 31.5768L60.4388 22.0601C61.1169 21.9549 61.739 22.3073 61.8442 22.9854L76.0665 92.2311C76.1684 92.8335 75.7436 93.5345 75.0655 93.6397Z" fill="#2E333D" stroke="#494E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M25.8894 66.3678L44.0407 62.6381" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M49.4513 61.5421L53.4374 60.7233" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M58.2779 59.724L62.2641 58.9052" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.8918 71.2795L45.0431 67.5497" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M50.4511 66.382L54.4404 65.6349" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M59.2086 64.6388L63.1948 63.82" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M27.8913 76.1198L46.0427 72.3901" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M51.4532 71.2941L55.4394 70.4752" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M60.2076 69.4789L64.1938 68.6601" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28.8907 80.9597L47.0451 77.3017" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.7086 94.6263L36.8324 93.5428" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30.9795 91.0672L40.0926 89.2366" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M58.9795 87.5576L68.0209 85.7302" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M52.4527 76.1341L56.4389 75.3153" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M61.2105 74.391L65.1967 73.5722" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.0229 40.0263C30.4601 40.3549 29.3699 41.6893 29.1897 43.3158C29.1342 43.6728 29.3913 43.9641 29.7317 44.0306C30.0721 44.0972 30.3429 43.833 30.3984 43.476C30.5023 42.4181 31.287 41.4934 32.2872 41.283C33.2874 41.0727 34.3641 41.537 34.8992 42.5295C35.0938 42.834 35.4481 42.9667 35.7328 42.7687C36.0176 42.5707 36.1356 42.2005 35.941 41.8961C35.1071 40.4138 33.5857 39.6976 32.0229 40.0263Z" fill="#949BAB"/>
<path d="M46.3652 37.0102C44.8024 37.3388 43.7122 38.6732 43.532 40.2997C43.4765 40.6567 43.7336 40.948 44.074 41.0145C44.4144 41.0811 44.6852 40.8169 44.7407 40.4599C44.8446 39.402 45.6293 38.4772 46.6295 38.2669C47.6296 38.0566 48.7064 38.5208 49.2415 39.5134C49.4361 39.8179 49.7904 39.9506 50.0751 39.7526C50.3599 39.5545 50.4779 39.1844 50.2833 38.8799C49.4494 37.3976 47.928 36.6815 46.3652 37.0102Z" fill="#949BAB"/>
<path d="M36.8384 45.4505L37.7418 45.2535C38.87 46.7546 41.0217 46.9405 42.4382 45.7579C43.003 45.3436 43.4377 44.6663 43.5792 43.9803L44.4827 43.7832C44.1881 46.1043 41.9289 47.7618 39.6622 47.3826C38.5983 47.1779 37.5131 46.541 36.8384 45.4505Z" fill="#949BAB"/>
<circle opacity="0.4" cx="51.3774" cy="45.5842" r="2.44265" transform="rotate(-11.8756 51.3774 45.5842)" fill="#494E59"/>
<circle opacity="0.4" cx="30.8881" cy="49.8926" r="2.44265" transform="rotate(-11.8756 30.8881 49.8926)" fill="#494E59"/>
<path d="M119.239 113.297L68.0595 116.294C67.3211 116.371 66.7513 115.793 66.6746 115.054L62.217 38.7769C62.1403 38.0385 62.7183 37.4687 63.4567 37.392L114.636 34.3953C115.375 34.3186 115.945 34.8965 116.021 35.6349L120.479 111.912C120.474 112.65 119.978 113.221 119.239 113.297Z" fill="#2E333D" stroke="#494E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M84.6531 78.4089L109.232 76.9741" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M84.405 73.9272L108.914 72.4919" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M84.9011 82.8906L109.48 81.4558" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M73.6652 87.9901L109.729 85.8672" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M73.9134 92.4716L94.5308 91.2189" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.8002 107.447L79.9122 107.134" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M74.6156 103.946L83.6492 103.381" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M101.716 102.319L110.679 101.823" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M81.192 83.8431C81.1219 83.8425 81.0519 83.842 81.0523 83.772L79.8767 81.245L75.4608 81.4885L74.6035 84.1387C74.6031 84.2086 74.5326 84.278 74.4625 84.2774L73.5512 84.3398C73.4812 84.3393 73.4111 84.3387 73.4115 84.2688C73.3419 84.1983 73.3419 84.1983 73.4124 84.1289L76.5546 74.5746C76.555 74.5047 76.6255 74.4353 76.6956 74.4359L77.9572 74.3763C78.0273 74.3769 78.0974 74.3775 78.0969 74.4474L82.3849 83.5732C82.3844 83.6431 82.384 83.713 82.384 83.713C82.3836 83.783 82.3135 83.7824 82.2434 83.7818L81.192 83.8431ZM75.7484 80.3021L79.3231 80.1216L77.2492 75.5593L75.7484 80.3021Z" fill="#494E59"/>
<path d="M98.4157 51.3897C99.4897 51.3212 100.351 50.3419 100.283 49.2679C100.214 48.1939 99.235 47.3322 98.161 47.4007C97.087 47.4693 96.2253 48.4486 96.2939 49.5226C96.3673 50.6733 97.265 51.4632 98.4157 51.3897Z" fill="#949BAB"/>
<path d="M82.903 52.4042C83.977 52.3356 84.8388 51.3563 84.7702 50.2823C84.7016 49.2083 83.7223 48.3466 82.6483 48.4151C81.5744 48.4837 80.7126 49.463 80.7812 50.537C80.9313 51.6828 81.8291 52.4727 82.903 52.4042Z" fill="#949BAB"/>
<path d="M87.4785 55.988C87.6662 57.7619 89.1418 59.0971 90.851 59.0008C92.5603 58.9045 93.8765 57.412 93.8638 55.6283L87.4785 55.988Z" fill="#949BAB"/>
<circle opacity="0.4" cx="102.544" cy="58.0973" r="2.64249" transform="rotate(-3.22458 102.544 58.0973)" fill="#494E59"/>
<circle opacity="0.4" cx="79.93" cy="59.3715" r="2.64249" transform="rotate(-3.22458 79.93 59.3715)" fill="#494E59"/>
<path d="M170.543 79.7581L124.63 91.2495C123.968 91.4312 123.321 91.0358 123.14 90.3741L105.993 21.8355C105.812 21.1738 106.207 20.5272 106.869 20.3456L152.782 8.85417C153.444 8.67253 154.09 9.06783 154.272 9.72959L171.418 78.2682C171.6 78.9299 171.204 79.5765 170.543 79.7581Z" fill="#2E333D" stroke="#494E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M118.233 49.6168L123.924 72.3254L161.077 63.0145L155.386 40.3059L118.233 49.6168Z" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M126.54 82.5659L131.77 81.2831" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M125.584 78.9597L134.861 76.6255" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M154.332 74.0635L163.609 71.7293" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M127.311 47.4185L132.989 70.1147" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M136.795 45.0022L142.542 67.6711" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M146.374 42.6272L152.052 65.3234" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M119.715 55.3826L156.822 46.0458" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M121.094 61.053L158.228 51.7851" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M122.568 66.7651L159.675 57.4284" stroke="#494E59" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M125.142 25.481C123.279 25.9313 122.02 27.571 121.862 29.5284C121.808 29.9586 122.127 30.2988 122.538 30.3664C122.949 30.434 123.264 30.1074 123.318 29.6772C123.404 28.4046 124.312 27.2671 125.504 26.9789C126.696 26.6906 128.005 27.2088 128.682 28.3801C128.927 28.7383 129.356 28.8847 129.691 28.637C130.025 28.3892 130.153 27.941 129.909 27.5828C128.855 25.8349 127.005 25.0306 125.142 25.481Z" fill="#949BAB"/>
<path d="M137.692 22.4468C135.985 22.8596 134.855 24.4678 134.747 26.4132C134.706 26.8404 135.006 27.185 135.386 27.2601C135.766 27.3352 136.05 27.0162 136.091 26.5889C136.147 25.3238 136.962 24.2089 138.054 23.9447C139.147 23.6805 140.362 24.2211 141.009 25.4C141.241 25.7611 141.639 25.9151 141.943 25.6748C142.246 25.4346 142.356 24.9909 142.124 24.6297C141.12 22.8697 139.4 22.0339 137.692 22.4468Z" fill="#949BAB"/>
<path d="M135.042 36.7297C136.166 36.4579 136.857 35.3265 136.585 34.2026C136.313 33.0787 135.182 32.3878 134.058 32.6596C132.934 32.9313 132.243 34.0627 132.515 35.1867C132.787 36.3106 133.918 37.0014 135.042 36.7297Z" fill="#949BAB"/>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

12
public/Logo_White.svg

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

77
public/View_Code.svg

@ -1,77 +0,0 @@
<svg width="119" height="117" viewBox="0 0 119 117" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M54.1022 115.078C83.4297 115.078 107.204 91.1538 107.204 61.6419C107.204 32.1301 83.4297 8.20596 54.1022 8.20596C24.7747 8.20596 1 32.1301 1 61.6419C1 91.1538 24.7747 115.078 54.1022 115.078Z"
fill="#F1F3F9" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" />
<path
d="M115.883 47.5493C117.538 46.0271 117.654 43.4428 116.141 41.7771C114.628 40.1114 112.06 39.9951 110.405 41.5173C108.75 43.0395 108.634 45.6238 110.147 47.2895C111.659 48.9552 114.228 49.0715 115.883 47.5493Z"
fill="#EAEEF9" />
<path
d="M23.4054 12.8115C24.8084 12.3526 25.576 10.8362 25.12 9.42442C24.664 8.01266 23.1571 7.24018 21.7541 7.69904C20.3512 8.1579 19.5835 9.67433 20.0395 11.0861C20.4955 12.4979 22.0025 13.2703 23.4054 12.8115Z"
fill="#EAEEF9" />
<path
d="M29.236 4.04143C30.19 3.72941 30.712 2.69823 30.4019 1.73823C30.0918 0.778235 29.0671 0.252949 28.1131 0.564974C27.1591 0.876998 26.6371 1.90817 26.9472 2.86817C27.2573 3.82817 28.282 4.35345 29.236 4.04143Z"
fill="#EAEEF9" />
<path
d="M116.183 29.3845C116.88 28.7436 116.928 27.6555 116.291 26.9541C115.655 26.2528 114.573 26.2038 113.876 26.8447C113.179 27.4856 113.131 28.5737 113.768 29.2751C114.404 29.9764 115.486 30.0254 116.183 29.3845Z"
fill="#EAEEF9" />
<path
d="M73.055 4.24387C73.5344 3.80304 73.5679 3.05462 73.1298 2.57222C72.6917 2.08983 71.948 2.05614 71.4686 2.49697C70.9892 2.9378 70.9557 3.68622 71.3938 4.16862C71.8319 4.65101 72.5756 4.6847 73.055 4.24387Z"
fill="#EAEEF9" />
<path
d="M100.051 100.588H9.37915C6.08199 100.588 3.53418 98.0402 3.53418 94.8929V30.1486C3.53418 27.0013 6.08199 24.4535 9.37915 24.4535H100.051C103.348 24.4535 105.896 27.0013 105.896 30.1486V94.8929C105.746 98.0402 103.198 100.588 100.051 100.588Z"
fill="white" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" />
<path
d="M55.0375 24.4535H100.448C103.746 24.4535 106.293 27.0013 106.293 30.1486V94.8929C106.293 98.0402 103.746 100.588 100.448 100.588H55.0375V24.4535Z"
fill="#F1F3F9" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" />
<path d="M3.53418 40.4896H105.746" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" />
<path
d="M12.6762 35.0942C14.0833 35.0942 15.224 33.9536 15.224 32.5464C15.224 31.1393 14.0833 29.9986 12.6762 29.9986C11.2691 29.9986 10.1284 31.1393 10.1284 32.5464C10.1284 33.9536 11.2691 35.0942 12.6762 35.0942Z"
fill="#AAB2C5" />
<path
d="M20.1699 35.0942C21.577 35.0942 22.7177 33.9536 22.7177 32.5464C22.7177 31.1393 21.577 29.9986 20.1699 29.9986C18.7628 29.9986 17.6221 31.1393 17.6221 32.5464C17.6221 33.9536 18.7628 35.0942 20.1699 35.0942Z"
fill="#AAB2C5" />
<path
d="M27.8129 35.0942C29.2201 35.0942 30.3608 33.9536 30.3608 32.5464C30.3608 31.1393 29.2201 29.9986 27.8129 29.9986C26.4058 29.9986 25.2651 31.1393 25.2651 32.5464C25.2651 33.9536 26.4058 35.0942 27.8129 35.0942Z"
fill="#AAB2C5" />
<path
d="M26.9141 49.1821H12.5265C11.6273 49.1821 10.8779 48.4328 10.8779 47.8333C10.8779 47.0839 11.6273 46.4844 12.5265 46.4844H26.9141C27.8134 46.4844 28.5627 47.2338 28.5627 47.8333C28.5627 48.5826 27.8134 49.1821 26.9141 49.1821Z"
fill="#AAB2C5" />
<path
d="M23.4671 72.4122H12.6764C11.7772 72.4122 11.0278 71.6628 11.0278 71.0633C11.0278 70.314 11.7772 69.7145 12.6764 69.7145H23.4671C24.3664 69.7145 25.1157 70.4638 25.1157 71.0633C24.9658 71.6628 24.2165 72.4122 23.4671 72.4122Z"
fill="#AAB2C5" />
<path
d="M36.6555 80.0557H18.3712C17.472 80.0557 16.7227 79.3064 16.7227 78.7069C16.7227 77.9575 17.472 77.358 18.3712 77.358H36.6555C37.5547 77.358 38.3041 78.1074 38.3041 78.7069C38.1542 79.4562 37.4049 80.0557 36.6555 80.0557Z"
fill="#D6DCE8" />
<path
d="M54.64 77.208V80.0556H43.6994C42.8001 80.0556 42.0508 79.3062 42.0508 78.7067C42.0508 78.2571 42.2007 77.9574 42.6503 77.6576C42.95 77.5077 43.2498 77.208 43.8492 77.208H54.64Z"
fill="#D6DCE8" />
<path
d="M33.6583 87.5493H12.5265C11.6273 87.5493 10.8779 86.7999 10.8779 86.2004C10.8779 85.4511 11.6273 84.8516 12.5265 84.8516H33.6583C34.5576 84.8516 35.3069 85.601 35.3069 86.2004C35.3069 86.9498 34.7074 87.5493 33.6583 87.5493Z"
fill="#AAB2C5" />
<path
d="M21.9685 95.3425H12.9762C12.077 95.3425 11.3276 94.5932 11.3276 93.9937C11.3276 93.2443 12.077 92.6448 12.9762 92.6448H21.9685C22.8677 92.6448 23.6171 93.3942 23.6171 93.9937C23.6171 94.743 23.0176 95.3425 21.9685 95.3425Z"
fill="#D6DCE8" />
<path
d="M29.6116 95.3425H27.6632C26.764 95.3425 26.0146 94.5932 26.0146 93.9937C26.0146 93.2443 26.764 92.6448 27.6632 92.6448H29.6116C30.5108 92.6448 31.2601 93.3942 31.2601 93.9937C31.2601 94.743 30.5108 95.3425 29.6116 95.3425Z"
fill="#D6DCE8" />
<path
d="M16.8728 64.9186H12.5265C11.6273 64.9186 10.8779 64.1692 10.8779 63.5698C10.8779 62.8204 11.6273 62.2209 12.5265 62.2209H16.8728C17.772 62.2209 18.5214 62.9703 18.5214 63.5698C18.5214 64.3191 17.772 64.9186 16.8728 64.9186Z"
fill="#D6DCE8" />
<path
d="M33.6583 56.9754H12.5265C11.6273 56.9754 10.8779 56.226 10.8779 55.6265C10.8779 54.8772 11.6273 54.2777 12.5265 54.2777H33.6583C34.5576 54.2777 35.3069 55.027 35.3069 55.6265C35.3069 56.3759 34.7074 56.9754 33.6583 56.9754Z"
fill="#D6DCE8" />
<path
d="M54.6403 54.128V56.9756H40.7023C39.8031 56.9756 39.0537 56.2262 39.0537 55.6267C39.0537 55.1771 39.2036 54.8774 39.6532 54.5776C39.8031 54.2779 40.2527 54.128 40.8522 54.128H54.6403Z"
fill="#AAB2C5" />
<path d="M54.6396 24.4535V100.588" stroke="#D6DCE8" stroke-width="2" stroke-miterlimit="10" />
<path
d="M99.7514 66.5673C95.4051 75.7094 85.9632 82.004 74.8728 82.004C63.9322 82.004 54.4903 75.7094 49.9941 66.5673C54.3404 57.4252 63.7823 51.1306 74.8728 51.1306C85.9632 51.1306 95.4051 57.4252 99.7514 66.5673Z"
fill="white" stroke="#C5CCDA" stroke-width="2" stroke-miterlimit="10" />
<path
d="M83.2655 66.5674C83.2655 71.2134 79.3689 74.8103 74.8728 74.8103C70.3766 74.8103 66.48 71.2134 66.48 66.7172V66.5674C66.48 65.6681 66.6299 64.7689 66.9296 64.0195C67.0795 63.5699 67.2293 63.1203 67.5291 62.6707C68.728 60.7224 70.3766 59.2237 72.6247 58.6242C73.2242 58.4743 73.8237 58.4743 74.2733 58.3244C74.4232 58.3244 74.7229 58.3244 74.8728 58.3244C79.5188 58.1746 83.2655 61.9214 83.2655 66.5674Z"
fill="#D6DCE8" stroke="#AAB2C5" stroke-width="2" stroke-miterlimit="10" />
<path
d="M76.8208 62.221C76.8208 64.6189 74.7226 66.7171 72.1748 66.7171C69.7769 66.7171 67.8286 64.9187 67.5288 62.5207C68.7278 60.5724 70.3764 59.0737 72.6244 58.4742C73.2239 58.3243 73.8234 58.3243 74.273 58.1745C75.7717 58.9238 76.8208 60.4225 76.8208 62.221Z"
fill="white" stroke="white" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

28
public/View_Code_Dark.svg

@ -1,28 +0,0 @@
<svg width="119" height="117" viewBox="0 0 119 117" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.1022 115.078C83.4297 115.078 107.204 91.1539 107.204 61.642C107.204 32.1302 83.4297 8.20605 54.1022 8.20605C24.7747 8.20605 1 32.1302 1 61.642C1 91.1539 24.7747 115.078 54.1022 115.078Z" fill="#1F2229" stroke="#484E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M115.883 47.5493C117.538 46.0271 117.654 43.4428 116.141 41.7771C114.628 40.1114 112.06 39.9951 110.405 41.5173C108.75 43.0395 108.634 45.6238 110.147 47.2895C111.659 48.9552 114.228 49.0715 115.883 47.5493Z" fill="#3B414E"/>
<path d="M23.4054 12.8114C24.8084 12.3525 25.576 10.8361 25.12 9.42433C24.664 8.01257 23.1571 7.24009 21.7541 7.69895C20.3512 8.15781 19.5835 9.67424 20.0395 11.086C20.4955 12.4978 22.0025 13.2702 23.4054 12.8114Z" fill="#3B414E"/>
<path d="M29.236 4.04137C30.19 3.72934 30.712 2.69817 30.4019 1.73817C30.0918 0.778174 29.0671 0.252888 28.1131 0.564913C27.1591 0.876937 26.6371 1.90811 26.9472 2.86811C27.2573 3.82811 28.282 4.35339 29.236 4.04137Z" fill="#3B414E"/>
<path d="M116.183 29.3847C116.88 28.7438 116.928 27.6556 116.291 26.9543C115.655 26.2529 114.573 26.204 113.876 26.8449C113.179 27.4858 113.131 28.5739 113.768 29.2753C114.404 29.9766 115.486 30.0256 116.183 29.3847Z" fill="#3B414E"/>
<path d="M73.055 4.24411C73.5344 3.80328 73.5679 3.05486 73.1298 2.57247C72.6917 2.09007 71.948 2.05638 71.4686 2.49721C70.9892 2.93805 70.9557 3.68647 71.3938 4.16886C71.8319 4.65125 72.5756 4.68495 73.055 4.24411Z" fill="#3B414E"/>
<path d="M100.051 100.588H9.37915C6.08199 100.588 3.53418 98.0403 3.53418 94.893V30.1487C3.53418 27.0014 6.08199 24.4536 9.37915 24.4536H100.051C103.348 24.4536 105.896 27.0014 105.896 30.1487V94.893C105.746 98.0403 103.198 100.588 100.051 100.588Z" fill="#2E333D" stroke="#484E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M55.0375 24.4538H100.448C103.746 24.4538 106.293 27.0017 106.293 30.149V94.8933C106.293 98.0406 103.746 100.588 100.448 100.588H55.0375V24.4538Z" fill="#1F2229" stroke="#484E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M3.53418 40.4897H105.746" stroke="#484E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M12.6762 35.0942C14.0833 35.0942 15.224 33.9535 15.224 32.5463C15.224 31.1392 14.0833 29.9985 12.6762 29.9985C11.2691 29.9985 10.1284 31.1392 10.1284 32.5463C10.1284 33.9535 11.2691 35.0942 12.6762 35.0942Z" fill="#3E4450"/>
<path d="M20.1699 35.0942C21.577 35.0942 22.7177 33.9535 22.7177 32.5463C22.7177 31.1392 21.577 29.9985 20.1699 29.9985C18.7628 29.9985 17.6221 31.1392 17.6221 32.5463C17.6221 33.9535 18.7628 35.0942 20.1699 35.0942Z" fill="#3E4450"/>
<path d="M27.8129 35.0942C29.2201 35.0942 30.3608 33.9535 30.3608 32.5463C30.3608 31.1392 29.2201 29.9985 27.8129 29.9985C26.4058 29.9985 25.2651 31.1392 25.2651 32.5463C25.2651 33.9535 26.4058 35.0942 27.8129 35.0942Z" fill="#3E4450"/>
<path d="M26.9141 49.1821H12.5265C11.6273 49.1821 10.8779 48.4327 10.8779 47.8332C10.8779 47.0839 11.6273 46.4844 12.5265 46.4844H26.9141C27.8134 46.4844 28.5627 47.2337 28.5627 47.8332C28.5627 48.5826 27.8134 49.1821 26.9141 49.1821Z" fill="#3E4450"/>
<path d="M23.4671 72.4125H12.6764C11.7772 72.4125 11.0278 71.6632 11.0278 71.0637C11.0278 70.3143 11.7772 69.7148 12.6764 69.7148H23.4671C24.3664 69.7148 25.1157 70.4642 25.1157 71.0637C24.9658 71.6632 24.2165 72.4125 23.4671 72.4125Z" fill="#3E4450"/>
<path d="M36.6555 80.0561H18.3712C17.472 80.0561 16.7227 79.3067 16.7227 78.7072C16.7227 77.9579 17.472 77.3584 18.3712 77.3584H36.6555C37.5547 77.3584 38.3041 78.1078 38.3041 78.7072C38.1542 79.4566 37.4049 80.0561 36.6555 80.0561Z" fill="#484E59"/>
<path d="M54.64 77.208V80.0556H43.6994C42.8001 80.0556 42.0508 79.3062 42.0508 78.7067C42.0508 78.2571 42.2007 77.9574 42.6503 77.6576C42.95 77.5077 43.2498 77.208 43.8492 77.208H54.64Z" fill="#484E59"/>
<path d="M33.6583 87.5492H12.5265C11.6273 87.5492 10.8779 86.7999 10.8779 86.2004C10.8779 85.451 11.6273 84.8516 12.5265 84.8516H33.6583C34.5576 84.8516 35.3069 85.6009 35.3069 86.2004C35.3069 86.9498 34.7074 87.5492 33.6583 87.5492Z" fill="#3E4450"/>
<path d="M21.9685 95.3427H12.9762C12.077 95.3427 11.3276 94.5933 11.3276 93.9939C11.3276 93.2445 12.077 92.645 12.9762 92.645H21.9685C22.8677 92.645 23.6171 93.3944 23.6171 93.9939C23.6171 94.7432 23.0176 95.3427 21.9685 95.3427Z" fill="#484E59"/>
<path d="M29.6116 95.3427H27.6632C26.764 95.3427 26.0146 94.5933 26.0146 93.9939C26.0146 93.2445 26.764 92.645 27.6632 92.645H29.6116C30.5108 92.645 31.2601 93.3944 31.2601 93.9939C31.2601 94.7432 30.5108 95.3427 29.6116 95.3427Z" fill="#484E59"/>
<path d="M16.8728 64.9189H12.5265C11.6273 64.9189 10.8779 64.1695 10.8779 63.57C10.8779 62.8207 11.6273 62.2212 12.5265 62.2212H16.8728C17.772 62.2212 18.5214 62.9705 18.5214 63.57C18.5214 64.3194 17.772 64.9189 16.8728 64.9189Z" fill="#484E59"/>
<path d="M33.6583 56.9755H12.5265C11.6273 56.9755 10.8779 56.2262 10.8779 55.6267C10.8779 54.8773 11.6273 54.2778 12.5265 54.2778H33.6583C34.5576 54.2778 35.3069 55.0272 35.3069 55.6267C35.3069 56.376 34.7074 56.9755 33.6583 56.9755Z" fill="#484E59"/>
<path d="M54.6403 54.1279V56.9755H40.7023C39.8031 56.9755 39.0537 56.2261 39.0537 55.6266C39.0537 55.177 39.2036 54.8773 39.6532 54.5775C39.8031 54.2778 40.2527 54.1279 40.8522 54.1279H54.6403Z" fill="#3E4450"/>
<path d="M54.6396 24.4536V100.588" stroke="#484E59" stroke-width="2" stroke-miterlimit="10"/>
<path d="M99.7514 66.5676C95.4051 75.7097 85.9632 82.0043 74.8728 82.0043C63.9322 82.0043 54.4903 75.7097 49.9941 66.5676C54.3404 57.4254 63.7823 51.1309 74.8728 51.1309C85.9632 51.1309 95.4051 57.4254 99.7514 66.5676Z" fill="#6D7381" stroke="#949BAB" stroke-width="2" stroke-miterlimit="10" stroke-linejoin="round"/>
<path d="M83.2655 66.5676C83.2655 71.2136 79.3689 74.8105 74.8728 74.8105C70.3766 74.8105 66.48 71.2136 66.48 66.7174V66.5676C66.48 65.6683 66.6299 64.7691 66.9296 64.0198C67.0795 63.5701 67.2293 63.1205 67.5291 62.6709C68.728 60.7226 70.3766 59.2239 72.6247 58.6244C73.1573 58.4912 73.69 58.4764 74.1175 58.3696C74.2218 58.3436 74.328 58.3247 74.4355 58.3247C74.5855 58.3247 74.7667 58.3247 74.8728 58.3247C79.5188 58.1748 83.2655 61.9216 83.2655 66.5676Z" fill="#C7CDDB"/>
<path d="M76.8208 62.2213C76.8208 64.6193 74.7226 66.7175 72.1748 66.7175C69.7769 66.7175 67.8286 64.919 67.5288 62.5211C68.7278 60.5727 70.3764 59.074 72.6244 58.4745C73.2239 58.3247 73.8234 58.3247 74.273 58.1748C75.7717 58.9242 76.8208 60.4229 76.8208 62.2213Z" fill="#6D7381"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

17
public/safari-tab.svg

@ -1,17 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1090 2655 c-399 -585 -725 -1068 -724 -1072 0 -4 58 -47 129 -95
l128 -88 27 38 c60 84 1422 2085 1426 2096 3 9 -244 187 -258 186 -2 0 -329
-480 -728 -1065z"/>
<path d="M3211 3669 c-18 -4 -45 -16 -60 -26 -30 -23 -1420 -2054 -1414 -2066
4 -6 236 -166 254 -175 4 -1 287 409 630 912 343 503 624 914 625 913 1 -1
282 -411 624 -912 343 -500 624 -911 625 -912 1 -2 61 38 133 88 111 76 131
93 122 107 -43 78 -1391 2029 -1411 2044 -33 25 -85 36 -128 27z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 893 B

35
src/App.tsx

@ -1,30 +1,17 @@
import type React from 'react';
import type React from "react";
import { Connection } from '@components/Connection';
import { BottomNav } from '@components/menu/BottomNav';
import { useRoute } from '@core/router';
import { useAppSelector } from '@hooks/useAppSelector';
import { Extensions } from '@pages/Extensions/Index';
import { MapPage } from '@pages/map';
import { Messages } from '@pages/Messages';
import { NotFound } from '@pages/NotFound';
import { Pane } from "evergreen-ui";
export const App = (): JSX.Element => {
const route = useRoute();
const appState = useAppSelector((state) => state.app);
import { AppLayout } from "@components/layout/AppLayout.js";
import { PageRouter } from "./PageRouter.js";
export const App = (): JSX.Element => {
return (
<div className={`h-screen w-screen ${appState.darkMode ? 'dark' : ''}`}>
<Connection />
<div className="flex h-full flex-col">
<div className="flex min-h-0 w-full flex-grow">
{route.name === 'messages' && <Messages />}
{route.name === 'map' && <MapPage />}
{route.name === 'extensions' && <Extensions />}
{route.name === false && <NotFound />}
</div>
<BottomNav />
</div>
</div>
<Pane display="flex">
<AppLayout>
<PageRouter />
</AppLayout>
</Pane>
);
};

84
src/DeviceWrapper.tsx

@ -0,0 +1,84 @@
import type React from "react";
import { Device, DeviceContext } from "./core/stores/deviceStore.js";
export interface DeviceProps {
children: React.ReactNode;
device: Device;
}
// const cleanupListeners = (connection: IConnection): void => {
// connection.onMeshPacket.cancelAll();
// connection.onDeviceStatus.cancelAll();
// connection.onMyNodeInfo.cancelAll();
// connection.onUserPacket.cancelAll();
// connection.onPositionPacket.cancelAll();
// connection.onNodeInfoPacket.cancelAll();
// connection.onAdminPacket.cancelAll();
// connection.onMeshHeartbeat.cancelAll();
// connection.onTextPacket.cancelAll();
// };
export const DeviceWrapper = ({
children,
device,
}: DeviceProps): JSX.Element => {
// const fetchConfig = useCallback(async (): Promise<void> => {
// /**
// * Get Config
// */
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.DEVICE_CONFIG
// );
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.WIFI_CONFIG
// );
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.POSITION_CONFIG
// );
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.DISPLAY_CONFIG
// );
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.LORA_CONFIG
// );
// await device.connection?.getConfig(
// Protobuf.AdminMessage_ConfigType.POWER_CONFIG
// );
// /**
// * Get Module Config
// */
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.MQTT_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.SERIAL_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.EXTNOTIF_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.STOREFORWARD_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.RANGETEST_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.TELEMETRY_CONFIG
// );
// await device.connection?.getModuleConfig(
// Protobuf.AdminMessage_ModuleConfigType.CANNEDMSG_CONFIG
// );
// }, [device.connection]);
// useEffect(() => {
// if (device.ready) {
// void fetchConfig();
// }
// }, [device.ready, fetchConfig]);
return (
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
);
};

22
src/PageRouter.tsx

@ -0,0 +1,22 @@
import type React from "react";
import { useDevice } from "./core/stores/deviceStore.js";
import { ChannelsPage } from "./pages/Channels/index.js";
import { ConfigPage } from "./pages/Config/index.js";
import { ExtensionsPage } from "./pages/Extensions/Index.js";
import { InfoPage } from "./pages/Info/index.js";
import { MessagesPage } from "./pages/Messages/index.js";
export const PageRouter = (): JSX.Element => {
const { activePage } = useDevice();
return (
<>
{activePage === "messages" && <MessagesPage />}
{/* {activePage === "map" && <MapPage />} */}
{activePage === "extensions" && <ExtensionsPage />}
{activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />}
{activePage === "info" && <InfoPage />}
</>
);
};

150
src/components/Connection.tsx

@ -1,150 +0,0 @@
import type React from 'react';
import { useEffect } from 'react';
import { m } from 'framer-motion';
import { BLE } from '@components/connection/BLE';
import { HTTP } from '@components/connection/HTTP';
import { Serial } from '@components/connection/Serial';
import { Select } from '@components/generic/form/Select';
import { Modal } from '@components/generic/Modal';
import { connectionUrl, setConnection } from '@core/connection';
import {
closeConnectionModal,
connType,
setConnectionParams,
setConnType,
} from '@core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Types } from '@meshtastic/meshtasticjs';
export const Connection = (): JSX.Element => {
const dispatch = useAppDispatch();
const meshtasticState = useAppSelector((state) => state.meshtastic);
const appState = useAppSelector((state) => state.app);
const chromiunm = !!window.chrome;
useEffect(() => {
if (!import.meta.env.VITE_PUBLIC_HOSTED) {
dispatch(
setConnectionParams({
type: connType.HTTP,
params: {
address: connectionUrl,
tls: false,
receiveBatchRequests: false,
fetchInterval: 2000,
},
}),
);
void setConnection(connType.HTTP);
}
}, [dispatch]);
useEffect(() => {
if (meshtasticState.ready) {
dispatch(closeConnectionModal());
}
}, [meshtasticState.ready, dispatch]);
return (
<Modal
title="Connect to a device"
open={appState.connectionModalOpen}
onClose={(): void => {
dispatch(closeConnectionModal());
}}
>
<div className="flex max-w-3xl flex-col gap-4 md:flex-row">
<div className="flex flex-col md:w-1/2">
<div className="flex flex-grow flex-col space-y-2">
<Select
label="Connection Method"
optionsEnum={connType}
value={appState.connType}
onChange={(e): void => {
dispatch(setConnType(parseInt(e.target.value)));
}}
disabled={
meshtasticState.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED
}
/>
{appState.connType === connType.HTTP && (
<HTTP
connecting={
meshtasticState.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED
}
/>
)}
{appState.connType === connType.BLE &&
(chromiunm ? (
<BLE
connecting={
meshtasticState.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED
}
/>
) : (
<div className="rounded-md border border-red-500 bg-red-500 bg-opacity-10 p-8 dark:text-white">
<p>Unsupported.</p>
<p>Please use a Chromium based browser.</p>
</div>
))}
{appState.connType === connType.SERIAL &&
(chromiunm ? (
<Serial
connecting={
meshtasticState.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED
}
/>
) : (
<div className="rounded-md border border-red-500 bg-red-500 bg-opacity-10 p-8 dark:text-white">
<p>Unsupported.</p>
<p>Please use a Chromium based browser.</p>
</div>
))}
</div>
</div>
<div className="md:w-1/2">
<div className="h-96 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 drop-shadow-md dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400">
{meshtasticState.logs.length === 0 && (
<div className="flex h-full w-full">
<m.img
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="m-auto h-40 w-40 text-green-500"
src={
appState.darkMode ? '/View_Code_Dark.svg' : '/View_Code.svg'
}
/>
</div>
)}
{meshtasticState.logs
.filter((log) => {
return ![
Types.Emitter.handleFromRadio,
Types.Emitter.handleMeshPacket,
Types.Emitter.sendPacket,
].includes(log.emitter);
})
.map((log, index) => (
<div key={index} className="flex">
<div className="truncate font-mono text-sm">
{log.message}
</div>
</div>
))}
</div>
</div>
</div>
</Modal>
);
};

141
src/components/Dialog/PeersDialog.tsx

@ -0,0 +1,141 @@
import type React from "react";
import {
Dialog,
HelperManagementIcon,
IconButton,
majorScale,
MoreIcon,
Table,
TagIcon,
Tooltip,
} from "evergreen-ui";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { toMGRS } from "@app/core/utils/toMGRS.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Protobuf } from "@meshtastic/meshtasticjs";
export interface PeersDialogProps {
isOpen: boolean;
close: () => void;
}
export const PeersDialog = ({
isOpen,
close,
}: PeersDialogProps): JSX.Element => {
const { hardware, nodes, connection } = useDevice();
return (
<Dialog
isShown={isOpen}
title="Peers"
onCloseComplete={close}
hasFooter={false}
width={majorScale(120)}
>
<Table>
<Table.Head>
<Table.HeaderCell flexBasis={48} flexShrink={0} flexGrow={0} />
<Table.TextHeaderCell flexBasis={96} flexShrink={0} flexGrow={0}>
Number
</Table.TextHeaderCell>
<Table.TextHeaderCell flexBasis={116} flexShrink={0} flexGrow={0}>
Name
</Table.TextHeaderCell>
<Table.TextHeaderCell flexBasis={48} flexShrink={0} flexGrow={0}>
SNR
</Table.TextHeaderCell>
<Table.TextHeaderCell>Location</Table.TextHeaderCell>
<Table.TextHeaderCell>Last Heard</Table.TextHeaderCell>
<Table.TextHeaderCell>Actions</Table.TextHeaderCell>
</Table.Head>
<Table.Body height={240}>
{nodes
.filter((n) => n.data.num !== hardware.myNodeNum)
.map((node) => (
<Table.Row
key={node.data.num}
isSelectable
onSelect={() => alert(node.data.num)}
>
<Table.Cell flexBasis={48} flexShrink={0} flexGrow={0}>
<Hashicon
value={node.data.num.toString()}
size={majorScale(3)}
/>
</Table.Cell>
<Table.TextCell flexBasis={96} flexShrink={0} flexGrow={0}>
{node.data.num}
</Table.TextCell>
<Table.TextCell flexBasis={116} flexShrink={0} flexGrow={0}>
{node.data.user?.longName}
</Table.TextCell>
<Table.TextCell flexBasis={48} flexShrink={0} flexGrow={0}>
{node.data.snr}
</Table.TextCell>
<Table.TextCell>
{toMGRS(
node.data.position?.latitudeI,
node.data.position?.longitudeI
)}
</Table.TextCell>
<Table.TextCell>
{new Date(node.data.lastHeard * 1000).toLocaleString()}
</Table.TextCell>
<Table.Cell gap={majorScale(1)}>
<Tooltip content="Manage">
<IconButton icon={HelperManagementIcon} />
</Tooltip>
<IconButton
icon={TagIcon}
onClick={() => {
void connection?.sendPacket(
Protobuf.AdminMessage.toBinary({
variant: {
oneofKind: "getConfigRequest",
getConfigRequest:
Protobuf.AdminMessage_ConfigType.LORA_CONFIG,
},
}),
Protobuf.PortNum.ADMIN_APP,
node.data.num,
true,
7,
true,
false,
async (test) => {
console.log(test);
console.log("got response");
return Promise.resolve();
}
);
}}
/>
<IconButton icon={MoreIcon} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
{/* <Pane
key={node.data.num}
display="flex"
borderRadius={majorScale(1)}
elevation={1}
gap={majorScale(1)}
padding={majorScale(1)}
>
<Heading>{node.data.user?.longName}</Heading>
{node.metrics.airUtilTx}
{node.metrics.}
{node.metrics.channelUtilization}
{node.metrics.}
{node.data.}
</Pane> */}
</Dialog>
);
};

112
src/components/Dialog/QRDialog.tsx

@ -0,0 +1,112 @@
import type React from "react";
import { useEffect, useState } from "react";
import { fromByteArray } from "base64-js";
import {
Checkbox,
ClipboardIcon,
Dialog,
FormField,
IconButton,
majorScale,
Pane,
TextInputField,
Tooltip,
} from "evergreen-ui";
import { QRCode } from "react-qrcode-logo";
import { Protobuf } from "@meshtastic/meshtasticjs";
export interface QRDialogProps {
isOpen: boolean;
close: () => void;
loraConfig?: Protobuf.Config_LoRaConfig;
channels: Protobuf.Channel[];
}
export const QRDialog = ({
isOpen,
close,
loraConfig,
channels,
}: QRDialogProps): JSX.Element => {
const [selectedChannels, setSelectedChannels] = useState<number[]>([]);
const [QRCodeURL, setQRCodeURL] = useState<string>("");
useEffect(() => {
const channelsToEncode = channels
.filter((channel) => selectedChannels.includes(channel.index))
.map((channel) => channel.settings)
.filter((ch): ch is Protobuf.ChannelSettings => !!ch);
const encoded = Protobuf.ChannelSet.toBinary(
Protobuf.ChannelSet.create({
loraConfig,
settings: channelsToEncode,
})
);
const base64 = fromByteArray(encoded);
setQRCodeURL(`https://www.meshtastic.org/e/#${base64}`);
}, [channels, selectedChannels, loraConfig]);
return (
<Dialog
isShown={isOpen}
title="Generate QR Code"
onCloseComplete={close}
hasFooter={false}
>
<Pane display="flex">
<FormField
width="12rem"
label="Channels to include"
description="The current LoRa configuration will also be shared."
>
{channels.map((channel) => (
<Checkbox
key={channel.index}
disabled={channel.role === Protobuf.Channel_Role.DISABLED}
label={
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`
}
checked={selectedChannels.includes(channel.index)}
onChange={() => {
if (selectedChannels.includes(channel.index)) {
setSelectedChannels(
selectedChannels.filter((c) => c !== channel.index)
);
} else {
setSelectedChannels([...selectedChannels, channel.index]);
}
}}
/>
))}
</FormField>
<Pane
display="flex"
flexDirection="column"
flexGrow={1}
margin={majorScale(1)}
>
<Pane display="flex" margin="auto">
<QRCode value={QRCodeURL} qrStyle="dots" />
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<TextInputField
label="Sharable URL"
value={QRCodeURL}
width="100%"
/>
<Tooltip content="Copy to Clipboard">
<IconButton icon={ClipboardIcon} marginTop="1.6rem" />
</Tooltip>
</Pane>
</Pane>
</Pane>
</Dialog>
);
};

16
src/components/ErrorFallback.tsx

@ -1,16 +0,0 @@
import type React from 'react';
import type { FallbackProps } from 'react-error-boundary';
export const ErrorFallback = ({
error,
resetErrorBoundary,
}: FallbackProps): JSX.Element => {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
};

137
src/components/MapBox/MapboxProvider.tsx

@ -1,137 +0,0 @@
import type React from 'react';
import { useEffect, useRef } from 'react';
import { ScaleControl } from 'mapbox-gl';
import { MapboxContext } from '@components/MapBox/mapboxContext';
import { MapStyles } from '@core/mapStyles';
import {
setBearing,
setLatLng,
setMapStyle,
setPitch,
setZoom,
} from '@core/slices/mapSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { useCreateMapbox } from '@hooks/useCreateMapbox';
export type MapboxProviderProps = {
children: React.ReactNode;
};
export const MapboxProvider = ({
children,
}: MapboxProviderProps): JSX.Element => {
const darkMode = useAppSelector((state) => state.app.darkMode);
const mapState = useAppSelector((state) => state.map);
const dispatch = useAppDispatch();
const ref = useRef<HTMLDivElement>(null);
const map = useCreateMapbox({
ref,
accessToken:
'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ',
options: {
center: mapState.latLng,
zoom: mapState.zoom,
bearing: mapState.bearing,
pitch: mapState.pitch,
style: MapStyles[mapState.style].data,
},
});
useEffect(() => {
map?.on('load', () => {
map.addControl(new ScaleControl());
});
map?.on('styledata', () => {
if (!map.getSource('mapbox-dem')) {
map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
});
}
map.setTerrain({
source: 'mapbox-dem',
exaggeration: mapState.exaggeration ? 1.5 : 0,
});
});
map?.on('dragend', (e) => {
dispatch(setLatLng(e.target.getCenter()));
});
map?.on('zoomend', (e) => {
dispatch(setZoom(e.target.getZoom()));
});
map?.on('rotate', (e) => {
dispatch(setBearing(e.target.getBearing()));
});
map?.on('pitch', (e) => {
dispatch(setPitch(e.target.getPitch()));
});
}, [dispatch, map, mapState.exaggeration]);
useEffect(() => {
const center = map?.getCenter();
if (center !== mapState.latLng) {
map?.setCenter(mapState.latLng);
}
}, [map, mapState.latLng]);
useEffect(() => {
if (['Light', 'Dark'].includes(mapState.style)) {
dispatch(setMapStyle(darkMode ? 'Dark' : 'Light'));
}
}, [dispatch, darkMode, mapState.style]);
/**
* Hill Shading
*/
useEffect(() => {
if (map?.loaded()) {
if (mapState.hillShade) {
map.addLayer(
{
id: 'hillshading',
source: 'mapbox-dem',
type: 'hillshade',
// insert below waterway-river-canal-shadow;
// where hillshading sits in the Mapbox Outdoors style
},
'waterway-river-canal-shadow',
);
} else {
map.removeLayer('hillshading');
}
}
}, [map, mapState.hillShade]);
/**
* Exaggeration
*/
useEffect(() => {
if (map?.loaded()) {
map.setTerrain({
source: 'mapbox-dem',
exaggeration: mapState.exaggeration ? 1.5 : 0,
});
}
}, [map, mapState.exaggeration]);
/**
* Map Style
*/
useEffect(() => {
if (map?.loaded()) {
map.setStyle(MapStyles[mapState.style].data);
}
}, [map, mapState.style]);
return (
<MapboxContext.Provider value={{ map, ref }}>
{children}
</MapboxContext.Provider>
);
};

13
src/components/MapBox/mapboxContext.ts

@ -1,13 +0,0 @@
import type React from 'react';
import { createContext } from 'react';
import type { Map } from 'mapbox-gl';
export interface MapboxContextValue {
ref: React.Ref<HTMLDivElement>;
map?: Map;
}
export const MapboxContext = createContext<MapboxContextValue>(
{} as MapboxContextValue,
);

117
src/components/PageComponents/Config/Device.tsx

@ -0,0 +1,117 @@
import type React from "react";
import { useEffect, useState } from "react";
import {
FormField,
SelectField,
Switch,
TextInputField,
toaster,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { DeviceValidation } from "@app/validation/config/device.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Device = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset,
} = useForm<DeviceValidation>({
defaultValues: config.device,
resolver: classValidatorResolver(DeviceValidation),
});
useEffect(() => {
reset(config.device);
}, [reset, config.device]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "device",
device: data,
},
},
async () => {
toaster.success("Successfully updated device config");
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<SelectField
label="Role"
description="This is a description."
isInvalid={!!errors.role?.message}
validationMessage={errors.role?.message}
{...register("role", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_DeviceConfig_Role)}
</SelectField>
<FormField
label="Serial Console Disabled"
description="Description"
isInvalid={!!errors.serialDisabled?.message}
validationMessage={errors.serialDisabled?.message}
>
<Controller
name="serialDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Factory Reset Device"
description="Description"
isInvalid={!!errors.factoryReset?.message}
validationMessage={errors.factoryReset?.message}
>
<Controller
name="factoryReset"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Enabled Debug Log"
description="Description"
isInvalid={!!errors.debugLogEnabled?.message}
validationMessage={errors.debugLogEnabled?.message}
>
<Controller
name="debugLogEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="NTP Server"
description="This is a description."
isInvalid={!!errors.ntpServer?.message}
validationMessage={errors.ntpServer?.message}
{...register("ntpServer")}
/>
</Form>
);
};

72
src/components/PageComponents/Config/Display.tsx

@ -0,0 +1,72 @@
import type React from "react";
import { useEffect, useState } from "react";
import { SelectField, TextInputField } from "evergreen-ui";
import { useForm } from "react-hook-form";
import { DisplayValidation } from "@app/validation/config/display.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Display = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm<Protobuf.Config_DisplayConfig>({
defaultValues: config.display,
resolver: classValidatorResolver(DisplayValidation),
});
useEffect(() => {
reset(config.display);
}, [reset, config.display]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "display",
display: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
label="Screen Timeout"
description="This is a description."
hint="Seconds"
type="number"
{...register("screenOnSecs", { valueAsNumber: true })}
/>
<TextInputField
label="Carousel Delay"
description="This is a description."
hint="Seconds"
type="number"
{...register("autoScreenCarouselSecs", { valueAsNumber: true })}
/>
<SelectField
label="GPS Display Units"
description="This is a description."
{...register("gpsFormat", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_DisplayConfig_GpsCoordinateFormat)}
</SelectField>
</Form>
);
};

163
src/components/PageComponents/Config/LoRa.tsx

@ -0,0 +1,163 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { LoRaValidation } from "@app/validation/config/lora.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const LoRa = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const [usePreset, setUsePreset] = useState(true);
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset,
} = useForm<LoRaValidation>({
defaultValues: config.lora,
resolver: classValidatorResolver(LoRaValidation),
});
useEffect(() => {
reset(config.lora);
}, [reset, config.lora]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "lora",
lora: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Use Preset"
description="Description"
isInvalid={!!errors.txDisabled?.message}
validationMessage={errors.txDisabled?.message}
>
<Switch
height={24}
marginLeft="auto"
checked={usePreset}
onChange={(e) => setUsePreset(e.target.checked)}
/>
</FormField>
<SelectField
display={usePreset ? "block" : "none"}
label="Preset"
description="This is a description."
isInvalid={!!errors.modemPreset?.message}
validationMessage={errors.modemPreset?.message}
{...register("modemPreset", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
</SelectField>
<TextInputField
display={usePreset ? "none" : "block"}
label="Bandwidth"
description="Max transmit power in dBm"
type="number"
hint="MHz"
isInvalid={!!errors.bandwidth?.message}
validationMessage={errors.bandwidth?.message}
{...register("bandwidth", {
valueAsNumber: true,
})}
/>
<TextInputField
display={usePreset ? "none" : "block"}
label="Spread Factor"
description="Max transmit power in dBm"
type="number"
hint="CPS"
isInvalid={!!errors.spreadFactor?.message}
validationMessage={errors.spreadFactor?.message}
{...register("spreadFactor", {
valueAsNumber: true,
})}
/>
<TextInputField
display={usePreset ? "none" : "block"}
label="Coding Rate"
description="Max transmit power in dBm"
type="number"
isInvalid={!!errors.codingRate?.message}
validationMessage={errors.codingRate?.message}
{...register("codingRate", {
valueAsNumber: true,
})}
/>
<TextInputField
label="Transmit Power"
description="Max transmit power in dBm"
type="number"
isInvalid={!!errors.txPower?.message}
validationMessage={errors.txPower?.message}
{...register("txPower", { valueAsNumber: true })}
/>
<TextInputField
label="Hop Count"
description="This is a description."
hint="Hops"
type="number"
isInvalid={!!errors.hopLimit?.message}
validationMessage={errors.hopLimit?.message}
{...register("hopLimit", { valueAsNumber: true })}
/>
<FormField
label="Transmit Disabled"
description="Description"
isInvalid={!!errors.txDisabled?.message}
validationMessage={errors.txDisabled?.message}
>
<Controller
name="txDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="Frequency Offset"
description="This is a description."
hint="Hz"
type="number"
isInvalid={!!errors.frequencyOffset?.message}
validationMessage={errors.frequencyOffset?.message}
{...register("frequencyOffset", { valueAsNumber: true })}
/>
<SelectField
label="Region"
description="This is a description."
isInvalid={!!errors.region?.message}
validationMessage={errors.region?.message}
{...register("region", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
</SelectField>
</Form>
);
};

224
src/components/PageComponents/Config/Position.tsx

@ -0,0 +1,224 @@
import type React from "react";
import { useEffect, useState } from "react";
import {
Button,
FormField,
SelectMenu,
Switch,
TextInputField,
} from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { PositionValidation } from "@app/validation/config/position.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { bitwiseDecode } from "@core/utils/bitwise";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Position = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<PositionValidation>({
defaultValues: config.position,
resolver: classValidatorResolver(PositionValidation),
// defaultValues: {
// ...preferences,
// positionBroadcastSecs:
// preferences.positionBroadcastSecs === 0
// ? preferences.role === Protobuf.Role.Router
// ? 43200
// : 900
// : preferences.positionBroadcastSecs,
// },
});
useEffect(() => {
reset(config.position);
}, [reset, config.position]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "position",
position: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
hint="Seconds"
label="Broadcast Interval"
description="This is a description."
type="number"
isInvalid={!!errors.positionBroadcastSecs?.message}
validationMessage={errors.positionBroadcastSecs?.message}
{...register("positionBroadcastSecs", { valueAsNumber: true })}
/>
<FormField
label="Disable Smart Position"
description="Description"
isInvalid={!!errors.positionBroadcastSmartDisabled?.message}
validationMessage={errors.positionBroadcastSmartDisabled?.message}
>
<Controller
name="positionBroadcastSmartDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Use Fixed Position"
description="Description"
isInvalid={!!errors.fixedPosition?.message}
validationMessage={errors.fixedPosition?.message}
>
<Controller
name="fixedPosition"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Disable GPS"
description="Description"
isInvalid={!!errors.gpsDisabled?.message}
validationMessage={errors.gpsDisabled?.message}
>
<Controller
name="gpsDisabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
hint="Seconds"
label="GPS Update Interval"
description="This is a description."
type="number"
isInvalid={!!errors.gpsUpdateInterval?.message}
validationMessage={errors.gpsUpdateInterval?.message}
{...register("gpsUpdateInterval", { valueAsNumber: true })}
/>
<TextInputField
label="Last GPS Attempt"
description="This is a description."
type="number"
isInvalid={!!errors.gpsAttemptTime?.message}
validationMessage={errors.gpsAttemptTime?.message}
{...register("gpsAttemptTime", { valueAsNumber: true })}
/>
<Controller
name="positionFlags"
control={control}
render={({ field, fieldState }): JSX.Element => {
const { value, onChange, ...rest } = field;
const { error } = fieldState;
const options = Object.entries(
Protobuf.Config_PositionConfig_PositionFlags
)
.filter((value) => typeof value[1] !== "number")
.filter(
(value) =>
parseInt(value[0]) !==
Protobuf.Config_PositionConfig_PositionFlags.POS_UNDEFINED
)
.map((value) => {
return {
value: parseInt(value[0]),
label: value[1].toString().replace("POS_", "").toLowerCase(),
};
});
const selected = bitwiseDecode(
value,
Protobuf.Config_PositionConfig_PositionFlags
).map((flag) =>
Protobuf.Config_PositionConfig_PositionFlags[flag]
.replace("POS_", "")
.toLowerCase()
);
// onChange={(e: { value: number; label: string }[]): void =>
// onChange(bitwiseEncode(e.map((v) => v.value)))
// }
return (
<FormField
label="Position Flags"
description="Description"
isInvalid={!!errors.positionFlags?.message}
validationMessage={errors.positionFlags?.message}
>
<SelectMenu
isMultiSelect
title="Select multiple names"
options={options}
selected={selected}
// onSelect={(item) => {
// const selected = [...selectedItemsState, item.value]
// const selectedItems = selected
// const selectedItemsLength = selectedItems.length
// let selectedNames = ''
// if (selectedItemsLength === 0) {
// selectedNames = ''
// } else if (selectedItemsLength === 1) {
// selectedNames = selectedItems.toString()
// } else if (selectedItemsLength > 1) {
// selectedNames = selectedItemsLength.toString() + ' selected...'
// }
// setSelectedItems(selectedItems)
// setSelectedItemNames(selectedNames)
// }}
// onDeselect={(item) => {
// const deselectedItemIndex = selectedItemsState.indexOf(item.value)
// const selectedItems = selectedItemsState.filter((_item, i) => i !== deselectedItemIndex)
// const selectedItemsLength = selectedItems.length
// let selectedNames = ''
// if (selectedItemsLength === 0) {
// selectedNames = ''
// } else if (selectedItemsLength === 1) {
// selectedNames = selectedItems.toString()
// } else if (selectedItemsLength > 1) {
// selectedNames = selectedItemsLength.toString() + ' selected...'
// }
// setSelectedItems(selectedItems)
// setSelectedItemNames(selectedNames)
// }}
>
<Button>
{selected.map(
(item, index) =>
`${item}${index !== selected.length - 1 ? ", " : ""}`
)}
</Button>
</SelectMenu>
</FormField>
);
}}
/>
</Form>
);
};

137
src/components/PageComponents/Config/Power.tsx

@ -0,0 +1,137 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { PowerValidation } from "@app/validation/config/power.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Power = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<PowerValidation>({
defaultValues: config.power,
resolver: classValidatorResolver(PowerValidation),
});
useEffect(() => {
reset(config.power);
}, [reset, config.power]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "power",
power: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<SelectField
label="Charge current"
description="This is a description."
isInvalid={!!errors.chargeCurrent?.message}
validationMessage={errors.chargeCurrent?.message}
{...register("chargeCurrent", { valueAsNumber: true })}
>
{renderOptions(Protobuf.Config_PowerConfig_ChargeCurrent)}
</SelectField>
<TextInputField
label="Shutdown on battery delay"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.onBatteryShutdownAfterSecs?.message}
validationMessage={errors.onBatteryShutdownAfterSecs?.message}
{...register("onBatteryShutdownAfterSecs", { valueAsNumber: true })}
/>
<FormField
label="Power Saving"
description="Description"
isInvalid={!!errors.isPowerSaving?.message}
validationMessage={errors.isPowerSaving?.message}
>
<Controller
name="isPowerSaving"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="ADC Multiplier Override ratio"
description="This is a description."
type="number"
isInvalid={!!errors.adcMultiplierOverride?.message}
validationMessage={errors.adcMultiplierOverride?.message}
{...register("adcMultiplierOverride", { valueAsNumber: true })}
/>
<TextInputField
label="Minimum Wake Time"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.minWakeSecs?.message}
validationMessage={errors.minWakeSecs?.message}
{...register("minWakeSecs", { valueAsNumber: true })}
/>
<TextInputField
label="Mesh SDS Timeout"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.meshSdsTimeoutSecs?.message}
validationMessage={errors.meshSdsTimeoutSecs?.message}
{...register("meshSdsTimeoutSecs", { valueAsNumber: true })}
/>
<TextInputField
label="SDS"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.sdsSecs?.message}
validationMessage={errors.sdsSecs?.message}
{...register("sdsSecs", { valueAsNumber: true })}
/>
<TextInputField
label="LS"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.lsSecs?.message}
validationMessage={errors.lsSecs?.message}
{...register("lsSecs", { valueAsNumber: true })}
/>
<TextInputField
label="Wait Bluetooth"
description="This is a description."
hint="Seconds"
type="number"
isInvalid={!!errors.waitBluetoothSecs?.message}
validationMessage={errors.waitBluetoothSecs?.message}
{...register("waitBluetoothSecs", { valueAsNumber: true })}
/>
</Form>
);
};

141
src/components/PageComponents/Config/User.tsx

@ -0,0 +1,141 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { base16 } from "rfc4648";
import { UserValidation } from "@app/validation/config/user.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const User = (): JSX.Element => {
const { hardware, nodes, connection } = useDevice();
const [loading, setLoading] = useState(false);
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<UserValidation>({
defaultValues: myNode?.data.user,
resolver: classValidatorResolver(UserValidation),
});
useEffect(() => {
reset({
longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed,
});
}, [reset, myNode]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
if (myNode?.data.user) {
void connection?.setOwner({ ...myNode.data.user, ...data }, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
}
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
{JSON.stringify(errors.antAzimuth?.message)}
{JSON.stringify(errors.antGainDbi?.message)}
{JSON.stringify(errors.hwModel?.message)}
{JSON.stringify(errors.id?.message)}
{JSON.stringify(errors.isLicensed?.message)}
{JSON.stringify(errors.longName?.message)}
{JSON.stringify(errors.shortName?.message)}
{JSON.stringify(errors.txPowerDbm?.message)}
<TextInputField
label="Device ID"
description="Preset unique identifier for this device."
isInvalid={!!errors.id?.message}
validationMessage={errors.id?.message}
{...register("id")}
readOnly
/>
<TextInputField
label="Device Name"
description="Personalised name for this device."
{...register("longName")}
/>
<TextInputField
label="Short Name"
description="This is a description."
maxLength={3}
{...register("shortName")}
/>
<TextInputField
label="Mac Address"
description="This is a description."
disabled
value={
base16
.stringify(myNode?.data.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? ""
}
readOnly
/>
<SelectField
label="Hardware"
description="This is a description."
disabled
isInvalid={!!errors.hwModel?.message}
validationMessage={errors.hwModel?.message}
{...register("hwModel", { valueAsNumber: true })}
// readOnly
>
{renderOptions(Protobuf.HardwareModel)}
</SelectField>
<FormField
label="Licenced Operator?"
description="Description"
isInvalid={!!errors.isLicensed?.message}
validationMessage={errors.isLicensed?.message}
>
<Controller
name="isLicensed"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="Transmit Power"
description="This is a description."
hint="dBm"
type="number"
{...register("txPowerDbm", { valueAsNumber: true })}
/>
<TextInputField
label="Antenna Gain"
description="This is a description."
hint="dBi"
type="number"
{...register("antGainDbi", { valueAsNumber: true })}
/>
<TextInputField
label="Antenna Azimuth"
description="This is a description."
hint="°"
type="number"
{...register("antAzimuth", { valueAsNumber: true })}
/>
</Form>
);
};

94
src/components/PageComponents/Config/WiFi copy.tsx

@ -0,0 +1,94 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField, toaster } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { WiFiValidation } from "@app/validation/config/wifi.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const WiFi = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset,
} = useForm<WiFiValidation>({
defaultValues: config.wifi,
resolver: classValidatorResolver(WiFiValidation),
});
useEffect(() => {
reset(config.wifi);
}, [reset, config.wifi]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "wifi",
wifi: data,
},
},
async () => {
toaster.success("Your source is now sending data");
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
label="SSID"
description="This is a description."
isInvalid={!!errors.ssid?.message}
validationMessage={errors.ssid?.message}
{...register("ssid", { valueAsNumber: true })}
/>
<TextInputField
label="PSK"
description="This is a description."
type="password"
isInvalid={!!errors.psk?.message}
validationMessage={errors.psk?.message}
{...register("psk", { valueAsNumber: true })}
/>
<FormField
label="Enable WiFi AP"
description="Description"
isInvalid={!!errors.apMode?.message}
validationMessage={errors.apMode?.message}
>
<Controller
name="apMode"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Don't broadcast SSID"
description="Description"
isInvalid={!!errors.apHidden?.message}
validationMessage={errors.apHidden?.message}
>
<Controller
name="apHidden"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

96
src/components/PageComponents/Config/WiFi.tsx

@ -0,0 +1,96 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField, toaster } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { WiFiValidation } from "@app/validation/config/wifi.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const WiFi = (): JSX.Element => {
const { config, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
control,
reset,
} = useForm<WiFiValidation>({
defaultValues: config.wifi,
resolver: classValidatorResolver(WiFiValidation),
});
useEffect(() => {
reset(config.wifi);
}, [reset, config.wifi]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setConfig(
{
payloadVariant: {
oneofKind: "wifi",
wifi: data,
},
},
async () => {
toaster.success("Successfully updated WiFi config");
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<TextInputField
label="SSID"
description="This is a description."
isInvalid={!!errors.ssid?.message}
validationMessage={errors.ssid?.message}
{...register("ssid")}
/>
<TextInputField
label="PSK"
type="password"
description="This is a description."
isInvalid={!!errors.psk?.message}
validationMessage={errors.psk?.message}
{...register("psk")}
/>
<FormField
label="Enable WiFi AP"
description="Description"
isInvalid={!!errors.apMode?.message}
validationMessage={errors.apMode?.message}
>
<Controller
name="apMode"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Don't broadcast SSID"
description="Description"
isInvalid={!!errors.apHidden?.message}
validationMessage={errors.apHidden?.message}
>
<Controller
name="apHidden"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

171
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -0,0 +1,171 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const CannedMessage = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<CannedMessageValidation>({
defaultValues: moduleConfig.cannedMessage,
resolver: classValidatorResolver(CannedMessageValidation),
});
const moduleEnabled = useWatch({
control,
name: "rotary1Enabled",
defaultValue: false,
});
useEffect(() => {
reset(moduleConfig.cannedMessage);
}, [reset, moduleConfig.cannedMessage]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "cannedMessage",
cannedMessage: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="This is a description."
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Rotary Encoder #1 Enabled"
description="This is a description."
isInvalid={!!errors.rotary1Enabled?.message}
validationMessage={errors.rotary1Enabled?.message}
>
<Controller
name="rotary1Enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="Encoder Pin A"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinA", { valueAsNumber: true })}
/>
<TextInputField
label="Encoder Pin B"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinB", { valueAsNumber: true })}
/>
<TextInputField
label="Endoer Pin Press"
description="Max transmit power in dBm"
type="number"
disabled={moduleEnabled}
{...register("inputbrokerPinPress", { valueAsNumber: true })}
/>
<SelectField
label="Clockwise event"
description="This is a description."
disabled={moduleEnabled}
{...register("inputbrokerEventCw", { valueAsNumber: true })}
>
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<SelectField
label="Counter Clockwise event"
description="This is a description."
disabled={moduleEnabled}
{...register("inputbrokerEventCcw", { valueAsNumber: true })}
>
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<SelectField
label="Press event"
description="This is a description."
disabled={moduleEnabled}
{...register("inputbrokerEventPress", { valueAsNumber: true })}
>
{renderOptions(
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
)}
</SelectField>
<FormField
label="Up Down enabled"
description="This is a description."
isInvalid={!!errors.updown1Enabled?.message}
validationMessage={errors.updown1Enabled?.message}
>
<Controller
name="updown1Enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="Allow Input Source"
description="Max transmit power in dBm"
disabled={moduleEnabled}
{...register("allowInputSource")}
/>
<FormField
label="Send Bell"
description="This is a description."
isInvalid={!!errors.sendBell?.message}
validationMessage={errors.sendBell?.message}
>
<Controller
name="sendBell"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

134
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -0,0 +1,134 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const ExternalNotification = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<ExternalNotificationValidation>({
defaultValues: moduleConfig.externalNotification,
resolver: classValidatorResolver(ExternalNotificationValidation),
});
useEffect(() => {
reset(moduleConfig.externalNotification);
}, [reset, moduleConfig.externalNotification]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "externalNotification",
externalNotification: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
const moduleEnabled = useWatch({
control,
name: "enabled",
defaultValue: false,
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
type="number"
label="Output MS"
description="Max transmit power in dBm"
hint="ms"
disabled={!moduleEnabled}
{...register("outputMs", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="Output"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("output", {
valueAsNumber: true,
})}
/>
<FormField
label="Active"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.active?.message}
validationMessage={errors.active?.message}
>
<Controller
name="active"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Message"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.alertMessage?.message}
validationMessage={errors.alertMessage?.message}
>
<Controller
name="alertMessage"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Bell"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.alertBell?.message}
validationMessage={errors.alertBell?.message}
>
<Controller
name="alertBell"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

105
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -0,0 +1,105 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const MQTT = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<MQTTValidation>({
defaultValues: moduleConfig.mqtt,
resolver: classValidatorResolver(MQTTValidation),
});
const moduleEnabled = useWatch({
control,
name: "disabled",
defaultValue: false,
});
useEffect(() => {
reset(moduleConfig.mqtt);
}, [reset, moduleConfig.mqtt]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "mqtt",
mqtt: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Disabled"
description="Description"
isInvalid={!!errors.disabled?.message}
validationMessage={errors.disabled?.message}
>
<Controller
name="disabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="MQTT Server Address"
description="Max transmit power in dBm"
disabled={moduleEnabled}
{...register("address")}
/>
<TextInputField
label="MQTT Username"
description="Max transmit power in dBm"
disabled={moduleEnabled}
{...register("username")}
/>
<TextInputField
label="MQTT Password"
description="Max transmit power in dBm"
type="password"
autoComplete="off"
disabled={moduleEnabled}
{...register("password")}
/>
<FormField
label="Encryption Enabled"
description="Description"
disabled={moduleEnabled}
isInvalid={!!errors.encryptionEnabled?.message}
validationMessage={errors.encryptionEnabled?.message}
>
<Controller
name="encryptionEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

96
src/components/PageComponents/ModuleConfig/RangeTest.tsx

@ -0,0 +1,96 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const RangeTest = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<RangeTestValidation>({
defaultValues: moduleConfig.rangeTest,
resolver: classValidatorResolver(RangeTestValidation),
});
useEffect(() => {
reset(moduleConfig.rangeTest);
}, [reset, moduleConfig.rangeTest]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "rangeTest",
rangeTest: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
const moduleEnabled = useWatch({
control,
name: "enabled",
defaultValue: false,
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
type="number"
label="Message Interval"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
hint="Seconds"
{...register("sender", {
valueAsNumber: true,
})}
/>
<FormField
label="Save CSV to storage"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.save?.message}
validationMessage={errors.save?.message}
>
<Controller
name="save"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
</Form>
);
};

131
src/components/PageComponents/ModuleConfig/Serial.tsx

@ -0,0 +1,131 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { SerialValidation } from "@app/validation/moduleConfig/serial.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const Serial = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<SerialValidation>({
defaultValues: moduleConfig.serial,
resolver: classValidatorResolver(SerialValidation),
});
useEffect(() => {
reset(moduleConfig.serial);
}, [reset, moduleConfig.serial]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "serial",
serial: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
const moduleEnabled = useWatch({
control,
name: "enabled",
defaultValue: false,
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Echo"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.echo?.message}
validationMessage={errors.echo?.message}
>
<Controller
name="echo"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
type="number"
label="RX"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("rxd", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="TX Pin"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("txd", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="Baud Rate"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("baud", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="Timeout"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("timeout", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="Mode"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("mode", {
valueAsNumber: true,
})}
/>
</Form>
);
};

114
src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -0,0 +1,114 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm, useWatch } from "react-hook-form";
import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
export const StoreForward = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<StoreForwardValidation>({
defaultValues: moduleConfig.storeForward,
resolver: classValidatorResolver(StoreForwardValidation),
});
useEffect(() => {
reset(moduleConfig.storeForward);
}, [reset, moduleConfig.storeForward]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "storeForward",
storeForward: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
const moduleEnabled = useWatch({
control,
name: "enabled",
defaultValue: false,
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Module Enabled"
description="Description"
isInvalid={!!errors.enabled?.message}
validationMessage={errors.enabled?.message}
>
<Controller
name="enabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Heartbeat Enabled"
description="Description"
disabled={!moduleEnabled}
isInvalid={!!errors.heartbeat?.message}
validationMessage={errors.heartbeat?.message}
>
<Controller
name="heartbeat"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
type="number"
label="Number of records"
description="Max transmit power in dBm"
hint="Records"
disabled={!moduleEnabled}
{...register("records", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="History return max"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("historyReturnMax", {
valueAsNumber: true,
})}
/>
<TextInputField
type="number"
label="History return window"
description="Max transmit power in dBm"
disabled={!moduleEnabled}
{...register("historyReturnWindow", {
valueAsNumber: true,
})}
/>
</Form>
);
};

135
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -0,0 +1,135 @@
import type React from "react";
import { useEffect, useState } from "react";
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
import { Controller, useForm } from "react-hook-form";
import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js";
import { Form } from "@components/form/Form";
import { useDevice } from "@core/stores/deviceStore.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import { Protobuf } from "@meshtastic/meshtasticjs";
export const Telemetry = (): JSX.Element => {
const { moduleConfig, connection } = useDevice();
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
control,
} = useForm<TelemetryValidation>({
defaultValues: moduleConfig.telemetry,
resolver: classValidatorResolver(TelemetryValidation),
});
useEffect(() => {
reset(moduleConfig.telemetry);
}, [reset, moduleConfig.telemetry]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection?.setModuleConfig(
{
payloadVariant: {
oneofKind: "telemetry",
telemetry: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
}
);
});
return (
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
<FormField
label="Measurement Enabled"
description="Description"
isInvalid={!!errors.environmentMeasurementEnabled?.message}
validationMessage={errors.environmentMeasurementEnabled?.message}
>
<Controller
name="environmentMeasurementEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<FormField
label="Displayed on Screen"
description="Description"
isInvalid={!!errors.environmentScreenEnabled?.message}
validationMessage={errors.environmentScreenEnabled?.message}
>
<Controller
name="environmentScreenEnabled"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<TextInputField
label="Read Error Count Threshold"
description="Max transmit power in dBm"
type="number"
{...register("environmentReadErrorCountThreshold", {
valueAsNumber: true,
})}
/>
<TextInputField
label="Update Interval"
description="Max transmit power in dBm"
hint="Seconds"
type="number"
{...register("environmentUpdateInterval", {
valueAsNumber: true,
})}
/>
<TextInputField
label="Recovery Interval"
description="Max transmit power in dBm"
hint="Seconds"
type="number"
{...register("environmentRecoveryInterval", {
valueAsNumber: true,
})}
/>
<FormField
label="Display Farenheit"
description="Description"
isInvalid={!!errors.environmentDisplayFahrenheit?.message}
validationMessage={errors.environmentDisplayFahrenheit?.message}
>
<Controller
name="environmentDisplayFahrenheit"
control={control}
render={({ field: { value, ...field } }) => (
<Switch height={24} marginLeft="auto" checked={value} {...field} />
)}
/>
</FormField>
<SelectField
label="Sensor Type"
description="This is a description."
{...register("environmentSensorType", { valueAsNumber: true })}
>
{renderOptions(Protobuf.TelemetrySensorType)}
</SelectField>
<TextInputField
label="Sensor Pin"
description="Max transmit power in dBm"
type="number"
{...register("environmentSensorPin", {
valueAsNumber: true,
})}
/>
</Form>
);
};

88
src/components/Progress.tsx

@ -0,0 +1,88 @@
import React, { useEffect } from "react";
import { majorScale, Pane, Spinner, StatusIndicator } from "evergreen-ui";
import { useDevice } from "@app/core/stores/deviceStore.js";
export const Progress = (): JSX.Element => {
const { hardware, channels, config, moduleConfig, setReady, nodes } =
useDevice();
useEffect(() => {
if (
hardware.myNodeNum !== 0 &&
Object.keys(config).length === 7 &&
Object.keys(moduleConfig).length === 7 &&
channels.length === hardware.maxChannels
) {
setReady(true);
}
}, [
config,
moduleConfig,
channels,
hardware.maxChannels,
hardware.myNodeNum,
setReady,
]);
return (
<Pane
display="flex"
flexGrow={1}
margin={majorScale(3)}
borderRadius={majorScale(1)}
elevation={1}
background="white"
>
<Pane display="flex" margin="auto" gap={majorScale(6)}>
<Pane
marginY="auto"
display="flex"
height="72px"
width="72px"
minWidth="72px"
backgroundColor="#F8E3DA"
borderRadius="50%"
>
<Spinner height="32px" width="32px" margin="auto" />
</Pane>
<Pane>
<Pane display="flex" flexDirection="column">
<StatusIndicator
color={hardware.myNodeNum !== 0 ? "success" : "disabled"}
>
Device Info
</StatusIndicator>
<StatusIndicator
color={Object.keys(config).length === 7 ? "success" : "disabled"}
>
Device Config {`(${Object.keys(config).length - 1} / 6)`}
</StatusIndicator>
<StatusIndicator
color={
Object.keys(moduleConfig).length === 7 ? "success" : "disabled"
}
>
Module Config {`(${Object.keys(moduleConfig).length - 1} / 6)`}
</StatusIndicator>
<StatusIndicator color={nodes.length ? "success" : "disabled"}>
Peers ({nodes.length})
</StatusIndicator>
<StatusIndicator
color={
channels.length > 0 && channels.length === hardware.maxChannels
? "success"
: "disabled"
}
>
Channels{" "}
{hardware.myNodeNum !== 0 &&
`(${channels.length} / ${hardware.maxChannels})`}
</StatusIndicator>
</Pane>
</Pane>
</Pane>
</Pane>
);
};

113
src/components/SlideSheets/NewDevice.tsx

@ -0,0 +1,113 @@
import type React from "react";
import { useState } from "react";
import {
Heading,
majorScale,
Pane,
Paragraph,
SideSheet,
Tab,
Tablist,
} from "evergreen-ui";
import type { IconType } from "react-icons";
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
import { BLE } from "../connect/BLE.js";
import { HTTP } from "../connect/HTTP.js";
import { Serial } from "../connect/Serial.js";
export interface NewDeviceProps {
open: boolean;
onClose: () => void;
}
export interface CloseProps {
close: () => void;
}
export type connType = "http" | "ble" | "serial";
export interface Tab {
name: connType;
icon: IconType;
displayName: string;
element: ({ close }: CloseProps) => JSX.Element;
}
export const NewDevice = ({ open, onClose }: NewDeviceProps) => {
const [selectedConnType, setSelectedConnType] = useState<connType>("ble");
const tabs: Tab[] = [
{
name: "ble",
icon: FiBluetooth,
displayName: "BLE",
element: BLE,
},
{
name: "http",
icon: FiWifi,
displayName: "HTTP",
element: HTTP,
},
{
name: "serial",
icon: FiTerminal,
displayName: "Serial",
element: Serial,
},
];
return (
<SideSheet
isShown={open}
onCloseComplete={onClose}
containerProps={{
display: "flex",
flex: "1",
flexDirection: "column",
}}
>
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white">
<Pane padding={16} borderBottom="muted">
<Heading size={600}>Connect new device</Heading>
<Paragraph size={400} color="muted">
Optional description or sub title
</Paragraph>
</Pane>
<Pane display="flex" padding={8}>
<Tablist>
{tabs.map((TabData, index) => (
<Tab
key={index}
gap={5}
isSelected={selectedConnType === TabData.name}
onSelect={() => setSelectedConnType(TabData.name)}
>
<>
<TabData.icon />
{TabData.displayName}
</>
</Tab>
))}
</Tablist>
</Pane>
</Pane>
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}>
{tabs.map((TabData, index) => (
<Pane
key={index}
borderRadius={majorScale(1)}
backgroundColor="white"
elevation={1}
flexGrow={1}
display={selectedConnType === TabData.name ? "block" : "none"}
>
<TabData.element close={onClose} />
</Pane>
))}
</Pane>
</SideSheet>
);
};

60
src/components/Tab.tsx

@ -1,60 +0,0 @@
import type React from 'react';
import { m } from 'framer-motion';
import type { Link } from 'type-route';
export interface TabProps {
link: Link;
icon: React.ReactNode;
title: string;
active: boolean;
activeRight: boolean;
activeLeft: boolean;
}
export const Tab = ({
link,
icon,
title,
active,
activeRight,
activeLeft,
}: TabProps): JSX.Element => {
return (
<div
className={`max-w-[10rem] md:flex-grow ${
active
? 'bg-white dark:bg-primaryDark'
: 'bg-gray-300 dark:bg-secondaryDark'
}`}
>
<div
className={`group flex flex-grow cursor-pointer select-none py-2 hover:underline dark:text-white ${
active
? 'z-10 rounded-t-lg bg-gray-300 shadow-inner dark:bg-secondaryDark'
: 'bg-white drop-shadow-md dark:bg-primaryDark'
} ${activeRight ? 'rounded-br-lg' : ''} ${
activeLeft ? 'rounded-bl-lg' : ''
}`}
{...(link && link)}
>
<div
className={`my-auto w-full px-3 ${
active || activeLeft
? ''
: 'border-l border-gray-400 dark:border-gray-600'
}`}
>
<m.div
className="flex gap-2"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="my-auto">{icon}</div>
<div className="hidden md:flex">{title}</div>
</m.div>
</div>
</div>
</div>
);
};

35
src/components/Tabs.tsx

@ -1,35 +0,0 @@
import type React from 'react';
import { Tab, TabProps } from '@components/Tab';
export interface TabsProps {
tabs: Omit<TabProps, 'activeLeft' | 'activeRight'>[];
}
export const Tabs = ({ tabs }: TabsProps): JSX.Element => {
return (
<div className="flex flex-grow bg-gray-300 dark:bg-secondaryDark">
<div
className={`h-full w-2 bg-white dark:bg-primaryDark ${
tabs[0].active ? 'rounded-br-lg' : ''
}`}
/>
{tabs.map((tab, index) => (
<Tab
key={index}
link={tab.link}
title={tab.title}
icon={tab.icon}
active={tab.active}
activeLeft={tabs[index - 1]?.active}
activeRight={tabs[index + 1]?.active}
/>
))}
<div
className={`h-full flex-grow bg-white drop-shadow-md dark:bg-primaryDark ${
tabs[tabs.length - 1].active ? 'rounded-bl-lg' : ''
}`}
/>
</div>
);
};

85
src/components/connect/BLE.tsx

@ -0,0 +1,85 @@
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button, majorScale, Pane } from "evergreen-ui";
import { FiPlusCircle } from "react-icons/fi";
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
import { subscribeAll } from "@app/core/subscriptions.js";
import { randId } from "@app/core/utils/randId.js";
import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs";
import type { CloseProps } from "../SlideSheets/NewDevice.js";
export const BLE = ({ close }: CloseProps): JSX.Element => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
setBleDevices(await navigator.bluetooth.getDevices());
}, []);
navigator.bluetooth.addEventListener("advertisementreceived", (e) => {
console.log(e);
});
navigator.bluetooth.addEventListener("availabilitychanged", (e) => {
console.log(e);
});
useEffect(() => {
void updateBleDeviceList();
}, [updateBleDeviceList]);
const onConnect = async (BLEDevice: BluetoothDevice) => {
const id = randId();
const device = addDevice(id);
const connection = new IBLEConnection(id);
await connection.connect({
device: BLEDevice,
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
};
return (
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
{bleDevices.map((device, index) => (
<Button
key={index}
onClick={() => {
void onConnect(device);
}}
>
{device.name}
</Button>
))}
<Button
appearance="primary"
gap={majorScale(1)}
onClick={() => {
void navigator.bluetooth
.requestDevice({
filters: [{ services: [Constants.SERVICE_UUID] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);
if (exists === -1) {
setBleDevices(bleDevices.concat(device));
}
});
}}
>
New device
<FiPlusCircle />
</Button>
</Pane>
);
};

60
src/components/connect/HTTP.tsx

@ -0,0 +1,60 @@
import type React from "react";
import { Button, majorScale, Pane, TextInputField } from "evergreen-ui";
import { useForm } from "react-hook-form";
import { FiPlusCircle } from "react-icons/fi";
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
import { subscribeAll } from "@app/core/subscriptions.js";
import { randId } from "@app/core/utils/randId.js";
import { IHTTPConnection } from "@meshtastic/meshtasticjs";
export interface HTTPProps {
close: () => void;
}
export const HTTP = ({ close }: HTTPProps): JSX.Element => {
const { addDevice } = useDeviceStore();
const { register, handleSubmit } = useForm<{
ip: string;
tls: boolean;
}>({
defaultValues: {
ip: "meshtastic.local",
tls: false,
},
});
const onSubmit = handleSubmit(async (data) => {
const id = randId();
const device = addDevice(id);
const connection = new IHTTPConnection(id);
// TODO: Promise never resolves
void connection.connect({
address: data.ip,
fetchInterval: 2000,
tls: data.tls,
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
});
return (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<form onSubmit={onSubmit}>
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
<TextInputField label="IP Address/Hostname" {...register("ip")} />
<Button appearance="primary" gap={majorScale(1)} type="submit">
Connect
<FiPlusCircle />
</Button>
</Pane>
</form>
);
};

102
src/components/connect/Serial.tsx

@ -0,0 +1,102 @@
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { Button, majorScale, Pane } from "evergreen-ui";
import { FiPlusCircle } from "react-icons/fi";
import { subscribeAll } from "@app/core/subscriptions.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { randId } from "@core/utils/randId.js";
import { ISerialConnection } from "@meshtastic/meshtasticjs";
import type { CloseProps } from "../SlideSheets/NewDevice.js";
interface USBID {
id: number;
name: string;
}
export const Serial = ({ close }: CloseProps): JSX.Element => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator.serial.getPorts());
}, []);
navigator.serial.addEventListener("connect", () => {
void updateSerialPortList();
});
navigator.serial.addEventListener("disconnect", () => {
void updateSerialPortList();
});
useEffect(() => {
void updateSerialPortList();
}, [updateSerialPortList]);
const onConnect = async (port: SerialPort) => {
const id = randId();
const device = addDevice(id);
const connection = new ISerialConnection(id);
await connection.connect({
port,
baudRate: 115200,
});
device.addConnection(connection);
subscribeAll(device, connection);
close();
};
const VID: USBID[] = [
{
id: 9114,
name: "TBA",
},
];
const PID: USBID[] = [
{
id: 32809,
name: "TBA",
},
];
return (
<Pane
display="flex"
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
{serialPorts.map((port, index) => (
<Button
key={index}
gap={5}
onClick={() => {
void onConnect(port);
}}
>
{VID.find((id) => id.id === port.getInfo().usbVendorId ?? 0)?.name ??
"Unknown"}{" "}
-{" "}
{PID.find((id) => id.id === port.getInfo().usbProductId ?? 0)?.name ??
"Unknown"}
<FiPlusCircle />
</Button>
))}
<Button
appearance="primary"
gap={majorScale(1)}
onClick={() => {
void navigator.serial.requestPort().then((port) => {
setSerialPorts(serialPorts.concat(port));
});
}}
>
New device
<FiPlusCircle />
</Button>
</Pane>
);
};

79
src/components/connection/BLE.tsx

@ -1,79 +0,0 @@
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { FiArrowRightCircle } from 'react-icons/fi';
import { Button } from '@components/generic/button/Button';
import { IconButton } from '@components/generic/button/IconButton';
import { connection, setConnection } from '@core/connection';
import { connType } from '@core/slices/appSlice';
import { IBLEConnection } from '@meshtastic/meshtasticjs';
export interface BLEProps {
connecting: boolean;
}
export const BLE = ({ connecting }: BLEProps): JSX.Element => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { handleSubmit } = useForm<{
device?: BluetoothDevice;
}>();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
const ble = new IBLEConnection();
const devices = await ble.getDevices();
setBleDevices(devices);
}, []);
useEffect(() => {
void updateBleDeviceList();
}, [updateBleDeviceList]);
const onSubmit = handleSubmit(async () => {
await setConnection(connType.BLE);
});
return (
<form onSubmit={onSubmit} className="flex flex-grow flex-col">
<div className="flex flex-grow flex-col gap-2 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400">
{bleDevices.length > 0 ? (
bleDevices.map((device, index) => (
<div
className="flex justify-between rounded-md bg-white p-2 dark:bg-primaryDark dark:text-white"
key={index}
>
<div className="my-auto">{device.name}</div>
<IconButton
nested
onClick={async (): Promise<void> => {
await setConnection(connType.BLE);
}}
icon={<FiArrowRightCircle />}
disabled={connecting}
/>
</div>
))
) : (
<div className="m-auto">
<p>No previously connected devices found</p>
</div>
)}
</div>
<Button
className="mt-2 ml-auto"
onClick={async (): Promise<void> => {
if (connecting) {
await connection.disconnect();
} else {
await onSubmit();
}
}}
border
>
{connecting ? 'Disconnect' : 'Connect'}
</Button>
</form>
);
};

93
src/components/connection/HTTP.tsx

@ -1,93 +0,0 @@
import type React from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Button } from '@components/generic/button/Button';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection, connectionUrl, setConnection } from '@core/connection';
import { connType, setConnectionParams } from '@core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
export interface HTTPProps {
connecting: boolean;
}
export const HTTP = ({ connecting }: HTTPProps): JSX.Element => {
const dispatch = useAppDispatch();
const { register, handleSubmit, control } = useForm<{
ipSource: 'local' | 'remote';
ip?: string;
tls: boolean;
}>({
defaultValues: {
ipSource: 'local',
ip: connectionUrl,
tls: false,
},
});
const watchIpSource = useWatch({
control,
name: 'ipSource',
defaultValue: 'local',
});
const onSubmit = handleSubmit(async (data) => {
if (data.ip) {
localStorage.setItem('connectionUrl', data.ip);
}
dispatch(
setConnectionParams({
type: connType.HTTP,
params: {
address: data.ip ?? connectionUrl,
tls: data.tls,
fetchInterval: 2000,
},
}),
);
await setConnection(connType.HTTP);
});
return (
<form onSubmit={onSubmit}>
<Select
label="Host Source"
options={[
{
name: 'Local',
value: 'local',
},
{
name: 'Remote',
value: 'remote',
},
]}
disabled={connecting}
{...register('ipSource')}
/>
{watchIpSource === 'local' ? (
<Input label="Host" value={connectionUrl} disabled />
) : (
<Input label="Host" disabled={connecting} {...register('ip')} />
)}
<Checkbox label="Use TLS?" disabled={connecting} {...register('tls')} />
<Button
className="mt-2 ml-auto"
onClick={async (): Promise<void> => {
if (connecting) {
await connection.disconnect();
} else {
await onSubmit();
}
}}
border
>
{connecting ? 'Disconnect' : 'Connect'}
</Button>
</form>
);
};

95
src/components/connection/Serial.tsx

@ -1,95 +0,0 @@
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { FiArrowRightCircle } from 'react-icons/fi';
import { Button } from '@components/generic/button/Button';
import { IconButton } from '@components/generic/button/IconButton';
import { connection, setConnection } from '@core/connection';
import { connType, setConnectionParams } from '@core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { ISerialConnection } from '@meshtastic/meshtasticjs';
export interface SerialProps {
connecting: boolean;
}
export const Serial = ({ connecting }: SerialProps): JSX.Element => {
const [serialDevices, setSerialDevices] = useState<SerialPort[]>([]);
const dispatch = useAppDispatch();
const { handleSubmit } = useForm<{
device?: SerialPort;
}>();
const updateSerialDeviceList = useCallback(async (): Promise<void> => {
const serial = new ISerialConnection();
const devices = await serial.getPorts();
setSerialDevices(devices);
}, []);
useEffect(() => {
void updateSerialDeviceList();
}, [updateSerialDeviceList]);
const onSubmit = handleSubmit(async () => {
await setConnection(connType.SERIAL);
});
return (
<form onSubmit={onSubmit} className="flex flex-grow flex-col">
<div className="flex flex-grow flex-col gap-2 overflow-y-auto rounded-md border border-gray-400 bg-gray-200 p-2 dark:border-gray-600 dark:bg-tertiaryDark dark:text-gray-400">
{serialDevices.length > 0 ? (
serialDevices.map((device, index) => (
<div
className="flex justify-between rounded-md bg-white p-2 dark:bg-primaryDark dark:text-white"
key={index}
>
<div className="my-auto flex gap-4">
<p>
Vendor: <small>{device.getInfo().usbVendorId}</small>
</p>
<p>
Device: <small>{device.getInfo().usbProductId}</small>
</p>
</div>
<IconButton
onClick={async (): Promise<void> => {
dispatch(
setConnectionParams({
type: connType.SERIAL,
params: {
port: device,
},
}),
);
await setConnection(connType.SERIAL);
}}
disabled={connecting}
icon={<FiArrowRightCircle />}
/>
</div>
))
) : (
<div className="m-auto">
<p>No previously connected devices found</p>
</div>
)}
</div>
<Button
className="mt-2 ml-auto"
onClick={async (): Promise<void> => {
if (connecting) {
await connection.disconnect();
} else {
await onSubmit();
}
}}
border
>
{connecting ? 'Disconnect' : 'Connect'}
</Button>
</form>
);
};

49
src/components/form/Form.tsx

@ -0,0 +1,49 @@
import type React from "react";
import type { HTMLProps } from "react";
import { Button, majorScale, Pane, Spinner } from "evergreen-ui";
import { FiSave } from "react-icons/fi";
export interface FormProps extends HTMLProps<HTMLFormElement> {
onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>;
loading: boolean;
dirty: boolean;
}
export const Form = ({
loading,
dirty,
children,
onSubmit,
...props
}: FormProps): JSX.Element => {
return (
// eslint-disable-next-line @typescript-eslint/no-misused-promises
<form onSubmit={onSubmit} style={{ position: "relative" }} {...props}>
{loading && (
<Pane
position="absolute"
display="flex"
width="100%"
height="100%"
backgroundColor="rgba(67, 90, 111, 0.2)"
zIndex={10}
borderRadius={majorScale(1)}
>
<Spinner margin="auto" />
</Pane>
)}
{children}
<Pane display="flex" marginTop={majorScale(2)}>
<Button
type="submit"
marginLeft="auto"
disabled={!dirty}
iconBefore={<FiSave />}
>
Save
</Button>
</Pane>
</form>
);
};

48
src/components/generic/Card.tsx

@ -1,48 +0,0 @@
import type React from 'react';
import { m } from 'framer-motion';
export interface CardProps {
className?: string;
title?: string;
actions?: React.ReactNode;
children: React.ReactNode;
border?: boolean;
}
export const Card = ({
className,
title,
actions,
border,
children,
}: CardProps): JSX.Element => {
return (
<div
className={`flex h-full w-full flex-col rounded-md drop-shadow-md ${
border ? 'border border-gray-400 dark:border-gray-600' : ''
} ${className ?? ''}`}
>
{(title || actions) && (
<div className="w-full select-none justify-between rounded-t-md border-b border-gray-400 bg-gray-200 p-2 px-2 text-lg font-medium dark:border-gray-600 dark:bg-tertiaryDark dark:text-white">
<div className="handle flex h-8 justify-between">
<div className="my-auto ml-2 truncate">{title}</div>
{actions}
</div>
</div>
)}
<m.div
className={`flex flex-grow select-none flex-col gap-4 bg-white p-4 dark:bg-primaryDark ${
title || actions ? 'rounded-b-md' : 'rounded-md'
}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{children}
</m.div>
</div>
);
};

23
src/components/generic/ContextItem.tsx

@ -1,23 +0,0 @@
import type React from 'react';
import { m } from 'framer-motion';
export interface ContextItem {
title: string;
icon: JSX.Element;
}
export const ContextItem = ({ title, icon }: ContextItem): JSX.Element => {
return (
<div className="cursor-pointer first:rounded-t-md last:rounded-b-md hover:dark:bg-secondaryDark">
<m.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex gap-2 p-2"
>
<div className="my-auto">{icon}</div>
<div className="truncate">{title}</div>
</m.div>
</div>
);
};

57
src/components/generic/ContextMenu.tsx

@ -1,57 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { FiActivity, FiAperture, FiTag } from 'react-icons/fi';
import { ContextItem } from '@components/generic/ContextItem';
export interface ContextMenuProps {
items?: JSX.Element;
children: React.ReactNode;
}
export const ContextMenu = ({
items,
children,
}: ContextMenuProps): JSX.Element => {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div
className="h-full"
onContextMenu={(e): void => {
e.preventDefault();
setVisible(false);
const newPosition = {
x: e.pageX,
y: e.pageY,
};
setPosition(newPosition);
setVisible(true);
}}
onClick={(): void => {
setVisible(false);
}}
>
{children}
{visible && (
<div
style={{ top: position.y, left: position.x }}
className="fixed z-50 w-60 gap-2 divide-y divide-gray-300 rounded-md border border-gray-400 font-medium drop-shadow-md backdrop-blur-xl dark:divide-gray-600 dark:border-gray-600 dark:text-gray-400"
>
{items}
<ContextItem title="Menu item" icon={<FiActivity />} />
<ContextItem title="Menu item 2" icon={<FiAperture />} />
<ContextItem
title="Menu item 3 with a very long name that should wrap"
icon={<FiTag />}
/>
</div>
)}
</div>
);
};

9
src/components/generic/Loading.tsx

@ -1,9 +0,0 @@
import type React from 'react';
export const Loading = (): JSX.Element => {
return (
<div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex rounded-md backdrop-blur-sm backdrop-filter">
<div className="m-auto text-lg font-medium text-gray-400">Loading</div>
</div>
);
};

72
src/components/generic/Modal.tsx

@ -1,72 +0,0 @@
import type React from 'react';
import { AnimatePresence, m } from 'framer-motion';
import { FiX } from 'react-icons/fi';
import { IconButton } from '@components/generic/button/IconButton';
import { Card, CardProps } from '@components/generic/Card';
import { useAppSelector } from '@hooks/useAppSelector';
export interface ModalProps extends CardProps {
open: boolean;
bgDismiss?: boolean;
onClose: () => void;
}
export const Modal = ({
open,
bgDismiss,
onClose,
actions,
...props
}: ModalProps): JSX.Element => {
const darkMode = useAppSelector((state) => state.app.darkMode);
return (
<AnimatePresence>
{open && (
<m.div
className={`fixed inset-0 ${darkMode ? 'dark' : ''} ${
open ? 'z-30' : 'z-0'
}`}
>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed h-full w-full backdrop-blur-md backdrop-brightness-75 backdrop-filter"
onClick={(): void => {
bgDismiss && onClose();
}}
/>
<m.div className="text-center ">
<span
className="inline-block h-screen align-middle "
aria-hidden="true"
>
&#8203;
</span>
<div className="inline-block w-full max-w-3xl align-middle">
<Card
border
actions={
<div className="flex gap-2">
{actions}
<IconButton
tooltip="Close"
icon={<FiX />}
onClick={onClose}
/>
</div>
}
className="relative flex-col"
{...props}
/>
</div>
</m.div>
</m.div>
)}
</AnimatePresence>
);
};

85
src/components/generic/Sidebar/CollapsibleSection.tsx

@ -1,85 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { AnimatePresence, m } from 'framer-motion';
import { FiArrowUp } from 'react-icons/fi';
export interface CollapsibleSectionProps {
title: string;
icon?: JSX.Element;
status?: boolean;
children: JSX.Element;
}
export const CollapsibleSection = ({
title,
icon,
status,
children,
}: CollapsibleSectionProps): JSX.Element => {
const [open, setOpen] = useState(false);
const toggleOpen = (): void => setOpen(!open);
return (
<m.div>
<m.div
layout
onClick={toggleOpen}
className={`w-full cursor-pointer select-none overflow-hidden border-l-4 border-b bg-gray-200 p-2 text-sm font-medium dark:border-primaryDark dark:bg-tertiaryDark dark:text-gray-400 ${
open
? 'border-l-primary dark:border-l-primary'
: 'border-gray-400 dark:border-secondaryDark'
}`}
>
<m.div
layout
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="my-auto flex justify-between gap-2"
>
<m.div className="flex gap-2">
<m.div className="my-auto flex gap-2">
{status !== undefined ? (
<>
<div
className={`my-auto h-2 w-2 rounded-full ${
status ? 'bg-green-500' : 'bg-red-500'
}`}
/>
{icon}
</>
) : (
<>{icon}</>
)}
</m.div>
{title}
</m.div>
<m.div
animate={open ? 'open' : 'closed'}
initial={{ rotate: 180 }}
variants={{
open: { rotate: 0 },
closed: { rotate: 180 },
}}
transition={{ type: 'just' }}
className="my-auto"
>
<FiArrowUp />
</m.div>
</m.div>
</m.div>
<AnimatePresence>
{open && (
<m.div
className="p-2"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</m.div>
)}
</AnimatePresence>
</m.div>
);
};

54
src/components/generic/Sidebar/ExternalSection.tsx

@ -1,54 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { m } from 'framer-motion';
import { FiChevronRight } from 'react-icons/fi';
export interface ExternalSectionProps {
title: string;
icon?: JSX.Element;
active?: boolean;
onClick: () => void;
}
export const ExternalSection = ({
title,
icon,
active,
onClick,
}: ExternalSectionProps): JSX.Element => {
const [open, setOpen] = 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 border-l-4 bg-gray-200 dark:bg-tertiaryDark dark:text-gray-400 ${
active
? 'border-l-primary dark:border-l-primary'
: 'border-gray-400 dark:border-secondaryDark'
}`}
>
<m.div
layout
onClick={toggleOpen}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex justify-between gap-2 border-b border-gray-400 p-2 text-sm font-medium dark:border-primaryDark"
>
<m.div className="flex gap-2 ">
<m.div className="my-auto">{icon}</m.div>
{title}
</m.div>
<m.div className="my-auto">
<FiChevronRight />
</m.div>
</m.div>
</m.div>
</m.div>
);
};

61
src/components/generic/Sidebar/SidebarOverlay.tsx

@ -1,61 +0,0 @@
import type React from 'react';
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion';
import { FiArrowLeft } from 'react-icons/fi';
import { IconButton } from '@components/generic/button/IconButton';
export interface SidebarOverlayProps {
title: string;
open: boolean;
close: () => void;
direction: 'x' | 'y';
children: React.ReactNode;
}
export const SidebarOverlay = ({
title,
open,
close,
direction,
children,
}: SidebarOverlayProps): JSX.Element => {
return (
<AnimatePresence>
{open && (
<m.div
className="absolute z-30 flex h-full w-full flex-col bg-white dark:bg-primaryDark"
animate={direction === 'x' ? { translateX: 0 } : { translateY: 0 }}
initial={
direction === 'x' ? { translateX: '-100%' } : { translateY: '100%' }
}
exit={
direction === 'x' ? { translateX: '-100%' } : { translateY: '100%' }
}
transition={{ type: 'just' }}
>
{/* @ts-expect-error */}
<AnimateSharedLayout>
{/* <div className="flex gap-2 border-b border-gray-400 p-2 dark:border-gray-600"> */}
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark">
<div className="flex h-10 gap-1">
<div className="my-auto">
<IconButton
onClick={(): void => {
close();
}}
icon={<FiArrowLeft />}
/>
</div>
<div className="my-auto text-lg font-medium dark:text-white">
{title}
</div>
</div>
</div>
<div className="flex-grow overflow-y-auto">{children}</div>
</AnimateSharedLayout>
</m.div>
)}
</AnimatePresence>
);
};

17
src/components/generic/Tooltip.tsx

@ -1,17 +0,0 @@
import 'tippy.js/dist/tippy.css';
import type React from 'react';
import Tippy, { TippyProps } from '@tippyjs/react';
export const Tooltip = ({
children,
content,
...props
}: TippyProps): JSX.Element => {
return (
<Tippy content={content} {...props}>
<div>{children}</div>
</Tippy>
);
};

79
src/components/generic/button/Button.tsx

@ -1,79 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { m } from 'framer-motion';
import { FiCheck } from 'react-icons/fi';
export enum ButtonSize {
Small = 'small',
Medium = 'medium',
Large = 'large',
}
export interface ButtonProps {
icon?: JSX.Element;
border?: boolean;
className?: string;
disabled?: boolean;
children?: React.ReactNode;
size?: ButtonSize;
onClick?: () => void;
confirmAction?: () => void;
}
export const Button = ({
icon,
className,
border,
size = ButtonSize.Medium,
confirmAction,
onClick,
disabled,
children,
}: ButtonProps): JSX.Element => {
const [hasConfirmed, setHasConfirmed] = useState(false);
const handleConfirm = (): void => {
if (typeof confirmAction == 'function') {
if (hasConfirmed) {
void confirmAction();
}
setHasConfirmed(true);
setTimeout(() => {
setHasConfirmed(false);
}, 3000);
}
};
return (
<m.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.97 }}
onClick={handleConfirm}
className={`flex select-none items-center space-x-3 rounded-md border border-transparent text-sm focus-within:border-primary focus-within:shadow-border dark:text-white dark:focus-within:border-primary
${
size === ButtonSize.Small
? 'p-0'
: size === ButtonSize.Medium
? 'p-2'
: 'p-4'
}
${
disabled
? 'cursor-not-allowed bg-white dark:bg-primaryDark'
: 'cursor-pointer hover:bg-white hover:drop-shadow-md dark:hover:bg-secondaryDark'
} ${border ? 'border-gray-400 dark:border-gray-200' : ''} ${
className ?? ''
}`}
onClickCapture={onClick}
>
{icon && (
<div className="text-gray-500 dark:text-gray-400">
{hasConfirmed ? <FiCheck /> : icon}
</div>
)}
<span>{children}</span>
</m.button>
);
};

50
src/components/generic/button/IconButton.tsx

@ -1,50 +0,0 @@
import type React from 'react';
import { m } from 'framer-motion';
import { Tooltip } from '@components/generic/Tooltip';
type DefaulButtonProps = JSX.IntrinsicElements['button'];
export interface IconButtonProps extends DefaulButtonProps {
icon: React.ReactNode;
tooltip?: string;
nested?: boolean;
active?: boolean;
}
export const IconButton = ({
icon,
tooltip,
nested,
active,
disabled,
className,
...props
}: IconButtonProps): JSX.Element => {
return (
<Tooltip disabled={!tooltip} content={tooltip}>
<button
type="button"
disabled={disabled}
className={`rounded-md p-2 hover:bg-gray-300 ${
active ? 'bg-gray-300 dark:bg-secondaryDark' : ''
} ${
nested ? 'dark:hover:bg-primaryDark' : 'dark:hover:bg-secondaryDark'
} ${
disabled ? 'cursor-not-allowed text-gray-400 dark:text-gray-700' : ''
} ${className ?? ''}`}
{...props}
>
<m.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.95 }}
className="my-auto text-gray-600 dark:text-gray-400"
>
{icon}
</m.div>
<span className="sr-only">Refresh</span>
</button>
</Tooltip>
);
};

49
src/components/generic/form/Checkbox.tsx

@ -1,49 +0,0 @@
import type React from 'react';
import { forwardRef } from 'react';
import { Label } from '@components/generic/form/Label';
type DefaultInputProps = JSX.IntrinsicElements['input'];
export interface CheckboxProps extends DefaultInputProps {
action?: (enabled: boolean) => void;
label: string;
valid?: boolean;
validationMessage?: string;
error?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Input(
{ label, valid, validationMessage, id, error, ...props }: CheckboxProps,
ref,
) {
return (
<div className="flex w-full flex-col">
<Label label={label} />
<div className="ml-auto">
<input
ref={ref}
type="checkbox"
id={id}
className={`h-8 w-8 appearance-none rounded-md border border-gray-400 transition duration-200 ease-in-out checked:border-transparent checked:bg-primary focus-within:shadow-border focus:outline-none dark:border-gray-200 ${
props.disabled
? 'border-gray-400 bg-gray-300 text-gray-500 dark:border-gray-700 dark:bg-secondaryDark dark:text-gray-400'
: ''
} ${
error
? 'border-red-500'
: props.disabled
? 'border-gray-200'
: 'focus-within:border-primary hover:border-primary dark:focus-within:border-primary dark:hover:border-primary'
}`}
{...props}
/>
</div>
{!valid && (
<div className="text-sm text-gray-600">{validationMessage}</div>
)}
</div>
);
},
);

36
src/components/generic/form/Form.tsx

@ -1,36 +0,0 @@
import type React from 'react';
import { FiSave } from 'react-icons/fi';
import { IconButton } from '@components/generic/button/IconButton';
import { Loading } from '@components/generic/Loading';
export interface FormProps {
submit: () => Promise<void>;
loading: boolean;
dirty: boolean;
children: React.ReactNode;
}
export const Form = ({
submit,
loading,
dirty,
children,
}: FormProps): JSX.Element => {
return (
<form
onSubmit={(e): void => {
e.preventDefault();
}}
>
{loading && <Loading />}
{children}
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="ml-auto p-2">
<IconButton disabled={dirty} onClick={submit} icon={<FiSave />} />
</div>
</div>
</form>
);
};

41
src/components/generic/form/Input.tsx

@ -1,41 +0,0 @@
import type React from 'react';
import { forwardRef } from 'react';
import { InputWrapper } from '@components/generic/form/InputWrapper';
import { Label } from '@components/generic/form/Label';
type DefaultInputProps = JSX.IntrinsicElements['input'];
export interface InputProps extends DefaultInputProps {
label?: string;
error?: string;
action?: JSX.Element;
prefix?: string;
suffix?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, error, action, suffix, className, disabled, ...props }: InputProps,
ref,
) {
return (
<div className="w-full">
{label && <Label label={label} error={error} />}
<InputWrapper error={error} disabled={disabled}>
<input
ref={ref}
className={`h-10 w-full bg-transparent px-3 py-2 focus:outline-none disabled:cursor-not-allowed dark:text-white ${
className ?? ''
}`}
{...props}
/>
{suffix && (
<span className="my-auto mr-3 text-sm font-medium text-gray-500 dark:text-gray-400">
{suffix}
</span>
)}
{action && <div className="mr-1 flex">{action}</div>}
</InputWrapper>
</div>
);
});

29
src/components/generic/form/InputWrapper.tsx

@ -1,29 +0,0 @@
import type React from 'react';
export interface LabelProps {
error?: string;
disabled?: boolean;
children: React.ReactNode;
}
export const InputWrapper = ({
error,
disabled,
children,
}: LabelProps): JSX.Element => (
<div
className={`flex w-full rounded-md border border-gray-400 transition duration-200 ease-in-out dark:border-gray-200 ${
disabled
? 'border-gray-400 bg-gray-300 text-gray-500 dark:border-gray-700 dark:bg-secondaryDark dark:text-gray-400'
: ''
} ${
error
? 'border-red-500 dark:border-red-500'
: disabled
? ''
: ' focus-within:border-primary focus-within:shadow-border hover:border-primary dark:focus-within:border-primary dark:hover:border-primary'
}`}
>
{children}
</div>
);

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

@ -1,14 +0,0 @@
import type React from 'react';
export interface LabelProps {
label: string;
error?: string;
}
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="my-auto ml-2 h-0.5 flex-grow rounded-full bg-gray-300 dark:bg-gray-700" />
</label>
);

69
src/components/generic/form/Select.tsx

@ -1,69 +0,0 @@
import type React from 'react';
import { forwardRef } from 'react';
import { InputWrapper } from '@components/generic/form/InputWrapper';
import { Label } from '@components/generic/form/Label';
type DefaultSelectProps = JSX.IntrinsicElements['select'];
export interface SelectProps extends DefaultSelectProps {
options?: {
name: string | number;
value: string | number;
}[];
optionsEnum?: { [s: string]: string | number };
label?: string;
error?: string;
small?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ options, optionsEnum, label, error, small, ...props }, ref) => {
const optionsEnumValues = optionsEnum
? Object.entries(optionsEnum).filter(
(value) => typeof value[1] === 'number',
)
: [];
return (
<div>
{label && <Label label={label} error={error} />}
<InputWrapper error={error} disabled={props.disabled}>
<select
ref={ref}
className={`w-full rounded-md bg-transparent focus:border-primary focus:outline-none disabled:cursor-not-allowed dark:text-white ${
small ? 'm-1' : 'mx-2 h-10'
}`}
disabled={
props.disabled
? true
: !(optionsEnumValues.length || options?.length)
}
{...props}
>
{!(optionsEnumValues.length || options?.length) && (
<option key="loading" className="dark:bg-gray-700">
Loading
</option>
)}
{optionsEnumValues.length &&
optionsEnumValues.map(([name, value], index) => (
<option key={index} className="dark:bg-gray-700" value={value}>
{name}
</option>
))}
{options &&
options.map((option, index) => (
<option
key={index}
className="dark:bg-gray-700"
value={option.value}
>
{option.name}
</option>
))}
</select>
</InputWrapper>
</div>
);
},
);

74
src/components/layout/AppLayout.tsx

@ -0,0 +1,74 @@
import type React from "react";
import { majorScale, Pane } from "evergreen-ui";
import { useAppStore } from "@app/core/stores/appStore.js";
import { DeviceWrapper } from "@app/DeviceWrapper.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { NoDevice } from "../misc/NoDevice.js";
import { Progress } from "../Progress.js";
import { Header } from "./Header.js";
import { Sidebar } from "./Sidebar/index.js";
export interface AppLayoutProps {
children: React.ReactNode;
}
export const AppLayout = ({ children }: AppLayoutProps): JSX.Element => {
const { getDevices } = useDeviceStore();
const { selectedDevice } = useAppStore();
const devices = getDevices();
return (
<Pane
width="100vw"
display="flex"
background="tint1"
flexDirection="column"
minHeight="100vh"
>
<Header />
<Pane display="flex" flex={1} height="100%" width="100%">
{devices.length ? (
devices.map((device, index) => (
<Pane
key={index}
width="100%"
height="100%"
display={index === selectedDevice ? "grid" : "none"}
gap={majorScale(3)}
gridTemplateColumns="16rem 1fr"
>
<DeviceWrapper device={device}>
{device && device.ready ? (
<>
<Sidebar />
<Pane height="100%" display="flex">
{children}
</Pane>
</>
) : (
<>
<Pane
width="100%"
flexGrow={1}
margin={majorScale(3)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
/>
<Progress />
</>
)}
</DeviceWrapper>
</Pane>
))
) : (
<NoDevice />
)}
</Pane>
</Pane>
);
};

147
src/components/layout/Header.tsx

@ -0,0 +1,147 @@
import type React from "react";
import { useState } from "react";
import {
Button,
CrossIcon,
GlobeIcon,
IconButton,
Link,
majorScale,
Pane,
PlusIcon,
StatusIndicator,
Tab,
Tablist,
Tooltip,
} from "evergreen-ui";
import { FiGithub } from "react-icons/fi";
import { useAppStore } from "@app/core/stores/appStore.js";
import { NewDevice } from "@components/SlideSheets/NewDevice.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Types } from "@meshtastic/meshtasticjs";
export const Header = (): JSX.Element => {
const { getDevices, removeDevice } = useDeviceStore();
const [newConnectionOpen, setNewConnectionOpen] = useState(false);
const { selectedDevice, setSelectedDevice } = useAppStore();
return (
<Pane
is="nav"
width="100%"
position="sticky"
top={0}
backgroundColor="white"
zIndex={10}
height={majorScale(8)}
flexShrink={0}
display="flex"
alignItems="center"
borderBottom="muted"
>
<NewDevice
open={newConnectionOpen}
onClose={() => {
setNewConnectionOpen(false);
}}
/>
<Pane
display="flex"
alignItems="center"
width={majorScale(12)}
marginRight={majorScale(22)}
>
<Link href="/">
<Pane
is="img"
width={100}
height={28}
src="/Logo_Black.svg"
cursor="pointer"
/>
</Link>
</Pane>
<Tablist display="flex" marginX={majorScale(4)}>
{getDevices().map((device, index) => (
<Tab
key={index}
gap={majorScale(1)}
isSelected={index === selectedDevice}
onSelect={() => {
setSelectedDevice(index);
}}
>
<Hashicon value={device.hardware.myNodeNum.toString()} size={20} />
{device.nodes.find((n) => n.data.num === device.hardware.myNodeNum)
?.data.user?.shortName ?? "UNK"}
<StatusIndicator
color={
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(device.status)
? "success"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(device.status)
? "warning"
: "danger"
}
/>
</Tab>
))}
</Tablist>
<Pane
display="flex"
marginLeft="auto"
gap={majorScale(1)}
marginRight={majorScale(2)}
>
<Tooltip content="Connect new device">
<Button
display="inline-flex"
marginY="auto"
appearance="primary"
iconBefore={<PlusIcon />}
onClick={() => {
setNewConnectionOpen(true);
}}
>
New
</Button>
</Tooltip>
{getDevices().length !== 0 && (
<Tooltip content="Disconnect active device">
<Button
iconAfter={CrossIcon}
onClick={() => {
removeDevice(selectedDevice ?? 0);
}}
>
Disconnect
</Button>
</Tooltip>
)}
<Tooltip content="Visit GitHub">
<Link
target="_blank"
href="https://github.com/meshtastic/meshtastic-web"
>
<IconButton icon={FiGithub} />
</Link>
</Tooltip>
<Tooltip content="Visit Meshtastic.org">
<Link target="_blank" href="https://meshtastic.org/">
<IconButton icon={GlobeIcon} />
</Link>
</Tooltip>
</Pane>
</Pane>
);
};

103
src/components/layout/Sidebar/DeviceCard.tsx

@ -0,0 +1,103 @@
import type React from "react";
import {
Badge,
Heading,
Link,
majorScale,
MapMarkerIcon,
Pane,
} from "evergreen-ui";
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
import { toMGRS } from "@app/core/utils/toMGRS.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Types } from "@meshtastic/meshtasticjs";
export const DeviceCard = (): JSX.Element => {
const { hardware, nodes, status, connection } = useDevice();
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
return (
<Pane
display="flex"
flexGrow={1}
flexDirection="column"
marginTop="auto"
gap={majorScale(1)}
>
<Pane display="flex" gap={majorScale(2)}>
<Hashicon value={hardware.myNodeNum.toString()} size={42} />
<Pane flexGrow={1}>
<Heading>{myNode?.data.user?.longName}</Heading>
<Link
target="_blank"
href="https://github.com/meshtastic/meshtastic-web/releases/"
>
<Badge
color="green"
width="100%"
marginRight={8}
display="flex"
marginTop={4}
>
{hardware.firmwareVersion}
</Badge>
</Link>
</Pane>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
<MapMarkerIcon />
<Badge
color={myNode?.data.position?.latitudeI ? "green" : "red"}
display="flex"
width="100%"
>
{toMGRS(
myNode?.data.position?.latitudeI,
myNode?.data.position?.longitudeI
)}
</Badge>
</Pane>
<Pane display="flex" gap={majorScale(1)}>
{connection?.connType === "ble" && <FiBluetooth />}
{connection?.connType === "http" && <FiWifi />}
{connection?.connType === "serial" && <FiTerminal />}
<Badge
color={
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(status)
? "green"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(status)
? "orange"
: "red"
}
display="flex"
width="100%"
>
{[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(status)
? "Connected"
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONNECTED,
].includes(status)
? "Connecting"
: "Disconnected"}
</Badge>
</Pane>
</Pane>
);
};

62
src/components/layout/Sidebar/Settings/Device.tsx

@ -1,62 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Device = (): JSX.Element => {
const deviceConfig = useAppSelector(
(state) => state.meshtastic.radio.config.device,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.Config_DeviceConfig>({
defaultValues: deviceConfig,
});
useEffect(() => {
reset(deviceConfig);
}, [reset, deviceConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setConfig(
{
payloadVariant: {
oneofKind: 'device',
device: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox
label="Serial Console Disabled"
{...register('serialDisabled')}
/>
<Checkbox label="Factory Reset Device" {...register('factoryReset')} />
<Checkbox label="Enabled Debug Log" {...register('debugLogEnabled')} />
<Checkbox
label="Disable Serial COnsole"
{...register('serialDisabled')}
/>
<Select
label="Role"
optionsEnum={Protobuf.Config_DeviceConfig_Role}
{...register('role', { valueAsNumber: true })}
/>
</Form>
);
};

64
src/components/layout/Sidebar/Settings/Display.tsx

@ -1,64 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Display = (): JSX.Element => {
const displayConfig = useAppSelector(
(state) => state.meshtastic.radio.config.display,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.Config_DisplayConfig>({
defaultValues: displayConfig,
});
useEffect(() => {
reset(displayConfig);
}, [reset, displayConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setConfig(
{
payloadVariant: {
oneofKind: 'display',
display: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Input
label="Screen Timeout"
type="number"
suffix="Seconds"
{...register('screenOnSecs', { valueAsNumber: true })}
/>
<Input
label="Carousel Delay"
type="number"
suffix="Seconds"
{...register('autoScreenCarouselSecs', { valueAsNumber: true })}
/>
<Select
label="GPS Display Units"
optionsEnum={Protobuf.Config_DisplayConfig_GpsCoordinateFormat}
{...register('gpsFormat', { valueAsNumber: true })}
/>
</Form>
);
};

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

@ -1,182 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import {
FiActivity,
FiAlignLeft,
FiBell,
FiFastForward,
FiLayers,
FiLayout,
FiMapPin,
FiMessageSquare,
FiPackage,
FiPower,
FiRss,
FiSmartphone,
FiTv,
FiUser,
FiWifi,
} from 'react-icons/fi';
import { CollapsibleSection } from '@components/generic/Sidebar/CollapsibleSection';
import { ExternalSection } from '@components/generic/Sidebar/ExternalSection';
import { SidebarOverlay } from '@components/generic/Sidebar/SidebarOverlay';
import { ChannelsGroup } from '@components/layout/Sidebar/Settings/channels/ChannelsGroup';
import { Display } from '@components/layout/Sidebar/Settings/Display';
import { Position } from '@app/components/layout/Sidebar/Settings/Position';
import { Interface } from '@components/layout/Sidebar/Settings/Interface';
import { LoRa } from '@components/layout/Sidebar/Settings/LoRa';
import { CannedMessage } from '@components/layout/Sidebar/Settings/modules/CannedMessage';
import { ExternalNotificationsSettingsPlanel } from '@components/layout/Sidebar/Settings/modules/ExternalNotifications';
import { MQTT } from '@components/layout/Sidebar/Settings/modules/MQTT';
import { RangeTestSettingsPanel } from '@components/layout/Sidebar/Settings/modules/RangeTest';
import { SerialSettingsPanel } from '@components/layout/Sidebar/Settings/modules/Serial';
import { StoreForwardSettingsPanel } from '@components/layout/Sidebar/Settings/modules/StoreForward';
import { Telemetry } from '@components/layout/Sidebar/Settings/modules/Telemetry';
import { Power } from '@components/layout/Sidebar/Settings/Power';
import { User } from '@components/layout/Sidebar/Settings/User';
import { WiFi } from '@components/layout/Sidebar/Settings/WiFi';
import { useAppSelector } from '@hooks/useAppSelector';
export interface SettingsProps {
open: boolean;
setOpen: (open: boolean) => void;
}
export const Settings = ({ open, setOpen }: SettingsProps): JSX.Element => {
const [modulesOpen, setModulesOpen] = useState(false);
const [channelsOpen, setChannelsOpen] = useState(false);
const moduleConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig,
);
const hasGps = true;
const hasWifi = true;
return (
<>
<SidebarOverlay
title="Settings"
open={open}
close={(): void => {
setOpen(false);
}}
direction="y"
>
<CollapsibleSection icon={<FiUser />} title="User">
<User />
</CollapsibleSection>
<CollapsibleSection icon={<FiSmartphone />} title="Device">
<WiFi />
</CollapsibleSection>
<CollapsibleSection icon={<FiMapPin />} title="Position">
<Position />
</CollapsibleSection>
<CollapsibleSection icon={<FiPower />} title="Power">
<Power />
</CollapsibleSection>
<CollapsibleSection icon={<FiWifi />} title="WiFi">
<WiFi />
</CollapsibleSection>
<CollapsibleSection icon={<FiTv />} title="Display">
<Display />
</CollapsibleSection>
<CollapsibleSection icon={<FiRss />} title="LoRa">
<LoRa />
</CollapsibleSection>
<ExternalSection
onClick={(): void => {
setChannelsOpen(true);
}}
icon={<FiLayers />}
title="Channels"
/>
<ExternalSection
onClick={(): void => {
setModulesOpen(true);
}}
icon={<FiPackage />}
title="Modules"
/>
<CollapsibleSection icon={<FiLayout />} title="Interface">
<Interface />
</CollapsibleSection>
</SidebarOverlay>
{/* Modules */}
<SidebarOverlay
title="Modules"
open={modulesOpen}
close={(): void => {
setModulesOpen(false);
}}
direction="x"
>
<CollapsibleSection
icon={<FiWifi />}
title="MQTT"
status={!moduleConfig.mqtt.disabled}
>
<MQTT />
</CollapsibleSection>
<CollapsibleSection
icon={<FiAlignLeft />}
title="Serial"
status={moduleConfig.serial.enabled}
>
<SerialSettingsPanel />
</CollapsibleSection>
<CollapsibleSection
icon={<FiBell />}
title="External Notifications"
status={moduleConfig.extNotification.enabled}
>
<ExternalNotificationsSettingsPlanel />
</CollapsibleSection>
<CollapsibleSection
icon={<FiFastForward />}
title="Store & Forward"
status={moduleConfig.storeForward.enabled}
>
<StoreForwardSettingsPanel />
</CollapsibleSection>
<CollapsibleSection
icon={<FiRss />}
title="Range Test"
status={moduleConfig.rangeTest.enabled}
>
<RangeTestSettingsPanel />
</CollapsibleSection>
<CollapsibleSection
icon={<FiActivity />}
title="Telemetry"
status={true}
>
<Telemetry />
</CollapsibleSection>
<CollapsibleSection
icon={<FiMessageSquare />}
title="Canned Message"
status={moduleConfig.cannedMessage.enabled}
>
<CannedMessage />
</CollapsibleSection>
</SidebarOverlay>
{/* End Modules */}
{/* Channels */}
<SidebarOverlay
title="Channels"
open={channelsOpen}
close={(): void => {
setChannelsOpen(false);
}}
direction="x"
>
<ChannelsGroup />
</SidebarOverlay>
{/* End Channels */}
</>
);
};

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

@ -1,28 +0,0 @@
import type React from 'react';
import { Select } from '@components/generic/form/Select';
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');
}}
/>
);
};

141
src/components/layout/Sidebar/Settings/LoRa.tsx

@ -1,141 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const LoRa = (): JSX.Element => {
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware.myNodeNum,
);
const loraConfig = useAppSelector(
(state) => state.meshtastic.radio.config.lora,
);
const [loading, setLoading] = useState(false);
const [usePreset, setUsePreset] = useState(true);
// const { register, handleSubmit, formState, reset } =
// useForm<Protobuf.RadioConfig_UserPreferences>({
// defaultValues: preferences,
// });
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.Config_LoRaConfig>({
defaultValues: loraConfig,
});
useEffect(() => {
reset(loraConfig);
}, [reset, loraConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
const packet = Protobuf.AdminMessage.create({
variant: {
oneofKind: 'setConfig',
setConfig: {
payloadVariant: {
oneofKind: 'lora',
lora: data,
},
},
},
});
void connection.sendPacket(
Protobuf.AdminMessage.toBinary(packet),
Protobuf.PortNum.ADMIN_APP,
myNodeNum,
true,
0,
true,
false,
async (num) => {
return await Promise.resolve();
},
);
// void connection.setPreferences(data, async () => {
// reset({ ...data });
// setLoading(false);
// await Promise.resolve();
// });
});
return (
<>
<Checkbox
checked={usePreset}
label="Use Presets"
onChange={(e): void => setUsePreset(e.target.checked)}
/>
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
{usePreset ? (
<Select
label="Preset"
optionsEnum={Protobuf.Config_LoRaConfig_ModemPreset}
{...register('modemPreset', {
valueAsNumber: true,
})}
/>
) : (
<>
<Input
label="Bandwidth"
type="number"
suffix="MHz"
{...register('bandwidth', {
valueAsNumber: true,
})}
/>
<Input
label="Spread Factor"
type="number"
suffix="CPS"
min={7}
max={12}
{...register('spreadFactor', {
valueAsNumber: true,
})}
/>
<Input
label="Coding Rate"
type="number"
{...register('codingRate', {
valueAsNumber: true,
})}
/>
</>
)}
<Input
label="Transmit Power"
type="number"
suffix="dBm"
{...register('txPower', { valueAsNumber: true })}
/>
<Input
label="Hop Count"
type="number"
suffix="Hops"
{...register('hopLimit', { valueAsNumber: true })}
/>
<Checkbox label="Transmit Disabled" {...register('txDisabled')} />
<Input
label="Frequency Offset"
type="number"
suffix="Hz"
{...register('frequencyOffset', { valueAsNumber: true })}
/>
<Select
label="Region"
optionsEnum={Protobuf.Config_LoRaConfig_RegionCode}
{...register('region', { valueAsNumber: true })}
/>
</Form>
</>
);
};

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

@ -1,140 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { MultiSelect } from 'react-multi-select-component';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Label } from '@components/generic/form/Label';
import { connection } from '@core/connection';
import { bitwiseDecode, bitwiseEncode } from '@core/utils/bitwise';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Position = (): JSX.Element => {
const positionConfig = useAppSelector(
(state) => state.meshtastic.radio.config.position,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.Config_PositionConfig>({
defaultValues: positionConfig,
// defaultValues: {
// ...preferences,
// positionBroadcastSecs:
// preferences.positionBroadcastSecs === 0
// ? preferences.role === Protobuf.Role.Router
// ? 43200
// : 900
// : preferences.positionBroadcastSecs,
// },
});
useEffect(() => {
reset(positionConfig);
}, [reset, positionConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setConfig(
{
payloadVariant: {
oneofKind: 'position',
position: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Input
label="Broadcast Interval"
type="number"
suffix="Seconds"
{...register('positionBroadcastSecs', { valueAsNumber: true })}
/>
<Checkbox
label="Disable Smart Position"
{...register('positionBroadcastSmartDisabled')}
/>
<Checkbox label="Use Fixed Position" {...register('fixedPosition')} />
<Checkbox
label="Disable Location Sharing"
{...register('locationShareDisabled')}
/>
<Checkbox label="Disable GPS" {...register('gpsDisabled')} />
<Input
label="GPS Update Interval"
type="number"
suffix="Seconds"
{...register('gpsUpdateInterval', { valueAsNumber: true })}
/>
<Input
label="Last GPS Attempt"
disabled
{...register('gpsAttemptTime', { valueAsNumber: true })}
/>
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} />
<Input
label="Max DOP"
type="number"
{...register('gpsMaxDop', { 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.Config_PositionConfig_PositionFlags,
)
.filter((value) => typeof value[1] !== 'number')
.filter(
(value) =>
parseInt(value[0]) !==
Protobuf.Config_PositionConfig_PositionFlags
.POS_UNDEFINED,
)
.map((value) => {
return {
value: parseInt(value[0]),
label: value[1].toString().replace('POS_', ''),
};
})}
value={bitwiseDecode(
value,
Protobuf.Config_PositionConfig_PositionFlags,
).map((flag) => {
return {
value: flag,
label: Protobuf.Config_PositionConfig_PositionFlags[
flag
].replace('POS_', ''),
};
})}
onChange={(e: { value: number; label: string }[]): void =>
onChange(bitwiseEncode(e.map((v) => v.value)))
}
labelledBy="Select"
/>
</div>
);
}}
/>
</Form>
);
};

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

@ -1,124 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Power = (): JSX.Element => {
const powerConfig = useAppSelector(
(state) => state.meshtastic.radio.config.power,
);
const deviceConfig = useAppSelector(
(state) => state.meshtastic.radio.config.device,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset } =
useForm<Protobuf.Config_PowerConfig>({
defaultValues: powerConfig,
// defaultValues: {
// ...preferences,
// isLowPower:
// preferences.role === Protobuf.Role.Router
// ? true
// : preferences.isLowPower,
// },
});
useEffect(() => {
reset(powerConfig);
}, [reset, powerConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setConfig(
{
payloadVariant: {
oneofKind: 'power',
power: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Select
label="Charge current"
optionsEnum={Protobuf.Config_PowerConfig_ChargeCurrent}
{...register('chargeCurrent', { valueAsNumber: true })}
/>
<Checkbox
label="Powered by low power source (solar)"
disabled={
deviceConfig.role === Protobuf.Config_DeviceConfig_Role.Router
}
validationMessage={
deviceConfig.role === Protobuf.Config_DeviceConfig_Role.Router
? 'Enabled by default in router mode'
: ''
}
{...register('isLowPower')}
/>
<Checkbox label="Always Powered" {...register('isAlwaysPowered')} />
<Input
label="Shutdown on battery delay"
type="number"
suffix="Seconds"
{...register('onBatteryShutdownAfterSecs', { valueAsNumber: true })}
/>
<Checkbox label="Power Saving" {...register('isPowerSaving')} />
<Input
label="ADC Multiplier Override ratio"
type="number"
{...register('adcMultiplierOverride', { valueAsNumber: true })}
/>
<Input
label="Minumum Wake Time"
suffix="Seconds"
type="number"
{...register('minWakeSecs', { valueAsNumber: true })}
/>
<Input
label="Phone Timeout"
suffix="Seconds"
type="number"
{...register('phoneTimeoutSecs', { valueAsNumber: true })}
/>
<Input
label="Mesh SDS Timeout"
suffix="Seconds"
type="number"
{...register('meshSdsTimeoutSecs', { valueAsNumber: true })}
/>
<Input
label="SDS"
suffix="Seconds"
type="number"
{...register('sdsSecs', { valueAsNumber: true })}
/>
<Input
label="LS"
suffix="Seconds"
type="number"
{...register('lsSecs', { valueAsNumber: true })}
/>
<Input
label="Wait Bluetooth"
suffix="Seconds"
type="number"
{...register('waitBluetoothSecs', { valueAsNumber: true })}
/>
</Form>
);
};

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

@ -1,106 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { base16 } from 'rfc4648';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const User = (): JSX.Element => {
const [loading, setLoading] = useState(false);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware,
).myNodeNum;
const node = useAppSelector((state) => state.meshtastic.nodes).find(
(node) => node.data.num === myNodeNum,
);
const { register, handleSubmit, formState, reset } = useForm<{
longName: string;
shortName: string;
isLicensed: boolean;
antAzimuth: number;
antGainDbi: number;
txPowerDbm: number;
}>({
defaultValues: {
longName: node?.data.user?.longName,
shortName: node?.data.user?.shortName,
isLicensed: node?.data.user?.isLicensed,
antAzimuth: node?.data.user?.antAzimuth,
antGainDbi: node?.data.user?.antGainDbi,
txPowerDbm: node?.data.user?.txPowerDbm,
},
});
useEffect(() => {
reset({
longName: node?.data.user?.longName,
shortName: node?.data.user?.shortName,
isLicensed: node?.data.user?.isLicensed,
});
}, [reset, node]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
if (node?.data.user) {
void connection.setOwner({ ...node.data.user, ...data }, async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
});
}
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Input label="Device ID" value={node?.data.user?.id} disabled />
<Input label="Device Name" {...register('longName')} />
<Input label="Short Name" maxLength={3} {...register('shortName')} />
<Input
label="Mac Address"
defaultValue={
base16
.stringify(node?.data.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(':') ?? ''
}
disabled
/>
<Input
label="Hardware (DEPRECATED)"
value={
Protobuf.HardwareModel[
node?.data.user?.hwModel ?? Protobuf.HardwareModel.UNSET
]
}
disabled
/>
<Checkbox label="Licenced Operator?" {...register('isLicensed')} />
<Input
label="Transmit Power"
suffix="dBm"
type="number"
{...register('txPowerDbm', { valueAsNumber: true })}
/>
<Input
label="Antenna Gain"
suffix="dBi"
type="number"
{...register('antGainDbi', { valueAsNumber: true })}
/>
<Input
label="Antenna Azimuth"
suffix="°"
type="number"
{...register('antAzimuth', { valueAsNumber: true })}
/>
</Form>
);
};

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

@ -1,62 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const WiFi = (): JSX.Element => {
const wifiConfig = useAppSelector(
(state) => state.meshtastic.radio.config.wifi,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.Config_WiFiConfig>({
defaultValues: wifiConfig,
});
const WifiApMode = useWatch({
control,
name: 'apMode',
defaultValue: false,
});
useEffect(() => {
reset(wifiConfig);
}, [reset, wifiConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setConfig(
{
payloadVariant: {
oneofKind: 'wifi',
wifi: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Enable WiFi AP" {...register('apMode')} />
<Input label="WiFi SSID" disabled={WifiApMode} {...register('ssid')} />
<Input
type="password"
autoComplete="off"
label="WiFi PSK"
disabled={WifiApMode}
{...register('psk')}
/>
</Form>
);
};

127
src/components/layout/Sidebar/Settings/channels/Channels.tsx

@ -1,127 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { fromByteArray, toByteArray } from 'base64-js';
import { useForm } from 'react-hook-form';
import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
import { IconButton } from '@components/generic/button/IconButton';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const Channels = ({ channel }: SettingsPanelProps): JSX.Element => {
const [loading, setLoading] = useState(false);
const [keySize, setKeySize] = useState<128 | 256>(256);
const [pskHidden, setPskHidden] = useState(true);
const { register, handleSubmit, setValue, formState, reset } = useForm<
Omit<Protobuf.ChannelSettings, 'psk'> & { psk: string; enabled: boolean }
>({
defaultValues: {
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
},
});
useEffect(() => {
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
});
}, [channel, reset]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
const channelData = Protobuf.Channel.create({
role:
channel?.role === Protobuf.Channel_Role.PRIMARY
? Protobuf.Channel_Role.PRIMARY
: data.enabled
? Protobuf.Channel_Role.SECONDARY
: Protobuf.Channel_Role.DISABLED,
index: channel?.index,
settings: {
...data,
psk: toByteArray(data.psk ?? ''),
},
});
await connection.setChannel(channelData, (): Promise<void> => {
reset({ ...data });
setLoading(false);
return Promise.resolve();
});
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
{channel?.index !== 0 && (
<>
<Checkbox
label="Enabled"
{...register('enabled', { valueAsNumber: true })}
/>
<Input label="Name" {...register('name')} />
</>
)}
<Select
label="Key Size"
options={[
{ name: '128 Bit', value: 128 },
{ name: '256 Bit', value: 256 },
]}
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
/>
<Input
label="Pre-Shared Key"
type={pskHidden ? 'password' : 'text'}
disabled
action={
<>
<IconButton
onClick={(): void => {
setPskHidden(!pskHidden);
}}
icon={pskHidden ? <MdVisibility /> : <MdVisibilityOff />}
/>
<IconButton
onClick={(): void => {
const key = new Uint8Array(keySize);
crypto.getRandomValues(key);
setValue('psk', fromByteArray(key));
}}
icon={<MdRefresh />}
/>
</>
}
{...register('psk')}
/>
<Checkbox label="Uplink Enabled" {...register('uplinkEnabled')} />
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
</Form>
);
};

43
src/components/layout/Sidebar/Settings/channels/ChannelsGroup.tsx

@ -1,43 +0,0 @@
import type React from 'react';
import { CollapsibleSection } from '@components/generic/Sidebar/CollapsibleSection';
import { Channels } from '@components/layout/Sidebar/Settings/channels/Channels';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const ChannelsGroup = (): JSX.Element => {
const channels = useAppSelector((state) => state.meshtastic.radio.channels);
return (
<>
{channels.map((channel) => {
return (
<div key={channel.index}>
<CollapsibleSection
title={
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channel.index}`
}
icon={
<div
className={`h-3 w-3 rounded-full ${
channel.role === Protobuf.Channel_Role.PRIMARY
? 'bg-orange-500'
: channel.role === Protobuf.Channel_Role.SECONDARY
? 'bg-green-500'
: 'bg-gray-500'
}`}
/>
}
>
<Channels channel={channel} />
</CollapsibleSection>
</div>
);
})}
</>
);
};

102
src/components/layout/Sidebar/Settings/modules/CannedMessage.tsx

@ -1,102 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const CannedMessage = (): JSX.Element => {
const cannedMessageConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.cannedMessage,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_CannedMessageConfig>({
defaultValues: cannedMessageConfig,
});
const moduleEnabled = useWatch({
control,
name: 'rotary1Enabled',
defaultValue: false,
});
useEffect(() => {
reset(cannedMessageConfig);
}, [reset, cannedMessageConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'cannedMessage',
cannedMessage: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Enabled" {...register('enabled')} />
<Checkbox
label="Rotary Encoder #1 Enabled"
{...register('rotary1Enabled')}
/>
<Input
label="Encoder Pin A"
type="number"
disabled={moduleEnabled}
{...register('inputbrokerPinA', { valueAsNumber: true })}
/>
<Input
label="Encoder Pin B"
type="number"
disabled={moduleEnabled}
{...register('inputbrokerPinB', { valueAsNumber: true })}
/>
<Input
label="Endoer Pin Press"
type="number"
disabled={moduleEnabled}
{...register('inputbrokerPinPress', { valueAsNumber: true })}
/>
<Select
label="Clockwise event"
disabled={moduleEnabled}
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar}
{...register('inputbrokerEventCw', { valueAsNumber: true })}
/>
<Select
label="Counter Clockwise event"
disabled={moduleEnabled}
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar}
{...register('inputbrokerEventCcw', { valueAsNumber: true })}
/>
<Select
label="Press event"
disabled={moduleEnabled}
optionsEnum={Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar}
{...register('inputbrokerEventPress', { valueAsNumber: true })}
/>
<Checkbox label="Up Down enabled" {...register('updown1Enabled')} />
<Input
label="Allow Input Source"
disabled={moduleEnabled}
{...register('allowInputSource')}
/>
<Checkbox label="Send Bell" {...register('sendBell')} />
</Form>
);
};

89
src/components/layout/Sidebar/Settings/modules/ExternalNotifications.tsx

@ -1,89 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
const [loading, setLoading] = useState(false);
const extNotificationConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.extNotification,
);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_ExternalNotificationConfig>({
defaultValues: extNotificationConfig,
});
useEffect(() => {
reset(extNotificationConfig);
}, [reset, extNotificationConfig]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'externalNotification',
externalNotification: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
const moduleEnabled = useWatch({
control,
name: 'enabled',
defaultValue: false,
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Enabled" {...register('enabled')} />
<Input
type="number"
label="Output MS"
suffix="ms"
disabled={!moduleEnabled}
{...register('outputMs', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="Output"
disabled={!moduleEnabled}
{...register('output', {
valueAsNumber: true,
})}
/>
<Checkbox
label="Active"
disabled={!moduleEnabled}
{...register('active')}
/>
<Checkbox
label="Message"
disabled={!moduleEnabled}
{...register('alertMessage')}
/>
<Checkbox
label="Bell"
disabled={!moduleEnabled}
{...register('alertBell')}
/>
</Form>
);
};

76
src/components/layout/Sidebar/Settings/modules/MQTT.tsx

@ -1,76 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const MQTT = (): JSX.Element => {
const mqttConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.mqtt,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_MQTTConfig>({
defaultValues: mqttConfig,
});
const moduleEnabled = useWatch({
control,
name: 'disabled',
defaultValue: false,
});
useEffect(() => {
reset(mqttConfig);
}, [reset, mqttConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'mqtt',
mqtt: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Disabled" {...register('disabled')} />
<Input
label="MQTT Server Address"
disabled={moduleEnabled}
{...register('address')}
/>
<Input
label="MQTT Username"
disabled={moduleEnabled}
{...register('username')}
/>
<Input
label="MQTT Password"
type="password"
autoComplete="off"
disabled={moduleEnabled}
{...register('password')}
/>
<Checkbox
label="Encryption Enabled"
disabled={moduleEnabled}
{...register('encryptionEnabled')}
/>
</Form>
);
};

71
src/components/layout/Sidebar/Settings/modules/RangeTest.tsx

@ -1,71 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const RangeTestSettingsPanel = (): JSX.Element => {
const [loading, setLoading] = useState(false);
const rangeTestConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.rangeTest,
);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_RangeTestConfig>({
defaultValues: rangeTestConfig,
});
useEffect(() => {
reset(rangeTestConfig);
}, [reset, rangeTestConfig]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'rangeTest',
rangeTest: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
const moduleEnabled = useWatch({
control,
name: 'enabled',
defaultValue: false,
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Enabled" {...register('enabled')} />
<Input
type="number"
label="Message Interval"
disabled={!moduleEnabled}
suffix="Seconds"
{...register('sender', {
valueAsNumber: true,
})}
/>
<Checkbox
label="Save CSV to storage"
disabled={!moduleEnabled}
{...register('save')}
/>
</Form>
);
};

99
src/components/layout/Sidebar/Settings/modules/Serial.tsx

@ -1,99 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const SerialSettingsPanel = (): JSX.Element => {
const [loading, setLoading] = useState(false);
const serialConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.serial,
);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_SerialConfig>({
defaultValues: serialConfig,
});
useEffect(() => {
reset(serialConfig);
}, [reset, serialConfig]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'serial',
serial: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
const moduleEnabled = useWatch({
control,
name: 'enabled',
defaultValue: false,
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Enabled" {...register('enabled')} />
<Checkbox label="Echo" disabled={!moduleEnabled} {...register('echo')} />
<Input
type="number"
label="RX"
disabled={!moduleEnabled}
{...register('rxd', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="TX Pin"
disabled={!moduleEnabled}
{...register('txd', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="Baud Rate"
disabled={!moduleEnabled}
{...register('baud', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="Timeout"
disabled={!moduleEnabled}
{...register('timeout', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="Mode"
disabled={!moduleEnabled}
{...register('mode', {
valueAsNumber: true,
})}
/>
</Form>
);
};

87
src/components/layout/Sidebar/Settings/modules/StoreForward.tsx

@ -1,87 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export const StoreForwardSettingsPanel = (): JSX.Element => {
const [loading, setLoading] = useState(false);
const storeForwardConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.storeForward,
);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_StoreForwardConfig>({
defaultValues: storeForwardConfig,
});
useEffect(() => {
reset(storeForwardConfig);
}, [reset, storeForwardConfig]);
const onSubmit = handleSubmit(async (data) => {
setLoading(true);
await connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'storeForward',
storeForward: data,
},
},
async (): Promise<void> => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
const moduleEnabled = useWatch({
control,
name: 'enabled',
defaultValue: false,
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox label="Module Enabled" {...register('enabled')} />
<Checkbox
label="Heartbeat Enabled"
disabled={!moduleEnabled}
{...register('heartbeat')}
/>
<Input
type="number"
label="Number of records"
suffix="Records"
disabled={!moduleEnabled}
{...register('records', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="History return max"
disabled={!moduleEnabled}
{...register('historyReturnMax', {
valueAsNumber: true,
})}
/>
<Input
type="number"
label="History return window"
disabled={!moduleEnabled}
{...register('historyReturnWindow', {
valueAsNumber: true,
})}
/>
</Form>
);
};

97
src/components/layout/Sidebar/Settings/modules/Telemetry.tsx

@ -1,97 +0,0 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Checkbox } from '@components/generic/form/Checkbox';
import { Form } from '@components/generic/form/Form';
import { Input } from '@components/generic/form/Input';
import { Select } from '@components/generic/form/Select';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf } from '@meshtastic/meshtasticjs';
export const Telemetry = (): JSX.Element => {
const telemetryConfig = useAppSelector(
(state) => state.meshtastic.radio.moduleConfig.telemetry,
);
const [loading, setLoading] = useState(false);
const { register, handleSubmit, formState, reset, control } =
useForm<Protobuf.ModuleConfig_TelemetryConfig>({
defaultValues: telemetryConfig,
});
useEffect(() => {
reset(telemetryConfig);
}, [reset, telemetryConfig]);
const onSubmit = handleSubmit((data) => {
setLoading(true);
void connection.setModuleConfig(
{
payloadVariant: {
oneofKind: 'telemetry',
telemetry: data,
},
},
async () => {
reset({ ...data });
setLoading(false);
await Promise.resolve();
},
);
});
return (
<Form loading={loading} dirty={!formState.isDirty} submit={onSubmit}>
<Checkbox
label="Measurement Enabled"
{...register('environmentMeasurementEnabled')}
/>
<Checkbox
label="Displayed on Screen"
{...register('environmentScreenEnabled')}
/>
<Input
label="Read Error Count Threshold"
type="number"
{...register('environmentReadErrorCountThreshold', {
valueAsNumber: true,
})}
/>
<Input
label="Update Interval"
suffix="Seconds"
type="number"
{...register('environmentUpdateInterval', {
valueAsNumber: true,
})}
/>
<Input
label="Recovery Interval"
suffix="Seconds"
type="number"
{...register('environmentRecoveryInterval', {
valueAsNumber: true,
})}
/>
<Checkbox
label="Display Farenheit"
{...register('environmentDisplayFahrenheit')}
/>
<Select
label="Sensor Type"
optionsEnum={Protobuf.TelemetrySensorType}
{...register('environmentSensorType', {
valueAsNumber: true,
})}
/>
<Input
label="Sensor Pin"
type="number"
{...register('environmentSensorPin', {
valueAsNumber: true,
})}
/>
</Form>
);
};

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

@ -1,38 +0,0 @@
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 (
<div
onClick={(): void => {
setSelected();
}}
className={`mx-2 flex cursor-pointer select-none rounded-md border bg-gray-200 p-2 shadow-md first:mt-2 last:mb-2 hover:border-primary dark:bg-tertiaryDark dark:hover:border-primary ${
selected ? 'border-primary' : 'border-gray-400 dark:border-gray-600'
}`}
>
<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>
</div>
);
};

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

@ -1,33 +1,120 @@
import type React from 'react';
import type React from "react";
import { useState } from "react";
import { Settings } from '@components/layout/Sidebar/Settings/Index';
import { useAppSelector } from '@hooks/useAppSelector';
import {
ArrayIcon,
GlobeIcon,
IconComponent,
InboxIcon,
InfoSignIcon,
LabTestIcon,
LayersIcon,
majorScale,
Pane,
SettingsIcon,
Tab,
Tablist,
} from "evergreen-ui";
export interface SidebarProps {
children: React.ReactNode;
setSettingsOpen: (settingsOpen: boolean) => void;
settingsOpen: boolean;
import { PeersDialog } from "@app/components/Dialog/PeersDialog.js";
import { Page, useDevice } from "@app/core/stores/deviceStore.js";
import { DeviceCard } from "./DeviceCard.js";
interface NavLink {
name: string;
icon: IconComponent;
page: Page;
disabled?: boolean;
}
export const Sidebar = ({
settingsOpen,
setSettingsOpen,
children,
}: SidebarProps): JSX.Element => {
const appState = useAppSelector((state) => state.app);
export const Sidebar = (): JSX.Element => {
const { activePage, setActivePage } = useDevice();
const [PeersDialogOpen, setPeersDialogOpen] = useState(false);
const navLinks: NavLink[] = [
{
name: "Messages",
icon: InboxIcon,
page: "messages",
},
{
name: "Map",
icon: GlobeIcon,
page: "map",
disabled: true,
},
{
name: "Extensions",
icon: LabTestIcon,
page: "extensions",
},
{
name: "Config",
icon: SettingsIcon,
page: "config",
},
{
name: "Channels",
icon: LayersIcon,
page: "channels",
},
{
name: "Info",
icon: InfoSignIcon,
page: "info",
},
];
return (
<div
className={`absolute z-20 h-full w-full flex-grow flex-col md:relative md:flex md:w-96 ${
appState.mobileNavOpen ? 'flex' : 'hidden'
}`}
<Pane
display="flex"
flexDirection="column"
width="100%"
flexGrow={1}
margin={majorScale(3)}
padding={majorScale(2)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
>
<div className="flex h-full w-full flex-col drop-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>
</div>
</div>
<Tablist>
{navLinks.map((Link) => (
<Tab
key={Link.name}
gap={majorScale(2)}
disabled={Link.disabled}
direction="vertical"
isSelected={Link.page === activePage}
onSelect={() => {
setActivePage(Link.page);
}}
>
<Link.icon />
{Link.name}
</Tab>
))}
<Tab
gap={5}
direction="vertical"
isSelected={PeersDialogOpen}
onSelect={() => {
setPeersDialogOpen(true);
}}
>
<ArrayIcon />
Peers
</Tab>
</Tablist>
<PeersDialog
isOpen={PeersDialogOpen}
close={() => {
setPeersDialogOpen(false);
}}
/>
<Pane display="flex" flexGrow={1}>
<DeviceCard />
</Pane>
</Pane>
);
};

91
src/components/layout/index.tsx

@ -1,91 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { FiMessageCircle, FiSettings } from 'react-icons/fi';
import { RiRoadMapLine } from 'react-icons/ri';
import { VscExtensions } from 'react-icons/vsc';
import { ErrorFallback } from '@components/ErrorFallback';
import { IconButton } from '@components/generic/button/IconButton';
import { Sidebar } from '@components/layout/Sidebar';
import type { TabProps } from '@components/Tab';
import { Tabs } from '@components/Tabs';
import { routes, useRoute } from '@core/router';
export interface LayoutProps {
title: string;
icon: React.ReactNode;
sidebarContents: React.ReactNode;
children: React.ReactNode;
}
export const Layout = ({
title,
icon,
sidebarContents,
children,
}: LayoutProps): JSX.Element => {
const [settingsOpen, setSettingsOpen] = useState(false);
const route = useRoute();
const tabs: Omit<TabProps, 'activeLeft' | 'activeRight'>[] = [
{
title: 'Messages',
icon: <FiMessageCircle />,
link: routes.messages().link,
active: route.name === 'messages',
},
{
title: 'Map',
icon: <RiRoadMapLine />,
link: routes.map().link,
active: route.name === 'map',
},
{
title: 'Extensions',
icon: <VscExtensions />,
link: routes.extensions().link,
active: route.name === 'extensions',
},
];
return (
<div className="relative flex w-full overflow-hidden bg-white dark:bg-secondaryDark">
<div className="flex flex-grow">
<Sidebar settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen}>
<div className="bg-white px-1 pt-1 drop-shadow-md dark:bg-primaryDark">
<div className="flex h-10 gap-1">
<div className="my-auto">
<IconButton icon={icon} />
</div>
<div className="my-auto text-lg font-medium dark:text-white">
{title}
</div>
</div>
</div>
<div className="flex flex-col gap-2">{sidebarContents}</div>
</Sidebar>
</div>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className="flex h-full w-full flex-col bg-gray-300 dark:bg-secondaryDark">
<div className="flex w-full bg-white pt-1 dark:bg-primaryDark">
<div className="z-10 -mr-2 h-8">
<IconButton
className="m-1"
icon={<FiSettings />}
onClick={(): void => {
setSettingsOpen(!settingsOpen);
}}
active={settingsOpen}
/>
</div>
<Tabs tabs={tabs} />
</div>
<div className="flex flex-grow">{children}</div>
</div>
</ErrorBoundary>
</div>
);
};

74
src/components/layout/page/TabbedContent.tsx

@ -0,0 +1,74 @@
import type React from "react";
import { IconComponent, majorScale, Pane, Tab, Tablist } from "evergreen-ui";
export interface Tab {
key: number;
name: string;
icon: IconComponent;
element: () => JSX.Element;
disabled?: boolean;
}
export interface TabbedContentProps {
active: number;
setActive: (index: number) => void;
tabs: Tab[];
actions?: (() => JSX.Element)[];
}
export const TabbedContent = ({
active,
setActive,
tabs,
actions,
}: TabbedContentProps): JSX.Element => {
return (
<Pane
margin={majorScale(3)}
borderRadius={majorScale(1)}
background="white"
elevation={1}
display="flex"
flexGrow={1}
flexDirection="column"
padding={majorScale(2)}
gap={majorScale(2)}
>
<Pane borderBottom="muted" paddingBottom={majorScale(2)}>
<Pane display="flex">
<Tablist>
{tabs.map((Entry) => (
<Tab
key={Entry.key}
disabled={Entry.disabled}
gap={5}
onSelect={() => setActive(Entry.key)}
isSelected={active === Entry.key}
>
<Entry.icon />
{Entry.name}
</Tab>
))}
</Tablist>
<Pane marginLeft="auto">
{actions?.map((Action, index) => (
<Action key={index} />
))}
</Pane>
</Pane>
</Pane>
{tabs.map((Entry) => (
<Pane
key={Entry.key}
display={active === Entry.key ? "flex" : "none"}
flexDirection="column"
flexGrow={1}
>
<Entry.element />
</Pane>
))}
</Pane>
);
};

203
src/components/menu/BottomNav.tsx

@ -1,203 +0,0 @@
import type React from 'react';
import { useState } from 'react';
import {
FiBluetooth,
FiCpu,
FiGitBranch,
FiMenu,
FiMoon,
FiSun,
FiWifi,
FiX,
} from 'react-icons/fi';
import {
IoBatteryChargingOutline,
IoBatteryDeadOutline,
IoBatteryFullOutline,
} from 'react-icons/io5';
import { MdUpgrade } from 'react-icons/md';
import {
RiArrowDownLine,
RiArrowUpDownLine,
RiArrowUpLine,
} from 'react-icons/ri';
import { BottomNavItem } from '@components/menu/BottomNavItem';
import { VersionInfo } from '@components/modals/VersionInfo';
import {
connType,
openConnectionModal,
setDarkModeEnabled,
toggleMobileNav,
} from '@core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
export const BottomNav = (): JSX.Element => {
const [showVersionInfo, setShowVersionInfo] = useState(false);
const dispatch = useAppDispatch();
const meshtasticState = useAppSelector((state) => state.meshtastic);
const appState = useAppSelector((state) => state.app);
const primaryChannelSettings = useAppSelector(
(state) => state.meshtastic.radio.channels,
).find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)?.settings;
const metrics =
meshtasticState.nodes[meshtasticState.radio.hardware.myNodeNum]?.metrics;
return (
<div className="z-20 flex justify-between divide-x divide-gray-400 border-t border-gray-400 bg-white dark:divide-gray-600 dark:border-gray-600 dark:bg-secondaryDark">
<BottomNavItem tooltip="Meshtastic WebUI">
<img
title="Logo"
className="my-auto w-5"
src={appState.darkMode ? '/Logo_White.svg' : '/Logo_Black.svg'}
/>
</BottomNavItem>
<BottomNavItem
tooltip="Connection Status"
onClick={(): void => {
dispatch(openConnectionModal());
}}
className={
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
].includes(meshtasticState.deviceStatus)
? 'bg-primary dark:bg-primary'
: [
Types.DeviceStatusEnum.DEVICE_CONNECTING,
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
].includes(meshtasticState.deviceStatus)
? 'bg-yellow-400 dark:bg-yellow-400'
: ''
}
>
{appState.connType === connType.BLE ? (
<FiBluetooth className="h-4" />
) : appState.connType === connType.SERIAL ? (
<FiCpu className="h-4" />
) : (
<FiWifi className="h-4" />
)}
<div className="truncate text-xs font-medium">
{meshtasticState.nodes.find(
(node) =>
node.data.num === meshtasticState.radio.hardware.myNodeNum,
)?.data.user?.longName ?? 'Disconnected'}
</div>
</BottomNavItem>
<BottomNavItem tooltip="Battery Level">
{!metrics?.batteryLevel ? (
<IoBatteryDeadOutline className="h-4" />
) : metrics?.batteryLevel > 50 ? (
<IoBatteryFullOutline className="h-4" />
) : metrics?.batteryLevel > 0 ? (
<IoBatteryFullOutline className="h-4" />
) : (
<IoBatteryChargingOutline className="h-4" />
)}
<div className="truncate text-xs font-medium">
{metrics?.batteryLevel
? `${metrics?.batteryLevel}% - ${metrics?.voltage}v`
: 'No Battery'}
</div>
</BottomNavItem>
<BottomNavItem tooltip="Network Utilization">
<div className="m-auto h-3 w-3 rounded-full bg-primary" />
<div className="truncate text-xs font-medium">
{`${metrics?.airUtilTx ?? 0}% - Air`} |
</div>
<div
className={`m-auto h-3 w-3 rounded-full ${
!metrics?.channelUtilization
? 'bg-primary'
: metrics?.channelUtilization > 50
? 'bg-red-400'
: metrics?.channelUtilization > 24
? 'bg-yellow-400'
: 'bg-primary'
}`}
/>
<div className="truncate text-xs font-medium">
{`${metrics?.channelUtilization ?? 0}% - Ch`}
</div>
</BottomNavItem>
<BottomNavItem tooltip="MQTT Status">
{primaryChannelSettings?.uplinkEnabled &&
primaryChannelSettings?.downlinkEnabled &&
!meshtasticState.radio.moduleConfig.mqtt.disabled ? (
<RiArrowUpDownLine className="h-4" />
) : primaryChannelSettings?.uplinkEnabled &&
!meshtasticState.radio.moduleConfig.mqtt.disabled ? (
<RiArrowUpLine className="h-4" />
) : primaryChannelSettings?.downlinkEnabled &&
!meshtasticState.radio.moduleConfig.mqtt.disabled ? (
<RiArrowDownLine className="h-4" />
) : (
<FiX className="h-4" />
)}
</BottomNavItem>
<div className="flex-grow">
<BottomNavItem
onClick={(): void => {
dispatch(toggleMobileNav());
}}
className="md:hidden"
>
{appState.mobileNavOpen ? (
<FiX className="m-auto h-4" />
) : (
<FiMenu className="m-auto h-4" />
)}
</BottomNavItem>
</div>
<BottomNavItem
tooltip={
appState.updateAvaliable ? 'Update Avaliable' : 'Current Commit'
}
onClick={(): void => {
setShowVersionInfo(true);
}}
className={appState.updateAvaliable ? 'animate-pulse' : ''}
>
{appState.updateAvaliable ? (
<MdUpgrade className="h-4" />
) : (
<FiGitBranch className="h-4" />
)}
<p className="text-xs opacity-60">{process.env.COMMIT_HASH}</p>
</BottomNavItem>
<BottomNavItem
tooltip="Toggle Theme"
onClick={(): void => {
dispatch(setDarkModeEnabled(!appState.darkMode));
}}
>
{appState.darkMode ? (
<FiSun className="h-4" />
) : (
<FiMoon className="h-4" />
)}
</BottomNavItem>
<VersionInfo
modalOpen={showVersionInfo}
onClose={(): void => {
setShowVersionInfo(false);
}}
/>
</div>
);
};

32
src/components/menu/BottomNavItem.tsx

@ -1,32 +0,0 @@
import type React from 'react';
import { m } from 'framer-motion';
import { Tooltip } from '@components/generic/Tooltip';
export interface BottomNavItemProps {
tooltip?: string;
onClick?: () => void;
className?: string;
children: React.ReactNode;
}
export const BottomNavItem = ({
tooltip,
onClick,
className,
children,
}: BottomNavItemProps) => {
return (
<Tooltip disabled={!tooltip} content={tooltip}>
<div
onClick={onClick}
className={`group flex h-full cursor-pointer select-none p-1 hover:bg-gray-300 dark:text-white dark:hover:bg-primaryDark ${className}`}
>
<m.div className="flex w-full gap-1" whileTap={{ scale: 0.99 }}>
{children}
</m.div>
</div>
</Tooltip>
);
};

31
src/components/menu/buttons/CopyButton.tsx

@ -1,31 +0,0 @@
import type React from 'react';
import { FiCheck, FiClipboard } from 'react-icons/fi';
import useCopyClipboard from 'react-use-clipboard';
import type { ButtonProps } from '@components/generic/button/Button';
import { IconButton } from '@components/generic/button/IconButton';
export interface CopyButtonProps extends ButtonProps {
data: string;
}
export const CopyButton = ({
data,
...props
}: CopyButtonProps): JSX.Element => {
const [isCopied, setCopied] = useCopyClipboard(data, {
successDuration: 1000,
});
return (
<IconButton
placeholder={``}
onClick={(): void => {
setCopied();
}}
icon={isCopied ? <FiCheck /> : <FiClipboard />}
{...props}
/>
);
};

18
src/components/misc/NoDevice.tsx

@ -0,0 +1,18 @@
import type React from "react";
import { DisableIcon, EmptyState, Pane } from "evergreen-ui";
export const NoDevice = (): JSX.Element => {
return (
<Pane elevation={1} margin="auto">
<EmptyState
title="No Device Connected"
orientation="horizontal"
background="light"
icon={<DisableIcon color="#EBAC91" />}
iconBgColor="#F8E3DA"
description="You must connect a Meshtastic device to continue."
/>
</Pane>
);
};

110
src/components/modals/VersionInfo.tsx

@ -1,110 +0,0 @@
import type React from 'react';
import { useEffect } from 'react';
import { MdUpgrade } from 'react-icons/md';
import useSWR from 'swr';
import { IconButton } from '@components/generic/button/IconButton';
import { Modal } from '@components/generic/Modal';
import { connectionUrl } from '@core/connection';
import { setUpdateAvaliable } from '@core/slices/appSlice';
import { fetcher } from '@core/utils/fetcher';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
export interface Commit {
sha: string;
node_id: string;
commit: {
author: string;
committer: {
date: string;
email: string;
mame: string;
};
message: string;
tree: {
sha: string;
url: string;
};
url: string;
comment_count: number;
};
url: string;
html_url: string;
comments_url: string;
}
export interface VersionInfoProps {
modalOpen: boolean;
onClose: () => void;
}
export const VersionInfo = ({
modalOpen,
onClose,
}: VersionInfoProps): JSX.Element => {
const appState = useAppSelector((state) => state.app);
const dispatch = useAppDispatch();
const { data } = useSWR<Commit[]>(
'https://api.github.com/repos/meshtastic/meshtastic-web/commits?per_page=100',
fetcher,
{
revalidateOnFocus: false,
},
);
useEffect(() => {
if (data) {
const index = data.findIndex(
(commit) => commit.sha.substring(0, 7) === process.env.COMMIT_HASH,
);
if (index === -1 || index > 0) {
dispatch(setUpdateAvaliable(true));
}
}
}, [data, dispatch]);
return (
<Modal
open={modalOpen}
title="Version Info"
bgDismiss
actions={
// TODO: Check if version is hosted, and merge pwa update button here
appState.updateAvaliable && (
<a href={`http://${connectionUrl}/admin/spiffs`}>
<IconButton tooltip="Update now" icon={<MdUpgrade />} />
</a>
)
}
onClose={(): void => {
onClose();
}}
>
<div className="flex h-96 flex-col gap-1 overflow-y-auto dark:text-white">
{data &&
data.map((commit) => (
<div
key={commit.sha}
className={`flex gap-2 rounded-md border border-transparent py-1 px-2 hover:border-primary ${
commit.sha.substring(0, 7) === process.env.COMMIT_HASH
? 'bg-primary'
: 'dark:bg-secondaryDark'
}`}
>
<div className="my-auto text-xs dark:text-gray-400">
{new Date(commit.commit.committer.date).toLocaleDateString()}
</div>
<div className="my-auto font-mono text-sm">
{commit.sha.substring(0, 7)}
</div>
<div className="truncate">{commit.commit.message}</div>
</div>
))}
</div>
</Modal>
);
};

29
src/components/pwa/ReloadPrompt.css

@ -1,29 +0,0 @@
.ReloadPrompt-container {
padding: 0;
margin: 0;
width: 0;
height: 0;
}
.ReloadPrompt-toast {
position: fixed;
right: 0;
bottom: 0;
margin: 16px;
padding: 12px;
border: 1px solid #8885;
border-radius: 4px;
z-index: 100;
text-align: left;
box-shadow: 3px 4px 5px 0 #8885;
background-color: white;
}
.ReloadPrompt-toast-message {
margin-bottom: 8px;
}
.ReloadPrompt-toast-button {
border: 1px solid #8885;
outline: none;
margin-right: 5px;
border-radius: 2px;
padding: 3px 10px;
}

60
src/components/pwa/ReloadPrompt.tsx

@ -1,60 +0,0 @@
import './ReloadPrompt.css';
// eslint-disable-next-line no-use-before-define
import type React from 'react';
// eslint-disable-next-line import/no-unresolved
import { useRegisterSW } from 'virtual:pwa-register/react';
export const ReloadPrompt = (): JSX.Element => {
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
// eslint-disable-next-line prefer-template
console.log(`SW Registered:`, r);
},
onRegisterError(error) {
console.log('SW registration error', error);
},
});
const close = (): void => {
setOfflineReady(false);
setNeedRefresh(false);
};
return (
<div className="ReloadPrompt-container">
{(offlineReady || needRefresh) && (
<div className="ReloadPrompt-toast">
<div className="ReloadPrompt-message">
{offlineReady ? (
<span>App ready to work offline</span>
) : (
<span>
New content available, click on reload button to update.
</span>
)}
</div>
{needRefresh && (
<button
className="ReloadPrompt-toast-button"
onClick={(): Promise<void> => updateServiceWorker(true)}
>
Reload
</button>
)}
<button
className="ReloadPrompt-toast-button"
onClick={(): void => close()}
>
Close
</button>
</div>
)}
</div>
);
};

205
src/core/connection.ts

@ -1,205 +0,0 @@
import { connType } from '@core/slices/appSlice';
import {
addChannel,
addChat,
addLogEvent,
addMessage,
addNode,
addPosition,
addUser,
resetState,
setConfig,
setDeviceStatus,
setLastMeshInterraction,
setModuleConfig,
setMyNodeInfo,
setReady,
updateLastInteraction,
} from '@core/slices/meshtasticSlice';
import { store } from '@core/store';
import {
IBLEConnection,
IHTTPConnection,
ISerialConnection,
Protobuf,
SettingsManager,
Types,
} from '@meshtastic/meshtasticjs';
type connectionType = IBLEConnection | IHTTPConnection | ISerialConnection;
export let connection: connectionType = new IHTTPConnection();
const appState = store.getState().app;
export const connectionUrl = appState.connectionParams.HTTP.address;
export const setConnection = async (conn: connType): Promise<void> => {
await connection.disconnect();
cleanupListeners();
switch (conn) {
case connType.HTTP:
connection = new IHTTPConnection();
break;
case connType.BLE:
connection = new IBLEConnection();
break;
case connType.SERIAL:
connection = new ISerialConnection();
break;
}
registerListeners();
const connectionParams = store.getState().app.connectionParams;
console.log(connectionParams);
switch (conn) {
case connType.HTTP:
await connection.connect(connectionParams.HTTP);
break;
case connType.BLE:
await connection.connect(
// @ts-ignore tmp
connectionParams.BLE,
);
break;
case connType.SERIAL:
await connection.connect(
// @ts-ignore tmp
connectionParams.SERIAL,
);
break;
}
};
export const cleanupListeners = (): void => {
connection.onMeshPacket.cancelAll();
connection.onDeviceStatus.cancelAll();
connection.onMyNodeInfo.cancelAll();
connection.onUserPacket.cancelAll();
connection.onPositionPacket.cancelAll();
connection.onNodeInfoPacket.cancelAll();
connection.onAdminPacket.cancelAll();
connection.onMeshHeartbeat.cancelAll();
connection.onTextPacket.cancelAll();
};
const registerListeners = (): void => {
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE;
connection.onLogEvent.subscribe((log) => {
store.dispatch(addLogEvent(log));
});
connection.onDeviceStatus.subscribe((status) => {
store.dispatch(setDeviceStatus(status));
if (status === Types.DeviceStatusEnum.DEVICE_CONFIGURED) {
store.dispatch(setReady(true));
void connection.getConfig(Protobuf.AdminMessage_ConfigType.DEVICE_CONFIG);
void connection.getConfig(Protobuf.AdminMessage_ConfigType.WIFI_CONFIG);
void connection.getConfig(
Protobuf.AdminMessage_ConfigType.POSITION_CONFIG,
);
void connection.getConfig(
Protobuf.AdminMessage_ConfigType.DISPLAY_CONFIG,
);
void connection.getConfig(Protobuf.AdminMessage_ConfigType.LORA_CONFIG);
void connection.getConfig(Protobuf.AdminMessage_ConfigType.POWER_CONFIG);
}
if (status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED) {
store.dispatch(setReady(false));
store.dispatch(resetState());
cleanupListeners();
}
});
connection.onMyNodeInfo.subscribe((nodeInfo) => {
store.dispatch(setMyNodeInfo(nodeInfo));
});
connection.onUserPacket.subscribe((user) => {
store.dispatch(addUser(user));
});
connection.onPositionPacket.subscribe((position) => {
store.dispatch(addPosition(position));
});
connection.onNodeInfoPacket.subscribe(
(nodeInfoPacket): void | { payload: Protobuf.NodeInfo; type: string } => {
store.dispatch(addNode(nodeInfoPacket.data));
store.dispatch(addChat(nodeInfoPacket.data.num));
},
);
connection.onAdminPacket.subscribe((adminPacket) => {
console.log(adminPacket.data.variant.oneofKind);
switch (adminPacket.data.variant.oneofKind) {
case 'getChannelResponse':
store.dispatch(addChannel(adminPacket.data.variant.getChannelResponse));
store.dispatch(
addChat(adminPacket.data.variant.getChannelResponse.index),
);
break;
case 'getOwnerResponse':
store.dispatch(
addUser({
data: adminPacket.data.variant.getOwnerResponse,
packet: adminPacket.packet,
}),
);
break;
case 'getConfigResponse':
store.dispatch(setConfig(adminPacket.data.variant.getConfigResponse));
break;
case 'getModuleConfigResponse':
store.dispatch(
setModuleConfig(adminPacket.data.variant.getModuleConfigResponse),
);
break;
}
});
connection.onMeshHeartbeat.subscribe(
(date): void | { payload: number; type: string } =>
store.dispatch(setLastMeshInterraction(date.getTime())),
);
connection.onRoutingPacket.subscribe((routingPacket) => {
console.log(routingPacket.data.variant.oneofKind);
switch (routingPacket.data.variant.oneofKind) {
case 'errorReason':
console.log(
Protobuf.Routing_Error[routingPacket.data.variant.errorReason],
);
break;
default:
break;
}
store.dispatch(
updateLastInteraction({
id: routingPacket.packet.from,
time: new Date(routingPacket.packet.rxTime * 1000),
}),
);
});
connection.onTextPacket.subscribe((message) => {
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;
store.dispatch(
addMessage({
message: message,
ack: message.packet.from !== myNodeNum,
received: message.packet.rxTime
? new Date(message.packet.rxTime * 1000)
: new Date(),
}),
);
});
};

60
src/core/mapStyles.ts

@ -1,60 +0,0 @@
import type { Style } from 'mapbox-gl';
export type MapStyleName =
| 'Streets'
| 'Outdoors'
| 'Light'
| 'Dark'
| 'Satellite';
export interface MapStyle {
title: MapStyleName;
data: Style | string;
}
type MapStyleType = {
[mapStyleType in MapStyleName]: MapStyle;
};
export const MapStyles: MapStyleType = {
Streets: {
title: 'Streets',
data: 'mapbox://styles/mapbox/streets-v11?optimize=true',
} as MapStyle,
Outdoors: {
title: 'Outdoors',
data: 'mapbox://styles/mapbox/outdoors-v11?optimize=true',
} as MapStyle,
Light: {
title: 'Light',
data: 'mapbox://styles/sachaw/cl03ij03g001414l20w0w0ivj?optimize=true',
} as MapStyle,
Dark: {
title: 'Dark',
data: 'mapbox://styles/sachaw/ckwzwm92e1oep14pjunjqlqbo?optimize=true',
} as MapStyle,
Satellite: {
title: 'Satellite',
data: {
version: 8,
layers: [
{
id: 'esri',
type: 'raster',
source: 'esri',
},
],
sources: {
esri: {
type: 'raster',
tiles: [
'https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
maxzoom: 17,
},
},
},
} as MapStyle,
};

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save