Merge branch 'master' into add_search_nodes
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
dist/build.tar
|
||||||
|
dist/output
|
||||||
@@ -13,11 +13,11 @@ jobs:
|
|||||||
build-and-package:
|
build-and-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
- name: Setup pnpm
|
||||||
version: latest
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|||||||
+6
-1
@@ -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 .
|
ADD dist .
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ Official [Meshtastic](https://meshtastic.org) web interface, that can be hosted
|
|||||||
## Self-host
|
## 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 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
|
```bash
|
||||||
# With Docker
|
# 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
|
#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
|
## Development & Building
|
||||||
|
|||||||
+59
-55
@@ -5,13 +5,18 @@
|
|||||||
"description": "Meshtastic web client",
|
"description": "Meshtastic web client",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rsbuild build",
|
"build": "pnpm check && rsbuild build",
|
||||||
"check": "biome check .",
|
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build",
|
||||||
"check:fix": "pnpm check --write",
|
"check": "biome check src/",
|
||||||
|
"check:fix": "pnpm check --write src/",
|
||||||
|
"format": "biome format --write src/",
|
||||||
"dev": "rsbuild dev --open",
|
"dev": "rsbuild dev --open",
|
||||||
"format": "biome format --write",
|
|
||||||
"preview": "rsbuild preview",
|
"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/)"
|
"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": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -23,65 +28,64 @@
|
|||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0",
|
"@bufbuild/protobuf": "^1.10.0",
|
||||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
|
||||||
"@meshtastic/js": "2.3.7-5",
|
"@meshtastic/js": "2.3.7-5",
|
||||||
"@noble/curves": "^1.5.0",
|
"@noble/curves": "^1.8.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.0",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.0",
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-menubar": "^1.1.1",
|
"@radix-ui/react-menubar": "^1.1.5",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.5",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.5",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.5",
|
||||||
"@radix-ui/react-tooltip": "^1.1.1",
|
"@radix-ui/react-tooltip": "^1.1.7",
|
||||||
"@turf/turf": "^6.5.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.4",
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"lucide-react": "^0.363.0",
|
"js-cookie": "^3.0.5",
|
||||||
"mapbox-gl": "^3.6.0",
|
"lucide-react": "^0.474.0",
|
||||||
|
"mapbox-gl": "^3.9.4",
|
||||||
"maplibre-gl": "4.1.2",
|
"maplibre-gl": "4.1.2",
|
||||||
"react": "^18.3.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.52.0",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-map-gl": "7.1.7",
|
"react-map-gl": "7.1.9",
|
||||||
"react-qrcode-logo": "^2.10.0",
|
"react-qrcode-logo": "^3.0.0",
|
||||||
"rfc4648": "^1.5.3",
|
"rfc4648": "^1.5.4",
|
||||||
"tailwind-merge": "^2.3.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"timeago-react": "^3.0.6",
|
"timeago-react": "^3.0.6",
|
||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
"zustand": "4.5.2"
|
"zustand": "5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.8.2",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1",
|
"@rsbuild/core": "^1.2.3",
|
||||||
"@rsbuild/core": "^1.0.10",
|
"@rsbuild/plugin-react": "^1.1.0",
|
||||||
"@rsbuild/plugin-react": "^1.0.3",
|
"@types/chrome": "^0.0.299",
|
||||||
"@types/chrome": "^0.0.263",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^22.12.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@types/w3c-web-serial": "^1.0.6",
|
"@types/w3c-web-serial": "^1.0.7",
|
||||||
"@types/web-bluetooth": "^0.0.20",
|
"@types/web-bluetooth": "^0.0.20",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"gzipper": "^7.2.0",
|
"gzipper": "^8.2.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.5.1",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"simple-git-hooks": "^2.11.1",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tar": "^6.2.1",
|
"tailwindcss": "^3.4.17",
|
||||||
"tslib": "^2.6.3",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.5.2"
|
"tar": "^7.4.3",
|
||||||
}
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.4"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2338
-2756
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
import { defineConfig } from "@rsbuild/core";
|
import { defineConfig } from "@rsbuild/core";
|
||||||
import { pluginReact } from "@rsbuild/plugin-react";
|
import { pluginReact } from "@rsbuild/plugin-react";
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
|
|
||||||
let hash = "";
|
let hash = "";
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -2,8 +2,9 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
|||||||
import { PageRouter } from "@app/PageRouter.tsx";
|
import { PageRouter } from "@app/PageRouter.tsx";
|
||||||
import { CommandPalette } from "@components/CommandPalette.tsx";
|
import { CommandPalette } from "@components/CommandPalette.tsx";
|
||||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||||
|
import { KeyBackupReminder } from "@components/KeyBackupReminder";
|
||||||
import { Toaster } from "@components/Toaster.tsx";
|
import { Toaster } from "@components/Toaster.tsx";
|
||||||
import Footer from "@components/UI/Footer.tsx";
|
import Footer from "@components/UI/Footer.tsx";
|
||||||
import { ThemeController } from "@components/generic/ThemeController.tsx";
|
import { ThemeController } from "@components/generic/ThemeController.tsx";
|
||||||
@@ -37,6 +38,7 @@ export const App = (): JSX.Element => {
|
|||||||
{device ? (
|
{device ? (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<DialogManager />
|
<DialogManager />
|
||||||
|
<KeyBackupReminder />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<PageRouter />
|
<PageRouter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ export interface DeviceWrapperProps {
|
|||||||
device?: Device;
|
device?: Device;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeviceWrapper = ({
|
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => {
|
||||||
children,
|
|
||||||
device,
|
|
||||||
}: DeviceWrapperProps): JSX.Element => {
|
|
||||||
return (
|
return (
|
||||||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
|
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { ChannelsPage } from "@pages/Channels.tsx";
|
import ChannelsPage from "@pages/Channels.tsx";
|
||||||
import { ConfigPage } from "@pages/Config/index.tsx";
|
import ConfigPage from "@pages/Config/index.tsx";
|
||||||
import { MapPage } from "@pages/Map.tsx";
|
import MapPage from "@pages/Map.tsx";
|
||||||
import { MessagesPage } from "@pages/Messages.tsx";
|
import MessagesPage from "@pages/Messages.tsx";
|
||||||
import { NodesPage } from "@pages/Nodes.tsx";
|
import NodesPage from "@pages/Nodes.tsx";
|
||||||
|
|
||||||
export const PageRouter = (): JSX.Element => {
|
export const PageRouter = () => {
|
||||||
const { activePage } = useDevice();
|
const { activePage } = useDevice();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Avatar } from "@components/UI/Avatar";
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
} from "@components/UI/Command.tsx";
|
} from "@components/UI/Command.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
|
||||||
import { useCommandState } from "cmdk";
|
import { useCommandState } from "cmdk";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRightIcon,
|
ArrowLeftRightIcon,
|
||||||
@@ -51,11 +51,11 @@ export interface Command {
|
|||||||
|
|
||||||
export interface SubItem {
|
export interface SubItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: JSX.Element;
|
icon: React.ReactNode;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommandPalette = (): JSX.Element => {
|
export const CommandPalette = () => {
|
||||||
const {
|
const {
|
||||||
commandPaletteOpen,
|
commandPaletteOpen,
|
||||||
setCommandPaletteOpen,
|
setCommandPaletteOpen,
|
||||||
@@ -125,9 +125,11 @@ export const CommandPalette = (): JSX.Element => {
|
|||||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||||
device.hardware.myNodeNum.toString(),
|
device.hardware.myNodeNum.toString(),
|
||||||
icon: (
|
icon: (
|
||||||
<Hashicon
|
<Avatar
|
||||||
size={16}
|
text={
|
||||||
value={device.hardware.myNodeNum.toString()}
|
device.nodes.get(device.hardware.myNodeNum)?.user
|
||||||
|
?.shortName ?? device.hardware.myNodeNum.toString()
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
action() {
|
action() {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Separator } from "@components/UI/Seperator.tsx";
|
|||||||
import { Code } from "@components/UI/Typography/Code.tsx";
|
import { Code } from "@components/UI/Typography/Code.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
LanguagesIcon,
|
LanguagesIcon,
|
||||||
@@ -12,6 +11,8 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { JSX } from "react";
|
||||||
|
import { Avatar } from "./UI/Avatar";
|
||||||
|
|
||||||
export const DeviceSelector = (): JSX.Element => {
|
export const DeviceSelector = (): JSX.Element => {
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
@@ -44,9 +45,12 @@ export const DeviceSelector = (): JSX.Element => {
|
|||||||
}}
|
}}
|
||||||
active={selectedDevice === device.id}
|
active={selectedDevice === device.id}
|
||||||
>
|
>
|
||||||
<Hashicon
|
<Avatar
|
||||||
size={24}
|
text={
|
||||||
value={device.hardware.myNodeNum.toString()}
|
device.nodes
|
||||||
|
.get(device.hardware.myNodeNum)
|
||||||
|
?.user?.shortName.toString() ?? "UNK"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</DeviceSelectorButton>
|
</DeviceSelectorButton>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ export const DeviceSelectorButton = ({
|
|||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: DeviceSelectorButtonProps): JSX.Element => (
|
}: DeviceSelectorButtonProps) => (
|
||||||
<li
|
<li
|
||||||
className="aspect-w-1 aspect-h-1 relative w-full"
|
className="aspect-w-1 aspect-h-1 relative w-full"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={onClick}
|
onKeyDown={onClick}
|
||||||
>
|
>
|
||||||
{active && (
|
{/* {active && (
|
||||||
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
|
<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">
|
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.tsx";
|
import { RemoveNodeDialog } from "@app/components/Dialog/RemoveNodeDialog.tsx";
|
||||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
||||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||||
|
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog";
|
||||||
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
import { QRDialog } from "@components/Dialog/QRDialog.tsx";
|
||||||
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||||
@@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => {
|
|||||||
setDialogOpen("nodeRemoval", open);
|
setDialogOpen("nodeRemoval", open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<PkiBackupDialog
|
||||||
|
open={dialog.pkiBackup}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("pkiBackup", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
type BrowserFeature,
|
||||||
|
useBrowserFeatureDetection,
|
||||||
|
} from "@app/core/hooks/useBrowserFeatureDetection";
|
||||||
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
|
||||||
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
|
||||||
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
|
||||||
@@ -13,8 +17,10 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@components/UI/Tabs.tsx";
|
} from "@components/UI/Tabs.tsx";
|
||||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.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 {
|
export interface TabElementProps {
|
||||||
closeDialog: () => void;
|
closeDialog: () => void;
|
||||||
@@ -23,44 +29,109 @@ export interface TabElementProps {
|
|||||||
export interface TabManifest {
|
export interface TabManifest {
|
||||||
label: string;
|
label: string;
|
||||||
element: React.FC<TabElementProps>;
|
element: React.FC<TabElementProps>;
|
||||||
disabled: boolean;
|
isDisabled: boolean;
|
||||||
disabledMessage: string;
|
|
||||||
disabledLink?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export interface NewDeviceProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 = ({
|
export const NewDeviceDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NewDeviceProps): JSX.Element => {
|
}: 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -70,46 +141,22 @@ export const NewDeviceDialog = ({
|
|||||||
<Tabs defaultValue="HTTP">
|
<Tabs defaultValue="HTTP">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger key={tab.label} value={tab.label}>
|
||||||
key={tab.label}
|
|
||||||
value={tab.label}
|
|
||||||
disabled={tab.disabled}
|
|
||||||
>
|
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<TabsContent key={tab.label} value={tab.label}>
|
||||||
{tab.disabled ? (
|
<fieldset disabled={tab.isDisabled}>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
{tab.isDisabled ? (
|
||||||
{tab.disabledMessage}
|
<ErrorMessage missingFeatures={unsupported} />
|
||||||
</p>
|
) : null}
|
||||||
) : (
|
|
||||||
<tab.element closeDialog={() => onOpenChange(false)} />
|
<tab.element closeDialog={() => onOpenChange(false)} />
|
||||||
)}
|
</fieldset>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BaseFormBuilderProps,
|
BaseFormBuilderProps,
|
||||||
GenericFormElementProps,
|
GenericFormElementProps,
|
||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
|
import type { ButtonVariant } from "@components/UI/Button";
|
||||||
import { Generator } from "@components/UI/Generator.tsx";
|
import { Generator } from "@components/UI/Generator.tsx";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import type { ChangeEventHandler, MouseEventHandler } from "react";
|
import type { ChangeEventHandler, MouseEventHandler } from "react";
|
||||||
@@ -15,7 +16,12 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
devicePSKBitCount: number;
|
devicePSKBitCount: number;
|
||||||
inputChange: ChangeEventHandler;
|
inputChange: ChangeEventHandler;
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
buttonClick: MouseEventHandler;
|
actionButtons: {
|
||||||
|
text: string;
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
className?: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PasswordGenerator<T extends FieldValues>({
|
export function PasswordGenerator<T extends FieldValues>({
|
||||||
@@ -47,10 +53,9 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
bits={field.bits}
|
bits={field.bits}
|
||||||
inputChange={field.inputChange}
|
inputChange={field.inputChange}
|
||||||
selectChange={field.selectChange}
|
selectChange={field.selectChange}
|
||||||
buttonClick={field.buttonClick}
|
|
||||||
value={value}
|
value={value}
|
||||||
variant={field.validationText ? "invalid" : "default"}
|
variant={field.validationText ? "invalid" : "default"}
|
||||||
buttonText="Generate"
|
actionButtons={field.actionButtons}
|
||||||
{...field.properties}
|
{...field.properties}
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
{optionsEnumValues.map(([name, value]) => (
|
||||||
<SelectItem key={name} value={value.toString()}>
|
<SelectItem key={name + value} value={value.toString()}>
|
||||||
{formatEnumName
|
{formatEnumName
|
||||||
? name
|
? name
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export const FieldWrapper = ({
|
|||||||
children,
|
children,
|
||||||
valid,
|
valid,
|
||||||
validationText,
|
validationText,
|
||||||
}: FieldWrapperProps): JSX.Element => (
|
}: FieldWrapperProps) => (
|
||||||
<div className="pt-6 sm:pt-5">
|
<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">
|
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4">
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
@@ -32,6 +32,6 @@ export const FieldWrapper = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 <></>;
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js";
|
|||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
import cryptoRandomString from "crypto-random-string";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog";
|
||||||
|
|
||||||
export interface SettingsPanelProps {
|
export interface SettingsPanelProps {
|
||||||
channel: Protobuf.Channel.Channel;
|
channel: Protobuf.Channel.Channel;
|
||||||
@@ -22,6 +23,8 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
channel?.settings?.psk.length ?? 16,
|
channel?.settings?.psk.length ?? 16,
|
||||||
);
|
);
|
||||||
const [validationText, setValidationText] = useState<string>();
|
const [validationText, setValidationText] = useState<string>();
|
||||||
|
const [preSharedDialogOpen, setPreSharedDialogOpen] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const onSubmit = (data: ChannelValidation) => {
|
const onSubmit = (data: ChannelValidation) => {
|
||||||
const channel = new Protobuf.Channel.Channel({
|
const channel = new Protobuf.Channel.Channel({
|
||||||
@@ -46,7 +49,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const clickEvent = () => {
|
const preSharedKeyRegenerate = () => {
|
||||||
setPass(
|
setPass(
|
||||||
btoa(
|
btoa(
|
||||||
cryptoRandomString({
|
cryptoRandomString({
|
||||||
@@ -56,6 +59,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
setValidationText(undefined);
|
setValidationText(undefined);
|
||||||
|
setPreSharedDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preSharedClickEvent = () => {
|
||||||
|
setPreSharedDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validatePass = (input: string, count: number) => {
|
const validatePass = (input: string, count: number) => {
|
||||||
@@ -79,6 +87,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DynamicForm<ChannelValidation>
|
<DynamicForm<ChannelValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitType="onSubmit"
|
submitType="onSubmit"
|
||||||
@@ -96,7 +105,8 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
preciseLocation:
|
preciseLocation:
|
||||||
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
channel?.settings?.moduleSettings?.positionPrecision === 32,
|
||||||
positionPrecision:
|
positionPrecision:
|
||||||
channel?.settings?.moduleSettings?.positionPrecision === undefined
|
channel?.settings?.moduleSettings?.positionPrecision ===
|
||||||
|
undefined
|
||||||
? 10
|
? 10
|
||||||
: channel?.settings?.moduleSettings?.positionPrecision,
|
: channel?.settings?.moduleSettings?.positionPrecision,
|
||||||
},
|
},
|
||||||
@@ -124,13 +134,20 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
{
|
{
|
||||||
type: "passwordGenerator",
|
type: "passwordGenerator",
|
||||||
name: "settings.psk",
|
name: "settings.psk",
|
||||||
label: "pre-Shared Key",
|
label: "Pre-Shared Key",
|
||||||
description: "256, 128, or 8 bit PSKs allowed",
|
description:
|
||||||
|
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
|
||||||
validationText: validationText,
|
validationText: validationText,
|
||||||
devicePSKBitCount: bitCount ?? 0,
|
devicePSKBitCount: bitCount ?? 0,
|
||||||
inputChange: inputChangeEvent,
|
inputChange: inputChangeEvent,
|
||||||
selectChange: selectChangeEvent,
|
selectChange: selectChangeEvent,
|
||||||
buttonClick: clickEvent,
|
actionButtons: [
|
||||||
|
{
|
||||||
|
text: "Generate",
|
||||||
|
variant: "success",
|
||||||
|
onClick: preSharedClickEvent,
|
||||||
|
},
|
||||||
|
],
|
||||||
hide: true,
|
hide: true,
|
||||||
properties: {
|
properties: {
|
||||||
value: pass,
|
value: pass,
|
||||||
@@ -206,5 +223,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<PkiRegenerateDialog
|
||||||
|
open={preSharedDialogOpen}
|
||||||
|
onOpenChange={() => setPreSharedDialogOpen(false)}
|
||||||
|
onSubmit={() => preSharedKeyRegenerate()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,20 +6,16 @@ import { useState } from "react";
|
|||||||
|
|
||||||
export const Bluetooth = (): JSX.Element => {
|
export const Bluetooth = (): JSX.Element => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const [bluetoothValidationText, setBluetoothValidationText] = useState<string>();
|
const [bluetoothValidationText, setBluetoothValidationText] =
|
||||||
|
useState<string>();
|
||||||
|
|
||||||
const bluetoothPinChangeEvent = (
|
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
if (e.target.value[0] === "0") {
|
||||||
) => {
|
|
||||||
if (e.target.value[0] == "0")
|
|
||||||
{
|
|
||||||
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
|
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
setBluetoothValidationText("");
|
setBluetoothValidationText("");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const onSubmit = (data: BluetoothValidation) => {
|
const onSubmit = (data: BluetoothValidation) => {
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const Device = (): JSX.Element => {
|
|||||||
"Lost and Found":
|
"Lost and Found":
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
|
||||||
"TAK Tracker":
|
"TAK Tracker":
|
||||||
Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
|
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
|
||||||
},
|
},
|
||||||
formatEnumName: true,
|
formatEnumName: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { Eye, EyeOff } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Security = (): JSX.Element => {
|
export const Security = (): JSX.Element => {
|
||||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
|
||||||
|
useDevice();
|
||||||
|
|
||||||
const [privateKey, setPrivateKey] = useState<string>(
|
const [privateKey, setPrivateKey] = useState<string>(
|
||||||
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
||||||
@@ -31,7 +32,8 @@ export const Security = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
const [adminKeyValidationText, setAdminKeyValidationText] =
|
const [adminKeyValidationText, setAdminKeyValidationText] =
|
||||||
useState<string>();
|
useState<string>();
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const onSubmit = (data: SecurityValidation) => {
|
const onSubmit = (data: SecurityValidation) => {
|
||||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||||
@@ -71,7 +73,11 @@ export const Security = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const privateKeyClickEvent = () => {
|
const privateKeyClickEvent = () => {
|
||||||
setDialogOpen(true);
|
setPrivateKeyDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pkiBackupClickEvent = () => {
|
||||||
|
setDialogOpen("pkiBackup", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pkiRegenerate = () => {
|
const pkiRegenerate = () => {
|
||||||
@@ -86,7 +92,7 @@ export const Security = (): JSX.Element => {
|
|||||||
setPrivateKeyValidationText,
|
setPrivateKeyValidationText,
|
||||||
);
|
);
|
||||||
|
|
||||||
setDialogOpen(false);
|
setPrivateKeyDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeyInputChangeEvent = (
|
const privateKeyInputChangeEvent = (
|
||||||
@@ -149,7 +155,18 @@ export const Security = (): JSX.Element => {
|
|||||||
inputChange: privateKeyInputChangeEvent,
|
inputChange: privateKeyInputChangeEvent,
|
||||||
selectChange: privateKeySelectChangeEvent,
|
selectChange: privateKeySelectChangeEvent,
|
||||||
hide: !privateKeyVisible,
|
hide: !privateKeyVisible,
|
||||||
buttonClick: privateKeyClickEvent,
|
actionButtons: [
|
||||||
|
{
|
||||||
|
text: "Generate",
|
||||||
|
onClick: privateKeyClickEvent,
|
||||||
|
variant: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Backup Key",
|
||||||
|
onClick: pkiBackupClickEvent,
|
||||||
|
variant: "subtle",
|
||||||
|
},
|
||||||
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: privateKey,
|
value: privateKey,
|
||||||
action: {
|
action: {
|
||||||
@@ -187,7 +204,7 @@ export const Security = (): JSX.Element => {
|
|||||||
name: "isManaged",
|
name: "isManaged",
|
||||||
label: "Managed",
|
label: "Managed",
|
||||||
description:
|
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.',
|
"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",
|
type: "text",
|
||||||
@@ -228,8 +245,8 @@ export const Security = (): JSX.Element => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PkiRegenerateDialog
|
<PkiRegenerateDialog
|
||||||
open={dialogOpen}
|
open={privateKeyDialogOpen}
|
||||||
onOpenChange={() => setDialogOpen(false)}
|
onOpenChange={() => setPrivateKeyDialogOpen(false)}
|
||||||
onSubmit={() => pkiRegenerate()}
|
onSubmit={() => pkiRegenerate()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export const Serial = ({ closeDialog }: TabElementProps): JSX.Element => {
|
|||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const updateSerialPortList = useCallback(async () => {
|
const updateSerialPortList = useCallback(async () => {
|
||||||
setSerialPorts(await navigator.serial.getPorts());
|
setSerialPorts(await navigator?.serial.getPorts());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
navigator.serial.addEventListener("connect", () => {
|
navigator?.serial?.addEventListener("connect", () => {
|
||||||
updateSerialPortList();
|
updateSerialPortList();
|
||||||
});
|
});
|
||||||
navigator.serial.addEventListener("disconnect", () => {
|
navigator?.serial?.addEventListener("disconnect", () => {
|
||||||
updateSerialPortList();
|
updateSerialPortList();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
|
import type { MessageWithState } from "@app/core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import { Avatar } from "@components/UI/Avatar";
|
||||||
import type { Protobuf } from "@meshtastic/js";
|
import type { Protobuf } from "@meshtastic/js";
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -13,11 +13,7 @@ export interface MessageProps {
|
|||||||
sender?: Protobuf.Mesh.NodeInfo;
|
sender?: Protobuf.Mesh.NodeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Message = ({
|
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||||
lastMsgSameUser,
|
|
||||||
message,
|
|
||||||
sender,
|
|
||||||
}: MessageProps): JSX.Element => {
|
|
||||||
return lastMsgSameUser ? (
|
return lastMsgSameUser ? (
|
||||||
<div className="ml-5 flex">
|
<div className="ml-5 flex">
|
||||||
{message.state === "ack" ? (
|
{message.state === "ack" ? (
|
||||||
@@ -39,11 +35,14 @@ export const Message = ({
|
|||||||
<div className="mx-4 mt-2 gap-2">
|
<div className="mx-4 mt-2 gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="w-6 cursor-pointer">
|
<div className="w-6 cursor-pointer">
|
||||||
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
|
<Avatar text={sender?.user?.shortName ?? "UNK"} />
|
||||||
</div>
|
</div>
|
||||||
<span className="cursor-pointer font-medium text-textPrimary">
|
<span className="cursor-pointer font-medium text-textPrimary">
|
||||||
{sender?.user?.longName ?? "UNK"}
|
{sender?.user?.longName ?? "UNK"}
|
||||||
</span>
|
</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">
|
<span className="mt-1 font-mono text-xs text-textSecondary">
|
||||||
{message.rxTime.toLocaleTimeString(undefined, {
|
{message.rxTime.toLocaleTimeString(undefined, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { debounce } from "@app/core/utils/debounce";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Types } from "@meshtastic/js";
|
import type { Types } from "@meshtastic/js";
|
||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
to: Types.Destination;
|
to: Types.Destination;
|
||||||
@@ -20,10 +22,16 @@ export const MessageInput = ({
|
|||||||
setMessageDraft,
|
setMessageDraft,
|
||||||
hardware,
|
hardware,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
|
|
||||||
const myNodeNum = hardware.myNodeNum;
|
const myNodeNum = hardware.myNodeNum;
|
||||||
|
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||||
|
|
||||||
const sendText = async (message: string) => {
|
const debouncedSetMessageDraft = useMemo(
|
||||||
|
() => debounce(setMessageDraft, 300),
|
||||||
|
[setMessageDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendText = useCallback(
|
||||||
|
async (message: string) => {
|
||||||
await connection
|
await connection
|
||||||
?.sendText(message, to, true, channel)
|
?.sendText(message, to, true, channel)
|
||||||
.then((id) =>
|
.then((id) =>
|
||||||
@@ -46,6 +54,14 @@ export const MessageInput = ({
|
|||||||
e.error,
|
e.error,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
[channel, connection, myNodeNum, setMessageState, to],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalDraft(newValue);
|
||||||
|
debouncedSetMessageDraft(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +70,8 @@ export const MessageInput = ({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendText(messageDraft);
|
sendText(localDraft);
|
||||||
|
setLocalDraft("");
|
||||||
setMessageDraft("");
|
setMessageDraft("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -64,8 +81,8 @@ export const MessageInput = ({
|
|||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
minLength={1}
|
minLength={1}
|
||||||
placeholder="Enter Message"
|
placeholder="Enter Message"
|
||||||
value={messageDraft}
|
value={localDraft}
|
||||||
onChange={(e) => setMessageDraft(e.target.value)}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const PageLayout = ({
|
|||||||
<div className="flex justify-end space-x-4">
|
<div className="flex justify-end space-x-4">
|
||||||
{actions?.map((action, index) => (
|
{actions?.map((action, index) => (
|
||||||
<button
|
<button
|
||||||
key={action.icon.name}
|
key={action.icon.displayName}
|
||||||
type="button"
|
type="button"
|
||||||
className="transition-all hover:text-accent"
|
className="transition-all hover:text-accent"
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import {
|
|||||||
MapIcon,
|
MapIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
SidebarCloseIcon,
|
||||||
|
SidebarOpenIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -25,6 +28,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
|||||||
const myNode = nodes.get(hardware.myNodeNum);
|
const myNode = nodes.get(hardware.myNodeNum);
|
||||||
const myMetadata = metadata.get(0);
|
const myMetadata = metadata.get(0);
|
||||||
const { activePage, setActivePage, setDialogOpen } = useDevice();
|
const { activePage, setActivePage, setDialogOpen } = useDevice();
|
||||||
|
const [showSidebar, setShowSidebar] = useState<boolean>(true);
|
||||||
|
|
||||||
interface NavLink {
|
interface NavLink {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -60,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="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 className="flex justify-between px-8 pt-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -76,11 +80,20 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
|||||||
>
|
>
|
||||||
<EditIcon size={16} />
|
<EditIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||||
|
<SidebarCloseIcon size={24} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 pb-6">
|
<div className="px-8 pb-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
|
<BatteryMediumIcon size={24} viewBox={"0 0 28 24"} />
|
||||||
<Subtle>{myNode?.deviceMetrics?.batteryLevel ? myNode?.deviceMetrics?.batteryLevel > 100 ? "Charging" : myNode?.deviceMetrics?.batteryLevel + "%" : "UNK"}</Subtle>
|
<Subtle>
|
||||||
|
{myNode?.deviceMetrics?.batteryLevel
|
||||||
|
? myNode?.deviceMetrics?.batteryLevel > 100
|
||||||
|
? "Charging"
|
||||||
|
: `${myNode?.deviceMetrics?.batteryLevel}%`
|
||||||
|
: "UNK"}
|
||||||
|
</Subtle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
||||||
@@ -109,5 +122,11 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
|||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-13
@@ -1,5 +1,3 @@
|
|||||||
import { useToast } from "@core/hooks/useToast.ts";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
@@ -7,24 +5,24 @@ import {
|
|||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "@components/UI/Toast.tsx";
|
} from "@components/UI/Toast";
|
||||||
|
import { useToast } from "@core/hooks/useToast";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
{toasts.map(({ id, title, description, action, duration, ...props }) => (
|
||||||
<Toast key={id} {...props}>
|
<Toast
|
||||||
|
key={id}
|
||||||
|
{...props}
|
||||||
|
duration={duration}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{title && (
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
<ToastTitle className="dark:text-white">{title}</ToastTitle>
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<ToastDescription className="dark:text-white-400">
|
|
||||||
{description}
|
|
||||||
</ToastDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{action}
|
{action}
|
||||||
<ToastClose />
|
<ToastClose />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {}
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
|||||||
devicePSKBitCount?: number;
|
devicePSKBitCount?: number;
|
||||||
value: string;
|
value: string;
|
||||||
variant: "default" | "invalid";
|
variant: "default" | "invalid";
|
||||||
buttonText?: string;
|
actionButtons: {
|
||||||
|
text: string;
|
||||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
className?: string;
|
||||||
|
}[];
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
|
||||||
action?: {
|
action?: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -35,15 +39,15 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
|||||||
devicePSKBitCount,
|
devicePSKBitCount,
|
||||||
variant,
|
variant,
|
||||||
value,
|
value,
|
||||||
buttonText,
|
actionButtons,
|
||||||
bits = [
|
bits = [
|
||||||
{ text: "256 bit", value: "32", key: "bit256" },
|
{ text: "256 bit", value: "32", key: "bit256" },
|
||||||
{ text: "128 bit", value: "16", key: "bit128" },
|
{ text: "128 bit", value: "16", key: "bit128" },
|
||||||
{ text: "8 bit", value: "1", key: "bit8" },
|
{ text: "8 bit", value: "1", key: "bit8" },
|
||||||
|
{ text: "Empty", value: "0", key: "empty" },
|
||||||
],
|
],
|
||||||
selectChange,
|
selectChange,
|
||||||
inputChange,
|
inputChange,
|
||||||
buttonClick,
|
|
||||||
action,
|
action,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
@@ -93,15 +97,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex ml-4 space-x-4">
|
||||||
|
{actionButtons?.map(({ text, onClick, variant, className }) => (
|
||||||
<Button
|
<Button
|
||||||
|
key={text}
|
||||||
type="button"
|
type="button"
|
||||||
variant="success"
|
onClick={onClick}
|
||||||
onClick={buttonClick}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
variant={variant}
|
||||||
|
className={className}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{text}
|
||||||
</Button>
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export interface SidebarSectionProps {
|
|||||||
export const SidebarSection = ({
|
export const SidebarSection = ({
|
||||||
label: title,
|
label: title,
|
||||||
children,
|
children,
|
||||||
}: SidebarSectionProps): JSX.Element => (
|
}: SidebarSectionProps) => (
|
||||||
<div className="px-4 py-2">
|
<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 className="space-y-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type VariantProps, cva } from "class-variance-authority";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn";
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider;
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -23,14 +23,14 @@ const ToastViewport = React.forwardRef<
|
|||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
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:
|
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: {
|
defaultVariants: {
|
||||||
@@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
|
|||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -76,7 +76,7 @@ const ToastClose = React.forwardRef<
|
|||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
|
|||||||
@@ -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 {
|
export interface LinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Link = ({ href, children }: LinkProps): JSX.Element => (
|
export const Link = ({ href, children, className }: LinkProps): JSX.Element => (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target={"_blank"}
|
target={"_blank"}
|
||||||
rel="noopener noreferrer"
|
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}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -74,15 +74,12 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
|
|||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{heading.title}
|
{heading.title}
|
||||||
{sortColumn === heading.title && (
|
{sortColumn === heading.title &&
|
||||||
<>
|
(sortOrder === "asc" ? (
|
||||||
{sortOrder === "asc" ? (
|
|
||||||
<ChevronUpIcon size={16} />
|
<ChevronUpIcon size={16} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDownIcon size={16} />
|
<ChevronDownIcon size={16} />
|
||||||
)}
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -94,8 +91,7 @@ export const Table = ({ headings, rows }: TableProps): JSX.Element => {
|
|||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
{row.map((item, index) => (
|
{row.map((item, index) => (
|
||||||
<td
|
<td
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: OK because column order never changes.
|
key={item.key ?? index}
|
||||||
key={index}
|
|
||||||
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
|
className="whitespace-nowrap py-2 text-sm text-textSecondary first:pl-2"
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
action?: ToastActionElement;
|
action?: ToastActionElement;
|
||||||
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
@@ -80,7 +81,7 @@ export const reducer = (state: State, action: Action): State => {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ export const reducer = (state: State, action: Action): State => {
|
|||||||
...t,
|
...t,
|
||||||
open: false,
|
open: false,
|
||||||
}
|
}
|
||||||
: t
|
: t,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ function dispatch(action: Action) {
|
|||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">;
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ delay = 0, ...props }: Toast) {
|
||||||
const id = genId();
|
const id = genId();
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
@@ -147,6 +148,7 @@ function toast({ ...props }: Toast) {
|
|||||||
});
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: "ADD_TOAST",
|
||||||
toast: {
|
toast: {
|
||||||
@@ -158,6 +160,7 @@ function toast({ ...props }: Toast) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}, delay);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export type DialogVariant =
|
|||||||
| "shutdown"
|
| "shutdown"
|
||||||
| "reboot"
|
| "reboot"
|
||||||
| "deviceName"
|
| "deviceName"
|
||||||
| "nodeRemoval";
|
| "nodeRemoval"
|
||||||
|
| "pkiBackup";
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -60,6 +61,7 @@ export interface Device {
|
|||||||
reboot: boolean;
|
reboot: boolean;
|
||||||
deviceName: boolean;
|
deviceName: boolean;
|
||||||
nodeRemoval: boolean;
|
nodeRemoval: boolean;
|
||||||
|
pkiBackup: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
@@ -142,6 +144,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
|||||||
reboot: false,
|
reboot: false,
|
||||||
deviceName: false,
|
deviceName: false,
|
||||||
nodeRemoval: false,
|
nodeRemoval: false,
|
||||||
|
pkiBackup: false,
|
||||||
},
|
},
|
||||||
pendingSettingsChanges: false,
|
pendingSettingsChanges: false,
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
type Callback<Args extends unknown[]> = (...args: Args) => void;
|
||||||
|
|
||||||
|
export function debounce<Args extends unknown[]>(
|
||||||
|
callback: Callback<Args>,
|
||||||
|
wait: number,
|
||||||
|
): Callback<Args> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
return (...args: Args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => callback(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -97,6 +97,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
-drag: none;
|
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
|
|||||||
? "Primary"
|
? "Primary"
|
||||||
: `Ch ${channel.index}`;
|
: `Ch ${channel.index}`;
|
||||||
|
|
||||||
export const ChannelsPage = (): JSX.Element => {
|
const ChannelsPage = () => {
|
||||||
const { channels, setDialogOpen } = useDevice();
|
const { channels, setDialogOpen } = useDevice();
|
||||||
const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>(
|
const [activeChannel, setActiveChannel] = useState<Types.ChannelNumber>(
|
||||||
Types.ChannelNumber.Primary,
|
Types.ChannelNumber.Primary,
|
||||||
@@ -69,3 +69,4 @@ export const ChannelsPage = (): JSX.Element => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default ChannelsPage;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
|||||||
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
|
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const ConfigPage = (): JSX.Element => {
|
const ConfigPage = (): JSX.Element => {
|
||||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
||||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||||
"device" | "module"
|
"device" | "module"
|
||||||
@@ -72,3 +72,5 @@ export const ConfigPage = (): JSX.Element => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ConfigPage;
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
const { setConnectDialogOpen } = useAppStore();
|
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
|
const { darkMode } = useAppStore();
|
||||||
|
|
||||||
const devices = useMemo(() => getDevices(), [getDevices]);
|
const devices = useMemo(() => getDevices(), [getDevices]);
|
||||||
|
|
||||||
@@ -38,7 +39,13 @@ export const Dashboard = () => {
|
|||||||
{devices.map((device) => {
|
{devices.map((device) => {
|
||||||
return (
|
return (
|
||||||
<li key={device.id}>
|
<li key={device.id}>
|
||||||
<div className="px-4 py-4 sm:px-6">
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full px-4 py-4 sm:px-6 ${darkMode ? "hover:bg-slate-800 focus:bg-slate-400 active:bg-slate-600" : "hover:bg-slate-50 focus:bg-slate-50 active:bg-slate-100"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDevice(device.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="truncate text-sm font-medium text-accent">
|
<p className="truncate text-sm font-medium text-accent">
|
||||||
{device.nodes.get(device.hardware.myNodeNum)?.user
|
{device.nodes.get(device.hardware.myNodeNum)?.user
|
||||||
@@ -75,7 +82,7 @@ export const Dashboard = () => {
|
|||||||
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+40
-12
@@ -1,3 +1,5 @@
|
|||||||
|
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
||||||
|
import { Avatar } from "@app/components/UI/Avatar";
|
||||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||||
import { cn } from "@app/core/utils/cn.ts";
|
import { cn } from "@app/core/utils/cn.ts";
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
@@ -6,7 +8,7 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
|||||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
import type { Protobuf } from "@meshtastic/js";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { bbox, lineString } from "@turf/turf";
|
import { bbox, lineString } from "@turf/turf";
|
||||||
import {
|
import {
|
||||||
@@ -15,18 +17,20 @@ import {
|
|||||||
ZoomInIcon,
|
ZoomInIcon,
|
||||||
ZoomOutIcon,
|
ZoomOutIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Marker, useMap } from "react-map-gl";
|
import { AttributionControl, Marker, Popup, useMap } from "react-map-gl";
|
||||||
import MapGl from "react-map-gl/maplibre";
|
import MapGl from "react-map-gl/maplibre";
|
||||||
|
|
||||||
export const MapPage = (): JSX.Element => {
|
const MapPage = (): JSX.Element => {
|
||||||
const { nodes, waypoints } = useDevice();
|
const { nodes, waypoints } = useDevice();
|
||||||
const { rasterSources, darkMode } = useAppStore();
|
const { rasterSources, darkMode } = useAppStore();
|
||||||
const { default: map } = useMap();
|
const { default: map } = useMap();
|
||||||
|
|
||||||
const [zoom, setZoom] = useState(0);
|
const [zoom, setZoom] = useState(0);
|
||||||
|
const [selectedNode, setSelectedNode] =
|
||||||
|
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||||
|
|
||||||
const allNodes = Array.from(nodes.values());
|
const allNodes = useMemo(() => Array.from(nodes.values()), [nodes]);
|
||||||
|
|
||||||
const getBBox = useCallback(() => {
|
const getBBox = useCallback(() => {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
@@ -126,13 +130,12 @@ export const MapPage = (): JSX.Element => {
|
|||||||
// }}
|
// }}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
renderWorldCopies={false}
|
renderWorldCopies={false}
|
||||||
maxPitch={0}
|
maxPitch={0}
|
||||||
style={{
|
style={{
|
||||||
filter: darkMode
|
filter: darkMode ? "brightness(0.8)" : "",
|
||||||
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
|
|
||||||
: "",
|
|
||||||
}}
|
}}
|
||||||
dragRotate={false}
|
dragRotate={false}
|
||||||
touchZoomRotate={false}
|
touchZoomRotate={false}
|
||||||
@@ -142,6 +145,12 @@ export const MapPage = (): JSX.Element => {
|
|||||||
longitude: 0,
|
longitude: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<AttributionControl
|
||||||
|
style={{
|
||||||
|
background: darkMode ? "#ffffff" : "",
|
||||||
|
color: darkMode ? "black" : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{waypoints.map((wp) => (
|
{waypoints.map((wp) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={wp.id}
|
key={wp.id}
|
||||||
@@ -160,15 +169,16 @@ export const MapPage = (): JSX.Element => {
|
|||||||
</Source>
|
</Source>
|
||||||
))} */}
|
))} */}
|
||||||
{allNodes.map((node) => {
|
{allNodes.map((node) => {
|
||||||
if (node.position?.latitudeI) {
|
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={node.num}
|
key={node.num}
|
||||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
||||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
||||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
// style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||||
anchor="bottom"
|
anchor="bottom"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setSelectedNode(node);
|
||||||
map?.easeTo({
|
map?.easeTo({
|
||||||
zoom: 12,
|
zoom: 12,
|
||||||
center: [
|
center: [
|
||||||
@@ -178,8 +188,13 @@ export const MapPage = (): JSX.Element => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
<div className="flex cursor-pointer gap-2 rounded-md bg-transparent p-1.5">
|
||||||
<Hashicon value={node.num.toString()} size={22} />
|
<Avatar
|
||||||
|
text={
|
||||||
|
node.user?.shortName.toString() ?? node.num.toString()
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||||
{node.user?.longName ||
|
{node.user?.longName ||
|
||||||
`!${numberToHexUnpadded(node.num)}`}
|
`!${numberToHexUnpadded(node.num)}`}
|
||||||
@@ -189,8 +204,21 @@ export const MapPage = (): JSX.Element => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
{selectedNode?.position && (
|
||||||
|
<Popup
|
||||||
|
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
||||||
|
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
||||||
|
anchor="left"
|
||||||
|
closeOnClick={false}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
>
|
||||||
|
<NodeDetail node={selectedNode} />
|
||||||
|
</Popup>
|
||||||
|
)}
|
||||||
</MapGl>
|
</MapGl>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default MapPage;
|
||||||
|
|||||||
+17
-6
@@ -1,18 +1,18 @@
|
|||||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
|
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||||
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { Device, useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
|
||||||
import { Protobuf, Types } from "@meshtastic/js";
|
import { Protobuf, Types } from "@meshtastic/js";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { getChannelName } from "@pages/Channels.tsx";
|
import { getChannelName } from "@pages/Channels.tsx";
|
||||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const MessagesPage = (): JSX.Element => {
|
const MessagesPage = () => {
|
||||||
const { channels, nodes, hardware, messages, traceroutes, connection } =
|
const { channels, nodes, hardware, messages, traceroutes, connection } =
|
||||||
useDevice();
|
useDevice();
|
||||||
const [chatType, setChatType] =
|
const [chatType, setChatType] =
|
||||||
@@ -68,18 +68,27 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
className="w-full p-2 border border-gray-300 rounded bg-white text-black"
|
className="w-full p-2 border border-gray-300 rounded bg-white text-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{filteredNodes.map((node) => (
|
{filteredNodes.map((node) => (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={node.num}
|
key={node.num}
|
||||||
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
|
label={
|
||||||
|
node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`
|
||||||
|
}
|
||||||
active={activeChat === node.num}
|
active={activeChat === node.num}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("direct");
|
setChatType("direct");
|
||||||
setActiveChat(node.num);
|
setActiveChat(node.num);
|
||||||
}}
|
}}
|
||||||
element={<Hashicon size={20} value={node.num.toString()} />}
|
element={
|
||||||
|
<Avatar
|
||||||
|
text={node.user?.shortName ?? node.num.toString()}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex flex-col flex-grow">
|
||||||
@@ -88,7 +97,7 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
chatType === "broadcast" && currentChannel
|
chatType === "broadcast" && currentChannel
|
||||||
? getChannelName(currentChannel)
|
? getChannelName(currentChannel)
|
||||||
: chatType === "direct" && nodes.get(activeChat)
|
: chatType === "direct" && nodes.get(activeChat)
|
||||||
? nodes.get(activeChat)?.user?.longName ?? nodeHex
|
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||||
: "Loading..."
|
: "Loading..."
|
||||||
}`}
|
}`}
|
||||||
actions={
|
actions={
|
||||||
@@ -158,3 +167,5 @@ export const MessagesPage = (): JSX.Element => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default MessagesPage;
|
||||||
|
|||||||
+9
-4
@@ -6,12 +6,11 @@ import { Mono } from "@components/generic/Mono.tsx";
|
|||||||
import { Table } from "@components/generic/Table/index.tsx";
|
import { Table } from "@components/generic/Table/index.tsx";
|
||||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
|
||||||
import { Protobuf } from "@meshtastic/js";
|
import { Protobuf } from "@meshtastic/js";
|
||||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Fragment } from "react";
|
import { Fragment, type JSX } from "react";
|
||||||
import { base16 } from "rfc4648";
|
import { base16 } from "rfc4648";
|
||||||
|
|
||||||
export interface DeleteNoteDialogProps {
|
export interface DeleteNoteDialogProps {
|
||||||
@@ -19,7 +18,7 @@ export interface DeleteNoteDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodesPage = (): JSX.Element => {
|
const NodesPage = (): JSX.Element => {
|
||||||
const { nodes, hardware, setDialogOpen } = useDevice();
|
const { nodes, hardware, setDialogOpen } = useDevice();
|
||||||
const { setNodeNumToBeRemoved } = useAppStore();
|
const { setNodeNumToBeRemoved } = useAppStore();
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
@@ -57,7 +56,11 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
{ title: "Remove", type: "normal", sortable: false },
|
{ title: "Remove", type: "normal", sortable: false },
|
||||||
]}
|
]}
|
||||||
rows={filteredNodes.map((node) => [
|
rows={filteredNodes.map((node) => [
|
||||||
<Hashicon key="icon" size={24} value={node.num.toString()} />,
|
<span
|
||||||
|
key={node.num}
|
||||||
|
className="h-3 w-3 rounded-full bg-accent"
|
||||||
|
/>,
|
||||||
|
|
||||||
<h1 key="header">
|
<h1 key="header">
|
||||||
{node.user?.longName ??
|
{node.user?.longName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
@@ -124,3 +127,5 @@ export const NodesPage = (): JSX.Element => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default NodesPage;
|
||||||
|
|||||||
+1
-1
@@ -35,6 +35,6 @@
|
|||||||
],
|
],
|
||||||
"strictPropertyInitialization": false,
|
"strictPropertyInitialization": false,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user