mirror of
https://github.com/meshtastic/web.git
synced 2026-04-21 06:19:53 -04:00
Merge branch 'master' into issue-249-cors
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/build.tar
|
||||
dist/output
|
||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -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
vendored
11
.github/workflows/pr.yml
vendored
@@ -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
vendored
Normal file
61
.github/workflows/release.yml
vendored
Normal file
@@ -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
vendored
9
.vscode/settings.json
vendored
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"quickfix.biome": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
||||
@@ -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 .
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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": "pnpm@9.15.4"
|
||||
}
|
||||
|
||||
9571
pnpm-lock.yaml
generated
9571
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
30
rsbuild.config.ts
Normal file
30
rsbuild.config.ts
Normal file
@@ -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
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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?.factoryReset();
|
||||
connection?.factoryResetDevice();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset Config",
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
connection?.factoryResetConfig();
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -350,7 +359,7 @@ export const CommandPalette = (): JSX.Element => {
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, []);
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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:
|
||||
<Link href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility">
|
||||
Web Bluetooth
|
||||
</Link>
|
||||
|
||||
<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
Normal file
134
src/components/Dialog/PKIBackupDialog.tsx
Normal file
@@ -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
Normal file
39
src/components/Dialog/PkiRegenerateDialog.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
Normal file
66
src/components/Form/FormPasswordGenerator.tsx
Normal file
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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, " ")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Normal file
19
src/components/KeyBackupReminder.tsx
Normal file
@@ -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 <></>;
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
Normal file
254
src/components/PageComponents/Config/Security.tsx
Normal file
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Normal file
171
src/components/PageComponents/Map/NodeDetail.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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 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 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
Normal file
36
src/components/PageComponents/Messages/TraceRoute.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
||||
<Subtle>
|
||||
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Normal file
94
src/components/UI/Avatar.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,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>,
|
||||
|
||||
@@ -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,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,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
Normal file
37
src/components/UI/Footer.tsx
Normal file
@@ -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
Normal file
121
src/components/UI/Generator.tsx
Normal file
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Normal file
14
src/components/UI/Typography/H5.tsx
Normal file
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Normal file
33
src/core/hooks/useBrowserFeatureDetection.ts
Normal file
@@ -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
Normal file
52
src/core/hooks/useCookie.ts
Normal file
@@ -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
Normal file
119
src/core/hooks/useKeyBackupReminder.tsx
Normal file
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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) {
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = useState<State>(memoryState);
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.push(listener);
|
||||
return function unsubscribe() {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
const getState = () => {
|
||||
return memoryState;
|
||||
};
|
||||
|
||||
function useToast() {
|
||||
const state = useSyncExternalStore(subscribe, getState);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -186,4 +193,4 @@ function useToast() {
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
export { toast, useToast };
|
||||
|
||||
@@ -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 have changed in this diff Show More
Reference in New Issue
Block a user