Initial 1.3.x rework
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
VITE_PUBLIC_DEVICE_IP=
|
||||
VITE_PUBLIC_HOSTED=
|
||||
@@ -1,5 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
.env
|
||||
stats.html
|
||||
.vercel
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"@meshtastic/eslint-config/prettier"
|
||||
{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Meshtastic Web
|
||||
|
||||
<!--Project specific badges here-->
|
||||
|
||||
[](https://github.com/meshtastic/meshtastic-web/actions/workflows/main.yml)
|
||||
[](https://cla-assistant.io/meshtastic/meshtastic-web)
|
||||
[](https://opencollective.com/meshtastic/)
|
||||
@@ -14,7 +15,7 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
||||
|
||||
## Stats
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
+2
-8
@@ -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
|
||||
|
||||
+32
-35
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1036
-2749
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
+11
-24
@@ -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";
|
||||
|
||||
import { AppLayout } from "@components/layout/AppLayout.js";
|
||||
|
||||
import { PageRouter } from "./PageRouter.js";
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
const route = useRoute();
|
||||
const appState = useAppSelector((state) => state.app);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
​
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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 have changed in this diff Show More
Reference in New Issue
Block a user