Merge branch 'master' into issue-249-cors

This commit is contained in:
Dan Ditomaso
2025-01-28 21:32:34 -05:00
committed by GitHub
131 changed files with 8485 additions and 5245 deletions

2
.dockerignore Normal file
View File

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

View File

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

View File

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

61
.github/workflows/release.yml vendored Normal file
View File

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

View File

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

View File

@@ -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 .

View File

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

View File

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

View File

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

9571
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

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

30
rsbuild.config.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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