Browse Source

Merge branch 'master' into issue-249-cors

pull/250/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
eb0ea4ea24
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .dockerignore
  2. 52
      .github/workflows/ci.yml
  3. 11
      .github/workflows/pr.yml
  4. 61
      .github/workflows/release.yml
  5. 9
      .vscode/settings.json
  6. 7
      Containerfile
  7. 6
      README.md
  8. 7
      biome.json
  9. 123
      package.json
  10. 9315
      pnpm-lock.yaml
  11. 4
      postcss.config.cjs
  12. 30
      rsbuild.config.ts
  13. 31
      src/App.tsx
  14. 9
      src/DeviceWrapper.tsx
  15. 14
      src/PageRouter.tsx
  16. 35
      src/components/CommandPalette.tsx
  17. 24
      src/components/DeviceSelector.tsx
  18. 6
      src/components/DeviceSelectorButton.tsx
  19. 10
      src/components/Dialog/DeviceNameDialog.tsx
  20. 21
      src/components/Dialog/DialogManager.tsx
  21. 47
      src/components/Dialog/ImportDialog.tsx
  22. 175
      src/components/Dialog/NewDeviceDialog.tsx
  23. 134
      src/components/Dialog/PKIBackupDialog.tsx
  24. 39
      src/components/Dialog/PkiRegenerateDialog.tsx
  25. 44
      src/components/Dialog/QRDialog.tsx
  26. 10
      src/components/Dialog/RebootDialog.tsx
  27. 12
      src/components/Dialog/RemoveNodeDialog.tsx
  28. 10
      src/components/Dialog/ShutdownDialog.tsx
  29. 50
      src/components/Form/DynamicForm.tsx
  30. 30
      src/components/Form/DynamicFormField.tsx
  31. 35
      src/components/Form/FormInput.tsx
  32. 66
      src/components/Form/FormPasswordGenerator.tsx
  33. 10
      src/components/Form/FormSelect.tsx
  34. 8
      src/components/Form/FormToggle.tsx
  35. 15
      src/components/Form/FormWrapper.tsx
  36. 19
      src/components/KeyBackupReminder.tsx
  37. 289
      src/components/PageComponents/Channel.tsx
  38. 19
      src/components/PageComponents/Config/Bluetooth.tsx
  39. 48
      src/components/PageComponents/Config/Device.tsx
  40. 6
      src/components/PageComponents/Config/Display.tsx
  41. 15
      src/components/PageComponents/Config/LoRa.tsx
  42. 33
      src/components/PageComponents/Config/Network.tsx
  43. 12
      src/components/PageComponents/Config/Position.tsx
  44. 6
      src/components/PageComponents/Config/Power.tsx
  45. 254
      src/components/PageComponents/Config/Security.tsx
  46. 14
      src/components/PageComponents/Connect/BLE.tsx
  47. 33
      src/components/PageComponents/Connect/HTTP.tsx
  48. 49
      src/components/PageComponents/Connect/Serial.tsx
  49. 171
      src/components/PageComponents/Map/NodeDetail.tsx
  50. 77
      src/components/PageComponents/Messages/ChannelChat.tsx
  51. 15
      src/components/PageComponents/Messages/Message.tsx
  52. 79
      src/components/PageComponents/Messages/MessageInput.tsx
  53. 36
      src/components/PageComponents/Messages/TraceRoute.tsx
  54. 6
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  55. 6
      src/components/PageComponents/ModuleConfig/Audio.tsx
  56. 6
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  57. 12
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  58. 6
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  59. 46
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  60. 8
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  61. 9
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  62. 6
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  63. 6
      src/components/PageComponents/ModuleConfig/Serial.tsx
  64. 6
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  65. 8
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  66. 71
      src/components/PageLayout.tsx
  67. 53
      src/components/Sidebar.tsx
  68. 18
      src/components/Toaster.tsx
  69. 94
      src/components/UI/Avatar.tsx
  70. 8
      src/components/UI/Button.tsx
  71. 2
      src/components/UI/Checkbox.tsx
  72. 4
      src/components/UI/Command.tsx
  73. 2
      src/components/UI/Dialog.tsx
  74. 2
      src/components/UI/DropdownMenu.tsx
  75. 37
      src/components/UI/Footer.tsx
  76. 121
      src/components/UI/Generator.tsx
  77. 28
      src/components/UI/Input.tsx
  78. 2
      src/components/UI/Label.tsx
  79. 2
      src/components/UI/Menubar.tsx
  80. 2
      src/components/UI/Popover.tsx
  81. 2
      src/components/UI/ScrollArea.tsx
  82. 2
      src/components/UI/Select.tsx
  83. 2
      src/components/UI/Seperator.tsx
  84. 6
      src/components/UI/Sidebar/SidebarSection.tsx
  85. 2
      src/components/UI/Sidebar/sidebarButton.tsx
  86. 2
      src/components/UI/Switch.tsx
  87. 4
      src/components/UI/Tabs.tsx
  88. 16
      src/components/UI/Toast.tsx
  89. 2
      src/components/UI/Tooltip.tsx
  90. 2
      src/components/UI/Typography/H4.tsx
  91. 14
      src/components/UI/Typography/H5.tsx
  92. 10
      src/components/UI/Typography/Link.tsx
  93. 2
      src/components/UI/Typography/Subtle.tsx
  94. 17
      src/components/generic/Table/index.tsx
  95. 2
      src/components/generic/ThemeController.tsx
  96. 33
      src/core/hooks/useBrowserFeatureDetection.ts
  97. 52
      src/core/hooks/useCookie.ts
  98. 119
      src/core/hooks/useKeyBackupReminder.tsx
  99. 65
      src/core/hooks/useToast.ts
  100. 9
      src/core/stores/appStore.ts

2
.dockerignore

@ -0,0 +1,2 @@
dist/build.tar
dist/output

52
.github/workflows/ci.yml

@ -1,6 +1,9 @@
name: CI
on: push
on:
push:
branches:
- master
permissions:
contents: write
@ -10,53 +13,14 @@ jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Upload Artifact
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
files: |
./dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

11
.github/workflows/pr.yml

@ -11,11 +11,18 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar

61
.github/workflows/release.yml

@ -0,0 +1,61 @@
name: 'Release'
on:
release:
types: [released]
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"

9
.vscode/settings.json

@ -1,4 +1,7 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
},
"editor.formatOnSave": true
}

7
Containerfile

@ -1,4 +1,9 @@
FROM registry.access.redhat.com/ubi9/nginx-122:1-45
FROM nginx:1.27.2-alpine
RUN rm -r /usr/share/nginx/html \
&& mkdir /usr/share/nginx/html
WORKDIR /usr/share/nginx/html
ADD dist .

6
README.md

@ -20,14 +20,14 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
## Self-host
The client can be self hosted using the precompiled container images with an OCI compatible runtime such as [Docker](https://www.docker.com/) or [Podman](https://podman.io/).
The base image used is [UBI9 Nginx 1.22](https://catalog.redhat.com/software/containers/ubi9/nginx-122/63f7653b9b0ca19f84f7e9a1)
The base image used is [Nginx 1.27](https://hub.docker.com/_/nginx)
```bash
# With Docker
docker run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
docker run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
#With Podman
podman run -d -p 8080:8080 -p 8443:8443 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
podman run -d -p 8080:8080 --restart always --name Meshtastic-Web ghcr.io/meshtastic/web
```
## Development & Building

7
biome.json

@ -1,10 +1,11 @@
{
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json",
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignoreUnknown": true
"ignoreUnknown": true,
"ignore": ["vercel.json"]
},
"vcs": {
"enabled": true,
@ -20,7 +21,7 @@
"linter": {
"enabled": true,
"rules": {
"all": true
"recommended": true
}
}
}

123
package.json

@ -5,11 +5,17 @@
"description": "Meshtastic web client",
"license": "GPL-3.0-only",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"check": "biome check .",
"preview": "vite preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)"
"build": "pnpm check && rsbuild build",
"check": "biome check src/",
"check:fix": "pnpm check --write src/",
"format": "biome format --write src/",
"dev": "rsbuild dev --open",
"preview": "rsbuild preview",
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)",
"postinstall": "npx simple-git-hooks"
},
"simple-git-hooks": {
"pre-commit": "npm run check:fix && npm run format"
},
"repository": {
"type": "git",
@ -20,64 +26,65 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^1.8.0",
"@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.4-0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@turf/turf": "^6.5.0",
"@bufbuild/protobuf": "^1.10.0",
"@meshtastic/js": "2.3.7-5",
"@noble/curves": "^1.8.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-menubar": "^1.1.5",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.5",
"@radix-ui/react-tooltip": "^1.1.7",
"@turf/turf": "^7.2.0",
"base64-js": "^1.5.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"immer": "^10.0.4",
"lucide-react": "^0.363.0",
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"crypto-random-string": "^5.0.0",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"mapbox-gl": "^3.9.4",
"maplibre-gl": "4.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.2",
"react-map-gl": "7.1.7",
"react-qrcode-logo": "^2.9.0",
"rfc4648": "^1.5.3",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-map-gl": "7.1.9",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"timeago-react": "^3.0.6",
"zustand": "4.5.2"
"vite-plugin-node-polyfills": "^0.23.0",
"zustand": "5.0.3"
},
"devDependencies": {
"@biomejs/biome": "^1.6.3",
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2",
"@types/chrome": "^0.0.263",
"@types/node": "^20.11.30",
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.23",
"@types/w3c-web-serial": "^1.0.6",
"@biomejs/biome": "^1.9.4",
"@rsbuild/core": "^1.2.3",
"@rsbuild/plugin-react": "^1.1.0",
"@types/chrome": "^0.0.299",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/w3c-web-serial": "^1.0.7",
"@types/web-bluetooth": "^0.0.20",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"gzipper": "^7.2.0",
"postcss": "^8.4.38",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.3",
"tar": "^6.2.1",
"tslib": "^2.6.2",
"typescript": "^5.4.3",
"vite": "^5.2.6",
"vite-plugin-environment": "^1.1.3"
}
"autoprefixer": "^10.4.20",
"gzipper": "^8.2.0",
"postcss": "^8.5.1",
"simple-git-hooks": "^2.11.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"typescript": "^5.7.3"
},
"packageManager": "[email protected]"
}

9315
pnpm-lock.yaml

File diff suppressed because it is too large

4
postcss.config.cjs

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

30
rsbuild.config.ts

@ -0,0 +1,30 @@
import { execSync } from "node:child_process";
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
let hash = "";
try {
hash = execSync("git rev-parse --short HEAD").toString().trim();
} catch (error) {
hash = "DEV";
}
export default defineConfig({
plugins: [pluginReact()],
source: {
define: {
"process.env.COMMIT_HASH": JSON.stringify(hash),
},
alias: {
"@app": "./src",
"@pages": "./src/pages",
"@components": "./src/components",
"@core": "./src/core",
"@layouts": "./src/layouts",
},
},
html: {
title: "Meshtastic Web",
},
});

