Merge branch 'master' into switch-rsbuild-to-vite

This commit is contained in:
Tom Fifield
2025-02-20 08:40:29 +08:00
committed by GitHub
70 changed files with 10074 additions and 302 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Code Formatting
on:
pull_request:
branches: [ main, master ]
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Biome
run: npm install --global @biomejs/biome
- name: Format with Biome
run: biome format --write .
- name: Check for changes
id: git-check
run: |
git diff --quiet || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git add -A
git commit -m "chore: format code"
git push
-1
View File
@@ -68,7 +68,6 @@
"react-map-gl": "7.1.9", "react-map-gl": "7.1.9",
"react-qrcode-logo": "^3.0.0", "react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4", "rfc4648": "^1.5.4",
"timeago-react": "^3.0.6",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3" "zustand": "5.0.3"
}, },
-18
View File
@@ -113,9 +113,6 @@ importers:
rfc4648: rfc4648:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
timeago-react:
specifier: ^3.0.6
version: 3.0.6(react@19.0.0)
vite-plugin-node-polyfills: vite-plugin-node-polyfills:
specifier: ^0.23.0 specifier: ^0.23.0
version: 0.23.0(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.6)(yaml@2.4.5)) version: 0.23.0(rollup@4.34.6)(vite@6.1.0(@types/node@22.12.0)(jiti@1.21.6)(yaml@2.4.5))
@@ -2860,14 +2857,6 @@ packages:
through2@4.0.2: through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
timeago-react@3.0.6:
resolution: {integrity: sha512-4ywnCX3iFjdp84WPK7gt8s4n0FxXbYM+xv8hYL73p83dpcMxzmO+0W4xJuxflnkWNvum5aEaqTe6LZ3lUIudjQ==}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
timeago.js@4.0.2:
resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==}
timers-browserify@2.0.12: timers-browserify@2.0.12:
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==}
engines: {node: '>=0.6.0'} engines: {node: '>=0.6.0'}
@@ -6629,13 +6618,6 @@ snapshots:
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
timeago-react@3.0.6(react@19.0.0):
dependencies:
react: 19.0.0
timeago.js: 4.0.2
timeago.js@4.0.2: {}
timers-browserify@2.0.12: timers-browserify@2.0.12:
dependencies: dependencies:
setimmediate: 1.0.5 setimmediate: 1.0.5
+5
View File
@@ -0,0 +1,5 @@
# Copyright Notice
Copyright © 2024 Meshtastic LLC. All Rights Reserved.
## In reference to the GNU GPLv3 License terms defined in Section 7e
Images (or assets) in this directory are protected under international copyright laws and treaties. Unauthorized reproduction, distribution, modification, or use of these images in any form, commercial or otherwise, outside of official Meshtastic creative works or its Backers and Partners is strictly prohibited without prior written consent from the copyright holder (Meshtastic LLC).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="528.89 806.04 942.22 446.84"><defs><style>.cls-1{fill:#e8eae8;}.cls-2{fill:#dbdddb;}.cls-3{fill:#c6842a;}.cls-4,.cls-5,.cls-7,.cls-9{fill:none;stroke-miterlimit:10;}.cls-4,.cls-5{stroke:#050606;}.cls-4,.cls-9{stroke-width:2.44px;}.cls-5,.cls-7{stroke-width:1.22px;}.cls-6{fill:#b7b7b7;}.cls-7,.cls-9{stroke:#b7b7b7;}.cls-8{fill:#cbcccb;}.cls-10{fill:#434543;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M1469.9,826.68v390.94a19.43,19.43,0,0,1-19.43,19.43H549.53a19.43,19.43,0,0,1-19.43-19.43V826.68a19.42,19.42,0,0,1,19.43-19.42h900.94A19.42,19.42,0,0,1,1469.9,826.68Z"></path><path class="cls-2" d="M574.23,807.26v429.79h-24.7a19.43,19.43,0,0,1-19.43-19.43V826.68a19.42,19.42,0,0,1,19.43-19.42Z"></path><path class="cls-2" d="M1469.9,826.68v390.94a19.43,19.43,0,0,1-19.43,19.43h-37.56V807.26h37.56A19.42,19.42,0,0,1,1469.9,826.68Z"></path><path class="cls-3" d="M574.23,1129.8h-7.47a4.55,4.55,0,0,1-4.55-4.54V919.05a4.55,4.55,0,0,1,4.55-4.55h7.47"></path><rect class="cls-4" x="530.11" y="807.26" width="939.78" height="429.79" rx="19.42"></rect><line class="cls-5" x1="574.23" y1="807.26" x2="574.23" y2="1237.05"></line><path class="cls-5" d="M574.23,1129.8h-7.47a4.55,4.55,0,0,1-4.55-4.54V919.05a4.55,4.55,0,0,1,4.55-4.55h7.47"></path><rect class="cls-6" x="599.01" y="970.1" width="11.52" height="104.11"></rect><rect class="cls-5" x="599.01" y="970.1" width="11.52" height="104.11"></rect><path class="cls-2" d="M610.53,816.78V935.32l38.37,11.55a7.85,7.85,0,0,1,5.6,7.53V1107a7.87,7.87,0,0,1-7.87,7.87h-36.1v110.58H1406V816.78Zm775.41,384.75H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66h754.51Z"></path><path class="cls-1" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><path class="cls-7" d="M610.53,816.78V935.32l38.37,11.55a7.85,7.85,0,0,1,5.6,7.53V1107a7.87,7.87,0,0,1-7.87,7.87h-36.1v110.58H1406V816.78Zm775.41,384.75H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66h754.51Z"></path><path class="cls-7" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><path class="cls-7" d="M1385.94,840.45v361.08H631.43a3.66,3.66,0,0,1-3.66-3.66V1138a3.65,3.65,0,0,1,3.66-3.65h28.3a14.7,14.7,0,0,0,14.71-14.71V931a14.7,14.7,0,0,0-14.71-14.71h-28.3a3.65,3.65,0,0,1-3.66-3.66V844.11a3.66,3.66,0,0,1,3.66-3.66Z"></path><line class="cls-7" x1="666.4" y1="1132.79" x2="666.4" y2="1201.53"></line><line class="cls-7" x1="663.66" y1="916.86" x2="663.66" y2="840.45"></line><circle class="cls-8" cx="644.99" cy="878.66" r="10.7"></circle><circle class="cls-8" cx="644.99" cy="1171.55" r="10.7"></circle><circle class="cls-9" cx="644.99" cy="878.66" r="10.7"></circle><circle class="cls-9" cx="644.99" cy="1171.55" r="10.7"></circle><path class="cls-10" d="M1225,1237.05l2.23,11.19a4.25,4.25,0,0,0,4.17,3.42H1249a4.26,4.26,0,0,0,4.18-3.42l2.22-11.19"></path><path class="cls-10" d="M1306.87,1237.05l2.22,11.19a4.26,4.26,0,0,0,4.18,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.22-11.19"></path><path class="cls-10" d="M1388.77,1237.05l2.23,11.19a4.24,4.24,0,0,0,4.17,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.23-11.19"></path><path class="cls-4" d="M1225,1237.05l2.23,11.19a4.25,4.25,0,0,0,4.17,3.42H1249a4.26,4.26,0,0,0,4.18-3.42l2.22-11.19"></path><path class="cls-4" d="M1306.87,1237.05l2.22,11.19a4.26,4.26,0,0,0,4.18,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.22-11.19"></path><path class="cls-4" d="M1388.77,1237.05l2.23,11.19a4.24,4.24,0,0,0,4.17,3.42h17.62a4.25,4.25,0,0,0,4.17-3.42l2.23-11.19"></path><line class="cls-4" x1="1412.9" y1="807.26" x2="1412.9" y2="1237.05"></line></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="479.57 786.58 1040.84 433.17"><defs><style>.cls-1{fill:#cccccb;}.cls-2{fill:#2b2b2b;}.cls-3,.cls-6,.cls-7,.cls-8{fill:none;stroke-miterlimit:10;}.cls-3,.cls-6,.cls-7{stroke:#050606;}.cls-3,.cls-8{stroke-width:1.65px;}.cls-4{fill:#40403f;}.cls-5{fill:#ddd;}.cls-6{stroke-width:1.62px;}.cls-7{stroke-width:1.64px;}.cls-8{stroke:#fff;}.cls-9{fill:#353535;}.cls-10{fill:#c08c2d;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M595,923.44H519.6A10.46,10.46,0,0,1,509.14,913V802.7a15.28,15.28,0,0,1,15.28-15.28h979.89a15.29,15.29,0,0,1,15.29,15.29v400.93a15.3,15.3,0,0,1-15.29,15.29H524.42a15.28,15.28,0,0,1-15.28-15.28V1102.1a10.46,10.46,0,0,1,10.46-10.46H595"></path><rect class="cls-2" x="611.37" y="796.48" width="819.83" height="411.47"></rect><line class="cls-3" x1="1441.99" y1="787.41" x2="1441.99" y2="1218.92"></line><path class="cls-4" d="M620.91,851.7v302.78a1.87,1.87,0,0,1-1.87,1.87h-13.8a8.7,8.7,0,0,1-.89,0,10.23,10.23,0,0,1-9.35-10.2v-286a10.24,10.24,0,0,1,9.35-10.2,8.7,8.7,0,0,1,.89,0H619A1.87,1.87,0,0,1,620.91,851.7Z"></path><rect class="cls-5" x="480.4" y="942.42" width="114.6" height="127.58"></rect><rect class="cls-3" x="480.4" y="942.42" width="114.6" height="127.58"></rect><path class="cls-6" d="M595,923.44H519.6A10.46,10.46,0,0,1,509.14,913V802.7a15.28,15.28,0,0,1,15.28-15.28h979.89a15.29,15.29,0,0,1,15.29,15.29v400.93a15.3,15.3,0,0,1-15.29,15.29H524.42a15.28,15.28,0,0,1-15.28-15.28V1102.1a10.46,10.46,0,0,1,10.46-10.46H595"></path><path class="cls-2" d="M584.65,970.14H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3V973.11A3,3,0,0,1,584.65,970.14Z"></path><rect class="cls-2" x="560.58" y="976.5" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-2" d="M584.65,1026.63H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3v-18.49A3,3,0,0,1,584.65,1026.63Z"></path><rect class="cls-2" x="560.58" y="1028.91" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-3" d="M584.65,970.14H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3V973.11A3,3,0,0,1,584.65,970.14Z"></path><rect class="cls-3" x="560.58" y="976.5" width="9.04" height="16.58" rx="2.72"></rect><path class="cls-3" d="M584.65,1026.63H595a0,0,0,0,1,0,0v24.44a0,0,0,0,1,0,0H584.65a3,3,0,0,1-3-3v-18.49A3,3,0,0,1,584.65,1026.63Z"></path><rect class="cls-3" x="560.58" y="1028.91" width="9.04" height="16.58" rx="2.72"></rect><polyline class="cls-7" points="611.37 1156.35 611.37 1207.95 1431.2 1207.95 1431.2 796.48 611.37 796.48 611.37 849.83"></polyline><line class="cls-3" x1="611.37" y1="1207.95" x2="611.37" y2="1218.93"></line><line class="cls-3" x1="611.37" y1="796.48" x2="611.37" y2="787.42"></line><rect class="cls-8" x="560.58" y="1107.17" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="1107.17" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="1166.28" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="1166.28" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="804.46" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="804.46" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="560.58" y="863.57" width="18.37" height="41.67" rx="4.91"></rect><rect class="cls-8" x="528.51" y="863.57" width="18.37" height="41.67" rx="4.91"></rect><circle class="cls-8" cx="1476.63" cy="831.74" r="27.15"></circle><circle class="cls-8" cx="1476.63" cy="1173.49" r="27.15"></circle><rect class="cls-9" x="676.15" y="804.6" width="742.79" height="396.03"></rect><path class="cls-10" d="M604.35,849.87v306.44a10.23,10.23,0,0,1-9.35-10.2v-286A10.24,10.24,0,0,1,604.35,849.87Z"></path><path class="cls-3" d="M605.24,849.83H619a1.87,1.87,0,0,1,1.87,1.87v302.78a1.87,1.87,0,0,1-1.87,1.87h-13.8A10.24,10.24,0,0,1,595,1146.11v-286A10.24,10.24,0,0,1,605.24,849.83Z"></path><rect class="cls-6" x="676.15" y="804.6" width="742.79" height="396.03"></rect></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 76 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="733.42 451.82 573.87 931.48"><defs><style>.cls-1{fill:#8e8d8e;}.cls-2{fill:#383839;}.cls-3{fill:#cccccb;}.cls-4{fill:#222226;}.cls-5,.cls-6{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-5{stroke-width:1.87px;}.cls-6{stroke-width:3.77px;}.cls-7{fill:#4c4c4d;}</style></defs><g id="Layer_4" data-name="Layer 4"><path class="cls-1" d="M1277.27,847.59h4.35a8.09,8.09,0,0,1,8.09,8.08v138a8.09,8.09,0,0,1-8.09,8.09h-4.35"></path><path class="cls-1" d="M1277.27,732.73h18a10.14,10.14,0,0,1,10.14,10.14v43A10.14,10.14,0,0,1,1295.26,796h-18a0,0,0,0,1,0,0V732.73A0,0,0,0,1,1277.27,732.73Z"></path><path class="cls-2" d="M1256.49,1200.6h0a14.19,14.19,0,0,1-2.83,12.5c-8.13,9.86-19.94,18.58-46,30.75-19.15,9-28.65,16-38.35,29.6a93.15,93.15,0,0,0-8.7,14.61c-6.95,15.17-11.77,44.44-11.77,65.66v3.61a24.09,24.09,0,0,1-24.1,24.09H887.83a24.09,24.09,0,0,1-24.1-24.09v-3.61c0-21.22-4.82-50.49-11.77-65.66a93.15,93.15,0,0,0-8.7-14.61c-9.7-13.63-19.2-20.65-38.35-29.6-26.06-12.17-37.87-20.89-46-30.75a14.22,14.22,0,0,1-2.82-12.5h0"></path><path class="cls-2" d="M756.09,634.53h0a14.19,14.19,0,0,1,2.83-12.5c8.12-9.86,19.93-18.58,46-30.75,19.15-8.95,28.65-16,38.35-29.6a93.15,93.15,0,0,0,8.7-14.61c6.95-15.17,11.77-44.44,11.77-65.66V477.8a24.09,24.09,0,0,1,24.1-24.09h236.92a24.09,24.09,0,0,1,24.1,24.09v3.61c0,21.22,4.82,50.49,11.77,65.66a93.15,93.15,0,0,0,8.7,14.61c9.7,13.63,19.2,20.65,38.35,29.6,26,12.17,37.86,20.89,46,30.75a14.19,14.19,0,0,1,2.83,12.5h0"></path><rect class="cls-3" x="735.31" y="598.25" width="541.96" height="638.99" rx="96.44"></rect><path class="cls-2" d="M1247.38,694.68v446.11a66.63,66.63,0,0,1-66.54,66.56H831.75a66.62,66.62,0,0,1-66.56-66.56V694.68a66.63,66.63,0,0,1,66.56-66.55h349.09A66.64,66.64,0,0,1,1247.38,694.68Z"></path><rect class="cls-4" x="817.71" y="721.76" width="379.03" height="388.6"></rect><path class="cls-5" d="M1247.38,694.68v446.11a66.63,66.63,0,0,1-66.54,66.56H831.75a66.62,66.62,0,0,1-66.56-66.56V694.68a66.63,66.63,0,0,1,66.56-66.55h349.09A66.64,66.64,0,0,1,1247.38,694.68Z"></path><rect class="cls-6" x="735.31" y="598.25" width="541.96" height="638.99" rx="96.44"></rect><path class="cls-6" d="M1256.49,1200.6h0a14.19,14.19,0,0,1-2.83,12.5c-8.13,9.86-19.94,18.58-46,30.75-19.15,9-28.65,16-38.35,29.6a93.15,93.15,0,0,0-8.7,14.61c-6.95,15.17-11.77,44.44-11.77,65.66v3.61a24.09,24.09,0,0,1-24.1,24.09H887.83a24.09,24.09,0,0,1-24.1-24.09v-3.61c0-21.22-4.82-50.49-11.77-65.66a93.15,93.15,0,0,0-8.7-14.61c-9.7-13.63-19.2-20.65-38.35-29.6-26.06-12.17-37.87-20.89-46-30.75a14.22,14.22,0,0,1-2.82-12.5h0"></path><path class="cls-6" d="M756.09,634.53h0a14.19,14.19,0,0,1,2.83-12.5c8.12-9.86,19.93-18.58,46-30.75,19.15-8.95,28.65-16,38.35-29.6a93.15,93.15,0,0,0,8.7-14.61c6.95-15.17,11.77-44.44,11.77-65.66V477.8a24.09,24.09,0,0,1,24.1-24.09h236.92a24.09,24.09,0,0,1,24.1,24.09v3.61c0,21.22,4.82,50.49,11.77,65.66a93.15,93.15,0,0,0,8.7,14.61c9.7,13.63,19.2,20.65,38.35,29.6,26,12.17,37.86,20.89,46,30.75a14.19,14.19,0,0,1,2.83,12.5h0"></path><rect class="cls-5" x="817.71" y="721.76" width="379.03" height="388.6"></rect><path class="cls-6" d="M1277.27,847.59h4.35a8.09,8.09,0,0,1,8.09,8.08v138a8.09,8.09,0,0,1-8.09,8.09h-4.35"></path><path class="cls-6" d="M1277.27,732.73h18a10.14,10.14,0,0,1,10.14,10.14v43A10.14,10.14,0,0,1,1295.26,796h-18a0,0,0,0,1,0,0V732.73A0,0,0,0,1,1277.27,732.73Z"></path><circle class="cls-7" cx="1083.08" cy="1177.35" r="16.6"></circle><rect class="cls-2" x="1280.24" y="739.77" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="750.91" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="762.06" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="773.2" width="16.77" height="4.59" rx="2.29"></rect><rect class="cls-2" x="1280.24" y="784.34" width="16.77" height="4.59" rx="2.29"></rect></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 112 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

+160
View File
@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
class="svg-icon"
style="overflow:hidden;fill:currentColor"
viewBox="0 0 909.87988 546.85529"
version="1.1"
id="svg3"
xml:space="preserve"
width="909.87988"
height="546.85529"
sodipodi:docname="unknown.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.57169944"
inkscape:cx="291.23695"
inkscape:cy="107.57401"
inkscape:window-width="1472"
inkscape:window-height="890"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_7" /><defs
id="defs3"><style
id="style1">.cls-1{fill:#383838;}.cls-2{fill:#9f9f9e;}.cls-3{fill:#cbcccb;}.cls-4{fill:#b7b7b7;}.cls-5{fill:#353535;}.cls-6{fill:#b1a368;}.cls-7{fill:#2c2d2d;}.cls-10,.cls-11,.cls-8,.cls-9{fill:none;stroke:#050606;}.cls-10,.cls-11,.cls-8{stroke-miterlimit:10;}.cls-8,.cls-9{stroke-width:2px;}.cls-9{stroke-linecap:round;stroke-linejoin:round;}.cls-10{stroke-width:2.04px;}.cls-11{stroke-width:1.99px;}.cls-12{fill:#c08c2d;}.cls-13{fill:#af7a2b;}</style></defs><g
id="Layer_7"
data-name="Layer 7"
transform="translate(-646.6554,-758.05941)"><path
class="cls-2"
d="m 1545.1753,893.49468 h 4.69 a 5.67,5.67 0 0 1 5.67,5.67 v 84.64998 a 5.67,5.67 0 0 1 -5.67,5.67 h -4.69"
id="path1-4" /><rect
class="cls-3"
x="647.6554"
y="862.80469"
width="897.52002"
height="441.10999"
rx="11.7"
id="rect2" /><path
class="cls-2"
d="m 681.12532,862.80468 v 113.47998 a 3.67,3.67 0 0 0 3.67,3.67 h 41 a 2.35,2.35 0 0 1 2.35,2.35 V 1303.9147 H 1517.6053 V 862.80468 Z M 1492.6453,1278.9147 H 753.18532 V 972.01466 a 17.06,17.06 0 0 0 -17.06,-17.06 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 h 783.99998 z"
id="path2-7" /><path
class="cls-3"
d="M 1492.6453,887.80468 V 1278.9147 H 753.18532 V 972.01466 a 17,17 0 0 0 -7.2,-13.92 v -70.28998 z"
id="path3-7"
style="fill:#ffffff" /><path
class="cls-4"
d="m 745.98532,887.80468 v 70.28998 a 17,17 0 0 0 -9.86,-3.14 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 z"
id="path4" /><rect
class="cls-2"
x="672.10535"
y="1011.4448"
width="13.53"
height="148.39999"
id="rect4" /><path
class="cls-6"
d="m 1077.2923,853.76468 h 71.71 a 2.55,2.55 0 0 1 2.55,2.55 v 6.48 h -76.8 v -6.48 a 2.55,2.55 0 0 1 2.54,-2.55 z"
id="path7" /><path
class="cls-8"
d="m 1082.9205,761.22647 h 60.8838 a 6.1958134,4.8451518 0 0 1 6.1958,4.84516 v 77.32638 h -73.2754 v -77.32638 a 6.1958134,4.8451518 0 0 1 6.1958,-4.84516 z"
id="path39"
style="fill:#b1a368" /><rect
class="cls-8"
x="1066.9833"
y="778.19855"
width="91.504646"
height="55.957298"
rx="5.5511622"
id="rect39"
style="fill:#b1a368" /><path
class="cls-2"
d="m 1158.4522,782.53954 v 47.24724 a 5.5153484,4.3130254 0 0 1 -5.5512,4.34102 h -80.3665 a 5.5511623,4.341032 0 0 1 -5.587,-4.34102 v -47.24724 a 5.5511623,4.341032 0 0 1 5.587,-4.34103 h 80.5098 a 5.5153484,4.3130254 0 0 1 5.4079,4.34103 z"
id="path41-4"
style="fill:none;stroke:#050606;stroke-width:3.16706;stroke-miterlimit:10" /><rect
class="cls-6"
x="1079.9424"
y="843.73468"
width="65.989998"
height="10.03"
id="rect8" /><path
class="cls-8"
d="M 1492.6453,887.80468 V 1278.9147 H 753.18532 V 972.01466 a 17.06,17.06 0 0 0 -17.06,-17.06 h -27.5 a 2.5,2.5 0 0 1 -2.5,-2.5 v -62.14998 a 2.5,2.5 0 0 1 2.5,-2.5 h 783.99998 m 25,-25 H 681.12532 v 113.47998 a 3.68,3.68 0 0 0 3.67,3.67 h 41 a 2.35,2.35 0 0 1 2.35,2.35 V 1303.9147 H 1517.6053 V 862.80468 Z"
id="path10" /><line
class="cls-8"
x1="745.99536"
y1="958.09467"
x2="745.99536"
y2="887.80469"
id="line10" /><rect
class="cls-8"
x="672.10535"
y="1011.4448"
width="13.53"
height="148.39999"
id="rect11" /><path
class="cls-8"
d="m 1545.1753,893.49468 h 4.69 a 5.67,5.67 0 0 1 5.67,5.67 v 84.64998 a 5.67,5.67 0 0 1 -5.67,5.67 h -4.69"
id="path14" /><path
class="cls-10"
d="m 1077.2923,853.76468 h 71.71 a 2.55,2.55 0 0 1 2.55,2.55 v 6.48 h -76.8 v -6.48 a 2.55,2.55 0 0 1 2.54,-2.55 z"
id="path16" /><rect
class="cls-11"
x="1079.9424"
y="843.73468"
width="65.989998"
height="10.03"
id="rect17" /><path
class="cls-2"
d="m 725.27532,910.38466 a 14,14 0 1 0 14,14 13.95,13.95 0 0 0 -14,-14 z m 0,21.5 a 7.55,7.55 0 1 1 7.54,-7.55 7.55,7.55 0 0 1 -7.54,7.55 z"
id="path19" /><circle
class="cls-8"
cx="725.27539"
cy="924.33466"
r="7.5500002"
id="circle19" /><circle
class="cls-8"
cx="725.27539"
cy="924.33466"
r="13.95"
id="circle20" /><path
d="m 445.36309,440.05365 c 0,11.52004 10.38375,20.85861 23.19309,20.85861 12.80937,0 23.19311,-9.33857 23.19311,-20.85861 0,-11.52005 -10.38374,-20.85861 -23.19311,-20.85861 -12.80934,0 -23.19309,9.33856 -23.19309,20.85861 z"
fill="#ccc"
id="path1"
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.458227"
transform="translate(646.6554,758.05941)" /><path
d="m 469.40305,538.40107 c -119.83415,0 -217.31582,-93.40624 -217.31582,-208.23067 0,-114.82425 97.48167,-208.23058 217.31582,-208.23058 119.83417,0 217.31585,93.40633 217.31585,208.23058 0,114.82443 -97.48168,208.23067 -217.31585,208.23067 z m 0,-386.58065 c -102.63515,0 -186.13149,80.00572 -186.13149,178.34998 0,98.32948 83.49634,178.34997 186.13149,178.34997 102.61966,0 186.13151,-80.01997 186.13151,-178.34997 0,-98.34426 -83.51185,-178.34998 -186.13151,-178.34998 z"
fill="#ccc"
id="path2"
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.474832"
transform="translate(646.6554,758.05941)" /><path
d="m 468.55618,391.96713 c -8.53552,0 -15.46205,-6.22977 -15.46205,-13.90533 v -23.51468 c 0,-22.75028 19.32709,-40.13201 36.39722,-55.47009 12.50833,-11.26363 25.45056,-22.88885 25.45056,-32.16398 0,-23.18095 -20.81195,-42.03718 -46.38573,-42.03718 -26.0067,0 -46.38619,18.0497 -46.38619,41.09158 0,7.67594 -6.92654,13.90533 -15.46208,13.90533 -8.53554,0 -15.46207,-6.22977 -15.46207,-13.9058 0,-37.99002 34.68046,-68.90262 77.31034,-68.90262 42.62989,0 77.31034,31.32967 77.31034,69.84869 0,20.81694 -17.54944,36.5856 -34.51132,51.84064 -13.452,12.07016 -27.33645,24.55758 -27.33645,35.77907 v 23.51468 c 0,7.6764 -6.92702,13.91969 -15.46257,13.91969 z"
fill="#ccc"
id="path3"
style="overflow:hidden;fill:#4d4d4d;stroke-width:0.458227;stroke:#000000;stroke-opacity:1"
transform="translate(646.6554,758.05941)" /><rect
class="cls-8"
x="647.6554"
y="862.80469"
width="897.52002"
height="441.10999"
rx="11.7"
id="rect28" /></g><path
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
id="path11" /><path
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
id="path12" /><path
style="fill:#ffffff;fill-opacity:0;stroke-width:0.92"
d="m 107.41785,363.03448 -0.23786,-156.30546 -2.99,-3.72057 -2.99,-3.72058 v -34.09394 -34.09394 l 150.64998,0.048 150.64999,0.048 -8.28,3.06943 c -19.31509,7.16019 -34.46167,14.82453 -50.21721,25.41044 -50.57644,33.98158 -84.35747,88.86991 -91.06203,147.96009 -1.43336,12.63279 -0.63536,44.7022 1.3876,55.76392 7.76201,42.44321 25.98398,77.92651 55.67763,108.41989 17.37837,17.84644 33.98994,30.29944 55.42867,41.55255 l 11.30534,5.93414 -134.54212,0.0167 -134.54213,0.0167 z"
id="path13" /></svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 120 KiB

+8
View File
@@ -6,6 +6,8 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { JSX } from "react";
import { NodeDetailsDialog } from "./NodeDetailsDialog";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -56,6 +58,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("pkiBackup", open); setDialogOpen("pkiBackup", open);
}} }}
/> />
<NodeDetailsDialog
open={dialog.nodeDetails}
onOpenChange={(open) => {
setDialogOpen("nodeDetails", open);
}}
/>
</> </>
); );
}; };
@@ -0,0 +1,62 @@
import { useDevice } from "@app/core/stores/deviceStore";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog";
import type { Protobuf, Types } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import type { JSX } from "react";
export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined;
open: boolean;
onOpenChange: () => void;
}
export const LocationResponseDialog = ({
location,
open,
onOpenChange,
}: LocationResponseDialogProps): JSX.Element => {
const { nodes } = useDevice();
const from = nodes.get(location?.from ?? 0);
const longName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName =
from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Location: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
<DialogDescription>
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${location?.data.latitudeI / 1e7}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{location?.data.latitudeI / 1e7},{" "}
{location?.data.longitudeI / 1e7}
</a>
</p>
<p>Altitude: {location?.data.altitude}m</p>
</span>
</div>
</DialogDescription>
</DialogContent>
</Dialog>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/UI/Accordion";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog";
import { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { DeviceImage } from "../generic/DeviceImage";
import { TimeAgo } from "../generic/TimeAgo";
import { Uptime } from "../generic/Uptime";
export interface NodeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const NodeDetailsDialog = ({
open,
onOpenChange,
}: NodeDetailsDialogProps) => {
const { nodes } = useDevice();
const { nodeNumDetails } = useAppStore();
const device: Protobuf.Mesh.NodeInfo = nodes.get(nodeNumDetails);
return device ? (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Node Details for {device.user?.longName ?? "UNKNOWN"} (
{device.user?.shortName ?? "UNK"})
</DialogTitle>
</DialogHeader>
<DialogFooter>
<div className="w-full">
<DeviceImage
className="w-32 h-32 mx-auto rounded-lg border-4 border-gray-200 dark:border-gray-800"
deviceType={
Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]
}
/>
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Details:
</p>
<p>
Hardware:{" "}
{Protobuf.Mesh.HardwareModel[device.user?.hwModel ?? 0]}
</p>
<p>Node Number: {device.num}</p>
<p>Node HEX: !{numberToHexUnpadded(device.num)}</p>
<p>
Role:{" "}
{
Protobuf.Config.Config_DeviceConfig_Role[
device.user?.role ?? 0
]
}
</p>
<p>
Last Heard:{" "}
{device.lastHeard === 0 ? (
"Never"
) : (
<TimeAgo timestamp={device.lastHeard * 1000} />
)}
</p>
</div>
{device.position ? (
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Position:
</p>
{device.position.latitudeI && device.position.longitudeI ? (
<p>
Coordinates:{" "}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${device.position.latitudeI / 1e7}&mlon=${device.position.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{device.position.latitudeI / 1e7},{" "}
{device.position.longitudeI / 1e7}
</a>
</p>
) : null}
{device.position.altitude ? (
<p>Altitude: {device.position.altitude}m</p>
) : null}
</div>
) : null}
{device.deviceMetrics ? (
<div className="mt-5 bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
Device Metrics:
</p>
{device.deviceMetrics.airUtilTx ? (
<p>
Air TX utilization:{" "}
{device.deviceMetrics.airUtilTx.toFixed(2)}%
</p>
) : null}
{device.deviceMetrics.channelUtilization ? (
<p>
Channel utilization:{" "}
{device.deviceMetrics.channelUtilization.toFixed(2)}%
</p>
) : null}
{device.deviceMetrics.batteryLevel ? (
<p>
Battery level:{" "}
{device.deviceMetrics.batteryLevel.toFixed(2)}%
</p>
) : null}
{device.deviceMetrics.voltage ? (
<p>Voltage: {device.deviceMetrics.voltage.toFixed(2)}V</p>
) : null}
{device.deviceMetrics.uptimeSeconds ? (
<p>
Uptime:{" "}
<Uptime seconds={device.deviceMetrics.uptimeSeconds} />
</p>
) : null}
</div>
) : null}
{device ? (
<div className="mt-5 w-full max-w-[464px] bg-gray-100 dark:bg-slate-800 p-3 rounded-lg mt-3">
<Accordion className="AccordionRoot" type="single" collapsible>
<AccordionItem className="AccordionItem" value="item-1">
<AccordionTrigger>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-50">
All Raw Metrics:
</p>
</AccordionTrigger>
<AccordionContent className="overflow-x-scroll">
<pre className="text-xs w-full">
{JSON.stringify(device, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
) : null;
};
+117
View File
@@ -0,0 +1,117 @@
import { toast } from "@app/core/hooks/useToast";
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog";
import type { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react";
import type { JSX } from "react";
import { Button } from "../UI/Button";
export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined;
open: boolean;
onOpenChange: () => void;
}
export const NodeOptionsDialog = ({
node,
open,
onOpenChange,
}: NodeOptionsDialogProps): JSX.Element => {
const { setDialogOpen, connection, setActivePage } = useDevice();
const {
setNodeNumToBeRemoved,
setNodeNumDetails,
setChatType,
setActiveChat,
} = useAppStore();
const longName =
node?.user?.longName ??
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
const shortName =
node?.user?.shortName ??
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() {
if (!node) return;
setChatType("direct");
setActiveChat(node.num);
setActivePage("messages");
}
function handleRequestPosition() {
if (!node) return;
toast({
title: "Requesting position, please wait...",
});
connection?.requestPosition(node.num).then(() =>
toast({
title: "Position request sent.",
}),
);
onOpenChange();
}
function handleTraceroute() {
if (!node) return;
toast({
title: "Sending Traceroute, please wait...",
});
connection?.traceRoute(node.num).then(() =>
toast({
title: "Traceroute sent.",
}),
);
onOpenChange();
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col space-y-1">
<div>
<Button onClick={handleDirectMessage}>Direct Message</Button>
</div>
<div>
<Button onClick={handleRequestPosition}>Request Position</Button>
</div>
<div>
<Button onClick={handleTraceroute}>Trace Route</Button>
</div>
<div>
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>
</div>
<div>
<Button
onClick={() => {
setNodeNumDetails(node.num);
setDialogOpen("nodeDetails", true);
}}
>
More Details
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,57 @@
import { useDevice } from "@app/core/stores/deviceStore";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog";
import type { Protobuf, Types } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import type { JSX } from "react";
import { TraceRoute } from "../PageComponents/Messages/TraceRoute";
export interface TracerouteResponseDialogProps {
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined;
open: boolean;
onOpenChange: () => void;
}
export const TracerouteResponseDialog = ({
traceroute,
open,
onOpenChange,
}: TracerouteResponseDialogProps): JSX.Element => {
const { nodes } = useDevice();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = traceroute?.data.snrTowards ?? [];
const snrBack = traceroute?.data.snrBack ?? [];
const from = nodes.get(traceroute?.from ?? 0);
const longName =
from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName =
from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = nodes.get(traceroute?.to ?? 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Traceroute: ${longName} (${shortName})`}</DialogTitle>
</DialogHeader>
<DialogDescription>
<TraceRoute
route={route}
routeBack={routeBack}
from={from}
to={to}
snrTowards={snrTowards}
snrBack={snrBack}
/>
</DialogDescription>
</DialogContent>
</Dialog>
);
};
+9 -11
View File
@@ -23,18 +23,13 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
window.location.hostname, window.location.hostname,
) )
? "meshtastic.local" ? "meshtastic.local"
: window.location.hostname, : window.location.host,
tls: location.protocol === "https:", tls: location.protocol === "https:",
}, },
}); });
const tlsEnabled = useWatch({
control,
name: "tls",
defaultValue: location.protocol === "https:",
});
const [connectionInProgress, setConnectionInProgress] = useState(false); const [connectionInProgress, setConnectionInProgress] = useState(false);
const [https, setHTTPS] = useState(false);
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true); setConnectionInProgress(true);
@@ -46,7 +41,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
await connection.connect({ await connection.connect({
address: data.ip, address: data.ip,
fetchInterval: 2000, fetchInterval: 2000,
tls: data.tls, tls: https,
}); });
setSelectedDevice(id); setSelectedDevice(id);
@@ -60,8 +55,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<div className="flex h-48 flex-col gap-2"> <div className="flex h-48 flex-col gap-2">
<Label>IP Address/Hostname</Label> <Label>IP Address/Hostname</Label>
<Input <Input
// label="IP Address/Hostname" prefix={https ? "https://" : "http://"}
prefix={tlsEnabled ? "https://" : "http://"}
placeholder="000.000.000.000 / meshtastic.local" placeholder="000.000.000.000 / meshtastic.local"
disabled={connectionInProgress} disabled={connectionInProgress}
{...register("ip")} {...register("ip")}
@@ -71,8 +65,12 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
control={control} control={control}
render={({ field: { value, ...rest } }) => ( render={({ field: { value, ...rest } }) => (
<> <>
<Label>Use TLS</Label> <Label>Use HTTPS</Label>
<Switch <Switch
onCheckedChange={(checked) => {
checked ? setHTTPS(true) : setHTTPS(false);
}}
// label="Use TLS" // label="Use TLS"
// description="Description" // description="Description"
disabled={ disabled={
@@ -1,9 +1,10 @@
import { Separator } from "@app/components/UI/Seperator"; import { Separator } from "@app/components/UI/Seperator";
import { H5 } from "@app/components/UI/Typography/H5.tsx"; import { H5 } from "@app/components/UI/Typography/H5.tsx";
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@app/core/utils/string";
import { Avatar } from "@components/UI/Avatar"; import { Avatar } from "@components/UI/Avatar";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import type { Protobuf as ProtobufType } from "@meshtastic/js"; import type { Protobuf as ProtobufType } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
@@ -25,12 +26,12 @@ export interface NodeDetailProps {
export const NodeDetail = ({ node }: NodeDetailProps) => { export const NodeDetail = ({ node }: NodeDetailProps) => {
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
const hardwareType = Protobuf.Mesh.HardwareModel[ const hwModel = node.user?.hwModel ?? 0;
node.user?.hwModel ?? 0 const hardwareType =
].replaceAll("_", " "); Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
return ( return (
<div className="dark:text-black"> <div className="dark:text-black p-1">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1"> <div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={node.user?.shortName} /> <Avatar text={node.user?.shortName} />
@@ -132,7 +133,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
className="ml-2 mr-1" className="ml-2 mr-1"
aria-label="Elevation" aria-label="Elevation"
/> />
<div>{node.position?.altitude} ft</div> <div>
{formatQuantity(node.position?.altitude, {
one: "meter",
other: "meters",
})}
</div>
</div> </div>
)} )}
</div> </div>
@@ -51,11 +51,11 @@ export const ChannelChat = ({
if (!messages?.length) { if (!messages?.length) {
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full container mx-auto">
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<EmptyState /> <EmptyState />
</div> </div>
<div className="flex-shrink-0 p-4 w-full bg-gray-900"> <div className="flex-shrink-0 p-4 w-full dark:bg-gray-900">
<MessageInput to={to} channel={channel} maxBytes={200} /> <MessageInput to={to} channel={channel} maxBytes={200} />
</div> </div>
</div> </div>
@@ -63,23 +63,25 @@ export const ChannelChat = ({
} }
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full container mx-auto">
<div className="flex-1 overflow-y-scroll w-full" ref={scrollContainerRef}> <div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end"> <div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => ( {messages.map((message, index) => {
<Message return (
key={message.id} <Message
message={message} key={message.id}
lastMsgSameUser={ message={message}
index > 0 && messages[index - 1].from === message.from sender={nodes.get(message.from)}
} lastMsgSameUser={
sender={nodes.get(message.from)} index > 0 && messages[index - 1].from === message.from
/> }
))} />
);
})}
<div ref={messagesEndRef} className="w-full" /> <div ref={messagesEndRef} className="w-full" />
</div> </div>
</div> </div>
<div className="flex-shrink-0 mt-2 p-4 w-full bg-gray-900"> <div className="flex-shrink-0 mt-2 p-4 w-full dark:bg-gray-900">
<MessageInput to={to} channel={channel} maxBytes={200} /> <MessageInput to={to} channel={channel} maxBytes={200} />
</div> </div>
</div> </div>
@@ -1,10 +1,21 @@
import type { MessageWithState } from "@app/core/stores/deviceStore.ts"; import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/UI/Tooltip";
import { useAppStore } from "@app/core/stores/appStore";
import {
type MessageWithState,
useDeviceStore,
} from "@app/core/stores/deviceStore.ts";
import { cn } from "@app/core/utils/cn"; import { cn } from "@app/core/utils/cn";
import { Avatar } from "@components/UI/Avatar"; import { Avatar } from "@components/UI/Avatar";
import type { Protobuf } from "@meshtastic/js"; import type { Protobuf } from "@meshtastic/js";
import * as Tooltip from "@radix-ui/react-tooltip";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useMemo } from "react";
const MESSAGE_STATES = { const MESSAGE_STATES = {
ACK: "ack", ACK: "ack",
@@ -17,7 +28,7 @@ type MessageState = MessageWithState["state"];
interface MessageProps { interface MessageProps {
lastMsgSameUser: boolean; lastMsgSameUser: boolean;
message: MessageWithState; message: MessageWithState;
sender?: Protobuf.Mesh.NodeInfo; sender: Protobuf.Mesh.NodeInfo;
} }
interface StatusTooltipProps { interface StatusTooltipProps {
@@ -45,22 +56,20 @@ const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state];
const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
<Tooltip.Provider> <TooltipProvider>
<Tooltip.Root> <Tooltip>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<Tooltip.Portal> <TooltipContent
<Tooltip.Content className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95" side="top"
side="top" align="center"
align="center" sideOffset={5}
sideOffset={5} >
> {getStatusText(state)}
{getStatusText(state)} <TooltipArrow className="fill-slate-800" />
<Tooltip.Arrow className="fill-slate-800" /> </TooltipContent>
</Tooltip.Content> </Tooltip>
</Tooltip.Portal> </TooltipProvider>
</Tooltip.Root>
</Tooltip.Provider>
); );
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
@@ -88,7 +97,7 @@ const getMessageTextStyles = (state: MessageState) => {
const isWaiting = state === MESSAGE_STATES.WAITING; const isWaiting = state === MESSAGE_STATES.WAITING;
return cn( return cn(
"pl-2 break-words overflow-hidden", "break-words overflow-hidden",
isAcknowledged isAcknowledged
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-black dark:text-gray-400", : "text-black dark:text-gray-400",
@@ -96,8 +105,11 @@ const getMessageTextStyles = (state: MessageState) => {
); );
}; };
const TimeDisplay = ({ date }: { date: Date }) => ( const TimeDisplay = ({
<div className="flex items-center gap-2 flex-shrink-0"> date,
className,
}: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 flex-shrink-0", className)}>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono"> <span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{date.toLocaleDateString()} {date.toLocaleDateString()}
</span> </span>
@@ -111,44 +123,46 @@ const TimeDisplay = ({ date }: { date: Date }) => (
); );
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
);
const messageUser = sender?.user;
const messageTextClass = getMessageTextStyles(message.state); const messageTextClass = getMessageTextStyles(message.state);
const isFailed = message.state === MESSAGE_STATES.ACK;
const baseMessageWrapper = cn(
"flex items-center gap-2 w-full max-w-full pl-11",
!lastMsgSameUser && "flex-wrap flex-grow",
);
const containerClass = cn(
"w-full px-4 relative",
lastMsgSameUser ? "mt-1" : "mt-2",
!lastMsgSameUser && "pt-2",
);
return ( return (
<div className={containerClass}> <div className="flex flex-col w-full px-4 justify-start">
{!lastMsgSameUser && ( <div
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> className={cn(
<div className="flex items-center gap-2 min-w-0"> "flex flex-col flex-wrap items-start py-1",
<Avatar isDeviceUser && "items-end",
text={sender?.user?.shortName ?? "UNK"} )}
className="flex-shrink-0" >
/> <div className="flex items-center gap-2 mb-2">
<span className="font-medium text-gray-900 dark:text-white truncate"> {!lastMsgSameUser ? (
{sender?.user?.longName ?? "UNK"} <div className="flex place-items-center gap-2 mb-1">
</span> <Avatar text={messageUser?.shortName} />
<div className="flex flex-col">
<span className="font-medium text-gray-900 dark:text-white truncate">
{messageUser?.longName}
</span>
</div>
</div>
) : null}
</div>
<TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
{message.data}
</div> </div>
<TimeDisplay date={message.rxTime} /> <StatusIcon state={message.state} />
</div> </div>
)}
<div className={baseMessageWrapper}>
<div className="flex-1 min-w-0 max-w-full">
<div className={messageTextClass}>{message.data}</div>
</div>
<StatusIcon
state={message.state}
className="ml-auto mr-6 flex-shrink-0"
/>
</div> </div>
</div> </div>
); );
@@ -4,7 +4,13 @@ import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/js"; import type { Types } from "@meshtastic/js";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { type JSX, useCallback, useMemo, useState } from "react"; import {
type JSX,
startTransition,
useCallback,
useMemo,
useState,
} from "react";
export interface MessageInputProps { export interface MessageInputProps {
to: Types.Destination; to: Types.Destination;
@@ -26,7 +32,7 @@ export const MessageInput = ({
} = useDevice(); } = useDevice();
const myNodeNum = hardware.myNodeNum; const myNodeNum = hardware.myNodeNum;
const [localDraft, setLocalDraft] = useState(messageDraft); const [localDraft, setLocalDraft] = useState(messageDraft);
const [messageBytes, setMessageBytes] = useState(maxBytes); const [messageBytes, setMessageBytes] = useState(0);
const debouncedSetMessageDraft = useMemo( const debouncedSetMessageDraft = useMemo(
() => debounce(setMessageDraft, 300), () => debounce(setMessageDraft, 300),
@@ -64,10 +70,11 @@ export const MessageInput = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const byteLength = new Blob([newValue]).size; const byteLength = new Blob([newValue]).size;
if (byteLength <= maxBytes) { if (byteLength <= maxBytes) {
setLocalDraft(newValue); setLocalDraft(newValue);
debouncedSetMessageDraft(newValue); debouncedSetMessageDraft(newValue);
setMessageBytes(maxBytes - byteLength); setMessageBytes(byteLength);
} }
}; };
@@ -75,11 +82,16 @@ export const MessageInput = ({
<div className="flex gap-2"> <div className="flex gap-2">
<form <form
className="w-full" className="w-full"
onSubmit={(e) => { action={async (formData: FormData) => {
e.preventDefault(); // prevent user from sending blank/empty message
sendText(localDraft); if (localDraft === "") return;
setLocalDraft(""); const message = formData.get("messageInput") as string;
setMessageDraft(""); startTransition(() => {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
});
}} }}
> >
<div className="flex flex-grow gap-2"> <div className="flex flex-grow gap-2">
@@ -87,14 +99,16 @@ export const MessageInput = ({
<Input <Input
autoFocus={true} autoFocus={true}
minLength={1} minLength={1}
name="messageInput"
placeholder="Enter Message" placeholder="Enter Message"
value={localDraft} value={localDraft}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</span> </span>
<div className="flex items-center"> <div className="flex items-center w-24 p-2 place-content-end">
{messageBytes}/{maxBytes} {messageBytes}/{maxBytes}
</div> </div>
<Button type="submit"> <Button type="submit">
<SendIcon size={16} /> <SendIcon size={16} />
</Button> </Button>
@@ -1,36 +1,60 @@
import { useDevice } from "@app/core/stores/deviceStore.ts"; import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/js"; import type { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import type { JSX } from "react";
export interface TraceRouteProps { export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo; from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo; to?: Protobuf.Mesh.NodeInfo;
route: Array<number>; route: Array<number>;
routeBack?: Array<number>;
snrTowards?: Array<number>;
snrBack?: Array<number>;
} }
export const TraceRoute = ({ export const TraceRoute = ({
from, from,
to, to,
route, route,
routeBack,
snrTowards,
snrBack,
}: TraceRouteProps): JSX.Element => { }: TraceRouteProps): JSX.Element => {
const { nodes } = useDevice(); const { nodes } = useDevice();
return route.length === 0 ? ( return (
<div className="ml-5 flex"> <div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> <span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}↔{from?.user?.longName} <p className="font-semibold">Route to destination:</p>
</span> <p>{to?.user?.longName}</p>
</div> <p>↓ {snrTowards?.[0] ? snrTowards[0] : "??"}dB</p>
) : ( {route.map((hop, i) => (
<div className="ml-5 flex"> <span key={nodes.get(hop)?.num}>
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> <p>
{to?.user?.longName}↔ {nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
{route.map((hop) => { </p>
const node = nodes.get(hop); <p>↓ {snrTowards?.[i + 1] ? snrTowards[i + 1] : "??"}dB</p>
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`; </span>
})} ))}
{from?.user?.longName} {from?.user?.longName}
</span> </span>
{routeBack ? (
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p className="font-semibold">Route back:</p>
<p>{from?.user?.longName}</p>
<p>↓ {snrBack?.[0] ? snrBack[0] : "??"}dB</p>
{routeBack.map((hop, i) => (
<span key={nodes.get(hop)?.num}>
<p>
{nodes.get(hop)?.user?.longName ??
`!${numberToHexUnpadded(hop)}`}
</p>
<p>↓ {snrBack?.[i + 1] ? snrBack[i + 1] : "??"}dB</p>
</span>
))}
{to?.user?.longName}
</span>
) : null}
</div> </div>
); );
}; };
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "@core/utils/cn.ts";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { type ComponentRef, forwardRef } from "react";
export const Accordion = AccordionPrimitive.Root;
export const AccordionHeader = AccordionPrimitive.Header;
export const AccordionItem = AccordionPrimitive.Item;
export const AccordionTrigger = forwardRef<
ComponentRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex justify-between items-center w-full p-4 border-b border-gray-200 dark:border-gray-800 group",
className,
)}
{...props}
>
{props.children}
<ChevronDownIcon
className="h-5 w-5 transition-transform duration-300 group-data-[state=open]:rotate-180"
aria-hidden
/>
</AccordionPrimitive.Trigger>
));
export const AccordionContent = forwardRef<
ComponentRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"p-4 border-b border-gray-200 dark:border-gray-800",
className,
)}
{...props}
/>
));
+1 -1
View File
@@ -15,7 +15,7 @@ const buttonVariants = cva(
success: success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-100",
subtle: subtle:
"bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100",
ghost: ghost:
+1 -1
View File
@@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed z-50 grid w-full scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0", "fixed z-50 grid w-full max-w-[512px] max-h-[100vh] overflow-y-scroll scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
"dark:bg-slate-900", "dark:bg-slate-900",
className, className,
)} )}
+1 -1
View File
@@ -28,7 +28,7 @@ const toastVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50", "border bg-backgroundPrimary text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
destructive: destructive:
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50", "group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
}, },
+8 -1
View File
@@ -9,6 +9,7 @@ const Tooltip = ({ ...props }) => <TooltipPrimitive.Root {...props} />;
Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; Tooltip.displayName = TooltipPrimitive.Tooltip.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipArrow = TooltipPrimitive.Arrow;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -26,4 +27,10 @@ const TooltipContent = React.forwardRef<
)); ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName; TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
TooltipArrow,
};
+53
View File
@@ -0,0 +1,53 @@
export interface DeviceImageProps {
deviceType: string;
className?: React.HTMLAttributes<HTMLImageElement>["className"];
}
const hardwareModelToFilename: { [key: string]: string } = {
DIY_V1: "diy.svg",
NANO_G2_ULTRA: "nano-g2-ultra.svg",
TBEAM: "tbeam.svg",
HELTEC_HT62: "heltec-ht62-esp32c3-sx1262.svg",
RPI_PICO: "pico.svg",
T_DECK: "t-deck.svg",
HELTEC_MESH_NODE_T114: "heltec-mesh-node-t114.svg",
HELTEC_MESH_NODE_T114_CASE: "heltec-mesh-node-t114-case.svg",
HELTEC_V3: "heltec-v3.svg",
HELTEC_V3_CASE: "heltec-v3-case.svg",
HELTEC_VISION_MASTER_E213: "heltec-vision-master-e213.svg",
HELTEC_VISION_MASTER_E290: "heltec-vision-master-e290.svg",
HELTEC_VISION_MASTER_T190: "heltec-vision-master-t190.svg",
HELTEC_WIRELESS_PAPER: "heltec-wireless-paper.svg",
HELTEC_WIRELESS_PAPER_V1_0: "heltec-wireless-paper-V1_0.svg",
HELTEC_WIRELESS_TRACKER: "heltec-wireless-tracker.svg",
HELTEC_WIRELESS_TRACKER_V1_0: "heltec-wireless-tracker-V1-0.svg",
HELTEC_WSL_V3: "heltec-wsl-v3.svg",
TLORA_C6: "tlora-c6.svg",
TLORA_T3_S3: "tlora-t3s3-v1.svg",
TLORA_T3_S3_EPAPER: "tlora-t3s3-epaper.svg",
TLORA_V2: "tlora-v2-1-1_6.svg",
TLORA_V2_1_1P6: "tlora-v2-1-1_6.svg",
TLORA_V2_1_1P8: "tlora-v2-1-1_8.svg",
RAK11310: "rak11310.svg",
RAK2560: "rak2560.svg",
RAK4631: "rak4631.svg",
RAK4631_CASE: "rak4631_case.svg",
WIO_WM1110: "wio-tracker-wm1110.svg",
WM1110_DEV_KIT: "wm1110_dev_kit.svg",
STATION_G2: "station-g2.svg",
TBEAM_V0P7: "tbeam-s3-core.svg",
T_ECHO: "t-echo.svg",
TRACKER_T1000_E: "tracker-t1000-e.svg",
T_WATCH_S3: "t-watch-s3.svg",
SEEED_XIAO_S3: "seeed-xiao-s3.svg",
SENSECAP_INDICATOR: "seeed-sensecap-indicator.svg",
PROMICRO: "promicro.svg",
RPIPICOW: "rpipicow.svg",
UNKNOWN: "unknown.svg",
};
export const DeviceImage = ({ deviceType, className }: DeviceImageProps) => {
const getPath = (device: string) => `/devices/${device}`;
const device = hardwareModelToFilename[deviceType] || "unknown.svg";
return <img className={className} src={getPath(device)} alt={device} />;
};
@@ -1,9 +0,0 @@
import TimeAgoReact from "timeago-react";
export interface TimeAgoProps {
timestamp: number;
}
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => {
return <TimeAgoReact datetime={timestamp} opts={{ minInterval: 10 }} />;
};
+66
View File
@@ -0,0 +1,66 @@
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import type { JSX } from "react";
export interface TimeAgoProps {
timestamp: number;
}
const getTimeAgo = (
unixTimestamp: number,
locale: Intl.LocalesArgument = "en",
): string => {
const timestamp = new Date(unixTimestamp);
const diff = (new Date().getTime() - timestamp.getTime()) / 1000;
const minutes = Math.floor(diff / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (years > 0) {
return rtf.format(0 - years, "year");
}
if (months > 0) {
return rtf.format(0 - months, "month");
}
if (days > 0) {
return rtf.format(0 - days, "day");
}
if (hours > 0) {
return rtf.format(0 - hours, "hour");
}
if (minutes > 0) {
return rtf.format(0 - minutes, "minute");
}
return rtf.format(Math.floor(0 - diff), "second");
};
export const TimeAgo = ({ timestamp }: TimeAgoProps): JSX.Element => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span>{getTimeAgo(timestamp)}</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{new Date(timestamp).toLocaleString()}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</TooltipProvider>
);
};
+17
View File
@@ -0,0 +1,17 @@
import type { JSX } from "react";
export interface UptimeProps {
seconds: number;
}
const getUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
const secondsLeft = Math.floor(((seconds % 86400) % 3600) % 60);
return `${days}d ${hours}h ${minutes}m ${secondsLeft}s`;
};
export const Uptime = ({ seconds }: UptimeProps): JSX.Element => {
return <span>{getUptime(seconds)}</span>;
};
+22
View File
@@ -1,3 +1,4 @@
import { Types } from "@meshtastic/js";
import { produce } from "immer"; import { produce } from "immer";
import { create } from "zustand"; import { create } from "zustand";
@@ -29,6 +30,9 @@ interface AppState {
nodeNumToBeRemoved: number; nodeNumToBeRemoved: number;
accent: AccentColor; accent: AccentColor;
connectDialogOpen: boolean; connectDialogOpen: boolean;
nodeNumDetails: number;
activeChat: number;
chatType: "broadcast" | "direct";
setRasterSources: (sources: RasterSource[]) => void; setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void; addRasterSource: (source: RasterSource) => void;
@@ -42,6 +46,9 @@ interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void; setNodeNumToBeRemoved: (nodeNum: number) => void;
setAccent: (color: AccentColor) => void; setAccent: (color: AccentColor) => void;
setConnectDialogOpen: (open: boolean) => void; setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
setActiveChat: (chat: number) => void;
setChatType: (type: "broadcast" | "direct") => void;
} }
export const useAppStore = create<AppState>()((set) => ({ export const useAppStore = create<AppState>()((set) => ({
@@ -57,6 +64,9 @@ export const useAppStore = create<AppState>()((set) => ({
accent: "orange", accent: "orange",
connectDialogOpen: false, connectDialogOpen: false,
nodeNumToBeRemoved: 0, nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast",
setRasterSources: (sources: RasterSource[]) => { setRasterSources: (sources: RasterSource[]) => {
set( set(
@@ -124,4 +134,16 @@ export const useAppStore = create<AppState>()((set) => ({
}), }),
); );
}, },
setNodeNumDetails: (nodeNum) =>
set((state) => ({
nodeNumDetails: nodeNum,
})),
setActiveChat: (chat) =>
set(() => ({
activeChat: chat,
})),
setChatType: (type) =>
set(() => ({
chatType: type,
})),
})); }));
+4 -1
View File
@@ -26,7 +26,8 @@ export type DialogVariant =
| "reboot" | "reboot"
| "deviceName" | "deviceName"
| "nodeRemoval" | "nodeRemoval"
| "pkiBackup"; | "pkiBackup"
| "nodeDetails";
export interface Device { export interface Device {
id: number; id: number;
@@ -62,6 +63,7 @@ export interface Device {
deviceName: boolean; deviceName: boolean;
nodeRemoval: boolean; nodeRemoval: boolean;
pkiBackup: boolean; pkiBackup: boolean;
nodeDetails: boolean;
}; };
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
@@ -145,6 +147,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
deviceName: false, deviceName: false,
nodeRemoval: false, nodeRemoval: false,
pkiBackup: false, pkiBackup: false,
nodeDetails: false,
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",
+31
View File
@@ -0,0 +1,31 @@
interface PluralForms {
one: string;
other: string;
[key: string]: string;
}
interface FormatOptions {
locale?: string;
pluralRules?: Intl.PluralRulesOptions;
numberFormat?: Intl.NumberFormatOptions;
}
export function formatQuantity(
value: number,
forms: PluralForms,
options: FormatOptions = {},
) {
const {
locale = "en-US",
pluralRules: pluralOptions = { type: "cardinal" },
numberFormat: numberOptions = {},
} = options;
const pluralRules = new Intl.PluralRules(locale, pluralOptions);
const numberFormat = new Intl.NumberFormat(locale, numberOptions);
const pluralCategory = pluralRules.select(value);
const word = forms[pluralCategory];
return `${numberFormat.format(value)} ${word}`;
}
+103 -126
View File
@@ -1,59 +1,93 @@
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail"; import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
import { Avatar } from "@app/components/UI/Avatar"; import { Avatar } from "@app/components/UI/Avatar";
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
import { cn } from "@app/core/utils/cn.ts";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/js"; import type { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { bbox, lineString } from "@turf/turf"; import { bbox, lineString } from "@turf/turf";
import { import { MapPinIcon } from "lucide-react";
BoxSelectIcon,
MapPinIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react";
import { type JSX, useCallback, useEffect, useMemo, useState } from "react"; import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl"; import {
AttributionControl,
GeolocateControl,
Marker,
NavigationControl,
Popup,
ScaleControl,
useMap,
} from "react-map-gl";
import MapGl from "react-map-gl/maplibre"; import MapGl from "react-map-gl/maplibre";
type NodePosition = {
latitude: number;
longitude: number;
};
const convertToLatLng = (position: {
latitudeI?: number;
longitudeI?: number;
}): NodePosition => ({
latitude: (position.latitudeI ?? 0) / 1e7,
longitude: (position.longitudeI ?? 0) / 1e7,
});
const MapPage = (): JSX.Element => { const MapPage = (): JSX.Element => {
const { nodes, waypoints } = useDevice(); const { nodes, waypoints } = useDevice();
const { rasterSources, darkMode } = useAppStore(); const { darkMode } = useAppStore();
const { default: map } = useMap(); const { default: map } = useMap();
const [zoom, setZoom] = useState(0);
const [selectedNode, setSelectedNode] = const [selectedNode, setSelectedNode] =
useState<Protobuf.Mesh.NodeInfo | null>(null); useState<Protobuf.Mesh.NodeInfo | null>(null);
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]); // Filter out nodes without a valid position
const validNodes = useMemo(
() =>
Array.from(nodes.values()).filter(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
[nodes],
);
const getBBox = useCallback(() => { const handleMarkerClick = useCallback(
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
event?.originalEvent?.stopPropagation();
setSelectedNode(node);
if (map) {
const position = convertToLatLng(node.position);
map.easeTo({
center: [position.longitude, position.latitude],
zoom: map?.getZoom(),
});
}
},
[map],
);
// Get the bounds of the map based on the nodes furtherest away from center
const getMapBounds = useCallback(() => {
if (!map) { if (!map) {
return; return;
} }
const nodesWithPosition = allNodes.filter(
(node) => node.position?.latitudeI, if (!validNodes.length) {
);
if (!nodesWithPosition.length) {
return; return;
} }
if (nodesWithPosition.length === 1) { if (validNodes.length === 1) {
map.easeTo({ map.easeTo({
zoom: 12, zoom: map.getZoom(),
center: [ center: [
(nodesWithPosition[0].position?.longitudeI ?? 0) / 1e7, (validNodes[0].position?.longitudeI ?? 0) / 1e7,
(nodesWithPosition[0].position?.latitudeI ?? 0) / 1e7, (validNodes[0].position?.latitudeI ?? 0) / 1e7,
], ],
}); });
return; return;
} }
const line = lineString( const line = lineString(
nodesWithPosition.map((n) => [ validNodes.map((n) => [
(n.position?.latitudeI ?? 0) / 1e7, (n.position?.latitudeI ?? 0) / 1e7,
(n.position?.longitudeI ?? 0) / 1e7, (n.position?.longitudeI ?? 0) / 1e7,
]), ]),
@@ -69,78 +103,54 @@ const MapPage = (): JSX.Element => {
if (center) { if (center) {
map.easeTo(center); map.easeTo(center);
} }
}, [allNodes, map]); }, [validNodes, map]);
useEffect(() => { // Generate all markers
map?.on("zoom", () => { const markers = useMemo(
setZoom(map?.getZoom() ?? 0); () =>
}); validNodes.map((node) => {
}, [map]); const position = convertToLatLng(node.position);
return (
<Marker
key={`marker-${node.num}`}
longitude={position.longitude}
latitude={position.latitude}
anchor="bottom"
onClick={(e) => handleMarkerClick(node, e)}
>
<Avatar
text={node.user?.shortName?.toString() ?? node.num.toString()}
className="border-[1.5px] border-slate-600 shadow-xl shadow-slate-600"
/>
</Marker>
);
}),
[validNodes, handleMarkerClick],
);
useEffect(() => { useEffect(() => {
map?.on("load", () => { map?.on("load", () => {
getBBox(); getMapBounds();
}); });
}, [map, getBBox]); }, [map, getMapBounds]);
return ( return (
<> <>
<Sidebar> <Sidebar />
<SidebarSection label="Sources"> <PageLayout label="Map" noPadding={true} actions={[]}>
{rasterSources.map((source) => (
<SidebarButton key={source.title} label={source.title} />
))}
</SidebarSection>
</Sidebar>
<PageLayout
label="Map"
noPadding={true}
actions={[
{
icon: ZoomInIcon,
onClick() {
map?.zoomIn();
},
},
{
icon: ZoomOutIcon,
onClick() {
map?.zoomOut();
},
},
{
icon: BoxSelectIcon,
onClick() {
getBBox();
},
},
]}
>
<MapGl <MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json" mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
// onClick={(e) => {
// const waypoint = new Protobuf.Waypoint({
// name: "test",
// description: "test description",
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
// });
// addWaypoint(waypoint);
// connection?.sendWaypoint(waypoint, "broadcast");
// }}
// @ts-ignore
attributionControl={false} attributionControl={false}
renderWorldCopies={false} renderWorldCopies={false}
maxPitch={0} maxPitch={0}
antialias={true}
style={{ style={{
filter: darkMode ? "brightness(0.8)" : "", filter: darkMode ? "brightness(0.9)" : "",
}} }}
dragRotate={false} dragRotate={false}
touchZoomRotate={false} touchZoomRotate={false}
initialViewState={{ initialViewState={{
zoom: 1.6, zoom: 1.8,
latitude: 35, latitude: 35,
longitude: 0, longitude: 0,
}} }}
@@ -151,6 +161,14 @@ const MapPage = (): JSX.Element => {
color: darkMode ? "black" : "", color: darkMode ? "black" : "",
}} }}
/> />
<GeolocateControl
position="top-right"
positionOptions={{ enableHighAccuracy: true }}
trackUserLocation
/>
<NavigationControl position="top-right" showCompass={false} />
<ScaleControl />
{waypoints.map((wp) => ( {waypoints.map((wp) => (
<Marker <Marker
key={wp.id} key={wp.id}
@@ -163,58 +181,17 @@ const MapPage = (): JSX.Element => {
</div> </div>
</Marker> </Marker>
))} ))}
{/* {rasterSources.map((source, index) => ( {markers}
<Source key={index} type="raster" {...source}> {selectedNode ? (
<Layer type="raster" />
</Source>
))} */}
{allNodes.map((node) => {
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
return (
<Marker
key={node.num}
longitude={(node.position.longitudeI ?? 0) / 1e7}
latitude={(node.position.latitudeI ?? 0) / 1e7}
// style={{ filter: darkMode ? "invert(1)" : "" }}
anchor="bottom"
onClick={() => {
setSelectedNode(node);
map?.easeTo({
zoom: 12,
center: [
(node.position?.longitudeI ?? 0) / 1e7,
(node.position?.latitudeI ?? 0) / 1e7,
],
});
}}
>
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5">
<Avatar
text={
node.user?.shortName.toString() ?? node.num.toString()
}
size="sm"
/>
<Subtle className={cn(zoom < 12 && "hidden")}>
{node.user?.longName ||
`!${numberToHexUnpadded(node.num)}`}
</Subtle>
</div>
</Marker>
);
}
})}
{selectedNode?.position && (
<Popup <Popup
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7} anchor="top"
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7} longitude={convertToLatLng(selectedNode.position).longitude}
anchor="left" latitude={convertToLatLng(selectedNode.position).latitude}
closeOnClick={false}
onClose={() => setSelectedNode(null)} onClose={() => setSelectedNode(null)}
> >
<NodeDetail node={selectedNode} /> <NodeDetail node={selectedNode} />
</Popup> </Popup>
)} ) : null}
</MapGl> </MapGl>
</PageLayout> </PageLayout>
</> </>
+4 -7
View File
@@ -1,3 +1,4 @@
import { useAppStore } from "@app/core/stores/appStore";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
@@ -5,21 +6,17 @@ import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { Device, useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.tsx"; import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const MessagesPage = () => { export const MessagesPage = () => {
const { channels, nodes, hardware, messages, traceroutes, connection } = const { channels, nodes, hardware, messages, traceroutes, connection } =
useDevice(); useDevice();
const [chatType, setChatType] = const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
useState<Types.PacketDestination>("broadcast");
const [activeChat, setActiveChat] = useState<number>(
Types.ChannelNumber.Primary,
);
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = Array.from(nodes.values()).filter((node) => {
if (node.num === hardware.myNodeNum) return false; if (node.num === hardware.myNodeNum) return false;
+87 -27
View File
@@ -1,16 +1,18 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog";
import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog";
import Footer from "@app/components/UI/Footer"; import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
import { Table } from "@components/generic/Table/index.tsx"; import { Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js"; import { Protobuf, type Types } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react"; import { LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react"; import { Fragment, type JSX, useCallback, useEffect, useState } from "react";
import { Fragment, type JSX } from "react";
import { base16 } from "rfc4648"; import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
@@ -19,8 +21,16 @@ export interface DeleteNoteDialogProps {
} }
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { nodes, hardware, setDialogOpen } = useDevice(); const { nodes, hardware, connection } = useDevice();
const { setNodeNumToBeRemoved } = useAppStore(); const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
const [selectedTraceroute, setSelectedTraceroute] = useState<
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [selectedLocation, setSelectedLocation] = useState<
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = Array.from(nodes.values()).filter((node) => {
@@ -29,6 +39,36 @@ const NodesPage = (): JSX.Element => {
return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
}); });
useEffect(() => {
if (!connection) return;
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
return () => {
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
};
}, [connection]);
const handleTraceroute = useCallback(
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
setSelectedTraceroute(traceroute);
},
[],
);
useEffect(() => {
if (!connection) return;
connection.events.onPositionPacket.subscribe(handleLocation);
return () => {
connection.events.onPositionPacket.subscribe(handleLocation);
};
}, [connection]);
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
setSelectedLocation(location);
},
[],
);
return ( return (
<> <>
<Sidebar /> <Sidebar />
@@ -46,22 +86,38 @@ const NodesPage = (): JSX.Element => {
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
{ title: "Name", type: "normal", sortable: true }, { title: "Short Name", type: "normal", sortable: true },
{ title: "Long Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true }, { title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true }, { title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Encryption", type: "normal", sortable: false }, { title: "Encryption", type: "normal", sortable: false },
{ title: "Connection", type: "normal", sortable: true }, { title: "Connection", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false },
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<span <div key={node.num}>
key={node.num} <Avatar text={node.user?.shortName.toString() ?? "UNK"} />
className="h-3 w-3 rounded-full bg-accent" </div>,
/>,
<h1 key="header"> <h1
key="shortName"
onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer"
>
{node.user?.shortName ??
(node.user?.macaddr
? `${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
</h1>,
<h1
key="longName"
onMouseDown={() => setSelectedNode(node)}
className="cursor-pointer"
>
{node.user?.longName ?? {node.user?.longName ??
(node.user?.macaddr (node.user?.macaddr
? `Meshtastic ${base16 ? `Meshtastic ${base16
@@ -95,7 +151,7 @@ const NodesPage = (): JSX.Element => {
{node.user?.publicKey && node.user?.publicKey.length > 0 ? ( {node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600" /> <LockIcon className="text-green-600" />
) : ( ) : (
<LockOpenIcon className="text-yellow-300" /> <LockOpenIcon className="text-yellow-300 mx-auto" />
)} )}
</Mono>, </Mono>,
<Mono key="hops"> <Mono key="hops">
@@ -108,19 +164,23 @@ const NodesPage = (): JSX.Element => {
: "-"} : "-"}
{node.viaMqtt === true ? ", via MQTT" : ""} {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])} ])}
/> />
<NodeOptionsDialog
node={selectedNode}
open={!!selectedNode}
onOpenChange={() => setSelectedNode(undefined)}
/>
<TracerouteResponseDialog
traceroute={selectedTraceroute}
open={!!selectedTraceroute}
onOpenChange={() => setSelectedTraceroute(undefined)}
/>
<LocationResponseDialog
location={selectedLocation}
open={!!selectedLocation}
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div> </div>
<Footer /> <Footer />
</div> </div>