31
src/App.tsx

@ -1,14 +1,16 @@
import { DeviceWrapper } from "@app/DeviceWrapper.js";
import { PageRouter } from "@app/PageRouter.js";
import { CommandPalette } from "@components/CommandPalette.js";
import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js";
import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Dashboard } from "@pages/Dashboard/index.js";
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { CommandPalette } from "@components/CommandPalette.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder";
import { Toaster } from "@components/Toaster.tsx";
import Footer from "@components/UI/Footer.tsx";
import { ThemeController } from "@components/generic/ThemeController.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { MapProvider } from "react-map-gl";
export const App = (): JSX.Element => {
@ -36,11 +38,16 @@ export const App = (): JSX.Element => {
{device ? (
<div className="flex h-screen">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<PageRouter />
</div>
) : (
<Dashboard />
<>
<Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)}
</div>
</div>

9
src/DeviceWrapper.tsx

@ -1,5 +1,5 @@
import { DeviceContext } from "@core/stores/deviceStore.js";
import type { Device } from "@core/stores/deviceStore.js";
import { DeviceContext } from "@core/stores/deviceStore.ts";
import type { Device } from "@core/stores/deviceStore.ts";
import type { ReactNode } from "react";
export interface DeviceWrapperProps {
@ -7,10 +7,7 @@ export interface DeviceWrapperProps {
device?: Device;
}
export const DeviceWrapper = ({
children,
device,
}: DeviceWrapperProps): JSX.Element => {
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
return (
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
);

14
src/PageRouter.tsx

@ -1,11 +1,11 @@
import { useDevice } from "@core/stores/deviceStore.js";
import { ChannelsPage } from "@pages/Channels.js";
import { ConfigPage } from "@pages/Config/index.js";
import { MapPage } from "@pages/Map.js";
import { MessagesPage } from "@pages/Messages.js";
import { NodesPage } from "@pages/Nodes.js";
import { useDevice } from "@core/stores/deviceStore.ts";
import ChannelsPage from "@pages/Channels.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import MapPage from "@pages/Map.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes.tsx";
export const PageRouter = (): JSX.Element => {
export const PageRouter = () => {
const { activePage } = useDevice();
return (
<>

35
src/components/CommandPalette.tsx

@ -1,3 +1,4 @@
import { Avatar } from "@components/UI/Avatar";
import {
CommandDialog,
CommandEmpty,
@ -5,10 +6,9 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@components/UI/Command.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
} from "@components/UI/Command.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { useCommandState } from "cmdk";
import {
ArrowLeftRightIcon,
@ -19,7 +19,7 @@ import {
LayersIcon,
LayoutIcon,
LinkIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
MoonIcon,
@ -51,11 +51,11 @@ export interface Command {
export interface SubItem {
label: string;
icon: JSX.Element;
icon: React.ReactNode;
action: () => void;
}
export const CommandPalette = (): JSX.Element => {
export const CommandPalette = () => {
const {
commandPaletteOpen,
setCommandPaletteOpen,
@ -125,9 +125,11 @@ export const CommandPalette = (): JSX.Element => {
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(),
icon: (
<Hashicon
size={16}
value={device.hardware.myNodeNum.toString()}
<Avatar
text={
device.nodes.get(device.hardware.myNodeNum)?.user
?.shortName ?? device.hardware.myNodeNum.toString()
}
/>
),
action() {
@ -200,10 +202,17 @@ export const CommandPalette = (): JSX.Element => {
},
},
{
label: "Factory Reset",
label: "Factory Reset Device",
icon: FactoryIcon,
action() {
connection?.factoryResetDevice();
},
},
{
label: "Factory Reset Config",
icon: FactoryIcon,
action() {
connection?.factoryReset();
connection?.factoryResetConfig();
},
},
],
@ -350,7 +359,7 @@ export const CommandPalette = (): JSX.Element => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, []);
}, [setCommandPaletteOpen]);
return (
<CommandDialog

24
src/components/DeviceSelector.tsx

@ -1,17 +1,18 @@
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.js";
import { Separator } from "@components/UI/Seperator.js";
import { Code } from "@components/UI/Typography/Code.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Code } from "@components/UI/Typography/Code.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import {
HomeIcon,
LanguagesIcon,
MoonIcon,
PlusIcon,
SunIcon,
SearchIcon,
SunIcon,
} from "lucide-react";
import type { JSX } from "react";
import { Avatar } from "./UI/Avatar";
export const DeviceSelector = (): JSX.Element => {
const { getDevices } = useDeviceStore();
@ -44,9 +45,12 @@ export const DeviceSelector = (): JSX.Element => {
}}
active={selectedDevice === device.id}
>
<Hashicon
size={24}
value={device.hardware.myNodeNum.toString()}
<Avatar
text={
device.nodes
.get(device.hardware.myNodeNum)
?.user?.shortName.toString() ?? "UNK"
}
/>
</DeviceSelectorButton>
))}

6
src/components/DeviceSelectorButton.tsx

@ -8,15 +8,15 @@ export const DeviceSelectorButton = ({
active,
onClick,
children,
}: DeviceSelectorButtonProps): JSX.Element => (
}: DeviceSelectorButtonProps) => (
<li
className="aspect-w-1 aspect-h-1 relative w-full"
onClick={onClick}
onKeyDown={onClick}
>
{active && (
{/* {active && (
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
)}
)} */}
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
{children}
</div>

10
src/components/Dialog/DeviceNameDialog.tsx

@ -1,5 +1,5 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import { Button } from "@components/UI/Button.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogContent,
@ -7,9 +7,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/js";
import { useForm } from "react-hook-form";

21
src/components/Dialog/DialogManager.tsx

@ -1,10 +1,11 @@
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.js";
import { ImportDialog } from "@components/Dialog/ImportDialog.js";
import { QRDialog } from "@components/Dialog/QRDialog.js";
import { RebootDialog } from "@components/Dialog/RebootDialog.js";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.js"
import { useDevice } from "@core/stores/deviceStore.js";
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.tsx";
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog";
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
export const DialogManager = (): JSX.Element => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("nodeRemoval", open);
}}
/>
<PkiBackupDialog
open={dialog.pkiBackup}
onOpenChange={(open) => {
setDialogOpen("pkiBackup", open);
}}
/>
</>
);
};

47
src/components/Dialog/ImportDialog.tsx

@ -1,5 +1,5 @@
import { Button } from "@components/UI/Button.js";
import { Checkbox } from "@components/UI/Checkbox.js";
import { Button } from "@components/UI/Button.tsx";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import {
Dialog,
DialogContent,
@ -7,11 +7,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
import { Switch } from "@components/UI/Switch.js";
import { useDevice } from "@core/stores/deviceStore.js";
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
@ -26,19 +26,34 @@ export const ImportDialog = ({
open,
onOpenChange,
}: ImportDialogProps): JSX.Element => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
const [validUrl, setValidUrl] = useState<boolean>(false);
const { connection } = useDevice();
useEffect(() => {
const base64String = qrCodeUrl.split("e/#")[1];
const paddedString = base64String
?.padEnd(base64String.length + ((4 - (base64String.length % 4)) % 4), "=")
.replace(/-/g, "+")
.replace(/_/g, "/");
// the channel information is contained in the URL's fragment, which will be present after a
// non-URL encoded `#`.
try {
const channelsUrl = new URL(importDialogInput);
if (
(channelsUrl.hostname !== "meshtastic.org" &&
channelsUrl.pathname !== "/e/") ||
!channelsUrl.hash
) {
throw "Invalid Meshtastic URL";
}
const encodedChannelConfig = channelsUrl.hash.substring(1);
const paddedString = encodedChannelConfig
.padEnd(
encodedChannelConfig.length +
((4 - (encodedChannelConfig.length % 4)) % 4),
"=",
)
.replace(/-/g, "+")
.replace(/_/g, "/");
setChannelSet(
Protobuf.AppOnly.ChannelSet.fromBinary(toByteArray(paddedString)),
);
@ -47,7 +62,7 @@ export const ImportDialog = ({
setValidUrl(false);
setChannelSet(undefined);
}
}, [qrCodeUrl]);
}, [importDialogInput]);
const apply = () => {
channelSet?.settings.map((ch, index) => {
@ -87,10 +102,10 @@ export const ImportDialog = ({
<div className="flex flex-col gap-3">
<Label>Channel Set/QR Code URL</Label>
<Input
value={qrCodeUrl}
value={importDialogInput}
suffix={validUrl ? "✅" : "❌"}
onChange={(e) => {
setQrCodeUrl(e.target.value);
setImportDialogInput(e.target.value);
}}
/>
{validUrl && (

175
src/components/Dialog/NewDeviceDialog.tsx

@ -1,20 +1,26 @@
import { BLE } from "@components/PageComponents/Connect/BLE.js";
import { HTTP } from "@components/PageComponents/Connect/HTTP.js";
import { Serial } from "@components/PageComponents/Connect/Serial.js";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@app/core/hooks/useBrowserFeatureDetection";
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
} from "@components/UI/Dialog.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.js";
import { Link } from "@components/UI/Typography/Link.js";
import { Subtle } from "@components/UI/Typography/Subtle.js";
} from "@components/UI/Tabs.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { AlertCircle, InfoIcon } from "lucide-react";
import { Fragment } from "react/jsx-runtime";
import { Link } from "../UI/Typography/Link";
export interface TabElementProps {
closeDialog: () => void;
@ -23,44 +29,109 @@ export interface TabElementProps {
export interface TabManifest {
label: string;
element: React.FC<TabElementProps>;
disabled: boolean;
disabledMessage: string;
disabledLink?: string;
isDisabled: boolean;
}
const tabs: TabManifest[] = [
{
label: "HTTP",
element: HTTP,
disabled: false,
disabledMessage: "Unsuported connection method",
},
{
label: "Bluetooth",
element: BLE,
disabled: !navigator.bluetooth,
disabledMessage:
"Web Bluetooth is currently only supported by Chromium-based browsers",
disabledLink:
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
},
{
label: "Serial",
element: Serial,
disabled: !navigator.serial,
disabledMessage:
"WebSerial is currently only supported by Chromium based browsers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
},
];
export interface NewDeviceProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
}
const links: { [key: string]: string } = {
"Web Bluetooth":
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
"Web Serial":
"https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
"Secure Context":
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
};
const listFormatter = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
});
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
if (missingFeatures.length === 0) return null;
const browserFeatures = missingFeatures.filter(
(feature) => feature !== "Secure Context",
);
const needsSecureContext = missingFeatures.includes("Secure Context");
const formatFeatureList = (features: string[]) => {
const parts = listFormatter.formatToParts(features);
return parts.map((part) => {
if (part.type === "element") {
return (
<Link key={part.value} href={links[part.value]}>
{part.value}
</Link>
);
}
return <Fragment key={part.value}>{part.value}</Fragment>;
});
};
return (
<Subtle className="flex flex-col items-start gap-2 text-black bg-red-200/80 p-4 rounded-md">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 flex-shrink-0" />
<div className="flex flex-col gap-3">
<p className="text-sm">
{browserFeatures.length > 0 && (
<>
This application requires {formatFeatureList(browserFeatures)}.
Please use a Chromium-based browser like Chrome or Edge.
</>
)}
{needsSecureContext && (
<>
{browserFeatures.length > 0 && " Additionally, it"}
{browserFeatures.length === 0 && "This application"} requires a{" "}
<Link href={links["Secure Context"]}>secure context</Link>.
Please connect using HTTPS or localhost.
</>
)}
</p>
</div>
</div>
</Subtle>
);
};
export const NewDeviceDialog = ({
open,
onOpenChange,
}: NewDeviceProps): JSX.Element => {
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
label: "HTTP",
element: HTTP,
isDisabled: false,
},
{
label: "Bluetooth",
element: BLE,
isDisabled:
unsupported.includes("Web Bluetooth") ||
unsupported.includes("Secure Context"),
},
{
label: "Serial",
element: Serial,
isDisabled:
unsupported.includes("Web Serial") ||
unsupported.includes("Secure Context"),
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -70,46 +141,22 @@ export const NewDeviceDialog = ({
<Tabs defaultValue="HTTP">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
disabled={tab.disabled}
>
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
{tab.disabled ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{tab.disabledMessage}
</p>
) : (
<fieldset disabled={tab.isDisabled}>
{tab.isDisabled ? (
<ErrorMessage missingFeatures={unsupported} />
) : null}
<tab.element closeDialog={() => onOpenChange(false)} />
)}
</fieldset>
</TabsContent>
))}
</Tabs>
{(!navigator.bluetooth || !navigator.serial) && (
<>
<Subtle>
Web Bluetooth and Web Serial are currently only supported by
Chromium-based browsers.
</Subtle>
<Subtle>
Read more:&nbsp;
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility">
Web Bluetooth
</Link>
&nbsp;
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility">
Web Serial
</Link>
</Subtle>
</>
)}
</DialogContent>
</Dialog>
);

134
src/components/Dialog/PKIBackupDialog.tsx

@ -0,0 +1,134 @@
import { useDevice } from "@app/core/stores/deviceStore";
import { Button } from "@components/UI/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
export interface PkiBackupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const PkiBackupDialog = ({
open,
onOpenChange,
}: PkiBackupDialogProps) => {
const { config, setDialogOpen } = useDevice();
const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
const decodeKeyData = React.useCallback(
(key: Uint8Array<ArrayBufferLike>) => {
if (!key) return "";
return fromByteArray(key ?? new Uint8Array(0));
},
[],
);
const closeDialog = React.useCallback(() => {
setDialogOpen("pkiBackup", false);
}, [setDialogOpen]);
const renderPrintWindow = React.useCallback(() => {
if (!privateKey || !publicKey) return;
const printWindow = window.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>=== MESHTASTIC KEYS ===</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
p { font-size: 14px; word-break: break-all; }
</style>
</head>
<body>
<h1>=== MESHTASTIC KEYS ===</h1>
<br>
<h2>Public Key:</h2>
<p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2>
<p>${decodeKeyData(privateKey)}</p>
<br>
<p>=== END OF KEYS ===</p>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) return;
const decodedPrivateKey = decodeKeyData(privateKey);
const decodedPublicKey = decodeKeyData(publicKey);
const formattedContent = [
"=== MESHTASTIC KEYS ===\n\n",
"Private Key:\n",
decodedPrivateKey,
"\n\nPublic Key:\n",
decodedPublicKey,
"\n\n=== END OF KEYS ===",
].join("");
const blob = new Blob([formattedContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "meshtastic_keys.txt";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Backup Keys</DialogTitle>
<DialogDescription>
Its important to backup your public and private keys and store your
backup securely!
</DialogDescription>
<DialogDescription>
<span className="font-bold break-before-auto">
If you lose your keys, you will need to reset your device.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
variant={"default"}
onClick={() => createDownloadKeyFile()}
className=""
>
<DownloadIcon size={20} className="mr-2" />
Download
</Button>
<Button variant={"default"} onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" />
Print
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

39
src/components/Dialog/PkiRegenerateDialog.tsx

@ -0,0 +1,39 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
open,
onOpenChange,
onSubmit,
}: PkiRegenerateDialogProps): JSX.Element => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

44
src/components/Dialog/QRDialog.tsx

@ -1,4 +1,4 @@
import { Checkbox } from "@components/UI/Checkbox.js";
import { Checkbox } from "@components/UI/Checkbox.tsx";
import {
Dialog,
DialogContent,
@ -6,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
import { Protobuf, Types } from "@meshtastic/js";
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/js";
import { fromByteArray } from "base64-js";
import { ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { QRCode } from "react-qrcode-logo";
export interface QRDialogProps {
@ -32,7 +32,7 @@ export const QRDialog = ({
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = Array.from(channels.values());
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
useEffect(() => {
const channelsToEncode = allChannels
@ -50,8 +50,10 @@ export const QRDialog = ({
.replace(/\+/g, "-")
.replace(/\//g, "_");
setQrCodeUrl(`https://meshtastic.org/e/#${base64}${qrCodeAdd ? "?add=true" : ""}`);
}, [channels, selectedChannels, qrCodeAdd, loraConfig]);
setQrCodeUrl(
`https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
);
}, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -97,18 +99,26 @@ export const QRDialog = ({
</div>
<div className="flex justify-center">
<button
type="button"
className={ "border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
type="button"
className={`border-black border-t border-l border-b rounded-l h-10 px-7 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(true)}
>
Add Channels
>
Add Channels
</button>
<button
type="button"
className={ "border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 " + (!qrCodeAdd ? "focus:ring-green-800 bg-green-800 text-white" : "focus:ring-slate-400 bg-slate-400 hover:bg-green-600") }
type="button"
className={`border-black border-t border-r border-b rounded-r h-10 px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 ${
!qrCodeAdd
? "focus:ring-green-800 bg-green-800 text-white"
: "focus:ring-slate-400 bg-slate-400 hover:bg-green-600"
}`}
onClick={() => setQrCodeAdd(false)}
>
Replace Channels
>
Replace Channels
</button>
</div>
</div>

10
src/components/Dialog/RebootDialog.tsx

@ -1,13 +1,13 @@
import { Button } from "@components/UI/Button.js";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { useDevice } from "@core/stores/deviceStore.js";
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, RefreshCwIcon } from "lucide-react";
import { useState } from "react";
@ -37,7 +37,7 @@ export const RebootDialog = ({
<Input
type="number"
value={time}
onChange={(e) => setTime(parseInt(e.target.value))}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
action={{
icon: ClockIcon,
onClick() {

12
src/components/Dialog/RemoveNodeDialog.tsx

@ -1,6 +1,6 @@
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore.js";
import { Button } from "@components/UI/Button.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogContent,
@ -8,8 +8,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Label } from "@components/UI/Label.js";
} from "@components/UI/Dialog.tsx";
import { Label } from "@components/UI/Label.tsx";
export interface RemoveNodeDialogProps {
open: boolean;
@ -44,7 +44,9 @@ export const RemoveNodeDialog = ({
</form>
</div>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>Remove</Button>
<Button variant="destructive" onClick={() => onSubmit()}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

10
src/components/Dialog/ShutdownDialog.tsx

@ -1,13 +1,13 @@
import { Button } from "@components/UI/Button.js";
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.js";
import { Input } from "@components/UI/Input.js";
import { useDevice } from "@core/stores/deviceStore.js";
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { ClockIcon, PowerIcon } from "lucide-react";
import { useState } from "react";
@ -38,7 +38,7 @@ export const ShutdownDialog = ({
<Input
type="number"
value={time}
onChange={(e) => setTime(parseInt(e.target.value))}
onChange={(e) => setTime(Number.parseInt(e.target.value))}
suffix="Minutes"
/>
<Button

50
src/components/Form/DynamicForm.tsx

@ -1,17 +1,17 @@
import {
DynamicFormField,
FieldProps,
} from "@components/Form/DynamicFormField.js";
import { FieldWrapper } from "@components/Form/FormWrapper.js";
import { Button } from "@components/UI/Button.js";
import { H4 } from "@components/UI/Typography/H4.js";
import { Subtle } from "@components/UI/Typography/Subtle.js";
type FieldProps,
} from "@components/Form/DynamicFormField.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { H4 } from "@components/UI/Typography/H4.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
Control,
DefaultValues,
FieldValues,
Path,
SubmitHandler,
type Control,
type DefaultValues,
type FieldValues,
type Path,
type SubmitHandler,
useForm,
} from "react-hook-form";
@ -23,10 +23,12 @@ interface DisabledBy<T> {
export interface BaseFormBuilderProps<T> {
name: Path<T>;
disabled?: boolean;
disabledBy?: DisabledBy<T>[];
label: string;
description?: string;
properties?: {};
validationText?: string;
properties?: Record<string, unknown>;
}
export interface GenericFormElementProps<T extends FieldValues, Y> {
@ -39,11 +41,12 @@ export interface DynamicFormProps<T extends FieldValues> {
onSubmit: SubmitHandler<T>;
submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean;
// defaultValues?: DeepPartial<T>;
defaultValues?: DefaultValues<T>;
fieldGroups: {
label: string;
description: string;
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[];
}[];
}
@ -60,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
defaultValues: defaultValues,
});
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
): boolean => {
if (disabled) return true;
if (!disabledBy) return false;
return disabledBy.some((field) => {
const value = getValues(field.fieldName);
if (value === "always") return true;
if (typeof value === "boolean") return field.invert ? value : !value;
if (typeof value === "number")
return field.invert
@ -94,12 +102,20 @@ export function DynamicForm<T extends FieldValues>({
</div>
{fieldGroup.fields.map((field) => (
<FieldWrapper label={field.label} description={field.description}>
<FieldWrapper
key={field.label}
label={field.label}
description={field.description}
valid={
field.validationText === undefined ||
field.validationText === ""
}
validationText={field.validationText}
>
<DynamicFormField
key={field.label}
field={field}
control={control}
disabled={isDisabled(field.disabledBy)}
disabled={isDisabled(field.disabledBy, field.disabled)}
/>
</FieldWrapper>
))}

30
src/components/Form/DynamicFormField.tsx

@ -1,12 +1,26 @@
import { GenericInput, InputFieldProps } from "@components/Form/FormInput.js";
import { SelectFieldProps, SelectInput } from "@components/Form/FormSelect.js";
import { ToggleFieldProps, ToggleInput } from "@components/Form/FormToggle.js";
import {
GenericInput,
type InputFieldProps,
} from "@components/Form/FormInput.tsx";
import {
PasswordGenerator,
type PasswordGeneratorProps,
} from "@components/Form/FormPasswordGenerator.tsx";
import {
type SelectFieldProps,
SelectInput,
} from "@components/Form/FormSelect.tsx";
import {
type ToggleFieldProps,
ToggleInput,
} from "@components/Form/FormToggle.tsx";
import type { Control, FieldValues } from "react-hook-form";
export type FieldProps<T> =
| InputFieldProps<T>
| SelectFieldProps<T>
| ToggleFieldProps<T>;
| ToggleFieldProps<T>
| PasswordGeneratorProps<T>;
export interface DynamicFormFieldProps<T extends FieldValues> {
field: FieldProps<T>;
@ -35,6 +49,14 @@ export function DynamicFormField<T extends FieldValues>({
return (
<SelectInput field={field} control={control} disabled={disabled} />
);
case "passwordGenerator":
return (
<PasswordGenerator
field={field}
control={control}
disabled={disabled}
/>
);
case "multiSelect":
return <div>tmp</div>;
}

35
src/components/Form/FormInput.tsx

@ -1,14 +1,19 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
} from "@components/Form/DynamicForm.tsx";
import { Input } from "@components/UI/Input.tsx";
import type { LucideIcon } from "lucide-react";
import { Controller, FieldValues } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: {
value?: string;
prefix?: string;
suffix?: string;
step?: number;
@ -24,22 +29,38 @@ export function GenericInput<T extends FieldValues>({
disabled,
field,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
<Input
type={field.type}
type={
field.type === "password" && passwordShown ? "text" : field.type
}
action={
field.type === "password"
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined
}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) =>
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
)
}
);
}}
{...field.properties}
{...rest}
disabled={disabled}

66
src/components/Form/FormPasswordGenerator.tsx

@ -0,0 +1,66 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx";
import type { ButtonVariant } from "@components/UI/Button";
import { Generator } from "@components/UI/Generator.tsx";
import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler, MouseEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
}
export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const [passwordShown, setPasswordShown] = useState(false);
const togglePasswordVisiblity = () => {
setPasswordShown(!passwordShown);
};
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, ...rest } }) => (
<Generator
type={field.hide && !passwordShown ? "password" : "text"}
action={
field.hide
? {
icon: passwordShown ? EyeOff : Eye,
onClick: togglePasswordVisiblity,
}
: undefined
}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
value={value}
variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons}
{...field.properties}
{...rest}
disabled={disabled}
/>
)}
/>
);
}

10
src/components/Form/FormSelect.tsx

@ -1,15 +1,15 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
} from "@components/Form/DynamicForm.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.js";
import { Controller, FieldValues } from "react-hook-form";
} from "@components/UI/Select.tsx";
import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select" | "multiSelect";
@ -40,7 +40,7 @@ export function SelectInput<T extends FieldValues>({
: [];
return (
<Select
onValueChange={(e) => onChange(parseInt(e))}
onValueChange={(e) => onChange(Number.parseInt(e))}
disabled={disabled}
value={value?.toString()}
{...remainingProperties}
@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, value]) => (
<SelectItem key={name} value={value.toString()}>
<SelectItem key={name + value} value={value.toString()}>
{formatEnumName
? name
.replace(/_/g, " ")

8
src/components/Form/FormToggle.tsx

@ -1,10 +1,10 @@
import type {
BaseFormBuilderProps,
GenericFormElementProps,
} from "@components/Form/DynamicForm.js";
import { Switch } from "@components/UI/Switch.js";
import { ChangeEvent } from "react";
import { Controller, FieldValues } from "react-hook-form";
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import type { ChangeEvent } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";

15
src/components/Form/FormWrapper.tsx

@ -1,30 +1,37 @@
import { Label } from "@components/UI/Label.js";
import { Label } from "@components/UI/Label.tsx";
export interface FieldWrapperProps {
label: string;
description?: string;
disabled?: boolean;
children?: React.ReactNode;
valid?: boolean;
validationText?: string;
}
export const FieldWrapper = ({
label,
description,
children,
}: FieldWrapperProps): JSX.Element => (
valid,
validationText,
}: FieldWrapperProps) => (
<div className="pt-6 sm:pt-5">
<div role="group" aria-labelledby="label-notifications">
<fieldset aria-labelledby="label-notifications">
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
<Label>{label}</Label>
<div className="sm:col-span-2">
<div className="max-w-lg">
<p className="text-sm text-gray-500">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500">
{validationText}
</p>
<div className="mt-4 space-y-4">
<div className="flex items-center">{children}</div>
</div>
</div>
</div>
</div>
</div>
</fieldset>
</div>
);

19
src/components/KeyBackupReminder.tsx

@ -0,0 +1,19 @@
import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder";
import { useDevice } from "@app/core/stores/deviceStore";
export const KeyBackupReminder = (): JSX.Element => {
const { setDialogOpen } = useDevice();
useBackupReminder({
reminderInDays: 7,
message:
"We recommend backing up your key data regularly. Would you like to back up now?",
onAccept: () => setDialogOpen("pkiBackup", true),
enabled: true,
cookieOptions: {
secure: true,
sameSite: "strict",
},
});
return <></>;
};

289
src/components/PageComponents/Channel.tsx

@ -1,9 +1,12 @@
import type{ ChannelValidation } from "@app/validation/channel.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useToast } from "@core/hooks/useToast.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { ChannelValidation } from "@app/validation/channel.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useState } from "react";
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
@ -13,15 +16,29 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
const { config, connection, addChannel } = useDevice();
const { toast } = useToast();
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [bitCount, setBits] = useState<number>(
channel?.settings?.psk.length ?? 16,
);
const [validationText, setValidationText] = useState<string>();
const [preSharedDialogOpen, setPreSharedDialogOpen] =
useState<boolean>(false);
const onSubmit = (data: ChannelValidation) => {
const channel = new Protobuf.Channel.Channel({
...data,
settings: {
...data.settings,
psk: toByteArray(data.settings.psk ?? ""),
psk: toByteArray(pass),
moduleSettings: {
positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation ? 32 : data.settings.positionPrecision : 0,
}
positionPrecision: data.settings.positionEnabled
? data.settings.preciseLocation
? 32
: data.settings.positionPrecision
: 0,
},
},
});
connection?.setChannel(channel).then(() => {
@ -32,93 +49,185 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
});
};
const preSharedKeyRegenerate = () => {
setPass(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
setPreSharedDialogOpen(false);
};
const preSharedClickEvent = () => {
setPreSharedDialogOpen(true);
};
const validatePass = (input: string, count: number) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
};
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setPass(psk);
validatePass(psk, bitCount);
};
const selectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setBits(count);
validatePass(pass, count);
};
return (
<DynamicForm<ChannelValidation>
onSubmit={onSubmit}
submitType="onSubmit"
hasSubmitButton={true}
defaultValues={{
...channel,
...{
settings: {
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
positionEnabled: channel?.settings?.moduleSettings?.positionPrecision != undefined && channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation: channel?.settings?.moduleSettings?.positionPrecision == 32,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision == undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision
<>
<DynamicForm<ChannelValidation>
onSubmit={onSubmit}
submitType="onSubmit"
hasSubmitButton={true}
defaultValues={{
...channel,
...{
settings: {
...channel?.settings,
psk: pass,
positionEnabled:
channel?.settings?.moduleSettings?.positionPrecision !==
undefined &&
channel?.settings?.moduleSettings?.positionPrecision > 0,
preciseLocation:
channel?.settings?.moduleSettings?.positionPrecision === 32,
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
},
}}
fieldGroups={[
{
label: "Channel Settings",
description: "Crypto, MQTT & misc settings",
fields: [
{
type: "select",
name: "role",
label: "Role",
description:
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
properties: {
enumValue: Protobuf.Channel.Channel_Role,
}}
fieldGroups={[
{
label: "Channel Settings",
description: "Crypto, MQTT & misc settings",
fields: [
{
type: "select",
name: "role",
label: "Role",
disabled: channel.index === 0,
description:
"Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
properties: {
enumValue:
channel.index === 0
? { PRIMARY: 1 }
: { DISABLED: 0, SECONDARY: 2 },
},
},
},
{
type: "password",
name: "settings.psk",
label: "pre-Shared Key",
description: "16, or 32 bytes",
properties: {
// act
{
type: "passwordGenerator",
name: "settings.psk",
label: "Pre-Shared Key",
description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
validationText: validationText,
devicePSKBitCount: bitCount ?? 0,
inputChange: inputChangeEvent,
selectChange: selectChangeEvent,
actionButtons: [
{
text: "Generate",
variant: "success",
onClick: preSharedClickEvent,
},
],
hide: true,
properties: {
value: pass,
},
},
},
{
type: "text",
name: "settings.name",
label: "Name",
description:
"A unique name for the channel <12 bytes, leave blank for default",
},
{
type: "toggle",
name: "settings.uplinkEnabled",
label: "Uplink Enabled",
description: "Send messages from the local mesh to MQTT",
},
{
type: "toggle",
name: "settings.downlinkEnabled",
label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh",
},
{
type: "toggle",
name: "settings.positionEnabled",
label: "Allow Position Requests",
description: "Send position to channel",
},
{
type: "toggle",
name: "settings.preciseLocation",
label: "Precise Location",
description: "Send precise location to channel",
},
{
type: "select",
name: "settings.positionPrecision",
label: "Approximate Location",
description:
"If not sharing precise location, position shared on channel will be accurate within this distance",
properties: {
enumValue: config.display?.units == 0 ?
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } :
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 }
{
type: "text",
name: "settings.name",
label: "Name",
description:
"A unique name for the channel <12 bytes, leave blank for default",
},
},
],
},
]}
/>
{
type: "toggle",
name: "settings.uplinkEnabled",
label: "Uplink Enabled",
description: "Send messages from the local mesh to MQTT",
},
{
type: "toggle",
name: "settings.downlinkEnabled",
label: "Downlink Enabled",
description: "Send messages from MQTT to the local mesh",
},
{
type: "toggle",
name: "settings.positionEnabled",
label: "Allow Position Requests",
description: "Send position to channel",
},
{
type: "toggle",
name: "settings.preciseLocation",
label: "Precise Location",
description: "Send precise location to channel",
},
{
type: "select",
name: "settings.positionPrecision",
label: "Approximate Location",
description:
"If not sharing precise location, position shared on channel will be accurate within this distance",
properties: {
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
},
],
},
]}
/>
<PkiRegenerateDialog
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}
/>
</>
);
};

19
src/components/PageComponents/Config/Bluetooth.tsx

@ -1,10 +1,21 @@
import type { BluetoothValidation } from "@app/validation/config/bluetooth.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { useState } from "react";
export const Bluetooth = (): JSX.Element => {
const { config, setWorkingConfig } = useDevice();
const [bluetoothValidationText, setBluetoothValidationText] =
useState<string>();
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value[0] === "0") {
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
} else {
setBluetoothValidationText("");
}
};
const onSubmit = (data: BluetoothValidation) => {
setWorkingConfig(
@ -52,6 +63,8 @@ export const Bluetooth = (): JSX.Element => {
name: "fixedPin",
label: "Pin",
description: "Pin to use when pairing",
validationText: bluetoothValidationText,
inputChange: bluetoothPinChangeEvent,
disabledBy: [
{
fieldName: "mode",

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

@ -1,6 +1,6 @@
import type { DeviceValidation } from "@app/validation/config/device.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { DeviceValidation } from "@app/validation/config/device.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Device = (): JSX.Element => {
@ -32,23 +32,25 @@ export const Device = (): JSX.Element => {
label: "Role",
description: "What role the device performs on the mesh",
properties: {
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
enumValue: {
Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
"Client Mute":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
"Client Hidden":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
"Lost and Found":
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
"TAK Tracker":
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
},
formatEnumName: true,
},
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Enable the device's serial console",
},
{
type: "toggle",
name: "debugLogEnabled",
label: "Enabled Debug Log",
description:
"Output debugging information to the device's serial port (auto disables when serial client is connected)",
},
{
type: "number",
name: "buttonGpio",
@ -86,18 +88,18 @@ export const Device = (): JSX.Element => {
label: "Double Tap as Button Press",
description: "Treat double tap as button press",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description: "Is this device managed by a mesh administator",
},
{
type: "toggle",
name: "disableTripleClick",
label: "Disable Triple Click",
description: "Disable triple click",
},
{
type: "toggle",
name: "ledHeartbeatDisabled",
label: "LED Heartbeat Disabled",
description: "Disable default blinking LED",
},
],
},
]}

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

@ -1,6 +1,6 @@
import type { DisplayValidation } from "@app/validation/config/display.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { DisplayValidation } from "@app/validation/config/display.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Display = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import type { LoRaValidation } from "@app/validation/config/lora.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { LoRaValidation } from "@app/validation/config/lora.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const LoRa = (): JSX.Element => {
@ -41,7 +41,7 @@ export const LoRa = (): JSX.Element => {
label: "Hop Limit",
description: "Maximum number of hops",
properties: {
enumValue: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7}
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
},
{
@ -56,6 +56,13 @@ export const LoRa = (): JSX.Element => {
label: "Ignore MQTT",
description: "Don't forward MQTT messages over the mesh",
},
{
type: "toggle",
name: "configOkToMqtt",
label: "OK to MQTT",
description:
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
},
],
},
{

33
src/components/PageComponents/Config/Network.tsx

@ -1,6 +1,10 @@
import type { NetworkValidation } from "@app/validation/config/network.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { NetworkValidation } from "@app/validation/config/network.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
convertIntToIpAddress,
convertIpAddressToInt,
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/js";
export const Network = (): JSX.Element => {
@ -13,9 +17,12 @@ export const Network = (): JSX.Element => {
case: "network",
value: {
...data,
ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config(
data.ipv4Config,
),
ipv4Config: new Protobuf.Config.Config_NetworkConfig_IpV4Config({
ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
}),
},
},
}),
@ -25,7 +32,19 @@ export const Network = (): JSX.Element => {
return (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
defaultValues={config.network}
defaultValues={{
...config.network,
ipv4Config: {
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
config.network?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
config.network?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
}}
fieldGroups={[
{
label: "WiFi Config",

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

@ -1,6 +1,6 @@
import type { PositionValidation } from "@app/validation/config/position.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { PositionValidation } from "@app/validation/config/position.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Position = (): JSX.Element => {
@ -77,12 +77,6 @@ export const Position = (): JSX.Element => {
label: "Enable Pin",
description: "GPS module enable pin override",
},
{
type: "number",
name: "channelPrecision",
label: "Channel Precision",
description: "GPS channel precision",
},
],
},
{

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

@ -1,6 +1,6 @@
import type { PowerValidation } from "@app/validation/config/power.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { PowerValidation } from "@app/validation/config/power.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Power = (): JSX.Element => {

254
src/components/PageComponents/Config/Security.tsx

@ -0,0 +1,254 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
useDevice();
const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
const [publicKey, setPublicKey] = useState<string>(
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
);
const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>();
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
setWorkingConfig(
new Protobuf.Config.Config({
payloadVariant: {
case: "security",
value: {
...data,
adminKey: [toByteArray(adminKey)],
privateKey: toByteArray(privateKey),
publicKey: toByteArray(publicKey),
},
},
}),
);
};
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
try {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
}
} catch (e) {
console.error(e);
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
}
};
const privateKeyClickEvent = () => {
setPrivateKeyDialogOpen(true);
};
const pkiBackupClickEvent = () => {
setDialogOpen("pkiBackup", true);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
setPrivateKey(fromByteArray(privateKey));
setPublicKey(fromByteArray(publicKey));
validateKey(
fromByteArray(privateKey),
privateKeyBitCount,
setPrivateKeyValidationText,
);
setPrivateKeyDialogOpen(false);
};
const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validateKey(privateKey, count, setPrivateKeyValidationText);
};
return (
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
actionButtons: [
{
text: "Generate",
onClick: privateKeyClickEvent,
variant: "success",
},
{
text: "Backup Key",
onClick: pkiBackupClickEvent,
variant: "subtle",
},
],
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
},
},
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
},
},
],
},
{
label: "Admin Settings",
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
},
{
type: "text",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
inputChange: adminKeyInputChangeEvent,
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description:
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
<PkiRegenerateDialog
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
);
};

14
src/components/PageComponents/Connect/BLE.tsx

@ -1,10 +1,10 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, Constants } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";

33
src/components/PageComponents/Connect/HTTP.tsx

@ -1,14 +1,14 @@
import React, { useState } from "react";
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import { Label } from "@components/UI/Label.js";
import { Switch } from "@components/UI/Switch.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { HttpConnection } from "@meshtastic/js";
import { useState } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
@ -20,7 +20,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
}>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
window.location.hostname
window.location.hostname,
)
? "meshtastic.local"
: window.location.hostname,
@ -34,7 +34,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
const id = randId();
const device = addDevice(id);
const connection = new HttpConnection(id);
@ -69,7 +69,12 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
<Label>Use HTTPS</Label>
<Switch
onCheckedChange={(checked) => {checked ? setHTTPS(true) : setHTTPS(false) }}
disabled={connectionInProgress}
// label="Use TLS"
// description="Description"
disabled={
location.protocol === "https:" || connectionInProgress
}
checked={value}
{...rest}
/>
</>
@ -77,7 +82,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
/>
</div>
<Button type="submit" disabled={connectionInProgress}>
<span>{connectionInProgress ? 'Connecting...' : 'Connect' }</span>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>
</form>
);

49
src/components/PageComponents/Connect/Serial.tsx

@ -1,10 +1,10 @@
import { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.js";
import { Mono } from "@components/generic/Mono.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
import { subscribeAll } from "@core/subscriptions.js";
import { randId } from "@core/utils/randId.js";
import type { TabElementProps } from "@app/components/Dialog/NewDeviceDialog";
import { Button } from "@components/UI/Button.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { SerialConnection } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
@ -14,13 +14,13 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
setSerialPorts(await navigator.serial.getPorts());
setSerialPorts(await navigator?.serial.getPorts());
}, []);
navigator.serial.addEventListener("connect", () => {
navigator?.serial?.addEventListener("connect", () => {
updateSerialPortList();
});
navigator.serial.addEventListener("disconnect", () => {
navigator?.serial?.addEventListener("disconnect", () => {
updateSerialPortList();
});
useEffect(() => {
@ -48,19 +48,22 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
return (
<div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => (
<Button
key={index}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${port.getInfo().usbVendorId ?? "UNK"} - ${
port.getInfo().usbProductId ?? "UNK"
}`}
</Button>
))}
{serialPorts.map((port, index) => {
const { usbProductId, usbVendorId } = port.getInfo();
return (
<Button
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
disabled={port.readable !== null}
onClick={async () => {
await onConnect(port);
}}
>
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
usbProductId ?? "UNK"
}`}
</Button>
);
})}
{serialPorts.length === 0 && (
<Mono className="m-auto select-none">No devices paired yet.</Mono>
)}

171
src/components/PageComponents/Map/NodeDetail.tsx

@ -0,0 +1,171 @@
import { Separator } from "@app/components/UI/Seperator";
import { H5 } from "@app/components/UI/Typography/H5.tsx";
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
import { Avatar } from "@components/UI/Avatar";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/js";
import type { Protobuf as ProtobufType } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import {
BatteryChargingIcon,
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
Dot,
LockIcon,
LockOpenIcon,
MountainSnow,
Star,
} from "lucide-react";
export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo;
}
export const NodeDetail = ({ node }: NodeDetailProps) => {
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
const hardwareType = Protobuf.Mesh.HardwareModel[
node.user?.hwModel ?? 0
].replaceAll("_", " ");
return (
<div className="dark:text-black">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={node.user?.shortName} />
<div>
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon
className="text-green-600"
size={12}
strokeWidth={3}
aria-label="Public Key Enabled"
/>
) : (
<LockOpenIcon
className="text-yellow-500"
size={12}
strokeWidth={3}
aria-label="No Public Key"
/>
)}
</div>
<Star
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
/>
</div>
<div>
<H5>{name}</H5>
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (
<div
className="flex items-center gap-1"
title={`${
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
} volts`}
>
{node.deviceMetrics?.batteryLevel > 100 ? (
<BatteryChargingIcon size={22} />
) : node.deviceMetrics?.batteryLevel > 80 ? (
<BatteryFullIcon size={22} />
) : node.deviceMetrics?.batteryLevel > 20 ? (
<BatteryMediumIcon size={22} />
) : (
<BatteryLowIcon size={22} />
)}
<Subtle aria-label="Battery">
{node.deviceMetrics?.batteryLevel > 100
? "Charging"
: `${node.deviceMetrics?.batteryLevel}%`}
</Subtle>
</div>
)}
<div className="flex gap-2 items-center">
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
{node.user?.id && <div>{node.user?.id}</div>}
</div>
<div
className="flex gap-1"
title={new Date(node.lastHeard * 1000).toLocaleString(
navigator.language,
)}
>
<div>
{node.lastHeard > 0 && (
<div>
Heard <TimeAgo timestamp={node.lastHeard * 1000} />
</div>
)}
</div>
{node.viaMqtt && (
<div style={{ color: "#660066" }} className="font-medium">
MQTT
</div>
)}
</div>
</div>
</div>
<Separator className="my-1" />
<div className="flex mt-2 text-sm">
<div className="flex items-center flex-grow">
<div className="border-2 border-black rounded px-0.5 mr-1">
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway}
</div>
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
</div>
{node.position?.altitude && (
<div className="flex items-center flex-grow">
<MountainSnow
size={15}
className="ml-2 mr-1"
aria-label="Elevation"
/>
<div>{node.position?.altitude} ft</div>
</div>
)}
</div>
<div className="flex mt-2">
{!!node.deviceMetrics?.channelUtilization && (
<div className="flex-grow">
<div>Channel Util</div>
<Mono>
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
</Mono>
</div>
)}
{!!node.deviceMetrics?.airUtilTx && (
<div className="flex-grow">
<div>Airtime Util</div>
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
</div>
)}
</div>
{node.snr !== 0 && (
<div className="mt-2">
<div>SNR</div>
<Mono className="flex items-center text-xs">
{node.snr}db
<Dot />
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
<Dot />
{(node.snr + 10) * 5}raw
</Mono>
</div>
)}
</div>
);
};

77
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,45 +1,74 @@
import { Subtle } from "@app/components/UI/Typography/Subtle.js";
import { MessageWithState, useDevice } from "@app/core/stores/deviceStore.js";
import { Message } from "@components/PageComponents/Messages/Message.js";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.js";
import type { Types } from "@meshtastic/js";
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
import {
type MessageWithState,
useDevice,
} from "@app/core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import type { Protobuf, Types } from "@meshtastic/js";
import { InboxIcon } from "lucide-react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
traceroutes?: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[];
}
export const ChannelChat = ({
messages,
channel,
to,
traceroutes,
}: ChannelChatProps): JSX.Element => {
const { nodes } = useDevice();
return (
<div className="flex flex-grow flex-col">
<div className="flex flex-grow flex-col">
{messages ? (
messages.map((message, index) => (
<Message
key={message.id}
message={message}
lastMsgSameUser={
index === 0 ? false : messages[index - 1].from === message.from
}
sender={nodes.get(message.from)}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
<div className="flex flex-grow">
<div className="flex flex-grow flex-col">
{messages ? (
messages.map((message, index) => (
<Message
key={message.id}
message={message}
lastMsgSameUser={
index === 0
? false
: messages[index - 1].from === message.from
}
sender={nodes.get(message.from)}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Messages</Subtle>
</div>
)}
</div>
<div
className={`flex flex-grow flex-col border-slate-400 border-l ${traceroutes === undefined ? "hidden" : ""}`}
>
{to === "broadcast" ? null : traceroutes ? (
traceroutes.map((traceroute, index) => (
<TraceRoute
key={traceroute.id}
from={nodes.get(traceroute.from)}
to={nodes.get(traceroute.to)}
route={traceroute.data.route}
/>
))
) : (
<div className="m-auto">
<InboxIcon className="m-auto" />
<Subtle>No Traceroutes</Subtle>
</div>
)}
</div>
</div>
<div className="p-3">
<div className="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} />
</div>
</div>

15
src/components/PageComponents/Messages/Message.tsx

@ -1,5 +1,5 @@
import type { MessageWithState } from "@app/core/stores/deviceStore.js";
import { Hashicon } from "@emeraldpay/hashicon-react";
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
import { Avatar } from "@components/UI/Avatar";
import type { Protobuf } from "@meshtastic/js";
import {
AlertCircleIcon,
@ -13,11 +13,7 @@ export interface MessageProps {
sender?: Protobuf.Mesh.NodeInfo;
}
export const Message = ({
lastMsgSameUser,
message,
sender,
}: MessageProps): JSX.Element => {
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
return lastMsgSameUser ? (
<div className="ml-5 flex">
{message.state === "ack" ? (
@ -39,11 +35,14 @@ export const Message = ({
<div className="mx-4 mt-2 gap-2">
<div className="flex gap-2">
<div className="w-6 cursor-pointer">
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
<Avatar text={sender?.user?.shortName ?? "UNK"} />
</div>
<span className="cursor-pointer font-medium text-textPrimary">
{sender?.user?.longName ?? "UNK"}
</span>
<span className="mt-1 font-mono text-xs text-textSecondary">
{message.rxTime.toLocaleDateString()}
</span>
<span className="mt-1 font-mono text-xs text-textSecondary">
{message.rxTime.toLocaleTimeString(undefined, {
hour: "2-digit",

79
src/components/PageComponents/Messages/MessageInput.tsx

@ -1,8 +1,10 @@
import { Button } from "@components/UI/Button.js";
import { Input } from "@components/UI/Input.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { debounce } from "@app/core/utils/debounce";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/js";
import { SendIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
export interface MessageInputProps {
to: Types.Destination;
@ -20,32 +22,46 @@ export const MessageInput = ({
setMessageDraft,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
const [localDraft, setLocalDraft] = useState(messageDraft);
const debouncedSetMessageDraft = useMemo(
() => debounce(setMessageDraft, 300),
[setMessageDraft],
);
const sendText = useCallback(
async (message: string) => {
await connection
?.sendText(message, to, true, channel)
.then((id) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
id,
"ack",
),
)
.catch((e: Types.PacketError) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
e.id,
e.error,
),
);
},
[channel, connection, myNodeNum, setMessageState, to],
);
const sendText = async (message: string) => {
await connection
?.sendText(message, to, true, channel)
.then((id) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
id,
"ack",
),
)
.catch((e: Types.PacketError) =>
setMessageState(
to === "broadcast" ? "broadcast" : "direct",
channel,
to as number,
myNodeNum,
e.id,
e.error,
),
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalDraft(newValue);
debouncedSetMessageDraft(newValue);
};
return (
@ -54,7 +70,8 @@ export const MessageInput = ({
className="w-full"
onSubmit={(e) => {
e.preventDefault();
sendText(messageDraft);
sendText(localDraft);
setLocalDraft("");
setMessageDraft("");
}}
>
@ -62,10 +79,10 @@ export const MessageInput = ({
<span className="w-full">
<Input
autoFocus={true}
minLength={2}
minLength={1}
placeholder="Enter Message"
value={messageDraft}
onChange={(e) => setMessageDraft(e.target.value)}
value={localDraft}
onChange={handleInputChange}
/>
</span>
<Button type="submit">

36
src/components/PageComponents/Messages/TraceRoute.tsx

@ -0,0 +1,36 @@
import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/js";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
export interface TraceRouteProps {
from?: Protobuf.Mesh.NodeInfo;
to?: Protobuf.Mesh.NodeInfo;
route: Array<number>;
}
export const TraceRoute = ({
from,
to,
route,
}: TraceRouteProps): JSX.Element => {
const { nodes } = useDevice();
return route.length === 0 ? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}{from?.user?.longName}
</span>
</div>
) : (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
{to?.user?.longName}
{route.map((hop) => {
const node = nodes.get(hop);
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}`;
})}
{from?.user?.longName}
</span>
</div>
);
};

6
src/components/PageComponents/ModuleConfig/AmbientLighting.tsx

@ -1,6 +1,6 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/js";
export const AmbientLighting = (): JSX.Element => {

6
src/components/PageComponents/ModuleConfig/Audio.tsx

@ -1,6 +1,6 @@
import type { AudioValidation } from "@app/validation/moduleConfig/audio.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { AudioValidation } from "@app/validation/moduleConfig/audio.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Audio = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const CannedMessage = (): JSX.Element => {

12
src/components/PageComponents/ModuleConfig/DetectionSensor.tsx

@ -1,6 +1,6 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { DetectionSensorValidation } from "@app/validation/moduleConfig/detectionSensor.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { DetectionSensorValidation } from "@app/validation/moduleConfig/detectionSensor.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/js";
export const DetectionSensor = (): JSX.Element => {
@ -38,9 +38,9 @@ export const DetectionSensor = (): JSX.Element => {
label: "Minimum Broadcast Seconds",
description:
"The interval in seconds of how often we can send a message to the mesh when a state change is detected",
properties: {
suffix: "Seconds",
},
properties: {
suffix: "Seconds",
},
disabledBy: [
{
fieldName: "enabled",

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

@ -1,6 +1,6 @@
import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const ExternalNotification = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { MqttValidation } from "@app/validation/moduleConfig/mqtt.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { MqttValidation } from "@app/validation/moduleConfig/mqtt.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/js";
export const MQTT = (): JSX.Element => {
@ -11,7 +11,13 @@ export const MQTT = (): JSX.Element => {
new Protobuf.ModuleConfig.ModuleConfig({
payloadVariant: {
case: "mqtt",
value: data,
value: {
...data,
mapReportSettings:
new Protobuf.ModuleConfig.ModuleConfig_MapReportSettings(
data.mapReportSettings,
),
},
},
}),
);
@ -70,7 +76,8 @@ export const MQTT = (): JSX.Element => {
type: "toggle",
name: "encryptionEnabled",
label: "Encryption Enabled",
description: "Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
description:
"Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data.",
disabledBy: [
{
fieldName: "enabled",
@ -157,9 +164,32 @@ export const MQTT = (): JSX.Element => {
description:
"Position shared will be accurate within this distance",
properties: {
enumValue: config.display?.units == 0 ?
{ "Within 23 km":10, "Within 12 km":11, "Within 5.8 km":12, "Within 2.9 km":13, "Within 1.5 km":14, "Within 700 m":15, "Within 350 m":16, "Within 200 m":17, "Within 90 m":18, "Within 50 m":19 } :
{ "Within 15 miles":10, "Within 7.3 miles":11, "Within 3.6 miles":12, "Within 1.8 miles":13, "Within 0.9 miles":14, "Within 0.5 miles":15, "Within 0.2 miles":16, "Within 600 feet":17, "Within 300 feet":18, "Within 150 feet":19 }
enumValue:
config.display?.units === 0
? {
"Within 23 km": 10,
"Within 12 km": 11,
"Within 5.8 km": 12,
"Within 2.9 km": 13,
"Within 1.5 km": 14,
"Within 700 m": 15,
"Within 350 m": 16,
"Within 200 m": 17,
"Within 90 m": 18,
"Within 50 m": 19,
}
: {
"Within 15 miles": 10,
"Within 7.3 miles": 11,
"Within 3.6 miles": 12,
"Within 1.8 miles": 13,
"Within 0.9 miles": 14,
"Within 0.5 miles": 15,
"Within 0.2 miles": 16,
"Within 600 feet": 17,
"Within 300 feet": 18,
"Within 150 feet": 19,
},
},
disabledBy: [
{

8
src/components/PageComponents/ModuleConfig/NeighborInfo.tsx

@ -1,6 +1,6 @@
import { useDevice } from "@app/core/stores/deviceStore.js";
import type { NeighborInfoValidation } from "@app/validation/moduleConfig/neighborInfo.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@app/core/stores/deviceStore.ts";
import type { NeighborInfoValidation } from "@app/validation/moduleConfig/neighborInfo.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/js";
export const NeighborInfo = (): JSX.Element => {
@ -36,7 +36,7 @@ export const NeighborInfo = (): JSX.Element => {
type: "number",
name: "updateInterval",
label: "Update Interval",
description:
description:
"Interval in seconds of how often we should try to send our Neighbor Info to the mesh",
properties: {
suffix: "Seconds",

9
src/components/PageComponents/ModuleConfig/Paxcounter.tsx

@ -1,6 +1,6 @@
import type { PaxcounterValidation } from "@app/validation/moduleConfig/paxcounter.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { PaxcounterValidation } from "@app/validation/moduleConfig/paxcounter.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Paxcounter = (): JSX.Element => {
@ -36,7 +36,8 @@ export const Paxcounter = (): JSX.Element => {
type: "number",
name: "paxcounterUpdateInterval",
label: "Update Interval (seconds)",
description: "How long to wait between sending paxcounter packets",
description:
"How long to wait between sending paxcounter packets",
properties: {
suffix: "Seconds",
},

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

@ -1,6 +1,6 @@
import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const RangeTest = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import type { SerialValidation } from "@app/validation/moduleConfig/serial.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { SerialValidation } from "@app/validation/moduleConfig/serial.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Serial = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.ts";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const StoreForward = (): JSX.Element => {

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

@ -1,6 +1,6 @@
import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js";
import { DynamicForm } from "@components/Form/DynamicForm.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js";
export const Telemetry = (): JSX.Element => {
@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh",
},
{
type: "text",
type: "toggle",
name: "powerScreenEnabled",
label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen",

71
src/components/PageLayout.tsx

@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, LucideIcon } from "lucide-react";
import { cn } from "@app/core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps {
label: string;
@ -7,6 +8,7 @@ export interface PageLayoutProps {
children: React.ReactNode;
actions?: {
icon: LucideIcon;
iconClasses?: string;
onClick: () => void;
}[];
}
@ -18,40 +20,43 @@ export const PageLayout = ({
children,
}: PageLayoutProps): JSX.Element => {
return (
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
</button>
))}
<>
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon className={action.iconClasses} />
</button>
))}
</div>
</div>
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
)}
>
{children}
<Footer />
</div>
</div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
</>
);
};

53
src/components/Sidebar.tsx

@ -1,28 +1,34 @@
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.js";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.js";
import { Subtle } from "@components/UI/Typography/Subtle.js";
import { useDevice } from "@core/stores/deviceStore.js";
import type { Page } from "@core/stores/deviceStore.js";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import {
BatteryMediumIcon,
CpuIcon,
EditIcon,
LayersIcon,
LucideIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
SidebarCloseIcon,
SidebarOpenIcon,
UsersIcon,
ZapIcon,
BatteryMediumIcon
} from "lucide-react";
import { useState } from "react";
export interface SidebarProps {
children?: React.ReactNode;
}
export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
const { hardware, nodes } = useDevice();
const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
const [showSidebar, setShowSidebar] = useState<boolean>(true);
interface NavLink {
name: string;
@ -58,7 +64,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
},
];
return (
return showSidebar ? (
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] border-slate-300 bg-transparent dark:border-slate-700">
<div className="flex justify-between px-8 pt-6">
<div>
@ -74,15 +80,30 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
>
<EditIcon size={16} />
</button>
<button type="button" onClick={() => setShowSidebar(false)}>
<SidebarCloseIcon size={24} />
</button>
</div>
<div className="px-8 pb-6">
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox={'0 0 28 24'}/>
<Subtle>{myNode?.deviceMetrics?.batteryLevel ?? "UNK"}%</Subtle>
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
<Subtle>
{myNode?.deviceMetrics?.batteryLevel
? myNode?.deviceMetrics?.batteryLevel > 100
? "Charging"
: `${myNode?.deviceMetrics?.batteryLevel}%`
: "UNK"}
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox={'0 0 36 24'}/>
<Subtle>{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts</Subtle>
<CpuIcon size={24} viewBox={"0 0 36 24"} />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
@ -101,5 +122,11 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
</SidebarSection>
{children}
</div>
) : (
<div className="px-1 pt-8 border-r-[0.5px] border-slate-700">
<button type="button" onClick={() => setShowSidebar(true)}>
<SidebarOpenIcon size={24} />
</button>
</div>
);
};

18
src/components/Toaster.tsx

@ -1,5 +1,3 @@
import { useToast } from "@core/hooks/useToast.js";
import {
Toast,
ToastClose,
@ -7,18 +5,24 @@ import {
ToastProvider,
ToastTitle,
ToastViewport,
} from "@components/UI/Toast.js";
} from "@components/UI/Toast";
import { useToast } from "@core/hooks/useToast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
{toasts.map(({ id, title, description, action, duration, ...props }) => (
<Toast
key={id}
{...props}
duration={duration}
className="flex flex-col gap-4"
>
<div className="grid gap-1">
{title && <ToastTitle className="dark:text-white">{title}</ToastTitle>}
{description && <ToastDescription className="dark:text-white-400">{description}</ToastDescription>}
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />

94
src/components/UI/Avatar.tsx

@ -0,0 +1,94 @@
import { cn } from "@app/core/utils/cn";
import type React from "react";
type RGBColor = {
r: number;
g: number;
b: number;
a: number;
};
interface AvatarProps {
text: string;
size?: "sm" | "lg";
className?: string;
}
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
class ColorUtils {
static hexToRgb(hex: number): RGBColor {
return {
r: (hex & 0xff0000) >> 16,
g: (hex & 0x00ff00) >> 8,
b: hex & 0x0000ff,
a: 255,
};
}
static rgbToHex(color: RGBColor): number {
return (
(Math.round(color.a) << 24) |
(Math.round(color.r) << 16) |
(Math.round(color.g) << 8) |
Math.round(color.b)
);
}
static isLight(color: RGBColor): boolean {
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
return brightness > 127.5;
}
}
export const Avatar: React.FC<AvatarProps> = ({
text,
size = "sm",
className,
}) => {
const sizes = {
sm: "size-11 text-xs",
lg: "size-16 text-lg",
};
// Pick a color based on the text provided to function
const getColorFromText = (text: string): RGBColor => {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
return {
r: (hash & 0xff0000) >> 16,
g: (hash & 0x00ff00) >> 8,
b: hash & 0x0000ff,
a: 255,
};
};
const bgColor = getColorFromText(text ?? "UNK");
const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK";
return (
<div
className={cn(
`
rounded-full
flex
items-center
justify-center
size-11
font-semibold`,
sizes[size],
className,
)}
style={{
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
color: textColor,
}}
>
<p className="p-1">{initials}</p>
</div>
);
};

8
src/components/UI/Button.tsx

@ -1,7 +1,7 @@
import { VariantProps, cva } from "class-variance-authority";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800",
@ -12,6 +12,8 @@ const buttonVariants = cva(
"bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900",
destructive:
"bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600",
success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100",
subtle:
@ -33,6 +35,8 @@ const buttonVariants = cva(
},
);
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}

2
src/components/UI/Checkbox.tsx

@ -2,7 +2,7 @@ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,

4
src/components/UI/Command.tsx

@ -3,8 +3,8 @@ import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent } from "@components/UI/Dialog.js";
import { cn } from "@core/utils/cn.js";
import { Dialog, DialogContent } from "@components/UI/Dialog.tsx";
import { cn } from "@core/utils/cn.ts";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,

2
src/components/UI/Dialog.tsx

@ -2,7 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Dialog = DialogPrimitive.Root;

2
src/components/UI/DropdownMenu.tsx

@ -2,7 +2,7 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const DropdownMenu = DropdownMenuPrimitive.Root;

37
src/components/UI/Footer.tsx

@ -0,0 +1,37 @@
import React from "react";
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
return (
<footer
className={`flex flex- justify-center p-2 ${className}`}
style={{
backgroundColor: "var(--backgroundPrimary)",
color: "var(--textPrimary)",
}}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Powered by Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Legal Information
</a>
</p>
</footer>
);
},
);
export default Footer;

121
src/components/UI/Generator.tsx

@ -0,0 +1,121 @@
import * as React from "react";
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/UI/Select.tsx";
import type { LucideIcon } from "lucide-react";
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
type: "text" | "password";
devicePSKBitCount?: number;
value: string;
variant: "default" | "invalid";
actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
action?: {
icon: LucideIcon;
onClick: () => void;
};
disabled?: boolean;
}
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
(
{
type,
devicePSKBitCount,
variant,
value,
actionButtons,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
{ text: "Empty", value: "0", key: "empty" },
],
selectChange,
inputChange,
action,
disabled,
...props
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
type={type}
id="pskInput"
variant={variant}
value={value}
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex ml-4 space-x-4">
{actionButtons?.map(({ text, onClick, variant, className }) => (
<Button
key={text}
type="button"
onClick={onClick}
disabled={disabled}
variant={variant}
className={className}
{...props}
>
{text}
</Button>
))}
</div>
</>
);
},
);
Generator.displayName = "Button";
export { Generator };

28
src/components/UI/Input.tsx

@ -1,10 +1,27 @@
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
import { type VariantProps, cva } from "class-variance-authority";
import type { LucideIcon } from "lucide-react";
const inputVariants = cva(
"flex h-10 w-full rounded-md border bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-700",
invalid: "border-red-500 dark:border-red-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
prefix?: string;
suffix?: string;
action?: {
@ -14,7 +31,7 @@ export interface InputProps
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, prefix, suffix, action, ...props }, ref) => {
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return (
<div className="relative w-full">
{prefix && (
@ -24,10 +41,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)}
<input
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
action && "pr-8",
className,
inputVariants({ variant }),
)}
value={value}
ref={ref}
{...props}
/>
@ -51,4 +69,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
);
Input.displayName = "Input";
export { Input };
export { Input, inputVariants };

2
src/components/UI/Label.tsx

@ -1,7 +1,7 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,

2
src/components/UI/Menubar.tsx

@ -2,7 +2,7 @@ import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const MenubarMenu = MenubarPrimitive.Menu;

2
src/components/UI/Popover.tsx

@ -1,7 +1,7 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Popover = PopoverPrimitive.Root;

2
src/components/UI/ScrollArea.tsx

@ -1,7 +1,7 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,

2
src/components/UI/Select.tsx

@ -2,7 +2,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Select = SelectPrimitive.Root;

2
src/components/UI/Seperator.tsx

@ -1,7 +1,7 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,

6
src/components/UI/Sidebar/SidebarSection.tsx

@ -1,4 +1,4 @@
import { H4 } from "@components/UI/Typography/H4.js";
import { H4 } from "@components/UI/Typography/H4.tsx";
export interface SidebarSectionProps {
label: string;
@ -9,9 +9,9 @@ export interface SidebarSectionProps {
export const SidebarSection = ({
label: title,
children,
}: SidebarSectionProps): JSX.Element => (
}: SidebarSectionProps) => (
<div className="px-4 py-2">
<H4 className="mb-2 ml-2">{title}</H4>
<H4 className="mb-3 ml-2">{title}</H4>
<div className="space-y-1">{children}</div>
</div>
);

2
src/components/UI/Sidebar/sidebarButton.tsx

@ -1,4 +1,4 @@
import { Button } from "@components/UI/Button.js";
import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react";
export interface SidebarButtonProps {

2
src/components/UI/Switch.tsx

@ -1,7 +1,7 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,

4
src/components/UI/Tabs.tsx

@ -1,7 +1,7 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const Tabs = TabsPrimitive.Root;
@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800",
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
className,
)}
{...props}

16
src/components/UI/Toast.tsx

@ -1,9 +1,9 @@
import * as ToastPrimitives from "@radix-ui/react-toast";
import { VariantProps, cva } from "class-variance-authority";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn";
const ToastProvider = ToastPrimitives.Provider;
@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-50 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-24 sm:right-6 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
@ -23,14 +23,14 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default:
"bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
"border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
destructive:
"group destructive bg-red-600 text-white border-red-600 dark:border-red-600",
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
},
},
defaultVariants: {
@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-red-100 group-[.destructive]:hover:border-slate-50 group-[.destructive]:hover:bg-red-100 group-[.destructive]:hover:text-red-600 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800",
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
@ -76,7 +76,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-slate-500 opacity-0 transition-opacity hover:text-slate-900 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:hover:text-slate-50",
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-slate-400 dark:hover:text-slate-50",
className,
)}
toast-close=""

2
src/components/UI/Tooltip.tsx

@ -1,7 +1,7 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@core/utils/cn.js";
import { cn } from "@core/utils/cn.ts";
const TooltipProvider = TooltipPrimitive.Provider;

2
src/components/UI/Typography/H4.tsx

@ -1,4 +1,4 @@
import { cn } from "@app/core/utils/cn.js";
import { cn } from "@app/core/utils/cn.ts";
export interface H4Props {
className?: string;

14
src/components/UI/Typography/H5.tsx

@ -0,0 +1,14 @@
import { cn } from "@app/core/utils/cn.ts";
export interface H5Props {
className?: string;
children: React.ReactNode;
}
export const H5 = ({ className, children }: H5Props): JSX.Element => (
<h5
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)}
>
{children}
</h5>
);

10
src/components/UI/Typography/Link.tsx

@ -1,14 +1,20 @@
import { cn } from "@app/core/utils/cn";
export interface LinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
export const Link = ({ href, children }: LinkProps): JSX.Element => (
export const Link = ({ href, children, className }: LinkProps): JSX.Element => (
<a
href={href}
target={"_blank"}
rel="noopener noreferrer"
className="font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50"
className={cn(
"font-medium text-slate-900 underline underline-offset-4 dark:text-slate-50",
className,
)}
>
{children}
</a>

2
src/components/UI/Typography/Subtle.tsx

@ -1,4 +1,4 @@
import { cn } from "@app/core/utils/cn.js";
import { cn } from "@app/core/utils/cn.ts";
export interface SubtleProps {
className?: string;

17
src/components/generic/Table/index.tsx

@ -34,8 +34,8 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") {
const aTimestamp = a[columnIndex].props.timestamp ? a[columnIndex].props.timestamp : 0;
const bTimestamp = b[columnIndex].props.timestamp ? b[columnIndex].props.timestamp : 0;
const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0;
if (aTimestamp < bTimestamp) {
return sortOrder === "asc" ? -1 : 1;
@ -70,12 +70,16 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
: ""
}`}
onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)}
>
<div className="flex gap-2">
{heading.title}
{sortColumn === heading.title && (
<>{sortOrder === "asc" ? <ChevronUpIcon size={16} /> : <ChevronDownIcon size={16} />}</>
)}
{sortColumn === heading.title &&
(sortOrder === "asc" ? (
<ChevronUpIcon size={16} />
) : (
<ChevronDownIcon size={16} />
))}
</div>
</th>
))}
@ -83,10 +87,11 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
</thead>
<tbody>
{sortedRows.map((row, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index}>
{row.map((item, index) => (
<td
key={index}
key={item.key ?? index}
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
>
{item}

2
src/components/generic/ThemeController.tsx

@ -1,4 +1,4 @@
import { useAppStore } from "@core/stores/appStore.js";
import { useAppStore } from "@core/stores/appStore.ts";
import type { ReactNode } from "react";
export interface ThemeControllerProps {

33
src/core/hooks/useBrowserFeatureDetection.ts

@ -0,0 +1,33 @@
import { useMemo } from "react";
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
interface BrowserSupport {
supported: BrowserFeature[];
unsupported: BrowserFeature[];
}
export function useBrowserFeatureDetection(): BrowserSupport {
const support = useMemo(() => {
const features: [BrowserFeature, boolean][] = [
["Web Bluetooth", !!navigator?.bluetooth],
["Web Serial", !!navigator?.serial],
[
"Secure Context",
window.location.protocol === "https:" ||
window.location.hostname === "localhost",
],
];
return features.reduce<BrowserSupport>(
(acc, [feature, isSupported]) => {
const list = isSupported ? acc.supported : acc.unsupported;
list.push(feature);
return acc;
},
{ supported: [], unsupported: [] },
);
}, []);
return support;
}

52
src/core/hooks/useCookie.ts

@ -0,0 +1,52 @@
import Cookies, { type CookieAttributes } from "js-cookie";
import { useCallback, useState } from "react";
interface CookieHookResult<T> {
value: T | undefined;
setCookie: (value: T, options?: CookieAttributes) => void;
removeCookie: () => void;
}
function useCookie<T extends object>(
cookieName: string,
initialValue?: T,
): CookieHookResult<T> {
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
try {
const cookie = Cookies.get(cookieName);
return cookie ? (JSON.parse(cookie) as T) : initialValue;
} catch (error) {
console.error(`Error parsing cookie ${cookieName}:`, error);
return initialValue;
}
});
const setCookie = useCallback(
(value: T, options?: CookieAttributes) => {
try {
Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value);
} catch (error) {
console.error(`Error setting cookie ${cookieName}:`, error);
}
},
[cookieName],
);
const removeCookie = useCallback(() => {
try {
Cookies.remove(cookieName);
setCookieValue(undefined);
} catch (error) {
console.error(`Error removing cookie ${cookieName}:`, error);
}
}, [cookieName]);
return {
value: cookieValue,
setCookie,
removeCookie,
};
}
export default useCookie;

119
src/core/hooks/useKeyBackupReminder.tsx

@ -0,0 +1,119 @@
import { Button } from "@app/components/UI/Button";
import type { CookieAttributes } from "js-cookie";
import { useCallback, useEffect, useRef } from "react";
import useCookie from "./useCookie";
import { useToast } from "./useToast";
interface UseBackupReminderOptions {
reminderInDays?: number;
message: string;
onAccept?: () => void | Promise<void>;
enabled: boolean;
cookieOptions?: CookieAttributes;
}
interface ReminderState {
suppressed: boolean;
lastShown: string;
}
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
const TOAST_DURATION = 30_000; // 30 seconds;:
// remind user in 1 year to backup keys again, if they accept the reminder;
const ON_ACCEPT_REMINDER_DAYS = 365;
function isReminderExpired(lastShown: string): boolean {
const lastShownDate = new Date(lastShown);
const now = new Date();
const daysSinceLastShown =
(now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceLastShown >= 7;
}
export function useBackupReminder({
reminderInDays = 7,
enabled,
message,
onAccept = () => {},
cookieOptions,
}: UseBackupReminderOptions) {
const { toast } = useToast();
const toastShownRef = useRef(false);
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
"key_backup_reminder",
);
const suppressReminder = useCallback(
(days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setCookie(
{
suppressed: true,
lastShown: new Date().toISOString(),
},
{ ...cookieOptions, expires: expiryDate },
);
},
[setCookie, cookieOptions],
);
useEffect(() => {
if (!enabled || toastShownRef.current) return;
const shouldShowReminder =
!reminderCookie?.suppressed ||
isReminderExpired(reminderCookie.lastShown);
if (!shouldShowReminder) return;
toastShownRef.current = true;
const { dismiss } = toast({
title: "Backup Reminder",
duration: TOAST_DURATION,
delay: TOAST_APPEAR_DELAY,
description: message,
action: (
<div className="flex gap-2">
<Button
type="button"
variant="default"
onClick={() => {
onAccept();
dismiss();
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
}}
>
Back up now
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
dismiss();
suppressReminder(reminderInDays);
}}
>
Remind me in {reminderInDays} days
</Button>
</div>
),
});
return () => {
if (!toastShownRef.current) {
dismiss();
}
};
}, [
enabled,
message,
onAccept,
reminderInDays,
suppressReminder,
toast,
reminderCookie,
]);
}

65
src/core/hooks/useToast.ts

@ -1,6 +1,6 @@
import { ReactNode, useEffect, useState } from "react";
import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.js";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
title?: ReactNode;
description?: ReactNode;
action?: ToastActionElement;
delay?: number;
};
const actionTypes = {
@ -92,9 +93,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
});
}
}
return {
@ -130,14 +131,14 @@ let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(memoryState);
});
}
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
function toast({ delay = 0, ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
@ -147,17 +148,19 @@ function toast({ ...props }: Toast) {
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
setTimeout(() => {
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
},
});
});
}, delay);
return {
id: id,
@ -166,18 +169,22 @@ function toast({ ...props }: Toast) {
};
}
const subscribe = (listener: () => void) => {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
const getState = () => {
return memoryState;
};
function useToast() {
const [state, setState] = useState<State>(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
const state = useSyncExternalStore(subscribe, getState);
return {
...state,
@ -186,4 +193,4 @@ function useToast() {
};
}
export { useToast, toast };
export { toast, useToast };

9
src/core/stores/appStore.ts

@ -50,7 +50,10 @@ export const useAppStore = create<AppState>()((set) => ({
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
darkMode: (localStorage.getItem('theme-dark') !== null ? (localStorage.getItem('theme-dark') === 'true' ? true : false) : window.matchMedia("(prefers-color-scheme: dark)").matches),
darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches,
accent: "orange",
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
@ -96,7 +99,7 @@ export const useAppStore = create<AppState>()((set) => ({
);
},
setDarkMode: (enabled: boolean) => {
localStorage.setItem('theme-dark', enabled.toString());
localStorage.setItem("theme-dark", enabled.toString());
set(
produce<AppState>((draft) => {
draft.darkMode = enabled;
@ -105,7 +108,7 @@ export const useAppStore = create<AppState>()((set) => ({
},
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum
nodeNumToBeRemoved: nodeNum,
})),
setAccent(color) {
set(

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

Loading…
Cancel
Save