mirror of
https://github.com/meshtastic/web.git
synced 2026-04-20 22:10:26 -04:00
2.0 Overhaul start
This commit is contained in:
@@ -1 +1,4 @@
|
||||
{}
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
|
||||
26
package.json
26
package.json
@@ -20,22 +20,23 @@
|
||||
"homepage": "https://meshtastic.org",
|
||||
"dependencies": {
|
||||
"@emeraldpay/hashicon-react": "^0.5.2",
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@headlessui/react": "^1.7.2",
|
||||
"@heroicons/react": "^2.0.11",
|
||||
"@hookform/resolvers": "^2.9.8",
|
||||
"@meshtastic/eslint-config": "^1.0.8",
|
||||
"@meshtastic/meshtasticjs": "^0.6.98",
|
||||
"@meshtastic/meshtasticjs": "^0.6.99",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@tailwindcss/typography": "^0.5.7",
|
||||
"base64-js": "^1.5.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"evergreen-ui": "^6.10.3",
|
||||
"geodesy": "^2.4.0",
|
||||
"immer": "^9.0.15",
|
||||
"mapbox-gl": "npm:empty-npm-package@^1.0.0",
|
||||
"maplibre-gl": "^2.4.0",
|
||||
"modern-css-reset": "^1.4.0",
|
||||
"prettier": "^2.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.34.2",
|
||||
"react-hook-form": "^7.35.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-json-pretty": "^2.2.0",
|
||||
"react-map-gl": "^7.0.19",
|
||||
@@ -44,20 +45,25 @@
|
||||
"zustand": "4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.196",
|
||||
"@types/chrome": "^0.0.197",
|
||||
"@types/geodesy": "^2.2.3",
|
||||
"@types/node": "^18.7.16",
|
||||
"@types/react": "^18.0.18",
|
||||
"@types/node": "^18.7.18",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/w3c-web-serial": "^1.0.2",
|
||||
"@types/web-bluetooth": "^0.0.15",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"autoprefixer": "^10.4.11",
|
||||
"gzipper": "^7.1.0",
|
||||
"postcss": "^8.4.16",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.13",
|
||||
"rollup-plugin-visualizer": "^5.8.1",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"tar": "^6.1.11",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.8.3",
|
||||
"vite": "^3.1.0",
|
||||
"vite": "^3.1.3",
|
||||
"vite-plugin-environment": "^1.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
1283
pnpm-lock.yaml
generated
1283
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -1,20 +1,37 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
import { MapProvider } from "react-map-gl";
|
||||
|
||||
import { AppLayout } from "@components/layout/AppLayout.js";
|
||||
import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
|
||||
import { DeviceSelector } from "./components/DeviceSelector.js";
|
||||
import { NewDevice } from "./components/NewDevice.js";
|
||||
import { PageNav } from "./components/PageNav.js";
|
||||
import { Sidebar } from "./components/Sidebar.js";
|
||||
import { PageRouter } from "./PageRouter.js";
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
const { getDevice } = useDeviceStore();
|
||||
const { selectedDevice } = useAppStore();
|
||||
|
||||
const device = getDevice(selectedDevice);
|
||||
|
||||
return (
|
||||
<Pane display="flex">
|
||||
<AppLayout>
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</AppLayout>
|
||||
</Pane>
|
||||
<div className="h-full flex w-full">
|
||||
<DeviceSelector />
|
||||
|
||||
{device && (
|
||||
<DeviceWrapper device={device}>
|
||||
<Sidebar />
|
||||
<PageNav />
|
||||
<MapProvider>
|
||||
<PageRouter />
|
||||
</MapProvider>
|
||||
</DeviceWrapper>
|
||||
)}
|
||||
{selectedDevice === 0 && <NewDevice />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from "react";
|
||||
import type React from 'react';
|
||||
|
||||
import { DeviceContext } from "@core/providers/useDevice.js";
|
||||
import type { Device } from "@core/stores/deviceStore.js";
|
||||
import { DeviceContext } from '@core/providers/useDevice.js';
|
||||
import type { Device } from '@core/stores/deviceStore.js';
|
||||
|
||||
export interface DeviceProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { ChannelsPage } from "@pages/Channels/index.js";
|
||||
import { ChannelsPage } from "@pages/Channels.js";
|
||||
import { ConfigPage } from "@pages/Config/index.js";
|
||||
import { ExtensionsPage } from "@pages/Extensions/Index.js";
|
||||
import { InfoPage } from "@pages/Info/index.js";
|
||||
import { MapPage } from "@pages/Map/index.js";
|
||||
import { MessagesPage } from "@pages/Messages/index.js";
|
||||
import { InfoPage } from "@pages/Info.js";
|
||||
import { MapPage } from "@pages/Map.js";
|
||||
import { MessagesPage } from "@pages/Messages.js";
|
||||
|
||||
export const PageRouter = (): JSX.Element => {
|
||||
const { activePage } = useDevice();
|
||||
|
||||
43
src/components/Button.tsx
Normal file
43
src/components/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type React from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "primary" | "secondary";
|
||||
iconBefore?: JSX.Element;
|
||||
iconAfter?: JSX.Element;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
iconBefore,
|
||||
iconAfter,
|
||||
children,
|
||||
disabled,
|
||||
...rest
|
||||
}: ButtonProps): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className={`px-3 w-full rounded-md flex border border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
|
||||
variant === "primary"
|
||||
? "bg-orange-600 text-white shadow-sm hover:bg-orange-700"
|
||||
: "bg-orange-100 text-orange-700 hover:bg-orange-200"
|
||||
} ${
|
||||
size === "sm"
|
||||
? "h-8 text-sm"
|
||||
: size === "md"
|
||||
? "h-10 text-sm"
|
||||
: "h-10 text-base"
|
||||
} ${disabled ? "cursor-not-allowed bg-red-400 focus:ring-red-500" : ""}`}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex items-center m-auto gap-2 font-medium">
|
||||
{iconBefore}
|
||||
{children}
|
||||
{iconAfter}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
48
src/components/DeviceSelector.tsx
Normal file
48
src/components/DeviceSelector.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@app/core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const DeviceSelector = (): JSX.Element => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { selectedDevice, setSelectedDevice } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="flex bg-slate-50 w-16 items-center whitespace-nowrap py-12 text-sm [writing-mode:vertical-rl] h-full">
|
||||
<span className="font-mono text-slate-500">Connected Devices</span>
|
||||
<span className="mt-6 flex gap-4 font-bold text-slate-900">
|
||||
{getDevices().map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
className="group flex w-8 h-8 p-0.5 cursor-pointer drop-shadow-md"
|
||||
>
|
||||
<Hashicon size={32} value={device.hardware.myNodeNum.toString()} />
|
||||
<div
|
||||
className={`absolute -left-1.5 w-0.5 h-7 rounded-full group-hover:bg-orange-300 ${
|
||||
device.id === selectedDevice
|
||||
? "bg-orange-400"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedDevice(0);
|
||||
}}
|
||||
className={`w-8 h-8 p-2 border-dashed border-2 rounded-md hover:border-orange-300 cursor-pointer ${
|
||||
selectedDevice === 0 ? "border-orange-400" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
</span>
|
||||
<img src="Logo_Black.svg" className="px-3 mt-auto" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { CogIcon, CrossIcon, IconButton, Text } from "evergreen-ui";
|
||||
|
||||
import { TabbedContent, TabType } from "../layout/page/TabbedContent.js";
|
||||
import { Dialog } from "./index.js";
|
||||
|
||||
export interface HelpDialogProps {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const HelpDialog = ({ isOpen, close }: HelpDialogProps): JSX.Element => {
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
name: "Device Config",
|
||||
icon: CogIcon,
|
||||
element: () => (
|
||||
<div>
|
||||
<Text>Title</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Device Config",
|
||||
icon: CogIcon,
|
||||
element: () => (
|
||||
<div>
|
||||
<Text>Title 2</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} close={close} title="Help">
|
||||
<TabbedContent
|
||||
tabs={tabs}
|
||||
actions={[() => <IconButton icon={CrossIcon} onClick={close} />]}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
HelperManagementIcon,
|
||||
IconButton,
|
||||
majorScale,
|
||||
MoreIcon,
|
||||
Table,
|
||||
TagIcon,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { toMGRS } from "@app/core/utils/toMGRS.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { Dialog } from "./index.js";
|
||||
|
||||
export interface PeersDialogProps {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const PeersDialog = ({
|
||||
isOpen,
|
||||
close,
|
||||
}: PeersDialogProps): JSX.Element => {
|
||||
const { hardware, nodes, connection, setPeerInfoOpen, setActivePeer } =
|
||||
useDevice();
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} close={close} width={majorScale(120)}>
|
||||
<Table>
|
||||
<Table.Head>
|
||||
<Table.HeaderCell flexBasis={48} flexShrink={0} flexGrow={0} />
|
||||
<Table.TextHeaderCell flexBasis={96} flexShrink={0} flexGrow={0}>
|
||||
Number
|
||||
</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell flexBasis={116} flexShrink={0} flexGrow={0}>
|
||||
Name
|
||||
</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell flexBasis={48} flexShrink={0} flexGrow={0}>
|
||||
SNR
|
||||
</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell>Location</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell>Telemetry</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell>Last Heard</Table.TextHeaderCell>
|
||||
<Table.TextHeaderCell>Actions</Table.TextHeaderCell>
|
||||
</Table.Head>
|
||||
<Table.Body height={240}>
|
||||
{nodes
|
||||
.filter((n) => n.data.num !== hardware.myNodeNum)
|
||||
.map((node) => (
|
||||
<Table.Row
|
||||
key={node.data.num}
|
||||
isSelectable
|
||||
onSelect={() => {
|
||||
setActivePeer(node.data.num);
|
||||
setPeerInfoOpen(true);
|
||||
}}
|
||||
>
|
||||
<Table.Cell flexBasis={48} flexShrink={0} flexGrow={0}>
|
||||
<Hashicon
|
||||
value={node.data.num.toString()}
|
||||
size={majorScale(3)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.TextCell flexBasis={96} flexShrink={0} flexGrow={0}>
|
||||
{node.data.num}
|
||||
</Table.TextCell>
|
||||
<Table.TextCell flexBasis={116} flexShrink={0} flexGrow={0}>
|
||||
{node.data.user?.longName}
|
||||
</Table.TextCell>
|
||||
<Table.TextCell flexBasis={48} flexShrink={0} flexGrow={0}>
|
||||
{node.data.snr}
|
||||
</Table.TextCell>
|
||||
<Table.TextCell>
|
||||
{toMGRS(
|
||||
node.data.position?.latitudeI,
|
||||
node.data.position?.longitudeI
|
||||
)}
|
||||
</Table.TextCell>
|
||||
<Table.TextCell>Tmp</Table.TextCell>
|
||||
<Table.TextCell>
|
||||
{new Date(node.data.lastHeard * 1000).toLocaleString()}
|
||||
</Table.TextCell>
|
||||
<Table.Cell gap={majorScale(1)}>
|
||||
<Tooltip content="Manage">
|
||||
<IconButton icon={HelperManagementIcon} />
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={TagIcon}
|
||||
onClick={() => {
|
||||
void connection?.sendPacket(
|
||||
Protobuf.AdminMessage.toBinary({
|
||||
payloadVariant: {
|
||||
oneofKind: "getConfigRequest",
|
||||
getConfigRequest:
|
||||
Protobuf.AdminMessage_ConfigType.LORA_CONFIG,
|
||||
},
|
||||
}),
|
||||
Protobuf.PortNum.ADMIN_APP,
|
||||
node.data.num,
|
||||
true,
|
||||
7,
|
||||
true,
|
||||
false,
|
||||
async (test) => {
|
||||
console.log(test);
|
||||
|
||||
console.log("got response");
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon={MoreIcon} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,21 +2,14 @@ import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fromByteArray } from "base64-js";
|
||||
import {
|
||||
Checkbox,
|
||||
ClipboardIcon,
|
||||
FormField,
|
||||
IconButton,
|
||||
majorScale,
|
||||
Pane,
|
||||
TextInputField,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
import { QRCode } from "react-qrcode-logo";
|
||||
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { Dialog } from "./index.js";
|
||||
import { Checkbox } from "../form/Checkbox.js";
|
||||
import { Input } from "../form/Input.js";
|
||||
|
||||
export interface QRDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -54,64 +47,65 @@ export const QRDialog = ({
|
||||
}, [channels, selectedChannels, loraConfig]);
|
||||
|
||||
return (
|
||||
// <Dialog
|
||||
// isShown={isOpen}
|
||||
//
|
||||
// onCloseComplete={close}
|
||||
// hasFooter={false}
|
||||
// >
|
||||
<Dialog isOpen={isOpen} close={close} title="Generate QR Code" background>
|
||||
<Pane display="flex">
|
||||
<FormField
|
||||
width="12rem"
|
||||
label="Channels to include"
|
||||
description="The current LoRa configuration will also be shared."
|
||||
>
|
||||
{channels.map((channel) => (
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
disabled={channel.role === Protobuf.Channel_Role.DISABLED}
|
||||
label={
|
||||
channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`
|
||||
}
|
||||
checked={selectedChannels.includes(channel.index)}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index)
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([...selectedChannels, channel.index]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormField>
|
||||
<Pane
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
margin={majorScale(1)}
|
||||
>
|
||||
<Pane display="flex" margin="auto">
|
||||
<QRCode value={QRCodeURL} size={250} qrStyle="dots" />
|
||||
</Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<TextInputField
|
||||
label="Sharable URL"
|
||||
value={QRCodeURL}
|
||||
width="100%"
|
||||
/>
|
||||
<Tooltip content="Copy to Clipboard">
|
||||
<IconButton icon={ClipboardIcon} marginTop="1.6rem" />
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Pane>
|
||||
<Dialog open={isOpen} onClose={close}>
|
||||
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="mx-auto max-w-sm rounded bg-white p-3">
|
||||
<Dialog.Title>Generate QR Code</Dialog.Title>
|
||||
|
||||
<Dialog.Description>
|
||||
This will permanently deactivate your account
|
||||
</Dialog.Description>
|
||||
<div className="flex">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-lg">Channels to include</span>
|
||||
<span className="text-sm text-slate-600">
|
||||
The current LoRa configuration will also be shared.
|
||||
</span>
|
||||
{channels.map((channel) => (
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
disabled={channel.role === Protobuf.Channel_Role.DISABLED}
|
||||
label={
|
||||
channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`
|
||||
}
|
||||
checked={selectedChannels.includes(channel.index)}
|
||||
onChange={() => {
|
||||
if (selectedChannels.includes(channel.index)) {
|
||||
setSelectedChannels(
|
||||
selectedChannels.filter((c) => c !== channel.index)
|
||||
);
|
||||
} else {
|
||||
setSelectedChannels([...selectedChannels, channel.index]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow m-2">
|
||||
<div className="flex m-auto">
|
||||
<QRCode value={QRCodeURL} size={250} qrStyle="dots" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="Sharable URL"
|
||||
value={QRCodeURL}
|
||||
action={{
|
||||
icon: <ClipboardIcon className="h-4" />,
|
||||
action: () => {
|
||||
console.log("");
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { SelectField } from "evergreen-ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { LoRaValidation } from "@app/validation/config/lora.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { renderOptions } from "@core/utils/selectEnumOptions.js";
|
||||
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { Form } from "../form/Form.js";
|
||||
import { Dialog } from "./index.js";
|
||||
|
||||
export interface RegionDialogProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const RegionDialog = ({ isOpen }: RegionDialogProps): JSX.Element => {
|
||||
const { config, connection } = useDevice();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
} = useForm<LoRaValidation>({
|
||||
defaultValues: config.lora,
|
||||
resolver: classValidatorResolver(LoRaValidation),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(config.lora);
|
||||
}, [reset, config.lora]);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
setLoading(true);
|
||||
void connection?.setConfig(
|
||||
{
|
||||
payloadVariant: {
|
||||
oneofKind: "lora",
|
||||
lora: data,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
reset({ ...data });
|
||||
setLoading(false);
|
||||
await Promise.resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} close={close} title="Set Device Region" background>
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<SelectField
|
||||
label="Region"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.region?.message}
|
||||
validationMessage={errors.region?.message}
|
||||
{...register("region", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
|
||||
</SelectField>
|
||||
</Form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
CrossIcon,
|
||||
Heading,
|
||||
IconButton,
|
||||
majorScale,
|
||||
Overlay,
|
||||
Pane,
|
||||
} from "evergreen-ui";
|
||||
|
||||
export interface DialogProps {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
title?: string;
|
||||
background?: boolean;
|
||||
width?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Dialog = ({
|
||||
isOpen,
|
||||
close,
|
||||
title,
|
||||
background,
|
||||
width,
|
||||
children,
|
||||
}: DialogProps): JSX.Element => {
|
||||
return (
|
||||
<Overlay
|
||||
isShown={isOpen}
|
||||
onExit={close}
|
||||
containerProps={{
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Pane
|
||||
role="dialog"
|
||||
width={width ?? majorScale(80)}
|
||||
margin="auto"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
zIndex={1}
|
||||
borderRadius={majorScale(1)}
|
||||
padding={majorScale(3)}
|
||||
background={background ? "white" : undefined}
|
||||
>
|
||||
{background && (
|
||||
<Pane
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
marginBottom={majorScale(2)}
|
||||
>
|
||||
<Heading size={600}>{title}</Heading>
|
||||
<IconButton icon={CrossIcon} onClick={close} />
|
||||
</Pane>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Pane>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
33
src/components/IconButton.tsx
Normal file
33
src/components/IconButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface IconButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "primary" | "secondary";
|
||||
icon?: JSX.Element;
|
||||
}
|
||||
|
||||
export const IconButton = ({
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
icon,
|
||||
disabled,
|
||||
...rest
|
||||
}: IconButtonProps): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className={`flex border border-transparent rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${
|
||||
variant === "primary"
|
||||
? "bg-orange-600 text-white shadow-sm hover:bg-orange-700"
|
||||
: "bg-orange-100 text-orange-700 hover:bg-orange-200"
|
||||
} ${
|
||||
size === "sm" ? "h-8 w-8" : size === "md" ? "h-10 w-10" : "h-12 w-12"
|
||||
} ${disabled ? "cursor-not-allowed bg-red-400 focus:ring-red-500" : ""}`}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
<div className="m-auto">{icon}</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
34
src/components/NewDevice.tsx
Normal file
34
src/components/NewDevice.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
|
||||
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
|
||||
|
||||
import { TabbedContent, TabType } from "./layout/page/TabbedContent.js";
|
||||
import { BLE } from "./PageComponents/Connect/BLE.js";
|
||||
import { HTTP } from "./PageComponents/Connect/HTTP.js";
|
||||
import { Serial } from "./PageComponents/Connect/Serial.js";
|
||||
|
||||
export const NewDevice = () => {
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
name: "BLE",
|
||||
icon: <FiBluetooth className="h-4" />,
|
||||
element: BLE,
|
||||
},
|
||||
{
|
||||
name: "HTTP",
|
||||
icon: <FiWifi className="h-4" />,
|
||||
element: HTTP,
|
||||
},
|
||||
{
|
||||
name: "Serial",
|
||||
icon: <FiTerminal className="h-4" />,
|
||||
element: Serial,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-96 h-96 m-auto">
|
||||
<TabbedContent tabs={tabs} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
198
src/components/PageComponents/Channel.tsx
Normal file
198
src/components/PageComponents/Channel.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { Select } from "../form/Select.js";
|
||||
import { Toggle } from "../form/Toggle.js";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel;
|
||||
}
|
||||
|
||||
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
const { connection } = useDevice();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keySize, setKeySize] = useState<128 | 256>(256);
|
||||
const [pskHidden, setPskHidden] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
} = useForm<
|
||||
Omit<Protobuf.ChannelSettings, "psk"> & { psk: string; enabled: boolean }
|
||||
>({
|
||||
defaultValues: {
|
||||
enabled: [
|
||||
Protobuf.Channel_Role.SECONDARY,
|
||||
Protobuf.Channel_Role.PRIMARY,
|
||||
].find((role) => role === channel?.role)
|
||||
? true
|
||||
: false,
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
enabled: [
|
||||
Protobuf.Channel_Role.SECONDARY,
|
||||
Protobuf.Channel_Role.PRIMARY,
|
||||
].find((role) => role === channel?.role)
|
||||
? true
|
||||
: false,
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
});
|
||||
}, [channel, reset]);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setLoading(true);
|
||||
const channelData = Protobuf.Channel.create({
|
||||
role:
|
||||
channel?.role === Protobuf.Channel_Role.PRIMARY
|
||||
? Protobuf.Channel_Role.PRIMARY
|
||||
: data.enabled
|
||||
? Protobuf.Channel_Role.SECONDARY
|
||||
: Protobuf.Channel_Role.DISABLED,
|
||||
index: channel?.index,
|
||||
settings: {
|
||||
...data,
|
||||
psk: toByteArray(data.psk ?? ""),
|
||||
},
|
||||
});
|
||||
|
||||
await connection?.setChannel(channelData, (): Promise<void> => {
|
||||
reset({ ...data });
|
||||
setLoading(false);
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
title="Channel Editor"
|
||||
breadcrumbs={[
|
||||
"Channels",
|
||||
channel.settings?.name.length
|
||||
? channel.settings.name
|
||||
: channel.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.index}`,
|
||||
]}
|
||||
reset={() =>
|
||||
reset({
|
||||
enabled: [
|
||||
Protobuf.Channel_Role.SECONDARY,
|
||||
Protobuf.Channel_Role.PRIMARY,
|
||||
].find((role) => role === channel?.role)
|
||||
? true
|
||||
: false,
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
})
|
||||
}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{channel?.index !== 0 && (
|
||||
<>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="Name"
|
||||
description="Max transmit power in dBm"
|
||||
{...register("name")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
label="Key Size"
|
||||
description="Desired size of generated key."
|
||||
value={keySize}
|
||||
onChange={(e): void => {
|
||||
setKeySize(parseInt(e.target.value) as 128 | 256);
|
||||
}}
|
||||
action={{
|
||||
icon: <ArrowPathIcon className="h-4" />,
|
||||
action: () => {
|
||||
const key = new Uint8Array(keySize / 8);
|
||||
crypto.getRandomValues(key);
|
||||
setValue("psk", fromByteArray(key));
|
||||
},
|
||||
}}
|
||||
>
|
||||
<option value={128}>128 Bit</option>
|
||||
<option value={256}>256 Bit</option>
|
||||
</Select>
|
||||
<Input
|
||||
width="100%"
|
||||
label="Pre-Shared Key"
|
||||
description="Max transmit power in dBm"
|
||||
type={pskHidden ? "password" : "text"}
|
||||
action={{
|
||||
icon: pskHidden ? (
|
||||
<EyeIcon className="w-4" />
|
||||
) : (
|
||||
<EyeSlashIcon className="w-4" />
|
||||
),
|
||||
action: () => {
|
||||
setPskHidden(!pskHidden);
|
||||
},
|
||||
}}
|
||||
{...register("psk")}
|
||||
/>
|
||||
<Controller
|
||||
name="uplinkEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Uplink Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="downlinkEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Downlink Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { BluetoothValidation } from "@app/validation/config/bluetooth.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -54,43 +56,41 @@ export const Bluetooth = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Bluetooth Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<SelectField
|
||||
<Form
|
||||
title="Bluetooth Config"
|
||||
breadcrumbs={["Config", "Bluetooth"]}
|
||||
reset={() => reset(config.bluetooth)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
label="Pairing mode"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.mode?.message}
|
||||
validationMessage={errors.mode?.message}
|
||||
{...register("mode", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_BluetoothConfig_PairingMode)}
|
||||
</SelectField>
|
||||
</Select>
|
||||
|
||||
<TextInputField
|
||||
display={
|
||||
<Input
|
||||
disabled={
|
||||
pairingMode !== Protobuf.Config_BluetoothConfig_PairingMode.FIXED_PIN
|
||||
? "none"
|
||||
: "block"
|
||||
}
|
||||
label="Pin"
|
||||
description="This is a description."
|
||||
type="number"
|
||||
isInvalid={!!errors.fixedPin?.message}
|
||||
validationMessage={errors.fixedPin?.message}
|
||||
{...register("fixedPin", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, toaster } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { DeviceValidation } from "@app/validation/config/device.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -39,7 +40,7 @@ export const Device = (): JSX.Element => {
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
toaster.success("Successfully updated device config");
|
||||
// toaster.success("Successfully updated device config");
|
||||
reset({ ...data });
|
||||
setLoading(false);
|
||||
await Promise.resolve();
|
||||
@@ -47,58 +48,45 @@ export const Device = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<SelectField
|
||||
<Form
|
||||
title="Device Config"
|
||||
breadcrumbs={["Config", "Device"]}
|
||||
reset={() => reset(config.device)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Select
|
||||
label="Role"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.role?.message}
|
||||
validationMessage={errors.role?.message}
|
||||
{...register("role", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_DeviceConfig_Role)}
|
||||
</SelectField>
|
||||
<FormField
|
||||
label="Serial Console Disabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.serialDisabled?.message}
|
||||
validationMessage={errors.serialDisabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="serialDisabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Factory Reset Device"
|
||||
description="Description"
|
||||
isInvalid={!!errors.factoryReset?.message}
|
||||
validationMessage={errors.factoryReset?.message}
|
||||
>
|
||||
<Controller
|
||||
name="factoryReset"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Enabled Debug Log"
|
||||
description="Description"
|
||||
isInvalid={!!errors.debugLogEnabled?.message}
|
||||
validationMessage={errors.debugLogEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="debugLogEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Select>
|
||||
<Controller
|
||||
name="serialEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Serial Output Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="debugLogEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Enabled Debug Log"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { DisplayValidation } from "@app/validation/config/display.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -46,42 +48,47 @@ export const Display = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Display Config"
|
||||
breadcrumbs={["Config", "Display"]}
|
||||
reset={() => reset(config.display)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label="Screen Timeout"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
{...register("screenOnSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Carousel Delay"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
{...register("autoScreenCarouselSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<SelectField
|
||||
<Select
|
||||
label="GPS Display Units"
|
||||
description="This is a description."
|
||||
{...register("gpsFormat", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_DisplayConfig_GpsCoordinateFormat)}
|
||||
</SelectField>
|
||||
<FormField
|
||||
label="Compass North Top"
|
||||
description="Description"
|
||||
isInvalid={!!errors.compassNorthTop?.message}
|
||||
validationMessage={errors.compassNorthTop?.message}
|
||||
>
|
||||
<Controller
|
||||
name="compassNorthTop"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Select>
|
||||
<Controller
|
||||
name="compassNorthTop"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Compass North Top"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { LoRaValidation } from "@app/validation/config/lora.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -14,7 +16,6 @@ import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
export const LoRa = (): JSX.Element => {
|
||||
const { config, connection } = useDevice();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [usePreset, setUsePreset] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -27,6 +28,12 @@ export const LoRa = (): JSX.Element => {
|
||||
resolver: classValidatorResolver(LoRaValidation),
|
||||
});
|
||||
|
||||
const usePreset = useWatch({
|
||||
control,
|
||||
name: "usePreset",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(config.lora);
|
||||
}, [reset, config.lora]);
|
||||
@@ -49,115 +56,109 @@ export const LoRa = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Use Preset"
|
||||
description="Description"
|
||||
isInvalid={!!errors.txDisabled?.message}
|
||||
validationMessage={errors.txDisabled?.message}
|
||||
>
|
||||
<Switch
|
||||
height={24}
|
||||
marginLeft="auto"
|
||||
checked={usePreset}
|
||||
onChange={(e) => setUsePreset(e.target.checked)}
|
||||
/>
|
||||
</FormField>
|
||||
<SelectField
|
||||
display={usePreset ? "block" : "none"}
|
||||
<Form
|
||||
title="LoRa Config"
|
||||
breadcrumbs={["Config", "LoRa"]}
|
||||
reset={() => reset(config.lora)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="usePreset"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Use Preset"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
label="Preset"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.modemPreset?.message}
|
||||
validationMessage={errors.modemPreset?.message}
|
||||
disabled={!usePreset}
|
||||
{...register("modemPreset", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
|
||||
</SelectField>
|
||||
</Select>
|
||||
|
||||
<TextInputField
|
||||
display={usePreset ? "none" : "block"}
|
||||
<Input
|
||||
label="Bandwidth"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
hint="MHz"
|
||||
isInvalid={!!errors.bandwidth?.message}
|
||||
validationMessage={errors.bandwidth?.message}
|
||||
suffix="MHz"
|
||||
error={errors.bandwidth?.message}
|
||||
{...register("bandwidth", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
disabled={usePreset}
|
||||
/>
|
||||
<TextInputField
|
||||
display={usePreset ? "none" : "block"}
|
||||
<Input
|
||||
label="Spread Factor"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
hint="CPS"
|
||||
isInvalid={!!errors.spreadFactor?.message}
|
||||
validationMessage={errors.spreadFactor?.message}
|
||||
suffix="CPS"
|
||||
error={errors.spreadFactor?.message}
|
||||
{...register("spreadFactor", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
disabled={usePreset}
|
||||
/>
|
||||
<TextInputField
|
||||
display={usePreset ? "none" : "block"}
|
||||
<Input
|
||||
label="Coding Rate"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
isInvalid={!!errors.codingRate?.message}
|
||||
validationMessage={errors.codingRate?.message}
|
||||
error={errors.codingRate?.message}
|
||||
{...register("codingRate", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
disabled={usePreset}
|
||||
/>
|
||||
<TextInputField
|
||||
label="Transmit Power"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
isInvalid={!!errors.txPower?.message}
|
||||
validationMessage={errors.txPower?.message}
|
||||
{...register("txPower", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
label="Hop Limit"
|
||||
description="This is a description."
|
||||
hint="Hops"
|
||||
type="number"
|
||||
isInvalid={!!errors.hopLimit?.message}
|
||||
validationMessage={errors.hopLimit?.message}
|
||||
{...register("hopLimit", { valueAsNumber: true })}
|
||||
/>
|
||||
<FormField
|
||||
label="Transmit Disabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.txDisabled?.message}
|
||||
validationMessage={errors.txDisabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="txDisabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Frequency Offset"
|
||||
description="This is a description."
|
||||
hint="Hz"
|
||||
suffix="Hz"
|
||||
type="number"
|
||||
isInvalid={!!errors.frequencyOffset?.message}
|
||||
validationMessage={errors.frequencyOffset?.message}
|
||||
error={errors.frequencyOffset?.message}
|
||||
{...register("frequencyOffset", { valueAsNumber: true })}
|
||||
/>
|
||||
<SelectField
|
||||
<Select
|
||||
label="Region"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.region?.message}
|
||||
validationMessage={errors.region?.message}
|
||||
{...register("region", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
|
||||
</SelectField>
|
||||
</Select>
|
||||
<Input
|
||||
label="Hop Limit"
|
||||
description="This is a description."
|
||||
suffix="Hops"
|
||||
type="number"
|
||||
error={errors.hopLimit?.message}
|
||||
{...register("hopLimit", { valueAsNumber: true })}
|
||||
/>
|
||||
<Controller
|
||||
name="txEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Transmit Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="Transmit Power"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
error={errors.txPower?.message}
|
||||
{...register("txPower", { valueAsNumber: true })}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
SelectField,
|
||||
Switch,
|
||||
TextInputField,
|
||||
toaster,
|
||||
} from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { renderOptions } from "@app/core/utils/selectEnumOptions.js";
|
||||
import { NetworkValidation } from "@app/validation/config/network.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
@@ -51,7 +47,7 @@ export const Network = (): JSX.Element => {
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
toaster.success("Successfully updated Network config");
|
||||
// toaster.success("Successfully updated Network config");
|
||||
reset({ ...data });
|
||||
setLoading(false);
|
||||
await Promise.resolve();
|
||||
@@ -59,48 +55,53 @@ export const Network = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="WiFi Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.wifiEnabled?.message}
|
||||
validationMessage={errors.wifiEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="wifiEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<SelectField
|
||||
<Form
|
||||
title="Network Config"
|
||||
breadcrumbs={["Config", "Network"]}
|
||||
reset={() => reset(config.network)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="wifiEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="WiFi Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
label="WiFi Mode"
|
||||
description="This is a description."
|
||||
disabled={!wifiEnabled}
|
||||
{...register("wifiMode", { valueAsNumber: true })}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_NetworkConfig_WiFiMode)}
|
||||
</SelectField>
|
||||
<TextInputField
|
||||
</Select>
|
||||
<Input
|
||||
label="SSID"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.wifiSsid?.message}
|
||||
validationMessage={errors.wifiSsid?.message}
|
||||
error={errors.wifiSsid?.message}
|
||||
disabled={!wifiEnabled}
|
||||
{...register("wifiSsid")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="PSK"
|
||||
type="password"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.wifiPsk?.message}
|
||||
validationMessage={errors.wifiPsk?.message}
|
||||
error={errors.wifiPsk?.message}
|
||||
disabled={!wifiEnabled}
|
||||
{...register("wifiPsk")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="NTP Server"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.ntpServer?.message}
|
||||
validationMessage={errors.ntpServer?.message}
|
||||
error={errors.ntpServer?.message}
|
||||
{...register("ntpServer")}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormField,
|
||||
SelectMenu,
|
||||
Switch,
|
||||
TextInputField,
|
||||
} from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { PositionValidation } from "@app/validation/config/position.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { bitwiseDecode } from "@core/utils/bitwise";
|
||||
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const Position = (): JSX.Element => {
|
||||
const { config, connection } = useDevice();
|
||||
@@ -29,15 +22,6 @@ export const Position = (): JSX.Element => {
|
||||
} = useForm<PositionValidation>({
|
||||
defaultValues: config.position,
|
||||
resolver: classValidatorResolver(PositionValidation),
|
||||
// defaultValues: {
|
||||
// ...preferences,
|
||||
// positionBroadcastSecs:
|
||||
// preferences.positionBroadcastSecs === 0
|
||||
// ? preferences.role === Protobuf.Role.Router
|
||||
// ? 43200
|
||||
// : 900
|
||||
// : preferences.positionBroadcastSecs,
|
||||
// },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,76 +46,74 @@ export const Position = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<TextInputField
|
||||
hint="Seconds"
|
||||
<Form
|
||||
title="Position Config"
|
||||
breadcrumbs={["Config", "Position"]}
|
||||
reset={() => reset(config.position)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
suffix="Seconds"
|
||||
label="Broadcast Interval"
|
||||
description="This is a description."
|
||||
type="number"
|
||||
isInvalid={!!errors.positionBroadcastSecs?.message}
|
||||
validationMessage={errors.positionBroadcastSecs?.message}
|
||||
error={errors.positionBroadcastSecs?.message}
|
||||
{...register("positionBroadcastSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<FormField
|
||||
label="Disable Smart Position"
|
||||
description="Description"
|
||||
isInvalid={!!errors.positionBroadcastSmartDisabled?.message}
|
||||
validationMessage={errors.positionBroadcastSmartDisabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="positionBroadcastSmartDisabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Use Fixed Position"
|
||||
description="Description"
|
||||
isInvalid={!!errors.fixedPosition?.message}
|
||||
validationMessage={errors.fixedPosition?.message}
|
||||
>
|
||||
<Controller
|
||||
name="fixedPosition"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Disable GPS"
|
||||
description="Description"
|
||||
isInvalid={!!errors.gpsDisabled?.message}
|
||||
validationMessage={errors.gpsDisabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="gpsDisabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
hint="Seconds"
|
||||
<Controller
|
||||
name="positionBroadcastSmartEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Enable Smart Position"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="fixedPosition"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Use Fixed Position"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="gpsEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="GPS Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
suffix="Seconds"
|
||||
label="GPS Update Interval"
|
||||
description="This is a description."
|
||||
type="number"
|
||||
isInvalid={!!errors.gpsUpdateInterval?.message}
|
||||
validationMessage={errors.gpsUpdateInterval?.message}
|
||||
error={errors.gpsUpdateInterval?.message}
|
||||
{...register("gpsUpdateInterval", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Last GPS Attempt"
|
||||
description="This is a description."
|
||||
type="number"
|
||||
isInvalid={!!errors.gpsAttemptTime?.message}
|
||||
validationMessage={errors.gpsAttemptTime?.message}
|
||||
error={errors.gpsAttemptTime?.message}
|
||||
{...register("gpsAttemptTime", { valueAsNumber: true })}
|
||||
/>
|
||||
<Controller
|
||||
{/* <Controller
|
||||
name="positionFlags"
|
||||
control={control}
|
||||
render={({ field, fieldState }): JSX.Element => {
|
||||
@@ -218,7 +200,7 @@ export const Position = (): JSX.Element => {
|
||||
</FormField>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { PowerValidation } from "@app/validation/config/power.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -44,81 +45,79 @@ export const Power = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Power Config"
|
||||
breadcrumbs={["Config", "Power"]}
|
||||
reset={() => reset(config.power)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label="Shutdown on battery delay"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.onBatteryShutdownAfterSecs?.message}
|
||||
validationMessage={errors.onBatteryShutdownAfterSecs?.message}
|
||||
error={errors.onBatteryShutdownAfterSecs?.message}
|
||||
{...register("onBatteryShutdownAfterSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<FormField
|
||||
label="Power Saving"
|
||||
description="Description"
|
||||
isInvalid={!!errors.isPowerSaving?.message}
|
||||
validationMessage={errors.isPowerSaving?.message}
|
||||
>
|
||||
<Controller
|
||||
name="isPowerSaving"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Controller
|
||||
name="isPowerSaving"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Power Saving"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="ADC Multiplier Override ratio"
|
||||
description="This is a description."
|
||||
type="number"
|
||||
isInvalid={!!errors.adcMultiplierOverride?.message}
|
||||
validationMessage={errors.adcMultiplierOverride?.message}
|
||||
error={errors.adcMultiplierOverride?.message}
|
||||
{...register("adcMultiplierOverride", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Minimum Wake Time"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.minWakeSecs?.message}
|
||||
validationMessage={errors.minWakeSecs?.message}
|
||||
error={errors.minWakeSecs?.message}
|
||||
{...register("minWakeSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Mesh SDS Timeout"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.meshSdsTimeoutSecs?.message}
|
||||
validationMessage={errors.meshSdsTimeoutSecs?.message}
|
||||
error={errors.meshSdsTimeoutSecs?.message}
|
||||
{...register("meshSdsTimeoutSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="SDS"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.sdsSecs?.message}
|
||||
validationMessage={errors.sdsSecs?.message}
|
||||
error={errors.sdsSecs?.message}
|
||||
{...register("sdsSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="LS"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.lsSecs?.message}
|
||||
validationMessage={errors.lsSecs?.message}
|
||||
error={errors.lsSecs?.message}
|
||||
{...register("lsSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Wait Bluetooth"
|
||||
description="This is a description."
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
isInvalid={!!errors.waitBluetoothSecs?.message}
|
||||
validationMessage={errors.waitBluetoothSecs?.message}
|
||||
error={errors.waitBluetoothSecs?.message}
|
||||
{...register("waitBluetoothSecs", { valueAsNumber: true })}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { UserValidation } from "@app/validation/config/user.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -50,27 +52,39 @@ export const User = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="User Config"
|
||||
breadcrumbs={["Config", "User"]}
|
||||
reset={() => {
|
||||
reset({
|
||||
longName: myNode?.data.user?.longName,
|
||||
shortName: myNode?.data.user?.shortName,
|
||||
isLicensed: myNode?.data.user?.isLicensed,
|
||||
});
|
||||
}}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Input
|
||||
label="Device ID"
|
||||
description="Preset unique identifier for this device."
|
||||
isInvalid={!!errors.id?.message}
|
||||
validationMessage={errors.id?.message}
|
||||
error={errors.id?.message}
|
||||
{...register("id")}
|
||||
readOnly
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Device Name"
|
||||
description="Personalised name for this device."
|
||||
{...register("longName")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Short Name"
|
||||
description="This is a description."
|
||||
maxLength={3}
|
||||
{...register("shortName")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Mac Address"
|
||||
description="This is a description."
|
||||
disabled
|
||||
@@ -81,28 +95,26 @@ export const User = (): JSX.Element => {
|
||||
?.join(":") ?? ""
|
||||
}
|
||||
/>
|
||||
<SelectField
|
||||
<Select
|
||||
label="Hardware"
|
||||
description="This is a description."
|
||||
disabled
|
||||
value={myNode?.data.user?.hwModel}
|
||||
>
|
||||
{renderOptions(Protobuf.HardwareModel)}
|
||||
</SelectField>
|
||||
<FormField
|
||||
label="Licenced Operator?"
|
||||
description="Description"
|
||||
isInvalid={!!errors.isLicensed?.message}
|
||||
validationMessage={errors.isLicensed?.message}
|
||||
>
|
||||
<Controller
|
||||
name="isLicensed"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Select>
|
||||
<Controller
|
||||
name="isLicensed"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Licenced Operator?"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button, majorScale, Pane } from "evergreen-ui";
|
||||
import { FiPlusCircle } from "react-icons/fi";
|
||||
|
||||
import type { CloseProps } from "@components/SlideSheets/NewDevice.js";
|
||||
import { Button } from "@components/Button.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 { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Constants, IBLEConnection } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const BLE = ({ close }: CloseProps): JSX.Element => {
|
||||
export const BLE = (): JSX.Element => {
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
@@ -34,30 +32,25 @@ export const BLE = ({ close }: CloseProps): JSX.Element => {
|
||||
});
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Pane
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
padding={majorScale(2)}
|
||||
gap={majorScale(2)}
|
||||
>
|
||||
{bleDevices.map((device, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
void onConnect(device);
|
||||
}}
|
||||
>
|
||||
{device.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col p-4 gap-2 w-full">
|
||||
<div className="flex gap-2 flex-col h-48 overflow-y-auto">
|
||||
{bleDevices.map((device, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void onConnect(device);
|
||||
}}
|
||||
>
|
||||
{device.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
gap={majorScale(1)}
|
||||
iconBefore={<PlusCircleIcon className="w-4" />}
|
||||
onClick={() => {
|
||||
void navigator.bluetooth
|
||||
.requestDevice({
|
||||
@@ -72,8 +65,7 @@ export const BLE = ({ close }: CloseProps): JSX.Element => {
|
||||
}}
|
||||
>
|
||||
New device
|
||||
<FiPlusCircle />
|
||||
</Button>
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,18 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormField,
|
||||
majorScale,
|
||||
Pane,
|
||||
Switch,
|
||||
TextInputField,
|
||||
} from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FiPlusCircle } from "react-icons/fi";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { Button } from "@components/Button.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 { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { IHTTPConnection } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export interface HTTPProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const HTTP = ({ close }: HTTPProps): JSX.Element => {
|
||||
export const HTTP = (): JSX.Element => {
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
const { register, handleSubmit, control } = useForm<{
|
||||
@@ -34,6 +25,12 @@ export const HTTP = ({ close }: HTTPProps): JSX.Element => {
|
||||
},
|
||||
});
|
||||
|
||||
const TLSEnabled = useWatch({
|
||||
control,
|
||||
name: "tls",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
const id = randId();
|
||||
const device = addDevice(id);
|
||||
@@ -47,42 +44,34 @@ export const HTTP = ({ close }: HTTPProps): JSX.Element => {
|
||||
});
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
close();
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
<form onSubmit={onSubmit}>
|
||||
<Pane
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
padding={majorScale(2)}
|
||||
gap={majorScale(2)}
|
||||
>
|
||||
<TextInputField
|
||||
<form className="w-full p-4 gap-2 flex flex-col" onSubmit={onSubmit}>
|
||||
<div className="h-48 flex flex-col gap-2">
|
||||
<Input
|
||||
label="IP Address/Hostname"
|
||||
prefix={TLSEnabled ? "https://" : "http://"}
|
||||
placeholder="000.000.000.000 / meshtastic.local"
|
||||
{...register("ip")}
|
||||
/>
|
||||
<FormField label="Use TLS">
|
||||
<Controller
|
||||
name="tls"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch
|
||||
height={24}
|
||||
marginLeft="auto"
|
||||
checked={value}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button appearance="primary" gap={majorScale(1)} type="submit">
|
||||
Connect
|
||||
<FiPlusCircle />
|
||||
</Button>
|
||||
</Pane>
|
||||
<Controller
|
||||
name="tls"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Use TLS"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button iconBefore={<PlusCircleIcon className="w-4" />} type="submit">
|
||||
Connect
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,12 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button, majorScale, Pane } from "evergreen-ui";
|
||||
import { FiPlusCircle } from "react-icons/fi";
|
||||
|
||||
import type { CloseProps } from "@components/SlideSheets/NewDevice.js";
|
||||
import { Button } from "@components/Button.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 { PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { ISerialConnection } from "@meshtastic/meshtasticjs";
|
||||
|
||||
interface USBID {
|
||||
@@ -16,7 +14,7 @@ interface USBID {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const Serial = ({ close }: CloseProps): JSX.Element => {
|
||||
export const Serial = (): JSX.Element => {
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
@@ -45,50 +43,25 @@ export const Serial = ({ close }: CloseProps): JSX.Element => {
|
||||
});
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
close();
|
||||
};
|
||||
|
||||
const VID: USBID[] = [
|
||||
{
|
||||
id: 9114,
|
||||
name: "TBA",
|
||||
},
|
||||
];
|
||||
|
||||
const PID: USBID[] = [
|
||||
{
|
||||
id: 32809,
|
||||
name: "TBA",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
padding={majorScale(2)}
|
||||
gap={majorScale(2)}
|
||||
>
|
||||
{serialPorts.map((port, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
gap={5}
|
||||
onClick={() => {
|
||||
void onConnect(port);
|
||||
}}
|
||||
>
|
||||
{VID.find((id) => id.id === port.getInfo().usbVendorId ?? 0)?.name ??
|
||||
"Unknown"}{" "}
|
||||
-{" "}
|
||||
{PID.find((id) => id.id === port.getInfo().usbProductId ?? 0)?.name ??
|
||||
"Unknown"}
|
||||
<FiPlusCircle />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col p-4 gap-2 w-full">
|
||||
<div className="flex gap-2 flex-col h-48 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void onConnect(port);
|
||||
}}
|
||||
>
|
||||
{port.getInfo().usbVendorId} - {port.getInfo().usbProductId}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
gap={majorScale(1)}
|
||||
iconBefore={<PlusCircleIcon className="w-4" />}
|
||||
onClick={() => {
|
||||
void navigator.serial.requestPort().then((port) => {
|
||||
setSerialPorts(serialPorts.concat(port));
|
||||
@@ -96,8 +69,7 @@ export const Serial = ({ close }: CloseProps): JSX.Element => {
|
||||
}}
|
||||
>
|
||||
New device
|
||||
<FiPlusCircle />
|
||||
</Button>
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
src/components/PageComponents/Messages/ChannelChat.tsx
Normal file
85
src/components/PageComponents/Messages/ChannelChat.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type React from "react";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { IconButton } from "@app/components/IconButton.js";
|
||||
import { Message } from "@components/PageComponents/Messages/Message.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import type { Channel } from "@core/stores/deviceStore.js";
|
||||
import { MapPinIcon, PaperAirplaneIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
export const ChannelChat = ({ channel }: ChannelChatProps): JSX.Element => {
|
||||
const { nodes, connection, ackMessage } = useDevice();
|
||||
const [currentMessage, setCurrentMessage] = useState("");
|
||||
|
||||
const sendMessage = (): void => {
|
||||
void connection?.sendText(
|
||||
currentMessage,
|
||||
undefined,
|
||||
true,
|
||||
channel.config.index,
|
||||
(id) => {
|
||||
ackMessage(channel.config.index, id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
setCurrentMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow">
|
||||
<div className="flex flex-col flex-grow">
|
||||
{channel.messages.map((message, index) => (
|
||||
<Message
|
||||
key={index}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0
|
||||
? false
|
||||
: channel.messages[index - 1].packet.from ===
|
||||
message.packet.from
|
||||
}
|
||||
sender={
|
||||
nodes.find((node) => node.data.num === message.packet.from)?.data
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e): void => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-grow gap-2">
|
||||
<span className="w-full">
|
||||
<Input
|
||||
minLength={2}
|
||||
label=""
|
||||
placeholder="Enter Message"
|
||||
value={currentMessage}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
icon={<PaperAirplaneIcon className="h-4 text-slate-500" />}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
icon={<MapPinIcon className="h-4 text-slate-500" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
89
src/components/PageComponents/Messages/Message.tsx
Normal file
89
src/components/PageComponents/Messages/Message.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type React from "react";
|
||||
|
||||
import { WaypointMessage } from "@components/PageComponents/Messages/WaypointMessage.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import type { AllMessageTypes } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
EllipsisHorizontalCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: AllMessageTypes;
|
||||
sender?: Protobuf.NodeInfo;
|
||||
}
|
||||
|
||||
export const Message = ({
|
||||
lastMsgSameUser,
|
||||
message,
|
||||
sender,
|
||||
}: MessageProps): JSX.Element => {
|
||||
const { setPeerInfoOpen, setActivePeer } = useDevice();
|
||||
|
||||
const openPeer = (): void => {
|
||||
setActivePeer(message.packet.from);
|
||||
setPeerInfoOpen(true);
|
||||
};
|
||||
|
||||
return lastMsgSameUser ? (
|
||||
<div className="flex ml-4">
|
||||
{message.ack ? (
|
||||
<CheckCircleIcon className="my-auto text-slate-200 h-4" />
|
||||
) : (
|
||||
<EllipsisHorizontalCircleIcon className="my-auto text-slate-200 h-4" />
|
||||
)}
|
||||
{"waypointID" in message ? (
|
||||
<WaypointMessage waypointID={message.waypointID} />
|
||||
) : (
|
||||
<span
|
||||
className={`ml-4 pl-2 border-l-2 border-l-slate-200 ${
|
||||
message.ack ? "text-black" : "text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 gap-2 mt-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="cursor-pointer w-6" onClick={openPeer}>
|
||||
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
|
||||
</div>
|
||||
<span
|
||||
className="cursor-pointer font-medium text-slate-700"
|
||||
onClick={openPeer}
|
||||
>
|
||||
{sender?.user?.longName ?? "UNK"}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{new Date(message.packet.rxTime).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{message.ack ? (
|
||||
<CheckCircleIcon className="my-auto text-slate-200 h-4" />
|
||||
) : (
|
||||
<EllipsisHorizontalCircleIcon className="my-auto text-slate-200 h-4" />
|
||||
)}
|
||||
{"waypointID" in message ? (
|
||||
<WaypointMessage waypointID={message.waypointID} />
|
||||
) : (
|
||||
<span
|
||||
className={`ml-4 pl-2 border-l-2 border-l-slate-200 ${
|
||||
message.ack ? "text-black" : "text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,8 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
majorScale,
|
||||
Pane,
|
||||
SelectField,
|
||||
TextInputField,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Button } from "@components/Button.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { renderOptions } from "@core/utils/selectEnumOptions.js";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
@@ -22,20 +17,19 @@ export const NewLocationMessage = (): JSX.Element => {
|
||||
const { connection } = useDevice();
|
||||
|
||||
return (
|
||||
<Pane width={240} margin={majorScale(2)}>
|
||||
<div className="w-96 m-4">
|
||||
<form
|
||||
onSubmit={(e): void => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<TextInputField label="Name" />
|
||||
<TextInputField label="Description" />
|
||||
<SelectField label="Type" value={LocationType.MGRS}>
|
||||
<Input label="Name" />
|
||||
<Input label="Description" />
|
||||
<Select label="Type" value={LocationType.MGRS}>
|
||||
{renderOptions(LocationType)}
|
||||
</SelectField>
|
||||
<TextInputField label="Coordinates" />
|
||||
</Select>
|
||||
<Input label="Coordinates" />
|
||||
<Button
|
||||
width="100%"
|
||||
onClick={() => {
|
||||
void connection?.sendWaypoint(
|
||||
Protobuf.Waypoint.create({
|
||||
@@ -50,6 +44,6 @@ export const NewLocationMessage = (): JSX.Element => {
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
src/components/PageComponents/Messages/WaypointMessage.tsx
Normal file
33
src/components/PageComponents/Messages/WaypointMessage.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
import { toMGRS } from "@core/utils/toMGRS.js";
|
||||
import { MapPinIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface WaypointMessageProps {
|
||||
waypointID: number;
|
||||
}
|
||||
|
||||
export const WaypointMessage = ({
|
||||
waypointID,
|
||||
}: WaypointMessageProps): JSX.Element => {
|
||||
const { waypoints } = useDevice();
|
||||
const waypoint = waypoints.find((wp) => wp.id === waypointID);
|
||||
|
||||
return (
|
||||
<div className="ml-4 pl-2 border-l-slate-200 border-l-2">
|
||||
<div className="gap-2 flex rounded-md p-2 shadow-md shadow-orange-300">
|
||||
<MapPinIcon className="m-auto w-6 text-slate-600" />
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<div className="font-bold">{waypoint?.name}</div>
|
||||
<span className="text-sm font-mono text-slate-500">
|
||||
{toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm">{waypoint?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, SelectField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Select } from "@app/components/form/Select.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -52,57 +54,60 @@ export const CannedMessage = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Rotary Encoder #1 Enabled"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.rotary1Enabled?.message}
|
||||
validationMessage={errors.rotary1Enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="rotary1Enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Canned Message Config"
|
||||
breadcrumbs={["Module Config", "Canned Message"]}
|
||||
reset={() => reset(moduleConfig.cannedMessage)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="rotary1Enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Rotary Encoder #1 Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="Encoder Pin A"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
disabled={moduleEnabled}
|
||||
{...register("inputbrokerPinA", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Encoder Pin B"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
disabled={moduleEnabled}
|
||||
{...register("inputbrokerPinB", { valueAsNumber: true })}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="Endoer Pin Press"
|
||||
description="Max transmit power in dBm"
|
||||
type="number"
|
||||
disabled={moduleEnabled}
|
||||
{...register("inputbrokerPinPress", { valueAsNumber: true })}
|
||||
/>
|
||||
<SelectField
|
||||
<Select
|
||||
label="Clockwise event"
|
||||
description="This is a description."
|
||||
disabled={moduleEnabled}
|
||||
@@ -111,8 +116,8 @@ export const CannedMessage = (): JSX.Element => {
|
||||
{renderOptions(
|
||||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
|
||||
)}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
</Select>
|
||||
<Select
|
||||
label="Counter Clockwise event"
|
||||
description="This is a description."
|
||||
disabled={moduleEnabled}
|
||||
@@ -121,8 +126,8 @@ export const CannedMessage = (): JSX.Element => {
|
||||
{renderOptions(
|
||||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
|
||||
)}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
</Select>
|
||||
<Select
|
||||
label="Press event"
|
||||
description="This is a description."
|
||||
disabled={moduleEnabled}
|
||||
@@ -131,41 +136,37 @@ export const CannedMessage = (): JSX.Element => {
|
||||
{renderOptions(
|
||||
Protobuf.ModuleConfig_CannedMessageConfig_InputEventChar
|
||||
)}
|
||||
</SelectField>
|
||||
<FormField
|
||||
label="Up Down enabled"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.updown1Enabled?.message}
|
||||
validationMessage={errors.updown1Enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="updown1Enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
</Select>
|
||||
<Controller
|
||||
name="updown1Enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Up Down enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="Allow Input Source"
|
||||
description="Max transmit power in dBm"
|
||||
disabled={moduleEnabled}
|
||||
{...register("allowInputSource")}
|
||||
/>
|
||||
<FormField
|
||||
label="Send Bell"
|
||||
description="This is a description."
|
||||
isInvalid={!!errors.sendBell?.message}
|
||||
validationMessage={errors.sendBell?.message}
|
||||
>
|
||||
<Controller
|
||||
name="sendBell"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="sendBell"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Send Bell"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -50,32 +51,37 @@ export const ExternalNotification = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="External Notification Config"
|
||||
breadcrumbs={["Module Config", "External Notification"]}
|
||||
reset={() => reset(moduleConfig.externalNotification)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Output MS"
|
||||
description="Max transmit power in dBm"
|
||||
hint="ms"
|
||||
suffix="ms"
|
||||
disabled={!moduleEnabled}
|
||||
{...register("outputMs", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="Output"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -84,51 +90,42 @@ export const ExternalNotification = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormField
|
||||
label="Active"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.active?.message}
|
||||
validationMessage={errors.active?.message}
|
||||
>
|
||||
<Controller
|
||||
name="active"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Message"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.alertMessage?.message}
|
||||
validationMessage={errors.alertMessage?.message}
|
||||
>
|
||||
<Controller
|
||||
name="alertMessage"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Bell"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.alertBell?.message}
|
||||
validationMessage={errors.alertBell?.message}
|
||||
>
|
||||
<Controller
|
||||
name="alertBell"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="active"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Active"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="alertMessage"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Message"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="alertBell"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Bell"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { MQTTValidation } from "@app/validation/moduleConfig/mqtt.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -50,34 +51,39 @@ export const MQTT = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="MQTT Config"
|
||||
breadcrumbs={["Module Config", "MQTT"]}
|
||||
reset={() => reset(moduleConfig.mqtt)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="MQTT Server Address"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
{...register("address")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="MQTT Username"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
{...register("username")}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
label="MQTT Password"
|
||||
description="Description"
|
||||
type="password"
|
||||
@@ -85,36 +91,30 @@ export const MQTT = (): JSX.Element => {
|
||||
disabled={!moduleEnabled}
|
||||
{...register("password")}
|
||||
/>
|
||||
<FormField
|
||||
label="Encryption Enabled"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.encryptionEnabled?.message}
|
||||
validationMessage={errors.encryptionEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="encryptionEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="JSON Output Enabled"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.jsonEnabled?.message}
|
||||
validationMessage={errors.jsonEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="jsonEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="encryptionEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Encryption Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="jsonEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="JSON Output Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -51,46 +52,48 @@ export const RangeTest = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Range Test Config"
|
||||
breadcrumbs={["Module Config", "Range Test"]}
|
||||
reset={() => reset(moduleConfig.rangeTest)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Message Interval"
|
||||
description="Max transmit power in dBm"
|
||||
disabled={!moduleEnabled}
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
{...register("sender", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormField
|
||||
label="Save CSV to storage"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.save?.message}
|
||||
validationMessage={errors.save?.message}
|
||||
>
|
||||
<Controller
|
||||
name="save"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="save"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Save CSV to storage"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { SerialValidation } from "@app/validation/moduleConfig/serial.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -51,37 +52,39 @@ export const Serial = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Echo"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.echo?.message}
|
||||
validationMessage={errors.echo?.message}
|
||||
>
|
||||
<Controller
|
||||
name="echo"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Serial Config"
|
||||
breadcrumbs={["Module Config", "Serial"]}
|
||||
reset={() => reset(moduleConfig.serial)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="echo"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Echo"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="RX"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -90,7 +93,7 @@ export const Serial = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="TX Pin"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -99,7 +102,7 @@ export const Serial = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="Baud Rate"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -108,7 +111,7 @@ export const Serial = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="Timeout"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -117,7 +120,7 @@ export const Serial = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="Mode"
|
||||
description="Max transmit power in dBm"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -51,47 +52,49 @@ export const StoreForward = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Heartbeat Enabled"
|
||||
description="Description"
|
||||
disabled={!moduleEnabled}
|
||||
isInvalid={!!errors.heartbeat?.message}
|
||||
validationMessage={errors.heartbeat?.message}
|
||||
>
|
||||
<Controller
|
||||
name="heartbeat"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Store & Forward Config"
|
||||
breadcrumbs={["Module Config", "Store & Forward"]}
|
||||
reset={() => reset(moduleConfig.storeForward)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="heartbeat"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Heartbeat Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Number of records"
|
||||
description="Max transmit power in dBm"
|
||||
hint="Records"
|
||||
suffix="Records"
|
||||
disabled={!moduleEnabled}
|
||||
{...register("records", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="History return max"
|
||||
description="Max transmit power in dBm"
|
||||
@@ -100,7 +103,7 @@ export const StoreForward = (): JSX.Element => {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<TextInputField
|
||||
<Input
|
||||
type="number"
|
||||
label="History return window"
|
||||
description="Max transmit power in dBm"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { FormField, Switch, TextInputField } from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Input } from "@app/components/form/Input.js";
|
||||
import { Toggle } from "@app/components/form/Toggle.js";
|
||||
import { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.js";
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -44,58 +45,59 @@ export const Telemetry = (): JSX.Element => {
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
<FormField
|
||||
label="Measurement Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.environmentMeasurementEnabled?.message}
|
||||
validationMessage={errors.environmentMeasurementEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="environmentMeasurementEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Displayed on Screen"
|
||||
description="Description"
|
||||
isInvalid={!!errors.environmentScreenEnabled?.message}
|
||||
validationMessage={errors.environmentScreenEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="environmentScreenEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
<Form
|
||||
title="Telemetry Config"
|
||||
breadcrumbs={["Module Config", "Telemetry"]}
|
||||
reset={() => reset(moduleConfig.telemetry)}
|
||||
loading={loading}
|
||||
dirty={isDirty}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Controller
|
||||
name="environmentMeasurementEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Module Enabled"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="environmentScreenEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Displayed on Screen"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
label="Update Interval"
|
||||
description="Max transmit power in dBm"
|
||||
hint="Seconds"
|
||||
suffix="Seconds"
|
||||
type="number"
|
||||
{...register("environmentUpdateInterval", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<FormField
|
||||
label="Display Farenheit"
|
||||
description="Description"
|
||||
isInvalid={!!errors.environmentDisplayFahrenheit?.message}
|
||||
validationMessage={errors.environmentDisplayFahrenheit?.message}
|
||||
>
|
||||
<Controller
|
||||
name="environmentDisplayFahrenheit"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Controller
|
||||
name="environmentDisplayFahrenheit"
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Toggle
|
||||
label="Display Farenheit"
|
||||
description="Description"
|
||||
checked={value}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
77
src/components/PageNav.tsx
Normal file
77
src/components/PageNav.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
import type { Page } from "@app/core/stores/deviceStore.js";
|
||||
import {
|
||||
BeakerIcon,
|
||||
Cog8ToothIcon,
|
||||
IdentificationIcon,
|
||||
InboxIcon,
|
||||
MapIcon,
|
||||
Square3Stack3DIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export const PageNav = (): JSX.Element => {
|
||||
const { activePage, setActivePage } = useDevice();
|
||||
|
||||
interface NavLink {
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
const pages: NavLink[] = [
|
||||
{
|
||||
name: "Messages",
|
||||
icon: <InboxIcon />,
|
||||
page: "messages",
|
||||
},
|
||||
{
|
||||
name: "Map",
|
||||
icon: <MapIcon />,
|
||||
page: "map",
|
||||
},
|
||||
{
|
||||
name: "Extensions",
|
||||
icon: <BeakerIcon />,
|
||||
page: "extensions",
|
||||
},
|
||||
{
|
||||
name: "Config",
|
||||
icon: <Cog8ToothIcon />,
|
||||
page: "config",
|
||||
},
|
||||
{
|
||||
name: "Channels",
|
||||
icon: <Square3Stack3DIcon />,
|
||||
page: "channels",
|
||||
},
|
||||
{
|
||||
name: "Info",
|
||||
icon: <IdentificationIcon />,
|
||||
page: "info",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex bg-slate-50 w-12 items-center whitespace-nowrap py-4 text-sm [writing-mode:vertical-rl] h-full border-r border-slate-200 flex-shrink-0">
|
||||
<span className="mt-6 flex gap-4 font-bold text-slate-500">
|
||||
{pages.map((Link) => (
|
||||
<div
|
||||
key={Link.name}
|
||||
onClick={() => {
|
||||
setActivePage(Link.page);
|
||||
}}
|
||||
className={`w-8 h-8 p-1 border-2 rounded-md hover:border-orange-300 cursor-pointer ${
|
||||
Link.page === activePage
|
||||
? "border-orange-400"
|
||||
: "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{Link.icon}
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
majorScale,
|
||||
Pane,
|
||||
ResetIcon,
|
||||
Spinner,
|
||||
StatusIndicator,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
|
||||
export const Progress = (): JSX.Element => {
|
||||
const {
|
||||
hardware,
|
||||
channels,
|
||||
config,
|
||||
moduleConfig,
|
||||
setReady,
|
||||
nodes,
|
||||
connection,
|
||||
} = useDevice();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hardware.myNodeNum !== 0 &&
|
||||
Object.keys(config).length === 7 &&
|
||||
Object.keys(moduleConfig).length === 7 &&
|
||||
channels.length === hardware.maxChannels
|
||||
) {
|
||||
setReady(true);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
moduleConfig,
|
||||
channels,
|
||||
hardware.maxChannels,
|
||||
hardware.myNodeNum,
|
||||
setReady,
|
||||
]);
|
||||
return (
|
||||
<Pane
|
||||
display="flex"
|
||||
flexGrow={1}
|
||||
margin={majorScale(3)}
|
||||
borderRadius={majorScale(1)}
|
||||
elevation={1}
|
||||
background="white"
|
||||
>
|
||||
<Pane display="flex" margin="auto" gap={majorScale(6)}>
|
||||
<Pane
|
||||
marginY="auto"
|
||||
display="flex"
|
||||
height="72px"
|
||||
width="72px"
|
||||
minWidth="72px"
|
||||
backgroundColor="#F8E3DA"
|
||||
borderRadius="50%"
|
||||
>
|
||||
<Spinner height="32px" width="32px" margin="auto" />
|
||||
</Pane>
|
||||
<Pane>
|
||||
<Pane display="flex" flexDirection="column">
|
||||
<StatusIndicator
|
||||
color={hardware.myNodeNum !== 0 ? "success" : "disabled"}
|
||||
>
|
||||
Device Info
|
||||
</StatusIndicator>
|
||||
<StatusIndicator color={nodes.length ? "success" : "disabled"}>
|
||||
Peers ({nodes.length})
|
||||
</StatusIndicator>
|
||||
<StatusIndicator
|
||||
color={Object.keys(config).length === 7 ? "success" : "disabled"}
|
||||
>
|
||||
Device Config {`(${Object.keys(config).length - 1} / 6)`}
|
||||
</StatusIndicator>
|
||||
<StatusIndicator
|
||||
color={
|
||||
Object.keys(moduleConfig).length === 7 ? "success" : "disabled"
|
||||
}
|
||||
>
|
||||
Module Config {`(${Object.keys(moduleConfig).length - 1} / 6)`}
|
||||
</StatusIndicator>
|
||||
<StatusIndicator
|
||||
color={
|
||||
channels.length > 0 && channels.length === hardware.maxChannels
|
||||
? "success"
|
||||
: "disabled"
|
||||
}
|
||||
>
|
||||
Channels{" "}
|
||||
{hardware.myNodeNum !== 0 &&
|
||||
`(${channels.length} / ${hardware.maxChannels})`}
|
||||
</StatusIndicator>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void connection?.configure();
|
||||
}}
|
||||
iconBefore={ResetIcon}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
80
src/components/Sidebar.tsx
Normal file
80
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type React from "react";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
import { Types } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { ConfiguringWidget } from "./Widgets/ConfiguringWidget.js";
|
||||
import { DeviceWidget } from "./Widgets/DeviceWidget.js";
|
||||
import { NodeInfoWidget } from "./Widgets/NodeInfoWidget.js";
|
||||
import { PeersWidget } from "./Widgets/PeersWidget.js";
|
||||
import { PositionWidget } from "./Widgets/PositionWidget.js";
|
||||
|
||||
export const Sidebar = (): JSX.Element => {
|
||||
const { removeDevice } = useDeviceStore();
|
||||
const { connection, hardware, nodes, status } = useDevice();
|
||||
const { selectedDevice, setSelectedDevice } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative bg-slate-50 w-80 p-2 border-x border-slate-200 gap-2 flex-shrink-0">
|
||||
<DeviceWidget
|
||||
name={
|
||||
nodes.find((n) => n.data.num === hardware.myNodeNum)?.data.user
|
||||
?.longName ?? "UNK"
|
||||
}
|
||||
nodeNum={hardware.myNodeNum.toString()}
|
||||
disconnected={status === Types.DeviceStatusEnum.DEVICE_DISCONNECTED}
|
||||
disconnect={() => {
|
||||
void connection?.disconnect();
|
||||
setSelectedDevice(0);
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
}}
|
||||
reconnect={() => {
|
||||
console.log("");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <div className="text-left">
|
||||
<p className="text-xl font-bold text-slate-900">
|
||||
<a href="/">Their Side</a>
|
||||
</p>
|
||||
<p className="mt-3 text-font-medium leading-8 text-slate-700">
|
||||
Conversations with the most tragically misunderstood people of our
|
||||
time.
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* */}
|
||||
{/* */}
|
||||
{/* */}
|
||||
{/* */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">Information</h3>
|
||||
<dl className="mt-2 divide-y divide-gray-200 border-t border-b border-gray-200">
|
||||
<div className="flex justify-between py-3 text-sm font-medium">
|
||||
<dt className="text-gray-500">Firmware version</dt>
|
||||
<dd className="whitespace-nowrap text-gray-900 hover:underline hover:text-orange-400 cursor-pointer">
|
||||
{hardware.firmwareVersion}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="flex justify-between py-3 text-sm font-medium">
|
||||
<dt className="text-gray-500">Bitrate</dt>
|
||||
<dd className="whitespace-nowrap text-gray-900">
|
||||
{hardware.bitrate.toFixed(2)}
|
||||
<span className="font-mono text-slate-500 text-sm ">bps</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<NodeInfoWidget />
|
||||
{/* <BatteryWidget /> */}
|
||||
<PeersWidget />
|
||||
<PositionWidget />
|
||||
|
||||
<ConfiguringWidget />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Heading,
|
||||
majorScale,
|
||||
Pane,
|
||||
Paragraph,
|
||||
SideSheet,
|
||||
Tab,
|
||||
Tablist,
|
||||
} from "evergreen-ui";
|
||||
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
|
||||
|
||||
import type { TabType } from "@components/layout/page/TabbedContent.js";
|
||||
import { BLE } from "@components/SlideSheets/tabs/connect/BLE.js";
|
||||
import { HTTP } from "@components/SlideSheets/tabs/connect/HTTP.js";
|
||||
import { Serial } from "@components/SlideSheets/tabs/connect/Serial.js";
|
||||
|
||||
export interface NewDeviceProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface CloseProps {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export type connType = "http" | "ble" | "serial";
|
||||
|
||||
export interface ConnTab extends Omit<TabType, "element"> {
|
||||
connType: connType;
|
||||
element: ({ close }: CloseProps) => JSX.Element;
|
||||
}
|
||||
|
||||
export const NewDevice = ({ open, onClose }: NewDeviceProps) => {
|
||||
const [selectedConnType, setSelectedConnType] = useState<connType>("ble");
|
||||
|
||||
const tabs: ConnTab[] = [
|
||||
{
|
||||
connType: "ble",
|
||||
icon: FiBluetooth,
|
||||
name: "BLE",
|
||||
element: BLE,
|
||||
disabled: !navigator.bluetooth,
|
||||
},
|
||||
{
|
||||
connType: "http",
|
||||
icon: FiWifi,
|
||||
name: "HTTP",
|
||||
element: HTTP,
|
||||
},
|
||||
{
|
||||
connType: "serial",
|
||||
icon: FiTerminal,
|
||||
name: "Serial",
|
||||
element: Serial,
|
||||
disabled: !navigator.serial,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
isShown={open}
|
||||
onCloseComplete={onClose}
|
||||
containerProps={{
|
||||
display: "flex",
|
||||
flex: "1",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white">
|
||||
<Pane padding={16} borderBottom="muted">
|
||||
<Heading size={600}>Connect new device</Heading>
|
||||
<Paragraph size={400} color="muted">
|
||||
Optional description or sub title
|
||||
</Paragraph>
|
||||
</Pane>
|
||||
<Pane display="flex" padding={8}>
|
||||
<Tablist>
|
||||
{tabs.map((TabData, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
gap={5}
|
||||
isSelected={selectedConnType === TabData.connType}
|
||||
onSelect={() => setSelectedConnType(TabData.connType)}
|
||||
disabled={TabData.disabled}
|
||||
>
|
||||
<>
|
||||
<TabData.icon />
|
||||
{TabData.name}
|
||||
</>
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
</Pane>
|
||||
</Pane>
|
||||
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}>
|
||||
{tabs.map((TabData, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
borderRadius={majorScale(1)}
|
||||
backgroundColor="white"
|
||||
elevation={1}
|
||||
flexGrow={1}
|
||||
display={selectedConnType === TabData.connType ? "block" : "none"}
|
||||
>
|
||||
{!TabData.disabled && <TabData.element close={onClose} />}
|
||||
</Pane>
|
||||
))}
|
||||
</Pane>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { GeolocationIcon, Pane, PropertyIcon, SideSheet } from "evergreen-ui";
|
||||
|
||||
import { SlideSheetTabbedContent } from "@components/layout/page/SlideSheetTabbedContent.js";
|
||||
import type { TabType } from "@components/layout/page/TabbedContent.js";
|
||||
import { Location } from "@components/SlideSheets/tabs/nodes/Location.js";
|
||||
import { Overview } from "@components/SlideSheets/tabs/nodes/Overview.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import type { Node } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const PeerInfo = () => {
|
||||
const { peerInfoOpen, activePeer, setPeerInfoOpen, nodes } = useDevice();
|
||||
const [node, setNode] = useState<Node | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setNode(nodes.find((n) => n.data.num === activePeer));
|
||||
}, [nodes, activePeer]);
|
||||
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
name: "Info",
|
||||
icon: PropertyIcon,
|
||||
element: () => <Overview node={node} />,
|
||||
},
|
||||
{
|
||||
name: "Location",
|
||||
icon: GeolocationIcon,
|
||||
element: () => <Location node={node} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
isShown={peerInfoOpen}
|
||||
onCloseComplete={() => {
|
||||
setPeerInfoOpen(false);
|
||||
}}
|
||||
containerProps={{
|
||||
display: "flex",
|
||||
flex: "1",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SlideSheetTabbedContent
|
||||
heading={node?.data.user?.longName ?? "UNK"}
|
||||
description={Protobuf.HardwareModel[node?.data.user?.hwModel ?? 0]}
|
||||
tabs={tabs}
|
||||
tabIcon={
|
||||
<Pane marginY="auto">
|
||||
<Hashicon size={32} value={(node?.data.num ?? 0).toString()} />
|
||||
</Pane>
|
||||
}
|
||||
/>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
import JSONPretty from "react-json-pretty";
|
||||
|
||||
import type { Node } from "@core/stores/deviceStore.js";
|
||||
|
||||
export interface LocationProps {
|
||||
node?: Node;
|
||||
}
|
||||
|
||||
export const Location = ({ node }: LocationProps): JSX.Element => {
|
||||
return (
|
||||
<Pane>
|
||||
<JSONPretty data={node?.data.position} />
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
import JSONPretty from "react-json-pretty";
|
||||
|
||||
import type { Node } from "@core/stores/deviceStore.js";
|
||||
|
||||
export interface OverviewProps {
|
||||
node?: Node;
|
||||
}
|
||||
export const Overview = ({ node }: OverviewProps): JSX.Element => {
|
||||
return (
|
||||
<Pane>
|
||||
<JSONPretty data={node?.data.user} />
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
117
src/components/Widgets/ConfiguringWidget.tsx
Normal file
117
src/components/Widgets/ConfiguringWidget.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
|
||||
export const ConfiguringWidget = (): JSX.Element => {
|
||||
const {
|
||||
hardware,
|
||||
channels,
|
||||
config,
|
||||
moduleConfig,
|
||||
setReady,
|
||||
nodes,
|
||||
connection,
|
||||
} = useDevice();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hardware.myNodeNum !== 0 &&
|
||||
Object.keys(config).length === 7 &&
|
||||
Object.keys(moduleConfig).length === 7 &&
|
||||
channels.length === hardware.maxChannels
|
||||
) {
|
||||
setReady(true);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
moduleConfig,
|
||||
channels,
|
||||
hardware.maxChannels,
|
||||
hardware.myNodeNum,
|
||||
setReady,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
|
||||
<p className="text-xl font-bold">Connecting to device</p>
|
||||
<ol className="flex flex-col overflow-hidden gap-3">
|
||||
<StatusIndicator
|
||||
title="Device Info"
|
||||
current={hardware.myNodeNum ? 1 : 0}
|
||||
total={0}
|
||||
/>
|
||||
<StatusIndicator title="Peers" current={nodes.length} total={0} />
|
||||
<StatusIndicator
|
||||
title="Device Config"
|
||||
current={Object.keys(config).length - 1}
|
||||
total={6}
|
||||
/>
|
||||
<StatusIndicator
|
||||
title="Module Config"
|
||||
current={Object.keys(moduleConfig).length - 1}
|
||||
total={6}
|
||||
/>
|
||||
<StatusIndicator
|
||||
title="Channels"
|
||||
current={channels.length}
|
||||
total={hardware.maxChannels ?? 0}
|
||||
/>
|
||||
</ol>
|
||||
<div
|
||||
className="mt-2 rounded-md bg-[#dabb6b] p-1 ring-[#f9e3aa] cursor-pointer text-center"
|
||||
onClick={() => {
|
||||
void connection?.configure();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface StatusIndicatorProps {
|
||||
title: string;
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const StatusIndicator = ({
|
||||
title,
|
||||
current,
|
||||
total,
|
||||
}: StatusIndicatorProps): JSX.Element => {
|
||||
return (
|
||||
<li className="relative">
|
||||
<div
|
||||
className={`absolute top-4 left-2.5 -ml-px h-full w-0.5 ${
|
||||
current >= total ? "bg-green-500" : "bg-[#f9e3aa]"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`flex relative z-10 h-5 w-5 rounded-full border-2 ${
|
||||
current === 0
|
||||
? "border-[#dabb6b] bg-[#f9e3aa]"
|
||||
: current >= total
|
||||
? "bg-green-500 border-green-500"
|
||||
: "bg-[#f9e3aa] border-green-500"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`m-auto h-1.5 w-1.5 rounded-full ${
|
||||
current > 0 ? "bg-green-500" : "bg-[#f9e3aa]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="flex text-sm ml-4 gap-1">
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="font-mono text-slate-500">
|
||||
({current}
|
||||
{total !== 0 && `/${total}`})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
50
src/components/Widgets/DeviceWidget.tsx
Normal file
50
src/components/Widgets/DeviceWidget.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { XCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { Button } from "../Button.js";
|
||||
|
||||
export interface DeviceWidgetProps {
|
||||
name: string;
|
||||
nodeNum: string;
|
||||
disconnected: boolean;
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
export const DeviceWidget = ({
|
||||
name,
|
||||
nodeNum,
|
||||
disconnected,
|
||||
disconnect,
|
||||
reconnect,
|
||||
}: DeviceWidgetProps): JSX.Element => {
|
||||
return (
|
||||
<div className="relative rounded-xl bg-emerald-400 overflow-hidden">
|
||||
<div className="absolute w-full h-full bottom-20">
|
||||
<Hashicon size={350} value={nodeNum} />
|
||||
</div>
|
||||
<div className="flex backdrop-blur-md backdrop-brightness-50 backdrop-hue-rotate-30 p-3">
|
||||
<div className="drop-shadow-md">
|
||||
<Hashicon size={96} value={nodeNum} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col">
|
||||
<span className="font-bold text-slate-200 ml-auto text-xl whitespace-nowrap">
|
||||
{name}
|
||||
</span>
|
||||
<div className="ml-auto my-auto">
|
||||
<Button
|
||||
onClick={disconnected ? reconnect : disconnect}
|
||||
variant={disconnected ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
iconAfter={<XCircleIcon className="h-4" />}
|
||||
>
|
||||
{disconnected ? "Reconnect" : "Disconnect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/components/Widgets/NodeInfoWidget.tsx
Normal file
11
src/components/Widgets/NodeInfoWidget.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface NodeInfoWidgetProps {}
|
||||
|
||||
export const NodeInfoWidget = ({}: NodeInfoWidgetProps): JSX.Element => {
|
||||
return (
|
||||
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
|
||||
node info
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/components/Widgets/PeersWidget.tsx
Normal file
11
src/components/Widgets/PeersWidget.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface PeersWidgetProps {}
|
||||
|
||||
export const PeersWidget = ({}: PeersWidgetProps): JSX.Element => {
|
||||
return (
|
||||
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
|
||||
Peers
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/components/Widgets/PositionWidget.tsx
Normal file
11
src/components/Widgets/PositionWidget.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface PositionWidgetProps {}
|
||||
|
||||
export const PositionWidget = ({}: PositionWidgetProps): JSX.Element => {
|
||||
return (
|
||||
<div className="p-6 flex flex-col rounded-2xl mb-4 text-sm space-y-3 bg-[#f9e3aa] text-black">
|
||||
position
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
src/components/form/Checkbox.tsx
Normal file
53
src/components/form/Checkbox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type React from "react";
|
||||
import { forwardRef, InputHTMLAttributes } from "react";
|
||||
|
||||
export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
description?: string;
|
||||
options?: string[];
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
icon: JSX.Element;
|
||||
action: () => void;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
function Input(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
prefix,
|
||||
suffix,
|
||||
action,
|
||||
error,
|
||||
children,
|
||||
...rest
|
||||
}: CheckboxProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="comments" className="font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
<p id="comments-description" className="text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,15 +1,29 @@
|
||||
import type React from "react";
|
||||
import type { HTMLProps } from "react";
|
||||
|
||||
import { Button, majorScale, Pane, SavedIcon, Spinner } from "evergreen-ui";
|
||||
import { FiSave } from "react-icons/fi";
|
||||
|
||||
import { Button } from "@components/Button.js";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export interface FormProps extends HTMLProps<HTMLFormElement> {
|
||||
title: string;
|
||||
breadcrumbs: string[];
|
||||
reset: () => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>;
|
||||
loading: boolean;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export const Form = ({
|
||||
title,
|
||||
breadcrumbs,
|
||||
reset,
|
||||
loading,
|
||||
dirty,
|
||||
children,
|
||||
@@ -18,31 +32,48 @@ export const Form = ({
|
||||
}: FormProps): JSX.Element => {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
<form onSubmit={onSubmit} style={{ position: "relative" }} {...props}>
|
||||
<form className="w-full" onSubmit={onSubmit} {...props}>
|
||||
{loading && (
|
||||
<Pane
|
||||
position="absolute"
|
||||
display="flex"
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundColor="rgba(67, 90, 111, 0.2)"
|
||||
zIndex={10}
|
||||
borderRadius={majorScale(1)}
|
||||
>
|
||||
<Spinner margin="auto" />
|
||||
</Pane>
|
||||
<div className="absolute flex w-full h-full bg-slate-600 rounded-md z-10">
|
||||
<ArrowPathIcon className="h-8 animate-spin m-auto" />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<Pane display="flex" marginTop={majorScale(2)}>
|
||||
<Button
|
||||
type="submit"
|
||||
marginLeft="auto"
|
||||
disabled={!dirty}
|
||||
iconBefore={<SavedIcon />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Pane>
|
||||
<div className="select-none rounded-md p-4 bg-gray-700">
|
||||
<ol className="flex gap-4">
|
||||
<li className="text-gray-400 hover:text-gray-200 cursor-pointer">
|
||||
<HomeIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
|
||||
</li>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<li key={index} className="flex gap-4">
|
||||
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-400 hover:text-gray-200 cursor-pointer">
|
||||
{breadcrumb}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-2 flex items-center">
|
||||
<h2 className="font-bold text-white truncate text-3xl tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
variant="secondary"
|
||||
iconBefore={<ArrowUturnLeftIcon className="w-4" />}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button disabled={!dirty} iconBefore={<FiSave className="w-4" />}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col p-2 gap-3">{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
75
src/components/form/Input.tsx
Normal file
75
src/components/form/Input.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type React from "react";
|
||||
import { forwardRef, InputHTMLAttributes } from "react";
|
||||
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
description?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
icon: JSX.Element;
|
||||
action: () => void;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, description, prefix, suffix, action, error, ...rest }: InputProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
{/* Label */}
|
||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||
{/* */}
|
||||
<div className="relative flex rounded-md shadow-sm">
|
||||
{prefix && (
|
||||
<span className="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`block w-full min-w-0 flex-1 rounded-md border border-gray-300 px-3 h-10 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
|
||||
prefix ? "rounded-l-none" : ""
|
||||
} ${action ? "rounded-r-none" : ""}`}
|
||||
{...rest}
|
||||
/>
|
||||
{suffix && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-gray-500 sm:text-sm" id="price-currency">
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.action}
|
||||
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
{action.icon}
|
||||
{/* <span>Sort</span> */}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600" id="email-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
65
src/components/form/Select.tsx
Normal file
65
src/components/form/Select.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type React from "react";
|
||||
import { forwardRef, SelectHTMLAttributes } from "react";
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string;
|
||||
description?: string;
|
||||
options?: string[];
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
action?: {
|
||||
icon: JSX.Element;
|
||||
action: () => void;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Input(
|
||||
{
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
prefix,
|
||||
suffix,
|
||||
action,
|
||||
error,
|
||||
children,
|
||||
...rest
|
||||
}: SelectProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="location"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex">
|
||||
<select
|
||||
ref={ref}
|
||||
className={`block w-full min-w-0 flex-1 rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
|
||||
prefix ? "rounded-l-none" : ""
|
||||
} ${action ? "rounded-r-none" : ""}`}
|
||||
{...rest}
|
||||
>
|
||||
{options &&
|
||||
options.map((option, index) => (
|
||||
<option key={index}>{option}</option>
|
||||
))}
|
||||
{children}
|
||||
</select>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.action}
|
||||
className="relative -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
48
src/components/form/Toggle.tsx
Normal file
48
src/components/form/Toggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Switch } from "@headlessui/react";
|
||||
|
||||
export interface ToggleProps {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export const Toggle = ({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: ToggleProps): JSX.Element => {
|
||||
return (
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<span className="flex flex-grow flex-col">
|
||||
<Switch.Label
|
||||
as="span"
|
||||
className="text-sm font-medium text-gray-900"
|
||||
passive
|
||||
>
|
||||
{label}
|
||||
</Switch.Label>
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
{description}
|
||||
</Switch.Description>
|
||||
</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
||||
checked ? "bg-indigo-600" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { majorScale, Pane } from "evergreen-ui";
|
||||
|
||||
import { useAppStore } from "@app/core/stores/appStore.js";
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
|
||||
import { NoDevice } from "../misc/NoDevice.js";
|
||||
import { Progress } from "../Progress.js";
|
||||
import { PeerInfo } from "../SlideSheets/PeerInfo.js";
|
||||
import { Header } from "./Header.js";
|
||||
import { Sidebar } from "./Sidebar/index.js";
|
||||
|
||||
export interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppLayout = ({ children }: AppLayoutProps): JSX.Element => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { selectedDevice } = useAppStore();
|
||||
|
||||
const devices = getDevices();
|
||||
|
||||
return (
|
||||
<Pane
|
||||
width="100vw"
|
||||
display="flex"
|
||||
background="tint1"
|
||||
flexDirection="column"
|
||||
minHeight="100vh"
|
||||
>
|
||||
<Header />
|
||||
<Pane display="flex" height="100%" width="100%">
|
||||
{devices.length ? (
|
||||
devices.map((device) => (
|
||||
<Pane
|
||||
key={device.id}
|
||||
width="100%"
|
||||
height="100%"
|
||||
display={device.id === selectedDevice ? "grid" : "none"}
|
||||
gap={majorScale(3)}
|
||||
gridTemplateColumns="16rem 1fr"
|
||||
>
|
||||
<DeviceWrapper device={device}>
|
||||
{device && device.ready ? (
|
||||
<>
|
||||
<Sidebar />
|
||||
<PeerInfo />
|
||||
<Pane height="100%" display="flex">
|
||||
{children}
|
||||
</Pane>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pane
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
margin={majorScale(3)}
|
||||
borderRadius={majorScale(1)}
|
||||
background="white"
|
||||
elevation={1}
|
||||
/>
|
||||
<Progress />
|
||||
</>
|
||||
)}
|
||||
</DeviceWrapper>
|
||||
</Pane>
|
||||
))
|
||||
) : (
|
||||
<NoDevice />
|
||||
)}
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
CrossIcon,
|
||||
GlobeIcon,
|
||||
HelpIcon,
|
||||
IconButton,
|
||||
Link,
|
||||
majorScale,
|
||||
Pane,
|
||||
PlusIcon,
|
||||
StatusIndicator,
|
||||
Tab,
|
||||
Tablist,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
import { FiGithub } from "react-icons/fi";
|
||||
|
||||
import { HelpDialog } from "@components/Dialog/HelpDialog.js";
|
||||
import { NewDevice } from "@components/SlideSheets/NewDevice.js";
|
||||
import { useAppStore } from "@core/stores/appStore.js";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Types } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const { getDevices, removeDevice } = useDeviceStore();
|
||||
const [newConnectionOpen, setNewConnectionOpen] = useState(false);
|
||||
const [helpDialogOpen, setHelpDialogOpen] = useState(false);
|
||||
const { selectedDevice, setSelectedDevice } = useAppStore();
|
||||
|
||||
return (
|
||||
<Pane
|
||||
is="nav"
|
||||
width="100%"
|
||||
position="sticky"
|
||||
top={0}
|
||||
backgroundColor="white"
|
||||
zIndex={10}
|
||||
height={majorScale(8)}
|
||||
flexShrink={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderBottom="muted"
|
||||
>
|
||||
<NewDevice
|
||||
open={newConnectionOpen}
|
||||
onClose={() => {
|
||||
setNewConnectionOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Pane
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
width={majorScale(12)}
|
||||
marginRight={majorScale(22)}
|
||||
>
|
||||
<Link href=".">
|
||||
<Pane
|
||||
is="img"
|
||||
width={100}
|
||||
height={28}
|
||||
src="Logo_Black.svg"
|
||||
cursor="pointer"
|
||||
/>
|
||||
</Link>
|
||||
</Pane>
|
||||
<Tablist display="flex" marginX={majorScale(4)}>
|
||||
{getDevices().map((device) => (
|
||||
<Tab
|
||||
key={device.id}
|
||||
gap={majorScale(1)}
|
||||
isSelected={device.id === selectedDevice}
|
||||
onSelect={() => {
|
||||
setSelectedDevice(device.id);
|
||||
}}
|
||||
>
|
||||
<Hashicon value={device.hardware.myNodeNum.toString()} size={20} />
|
||||
{device.nodes.find((n) => n.data.num === device.hardware.myNodeNum)
|
||||
?.data.user?.shortName ?? "UNK"}
|
||||
<StatusIndicator
|
||||
color={
|
||||
[
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
|
||||
].includes(device.status)
|
||||
? "success"
|
||||
: [
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
].includes(device.status)
|
||||
? "warning"
|
||||
: "danger"
|
||||
}
|
||||
/>
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
<Pane
|
||||
display="flex"
|
||||
marginLeft="auto"
|
||||
gap={majorScale(1)}
|
||||
marginRight={majorScale(2)}
|
||||
>
|
||||
<Tooltip content="Connect new device">
|
||||
<Button
|
||||
display="inline-flex"
|
||||
marginY="auto"
|
||||
appearance="primary"
|
||||
iconBefore={<PlusIcon />}
|
||||
onClick={() => {
|
||||
setNewConnectionOpen(true);
|
||||
}}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{getDevices().length !== 0 && (
|
||||
<Tooltip content="Disconnect active device">
|
||||
<Button
|
||||
iconBefore={CrossIcon}
|
||||
onClick={() => {
|
||||
void getDevices()
|
||||
.find((d) => d.id === selectedDevice)
|
||||
?.connection?.disconnect();
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="Visit GitHub">
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://github.com/meshtastic/meshtastic-web"
|
||||
>
|
||||
<Button iconBefore={FiGithub}>
|
||||
{process.env.COMMIT_HASH ?? "DEVELOPMENT"}
|
||||
</Button>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={HelpIcon}
|
||||
onClick={() => {
|
||||
setHelpDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<HelpDialog
|
||||
isOpen={helpDialogOpen}
|
||||
close={() => {
|
||||
setHelpDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Tooltip content="Visit Meshtastic.org">
|
||||
<Link target="_blank" href="https://meshtastic.org/">
|
||||
<IconButton icon={GlobeIcon} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Heading,
|
||||
Link,
|
||||
majorScale,
|
||||
MapMarkerIcon,
|
||||
Pane,
|
||||
} from "evergreen-ui";
|
||||
import { FiBluetooth, FiTerminal, FiWifi } from "react-icons/fi";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { toMGRS } from "@core/utils/toMGRS.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Types } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export const DeviceCard = (): JSX.Element => {
|
||||
const { hardware, nodes, status, connection } = useDevice();
|
||||
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
|
||||
|
||||
return (
|
||||
<Pane
|
||||
display="flex"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
marginTop="auto"
|
||||
gap={majorScale(1)}
|
||||
>
|
||||
<Pane display="flex" gap={majorScale(2)}>
|
||||
<Hashicon value={hardware.myNodeNum.toString()} size={42} />
|
||||
<Pane flexGrow={1}>
|
||||
<Heading>{myNode?.data.user?.longName}</Heading>
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://github.com/meshtastic/meshtastic-device/releases/"
|
||||
>
|
||||
<Badge
|
||||
color="green"
|
||||
width="100%"
|
||||
marginRight={8}
|
||||
display="flex"
|
||||
marginTop={4}
|
||||
>
|
||||
{hardware.firmwareVersion}
|
||||
</Badge>
|
||||
</Link>
|
||||
</Pane>
|
||||
</Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<MapMarkerIcon />
|
||||
<Badge
|
||||
color={myNode?.data.position?.latitudeI ? "green" : "red"}
|
||||
display="flex"
|
||||
width="100%"
|
||||
>
|
||||
{toMGRS(
|
||||
myNode?.data.position?.latitudeI,
|
||||
myNode?.data.position?.longitudeI
|
||||
)}
|
||||
</Badge>
|
||||
</Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
{connection?.connType === "ble" && <FiBluetooth />}
|
||||
{connection?.connType === "http" && <FiWifi />}
|
||||
{connection?.connType === "serial" && <FiTerminal />}
|
||||
<Badge
|
||||
color={
|
||||
[
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
|
||||
].includes(status)
|
||||
? "green"
|
||||
: [
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
].includes(status)
|
||||
? "orange"
|
||||
: "red"
|
||||
}
|
||||
display="flex"
|
||||
width="100%"
|
||||
>
|
||||
{[
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
|
||||
Types.DeviceStatusEnum.DEVICE_CONFIGURING,
|
||||
].includes(status)
|
||||
? "Connected"
|
||||
: [
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_RECONNECTING,
|
||||
Types.DeviceStatusEnum.DEVICE_CONNECTED,
|
||||
].includes(status)
|
||||
? "Connecting"
|
||||
: "Disconnected"}
|
||||
</Badge>
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ArrayIcon,
|
||||
GlobeIcon,
|
||||
IconComponent,
|
||||
InboxIcon,
|
||||
InfoSignIcon,
|
||||
LabTestIcon,
|
||||
LayersIcon,
|
||||
majorScale,
|
||||
Pane,
|
||||
SettingsIcon,
|
||||
Tab,
|
||||
Tablist,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { PeersDialog } from "@components/Dialog/PeersDialog.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import type { Page } from "@core/stores/deviceStore.js";
|
||||
|
||||
import { DeviceCard } from "./DeviceCard.js";
|
||||
|
||||
interface NavLink {
|
||||
name: string;
|
||||
icon: IconComponent;
|
||||
page: Page;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar = (): JSX.Element => {
|
||||
const { activePage, setActivePage } = useDevice();
|
||||
const [PeersDialogOpen, setPeersDialogOpen] = useState(false);
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{
|
||||
name: "Messages",
|
||||
icon: InboxIcon,
|
||||
page: "messages",
|
||||
},
|
||||
{
|
||||
name: "Map",
|
||||
icon: GlobeIcon,
|
||||
page: "map",
|
||||
},
|
||||
{
|
||||
name: "Extensions",
|
||||
icon: LabTestIcon,
|
||||
page: "extensions",
|
||||
},
|
||||
{
|
||||
name: "Config",
|
||||
icon: SettingsIcon,
|
||||
page: "config",
|
||||
},
|
||||
{
|
||||
name: "Channels",
|
||||
icon: LayersIcon,
|
||||
page: "channels",
|
||||
},
|
||||
{
|
||||
name: "Info",
|
||||
icon: InfoSignIcon,
|
||||
page: "info",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
margin={majorScale(3)}
|
||||
padding={majorScale(2)}
|
||||
borderRadius={majorScale(1)}
|
||||
background="white"
|
||||
elevation={1}
|
||||
>
|
||||
<Tablist>
|
||||
{navLinks.map((Link) => (
|
||||
<Tab
|
||||
key={Link.name}
|
||||
userSelect="none"
|
||||
gap={majorScale(2)}
|
||||
disabled={Link.disabled}
|
||||
direction="vertical"
|
||||
isSelected={Link.page === activePage}
|
||||
onSelect={() => {
|
||||
setActivePage(Link.page);
|
||||
}}
|
||||
>
|
||||
<Link.icon />
|
||||
{Link.name}
|
||||
</Tab>
|
||||
))}
|
||||
<Tab
|
||||
userSelect="none"
|
||||
gap={5}
|
||||
direction="vertical"
|
||||
isSelected={PeersDialogOpen}
|
||||
onSelect={() => {
|
||||
setPeersDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<ArrayIcon />
|
||||
Peers
|
||||
</Tab>
|
||||
</Tablist>
|
||||
<PeersDialog
|
||||
isOpen={PeersDialogOpen}
|
||||
close={() => {
|
||||
setPeersDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Pane display="flex" flexGrow={1}>
|
||||
<DeviceCard />
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Heading,
|
||||
IconComponent,
|
||||
majorScale,
|
||||
Pane,
|
||||
Paragraph,
|
||||
Tab,
|
||||
Tablist,
|
||||
} from "evergreen-ui";
|
||||
import type { IconType } from "react-icons";
|
||||
|
||||
export interface TabType {
|
||||
name: string;
|
||||
icon: IconComponent | IconType;
|
||||
element: () => JSX.Element;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SlideSheetTabbedContentProps {
|
||||
heading: string;
|
||||
description: string;
|
||||
tabs: TabType[];
|
||||
tabIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SlideSheetTabbedContent = ({
|
||||
heading,
|
||||
description,
|
||||
tabs,
|
||||
tabIcon,
|
||||
}: SlideSheetTabbedContentProps): JSX.Element => {
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pane zIndex={1} flexShrink={0} elevation={1} backgroundColor="white">
|
||||
<Pane
|
||||
display="flex"
|
||||
padding={16}
|
||||
borderBottom="muted"
|
||||
gap={majorScale(1)}
|
||||
>
|
||||
{tabIcon}
|
||||
<Pane>
|
||||
<Heading size={600}>{heading}</Heading>
|
||||
<Paragraph size={400} color="muted">
|
||||
{description}
|
||||
</Paragraph>
|
||||
</Pane>
|
||||
</Pane>
|
||||
<Pane display="flex" padding={8}>
|
||||
<Tablist>
|
||||
{tabs.map((Entry, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
userSelect="none"
|
||||
disabled={Entry.disabled}
|
||||
gap={5}
|
||||
onSelect={() => setSelectedTab(index)}
|
||||
isSelected={selectedTab === index}
|
||||
>
|
||||
<Entry.icon />
|
||||
{Entry.name}
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
</Pane>
|
||||
</Pane>
|
||||
<Pane display="flex" overflowY="scroll" background="tint1" padding={16}>
|
||||
{tabs.map((Entry, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
borderRadius={majorScale(1)}
|
||||
backgroundColor="white"
|
||||
elevation={1}
|
||||
flexGrow={1}
|
||||
display={selectedTab === index ? "block" : "none"}
|
||||
>
|
||||
{!Entry.disabled && <Entry.element />}
|
||||
</Pane>
|
||||
))}
|
||||
</Pane>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { IconComponent, majorScale, Pane, Tab, Tablist } from "evergreen-ui";
|
||||
import type { IconType } from "react-icons";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export interface TabType {
|
||||
name: string;
|
||||
icon: IconComponent | IconType;
|
||||
icon?: JSX.Element;
|
||||
element: () => JSX.Element;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -20,55 +19,41 @@ export const TabbedContent = ({
|
||||
tabs,
|
||||
actions,
|
||||
}: TabbedContentProps): JSX.Element => {
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
|
||||
return (
|
||||
<Pane
|
||||
margin={majorScale(3)}
|
||||
borderRadius={majorScale(1)}
|
||||
background="white"
|
||||
elevation={1}
|
||||
display="flex"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
padding={majorScale(2)}
|
||||
gap={majorScale(2)}
|
||||
>
|
||||
<Pane borderBottom="muted" paddingBottom={majorScale(2)}>
|
||||
<Pane display="flex">
|
||||
<Tablist>
|
||||
{tabs.map((Entry, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
userSelect="none"
|
||||
disabled={Entry.disabled}
|
||||
gap={5}
|
||||
onSelect={() => setSelectedTab(index)}
|
||||
isSelected={selectedTab === index}
|
||||
<Tab.Group as="div" className="flex flex-col gap-2 p-4 flex-grow">
|
||||
<Tab.List className="flex gap-4 border-b pb-3">
|
||||
{tabs.map((entry, index) => (
|
||||
<Tab key={index}>
|
||||
{({ selected }) => (
|
||||
<div
|
||||
className={`flex gap-3 h-10 font-medium text-sm rounded-md cursor-pointer px-3 ${
|
||||
selected
|
||||
? "bg-gray-100 text-gray-700"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Entry.icon />
|
||||
{Entry.name}
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
|
||||
<Pane marginLeft="auto">
|
||||
{actions?.map((Action, index) => (
|
||||
<Action key={index} />
|
||||
))}
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Pane>
|
||||
{tabs.map((Entry, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
display={selectedTab === index ? "flex" : "none"}
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
>
|
||||
{!Entry.disabled && <Entry.element />}
|
||||
</Pane>
|
||||
))}
|
||||
</Pane>
|
||||
{entry.icon && (
|
||||
<div className="m-auto text-slate-500">{entry.icon}</div>
|
||||
)}
|
||||
<span className="m-auto">{entry.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
{actions?.map((Action, index) => (
|
||||
<Action key={index} />
|
||||
))}
|
||||
</div>
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
{tabs.map((entry, index) => (
|
||||
<Tab.Panel key={index} className="flex flex-grow">
|
||||
<entry.element />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import { DisableIcon, EmptyState, Pane } from "evergreen-ui";
|
||||
|
||||
export const NoDevice = (): JSX.Element => {
|
||||
return (
|
||||
<Pane elevation={1} margin="auto">
|
||||
<EmptyState
|
||||
title="No Device Connected"
|
||||
orientation="horizontal"
|
||||
background="light"
|
||||
icon={<DisableIcon color="#EBAC91" />}
|
||||
iconBgColor="#F8E3DA"
|
||||
description="You must connect a Meshtastic device to continue."
|
||||
/>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { Device } from "@core/stores/deviceStore.js";
|
||||
import type { Device } from '@core/stores/deviceStore.js';
|
||||
|
||||
export const DeviceContext = createContext<Device | undefined>(undefined);
|
||||
|
||||
export const useDevice = (): Device => {
|
||||
const context = useContext(DeviceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDevice must be used within a ConnectionProvider");
|
||||
throw new Error("useDevice must be used within a DeviceProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import create from "zustand";
|
||||
import create from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
selectedDevice?: number;
|
||||
selectedDevice: number;
|
||||
devices: {
|
||||
id: number;
|
||||
num: number;
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface DeviceState {
|
||||
addDevice: (id: number) => Device;
|
||||
removeDevice: (id: number) => void;
|
||||
getDevices: () => Device[];
|
||||
getDevice: (id: number) => Device | undefined;
|
||||
}
|
||||
|
||||
export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
@@ -470,6 +471,8 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
},
|
||||
|
||||
getDevices: () => Array.from(get().devices.values()),
|
||||
|
||||
getDevice: (id) => get().devices.get(id),
|
||||
}));
|
||||
|
||||
export const DeviceContext = createContext<Device | undefined>(undefined);
|
||||
|
||||
3
src/index.css
Normal file
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,4 +1,4 @@
|
||||
import "modern-css-reset/dist/reset.min.css";
|
||||
import "./index.css";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, LayerIcon, LayerOutlineIcon, Tooltip } from "evergreen-ui";
|
||||
import { IoQrCodeOutline } from "react-icons/io5";
|
||||
|
||||
import { Channel } from "@app/components/PageComponents/Channel.js";
|
||||
import { Button } from "@components/Button.js";
|
||||
import { QRDialog } from "@components/Dialog/QRDialog.js";
|
||||
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { QrCodeIcon } from "@heroicons/react/24/outline";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
import { Channel } from "@pages/Channels/Channel.js";
|
||||
|
||||
export const ChannelsPage = (): JSX.Element => {
|
||||
const { channels, config } = useDevice();
|
||||
@@ -21,10 +20,6 @@ export const ChannelsPage = (): JSX.Element => {
|
||||
: channel.config.role === Protobuf.Channel_Role.PRIMARY
|
||||
? "Primary"
|
||||
: `Channel: ${channel.config.index}`,
|
||||
icon:
|
||||
channel.config.role !== Protobuf.Channel_Role.DISABLED
|
||||
? LayerIcon
|
||||
: LayerOutlineIcon,
|
||||
element: () => <Channel channel={channel.config} />,
|
||||
};
|
||||
});
|
||||
@@ -43,16 +38,15 @@ export const ChannelsPage = (): JSX.Element => {
|
||||
tabs={tabs}
|
||||
actions={[
|
||||
() => (
|
||||
<Tooltip content="Open QR code generator">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setQRDialogOpen(true);
|
||||
}}
|
||||
iconBefore={IoQrCodeOutline}
|
||||
>
|
||||
QR Code
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconBefore={<QrCodeIcon className="w-4" />}
|
||||
onClick={() => {
|
||||
setQRDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
QR Code
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
@@ -1,205 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import {
|
||||
Button,
|
||||
EyeOffIcon,
|
||||
EyeOpenIcon,
|
||||
FormField,
|
||||
IconButton,
|
||||
majorScale,
|
||||
Pane,
|
||||
RefreshIcon,
|
||||
SelectField,
|
||||
Switch,
|
||||
TextInputField,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@components/form/Form";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel;
|
||||
}
|
||||
|
||||
export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
|
||||
const { connection } = useDevice();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keySize, setKeySize] = useState<128 | 256>(256);
|
||||
const [pskHidden, setPskHidden] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
} = useForm<
|
||||
Omit<Protobuf.ChannelSettings, "psk"> & { psk: string; enabled: boolean }
|
||||
>({
|
||||
defaultValues: {
|
||||
enabled: [
|
||||
Protobuf.Channel_Role.SECONDARY,
|
||||
Protobuf.Channel_Role.PRIMARY,
|
||||
].find((role) => role === channel?.role)
|
||||
? true
|
||||
: false,
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
enabled: [
|
||||
Protobuf.Channel_Role.SECONDARY,
|
||||
Protobuf.Channel_Role.PRIMARY,
|
||||
].find((role) => role === channel?.role)
|
||||
? true
|
||||
: false,
|
||||
...channel?.settings,
|
||||
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
});
|
||||
}, [channel, reset]);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setLoading(true);
|
||||
const channelData = Protobuf.Channel.create({
|
||||
role:
|
||||
channel?.role === Protobuf.Channel_Role.PRIMARY
|
||||
? Protobuf.Channel_Role.PRIMARY
|
||||
: data.enabled
|
||||
? Protobuf.Channel_Role.SECONDARY
|
||||
: Protobuf.Channel_Role.DISABLED,
|
||||
index: channel?.index,
|
||||
settings: {
|
||||
...data,
|
||||
psk: toByteArray(data.psk ?? ""),
|
||||
},
|
||||
});
|
||||
|
||||
await connection?.setChannel(channelData, (): Promise<void> => {
|
||||
reset({ ...data });
|
||||
setLoading(false);
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Form loading={loading} dirty={isDirty} onSubmit={onSubmit}>
|
||||
{channel?.index !== 0 && (
|
||||
<>
|
||||
<FormField
|
||||
label="Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.enabled?.message}
|
||||
validationMessage={errors.enabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch
|
||||
height={24}
|
||||
marginLeft="auto"
|
||||
checked={value}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<TextInputField
|
||||
label="Name"
|
||||
description="Max transmit power in dBm"
|
||||
{...register("name")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<SelectField
|
||||
width="100%"
|
||||
label="Key Size"
|
||||
description="Desired size of generated key."
|
||||
value={keySize}
|
||||
onChange={(e): void => {
|
||||
setKeySize(parseInt(e.target.value) as 128 | 256);
|
||||
}}
|
||||
>
|
||||
<option value={128}>128 Bit</option>
|
||||
<option value={256}>256 Bit</option>
|
||||
</SelectField>
|
||||
<Tooltip content="Generate new key">
|
||||
<IconButton
|
||||
marginTop={majorScale(6)}
|
||||
onClick={(
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
const key = new Uint8Array(keySize / 8);
|
||||
crypto.getRandomValues(key);
|
||||
setValue("psk", fromByteArray(key));
|
||||
}}
|
||||
icon={<RefreshIcon />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<TextInputField
|
||||
width="100%"
|
||||
label="Pre-Shared Key"
|
||||
description="Max transmit power in dBm"
|
||||
type={pskHidden ? "password" : "text"}
|
||||
{...register("psk")}
|
||||
/>
|
||||
<Tooltip content={pskHidden ? "Show key" : "Hide key"}>
|
||||
<Button
|
||||
marginTop={majorScale(6)}
|
||||
width={majorScale(12)}
|
||||
onClick={(
|
||||
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
setPskHidden(!pskHidden);
|
||||
}}
|
||||
iconBefore={pskHidden ? <EyeOpenIcon /> : <EyeOffIcon />}
|
||||
>
|
||||
{pskHidden ? "Show" : "Hide"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
<FormField
|
||||
label="Uplink Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.uplinkEnabled?.message}
|
||||
validationMessage={errors.uplinkEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="uplinkEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Downlink Enabled"
|
||||
description="Description"
|
||||
isInvalid={!!errors.downlinkEnabled?.message}
|
||||
validationMessage={errors.downlinkEnabled?.message}
|
||||
>
|
||||
<Controller
|
||||
name="downlinkEnabled"
|
||||
control={control}
|
||||
render={({ field: { value, ...field } }) => (
|
||||
<Switch height={24} marginLeft="auto" checked={value} {...field} />
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,12 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Pane, Tab, Tablist } from "evergreen-ui";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
|
||||
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.js";
|
||||
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const AppConfig = (): JSX.Element => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const configSections = [
|
||||
{
|
||||
label: "Interface",
|
||||
@@ -26,31 +23,31 @@ export const AppConfig = (): JSX.Element => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane display="flex">
|
||||
<Pane flexBasis={150} marginRight={24}>
|
||||
<Tablist>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
direction="vertical"
|
||||
isSelected={index === selectedIndex}
|
||||
onSelect={() => setSelectedIndex(index)}
|
||||
>
|
||||
{Config.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
</Pane>
|
||||
<Pane flex="1">
|
||||
<Tab.Group as="div" className="flex gap-3 w-full">
|
||||
<Tab.List className="flex flex-col w-44 gap-1">
|
||||
{configSections.map((Config, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
display={index === selectedIndex ? "block" : "none"}
|
||||
>
|
||||
<Config.element />
|
||||
</Pane>
|
||||
<Tab key={index} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<div
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
|
||||
selected
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{Config.label}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab.Panel key={index} as={Fragment}>
|
||||
<Config.element />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Pane, Tab, Tablist } from "evergreen-ui";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Network } from "@app/components/PageComponents/Config/Network.js";
|
||||
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.js";
|
||||
@@ -12,9 +10,9 @@ import { Position } from "@components/PageComponents/Config/Position.js";
|
||||
import { Power } from "@components/PageComponents/Config/Power.js";
|
||||
import { User } from "@components/PageComponents/Config/User.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const DeviceConfig = (): JSX.Element => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const { hardware } = useDevice();
|
||||
|
||||
const configSections = [
|
||||
@@ -54,32 +52,31 @@ export const DeviceConfig = (): JSX.Element => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane display="flex">
|
||||
<Pane flexBasis={150} marginRight={24}>
|
||||
<Tablist>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
direction="vertical"
|
||||
isSelected={index === selectedIndex}
|
||||
onSelect={() => setSelectedIndex(index)}
|
||||
disabled={Config.disabled}
|
||||
>
|
||||
{Config.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
</Pane>
|
||||
<Pane flex="1">
|
||||
<Tab.Group as="div" className="flex gap-3 w-full">
|
||||
<Tab.List className="flex flex-col w-44 gap-1">
|
||||
{configSections.map((Config, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
display={index === selectedIndex ? "block" : "none"}
|
||||
>
|
||||
<Config.element />
|
||||
</Pane>
|
||||
<Tab key={index} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<div
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
|
||||
selected
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{Config.label}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab.Panel key={index} as={Fragment}>
|
||||
<Config.element />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Pane, Tab, Tablist } from "evergreen-ui";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage";
|
||||
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.js";
|
||||
@@ -10,6 +8,7 @@ import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.js"
|
||||
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.js";
|
||||
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.js";
|
||||
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.js";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
export const ModuleConfig = (): JSX.Element => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -46,31 +45,31 @@ export const ModuleConfig = (): JSX.Element => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane display="flex">
|
||||
<Pane flexBasis={150} marginRight={24}>
|
||||
<Tablist>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
direction="vertical"
|
||||
isSelected={index === selectedIndex}
|
||||
onSelect={() => setSelectedIndex(index)}
|
||||
>
|
||||
{Config.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Tablist>
|
||||
</Pane>
|
||||
<Pane flex="1">
|
||||
<Tab.Group as="div" className="flex gap-3 w-full">
|
||||
<Tab.List className="flex flex-col gap-1 w-44">
|
||||
{configSections.map((Config, index) => (
|
||||
<Pane
|
||||
key={index}
|
||||
display={index === selectedIndex ? "block" : "none"}
|
||||
>
|
||||
<Config.element />
|
||||
</Pane>
|
||||
<Tab key={index} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<div
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md cursor-pointer ${
|
||||
selected
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{Config.label}
|
||||
</div>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
{configSections.map((Config, index) => (
|
||||
<Tab.Panel key={index} as={Fragment}>
|
||||
<Config.element />
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type React from "react";
|
||||
|
||||
import { ApplicationsIcon, CogIcon, CubeIcon } from "evergreen-ui";
|
||||
|
||||
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
|
||||
import {
|
||||
Cog8ToothIcon,
|
||||
CubeTransparentIcon,
|
||||
WindowIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { AppConfig } from "@pages/Config/AppConfig.js";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.js";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.js";
|
||||
@@ -11,17 +14,17 @@ export const ConfigPage = (): JSX.Element => {
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
name: "Device Config",
|
||||
icon: CogIcon,
|
||||
icon: <Cog8ToothIcon className="h-4" />,
|
||||
element: DeviceConfig,
|
||||
},
|
||||
{
|
||||
name: "Module Config",
|
||||
icon: CubeIcon,
|
||||
icon: <CubeTransparentIcon className="h-4" />,
|
||||
element: ModuleConfig,
|
||||
},
|
||||
{
|
||||
name: "App Config",
|
||||
icon: ApplicationsIcon,
|
||||
icon: <WindowIcon className="h-4" />,
|
||||
element: AppConfig,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
|
||||
export const Environment = (): JSX.Element => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<div>
|
||||
{nodes.map((node, index) => (
|
||||
<Pane key={index}>{JSON.stringify(node.environmentMetrics)}</Pane>
|
||||
<div key={index}>{JSON.stringify(node.environmentMetrics)}</div>
|
||||
))}
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
|
||||
export interface File {
|
||||
nameModified: string;
|
||||
name: string;
|
||||
@@ -33,9 +31,9 @@ export const FileBrowser = (): JSX.Element => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<div>
|
||||
{data?.data.files.map((file) => (
|
||||
<Pane key={file.name}>
|
||||
<div key={file.name}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -43,8 +41,8 @@ export const FileBrowser = (): JSX.Element => {
|
||||
>
|
||||
{file.name.replace("static/", "").replace(".gz", "")}
|
||||
</a>
|
||||
</Pane>
|
||||
</div>
|
||||
))}
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type React from "react";
|
||||
|
||||
import { DocumentIcon, GanttChartIcon, RainIcon } from "evergreen-ui";
|
||||
|
||||
import { TabbedContent, TabType } from "@components/layout/page/TabbedContent";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import {
|
||||
CloudIcon,
|
||||
DocumentIcon,
|
||||
SignalIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Environment } from "@pages/Extensions/Environment.js";
|
||||
import { FileBrowser } from "@pages/Extensions/FileBrowser";
|
||||
|
||||
@@ -13,19 +16,19 @@ export const ExtensionsPage = (): JSX.Element => {
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
name: "File Browser",
|
||||
icon: DocumentIcon,
|
||||
icon: <DocumentIcon className="h-4" />,
|
||||
element: FileBrowser,
|
||||
disabled: !hardware.hasWifi,
|
||||
},
|
||||
{
|
||||
name: "Range Test",
|
||||
icon: GanttChartIcon,
|
||||
icon: <SignalIcon className="h-4" />,
|
||||
element: FileBrowser,
|
||||
disabled: !hardware.hasWifi,
|
||||
},
|
||||
{
|
||||
name: "Environment",
|
||||
icon: RainIcon,
|
||||
icon: <CloudIcon className="h-4" />,
|
||||
element: Environment,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Pane } from "evergreen-ui";
|
||||
import JSONPretty from "react-json-pretty";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
@@ -11,9 +10,9 @@ export const InfoPage = (): JSX.Element => {
|
||||
const myNode = nodes.find((n) => n.data.num === hardware.myNodeNum);
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<div>
|
||||
<JSONPretty data={myNode} />
|
||||
<JSONPretty data={hardware} />
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +1,29 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
Heading,
|
||||
IconButton,
|
||||
LocateIcon,
|
||||
majorScale,
|
||||
MapMarkerIcon,
|
||||
Pane,
|
||||
Text,
|
||||
} from "evergreen-ui";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Map, Marker, useMap } from "react-map-gl";
|
||||
|
||||
import { IconButton } from "@app/components/IconButton.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { MapPinIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const MapPage = (): JSX.Element => {
|
||||
const { nodes, waypoints } = useDevice();
|
||||
const { current: map } = useMap();
|
||||
|
||||
return (
|
||||
<Pane
|
||||
margin={majorScale(3)}
|
||||
borderRadius={majorScale(1)}
|
||||
elevation={1}
|
||||
display="flex"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
gap={majorScale(2)}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
<Pane
|
||||
position="absolute"
|
||||
zIndex={10}
|
||||
right={0}
|
||||
top={0}
|
||||
borderRadius={majorScale(1)}
|
||||
padding={majorScale(1)}
|
||||
margin={majorScale(1)}
|
||||
background="tint1"
|
||||
width={majorScale(28)}
|
||||
elevation={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Pane padding={majorScale(1)} background="tint2">
|
||||
<Heading>Title</Heading>
|
||||
</Pane>
|
||||
<Pane display="flex" flexDirection="column" gap={majorScale(1)}>
|
||||
<div className="flex-grow">
|
||||
<div className="absolute z-10 right-0 top-0 m-2 rounded-md p-2 shadow-md bg-white">
|
||||
<div className="font-medium text-lg p-1">Title</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{nodes.map((n) => (
|
||||
<Pane key={n.data.num} display="flex" gap={majorScale(1)}>
|
||||
<div key={n.data.num} className="flex gap-2">
|
||||
<Hashicon value={n.data.num.toString()} size={24} />
|
||||
<Text>{n.data.user?.longName}</Text>
|
||||
<div>{n.data.user?.longName}</div>
|
||||
<IconButton
|
||||
icon={LocateIcon}
|
||||
marginLeft="auto"
|
||||
size="small"
|
||||
icon={<MapPinIcon className="h-4" />}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (n.data.position?.latitudeI) {
|
||||
map?.flyTo({
|
||||
@@ -68,10 +36,10 @@ export const MapPage = (): JSX.Element => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pane>
|
||||
</div>
|
||||
))}
|
||||
</Pane>
|
||||
</Pane>
|
||||
</div>
|
||||
</div>
|
||||
<Map
|
||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||
mapLib={maplibregl}
|
||||
@@ -84,9 +52,9 @@ export const MapPage = (): JSX.Element => {
|
||||
latitude={wp.latitudeI / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<Pane>
|
||||
<MapMarkerIcon />
|
||||
</Pane>
|
||||
<div>
|
||||
<MapPinIcon className="h-4" />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{nodes.map((n) => {
|
||||
@@ -104,6 +72,6 @@ export const MapPage = (): JSX.Element => {
|
||||
}
|
||||
})}
|
||||
</Map>
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,19 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
CircleIcon,
|
||||
EditIcon,
|
||||
IconButton,
|
||||
Pane,
|
||||
RingIcon,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { IconButton } from "@app/components/IconButton.js";
|
||||
import {
|
||||
TabbedContent,
|
||||
TabType,
|
||||
} from "@components/layout/page/TabbedContent.js";
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import {
|
||||
EllipsisHorizontalCircleIcon,
|
||||
PencilIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
import { ChannelChat } from "./ChannelChat.js";
|
||||
|
||||
export const MessagesPage = (): JSX.Element => {
|
||||
const { channels, setActivePage } = useDevice();
|
||||
|
||||
@@ -28,29 +24,32 @@ export const MessagesPage = (): JSX.Element => {
|
||||
: channel.config.index === 0
|
||||
? "Primary"
|
||||
: `Ch ${channel.config.index}`,
|
||||
icon: channel.messages.length ? RingIcon : CircleIcon,
|
||||
icon: channel.messages.length ? (
|
||||
<EllipsisHorizontalCircleIcon className="h-4" />
|
||||
) : (
|
||||
<XCircleIcon className="h-4" />
|
||||
),
|
||||
element: () => <ChannelChat channel={channel} />,
|
||||
disabled: channel.config.role === Protobuf.Channel_Role.DISABLED,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Pane display="flex" flexDirection="column" width="100%">
|
||||
<div className="flex flex-col w-full">
|
||||
<TabbedContent
|
||||
tabs={tabs}
|
||||
actions={[
|
||||
() => (
|
||||
<Tooltip content="Edit Channels">
|
||||
<IconButton
|
||||
icon={EditIcon}
|
||||
onClick={() => {
|
||||
setActivePage("channels");
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
icon={<PencilIcon className="h-4" />}
|
||||
onClick={() => {
|
||||
setActivePage("channels");
|
||||
}}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import type React from "react";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
import {
|
||||
AddLocationIcon,
|
||||
IconButton,
|
||||
majorScale,
|
||||
Pane,
|
||||
Popover,
|
||||
SendMessageIcon,
|
||||
TextInputField,
|
||||
Tooltip,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import type { Channel } from "@core/stores/deviceStore.js";
|
||||
import { Message } from "@pages/Messages/Message.js";
|
||||
import { NewLocationMessage } from "@pages/Messages/NewLocationMessage.js";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
export const ChannelChat = ({ channel }: ChannelChatProps): JSX.Element => {
|
||||
const { nodes, connection, ackMessage } = useDevice();
|
||||
const [currentMessage, setCurrentMessage] = useState("");
|
||||
|
||||
const sendMessage = (): void => {
|
||||
void connection?.sendText(
|
||||
currentMessage,
|
||||
undefined,
|
||||
true,
|
||||
channel.config.index,
|
||||
(id) => {
|
||||
ackMessage(channel.config.index, id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
setCurrentMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Pane display="flex" flexDirection="column" flexGrow={1}>
|
||||
<Pane display="flex" flexDirection="column" flexGrow={1}>
|
||||
{channel.messages.map((message, index) => (
|
||||
<Message
|
||||
key={index}
|
||||
message={message}
|
||||
lastMsgSameUser={
|
||||
index === 0
|
||||
? false
|
||||
: channel.messages[index - 1].packet.from ===
|
||||
message.packet.from
|
||||
}
|
||||
sender={
|
||||
nodes.find((node) => node.data.num === message.packet.from)?.data
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<form
|
||||
style={{ display: "flex", flexGrow: 1 }}
|
||||
onSubmit={(e): void => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
<Pane display="flex" flexGrow={1} gap={majorScale(1)}>
|
||||
<TextInputField
|
||||
marginTop="auto"
|
||||
minLength={2}
|
||||
width="100%"
|
||||
label=""
|
||||
placeholder="Enter Message"
|
||||
marginBottom={0}
|
||||
value={currentMessage}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Tooltip content="Send">
|
||||
<IconButton
|
||||
icon={SendMessageIcon}
|
||||
marginTop={majorScale(2)}
|
||||
width={majorScale(8)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
</form>
|
||||
<Tooltip content="Send Location">
|
||||
<Popover content={<NewLocationMessage />}>
|
||||
<IconButton
|
||||
icon={AddLocationIcon}
|
||||
marginTop={majorScale(2)}
|
||||
width={majorScale(8)}
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
CircleIcon,
|
||||
FullCircleIcon,
|
||||
majorScale,
|
||||
Pane,
|
||||
Small,
|
||||
Strong,
|
||||
Text,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import type { AllMessageTypes } from "@app/core/stores/deviceStore.js";
|
||||
import { WaypointMessage } from "@app/pages/Messages/WaypointMessage.js";
|
||||
import { useDevice } from "@core/providers/useDevice.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import type { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: AllMessageTypes;
|
||||
sender?: Protobuf.NodeInfo;
|
||||
}
|
||||
|
||||
export const Message = ({
|
||||
lastMsgSameUser,
|
||||
message,
|
||||
sender,
|
||||
}: MessageProps): JSX.Element => {
|
||||
const { setPeerInfoOpen, setActivePeer } = useDevice();
|
||||
|
||||
const openPeer = (): void => {
|
||||
setActivePeer(message.packet.from);
|
||||
setPeerInfoOpen(true);
|
||||
};
|
||||
|
||||
return lastMsgSameUser ? (
|
||||
<Pane display="flex" marginLeft={majorScale(3)}>
|
||||
{message.ack ? (
|
||||
<FullCircleIcon color="#9c9fab" marginY="auto" size={8} />
|
||||
) : (
|
||||
<CircleIcon color="#9c9fab" marginY="auto" size={8} />
|
||||
)}
|
||||
{"waypointID" in message ? (
|
||||
<WaypointMessage waypointID={message.waypointID} />
|
||||
) : (
|
||||
<Text
|
||||
color={message.ack ? "#474d66" : "#9c9fab"}
|
||||
marginLeft={majorScale(2)}
|
||||
paddingLeft={majorScale(1)}
|
||||
borderLeft="3px solid #e6e6e6"
|
||||
>
|
||||
{message.text}
|
||||
</Text>
|
||||
)}
|
||||
</Pane>
|
||||
) : (
|
||||
<Pane marginX={majorScale(2)} gap={majorScale(1)} marginTop={majorScale(1)}>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<Pane onClick={openPeer} cursor="pointer" width={majorScale(3)}>
|
||||
<Hashicon value={(sender?.num ?? 0).toString()} size={32} />
|
||||
</Pane>
|
||||
<Strong onClick={openPeer} cursor="pointer" size={500}>
|
||||
{sender?.user?.longName ?? "UNK"}
|
||||
</Strong>
|
||||
<Small>
|
||||
{new Date(message.packet.rxTime).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Small>
|
||||
</Pane>
|
||||
<Pane display="flex" marginLeft={majorScale(1)}>
|
||||
{message.ack ? (
|
||||
<FullCircleIcon color="#9c9fab" marginY="auto" size={8} />
|
||||
) : (
|
||||
<CircleIcon color="#9c9fab" marginY="auto" size={8} />
|
||||
)}
|
||||
{"waypointID" in message ? (
|
||||
<WaypointMessage waypointID={message.waypointID} />
|
||||
) : (
|
||||
<Text
|
||||
color={message.ack ? "#474d66" : "#9c9fab"}
|
||||
marginLeft={majorScale(2)}
|
||||
paddingLeft={majorScale(1)}
|
||||
borderLeft="3px solid #e6e6e6"
|
||||
>
|
||||
{message.text}
|
||||
</Text>
|
||||
)}
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
Heading,
|
||||
LocateIcon,
|
||||
majorScale,
|
||||
minorScale,
|
||||
Pane,
|
||||
Small,
|
||||
Text,
|
||||
} from "evergreen-ui";
|
||||
|
||||
import { useDevice } from "@app/core/providers/useDevice.js";
|
||||
import { toMGRS } from "@core/utils/toMGRS.js";
|
||||
|
||||
export interface WaypointMessageProps {
|
||||
waypointID: number;
|
||||
}
|
||||
|
||||
export const WaypointMessage = ({
|
||||
waypointID,
|
||||
}: WaypointMessageProps): JSX.Element => {
|
||||
const { waypoints } = useDevice();
|
||||
const waypoint = waypoints.find((wp) => wp.id === waypointID);
|
||||
|
||||
return (
|
||||
<Pane
|
||||
marginLeft={majorScale(2)}
|
||||
paddingLeft={majorScale(1)}
|
||||
borderLeft="3px solid #e6e6e6"
|
||||
>
|
||||
<Pane
|
||||
gap={majorScale(1)}
|
||||
display="flex"
|
||||
borderRadius={majorScale(1)}
|
||||
elevation={1}
|
||||
padding={minorScale(1)}
|
||||
>
|
||||
<LocateIcon color="#474d66" marginY="auto" />
|
||||
<Pane>
|
||||
<Pane display="flex" gap={majorScale(1)}>
|
||||
<Heading>{waypoint?.name}</Heading>
|
||||
<Text color="orange">
|
||||
{toMGRS(waypoint?.latitudeI, waypoint?.longitudeI)}
|
||||
</Text>
|
||||
</Pane>
|
||||
<Small>{waypoint?.description}</Small>
|
||||
</Pane>
|
||||
</Pane>
|
||||
</Pane>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,13 @@
|
||||
import { IsBoolean, IsEnum } from "class-validator";
|
||||
import { IsBoolean, IsEnum } from 'class-validator';
|
||||
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export class DeviceValidation implements Protobuf.Config_DeviceConfig {
|
||||
@IsEnum(Protobuf.Config_DeviceConfig_Role)
|
||||
role: Protobuf.Config_DeviceConfig_Role;
|
||||
|
||||
@IsBoolean()
|
||||
serialDisabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
factoryReset: boolean;
|
||||
serialEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
debugLogEnabled: boolean;
|
||||
|
||||
@@ -3,10 +3,8 @@ import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
|
||||
import { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
|
||||
export class LoRaValidation implements Protobuf.Config_LoRaConfig {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
txPower: number;
|
||||
@IsBoolean()
|
||||
usePreset: boolean;
|
||||
|
||||
@IsEnum(Protobuf.Config_LoRaConfig_ModemPreset)
|
||||
modemPreset: Protobuf.Config_LoRaConfig_ModemPreset;
|
||||
@@ -36,7 +34,11 @@ export class LoRaValidation implements Protobuf.Config_LoRaConfig {
|
||||
hopLimit: number;
|
||||
|
||||
@IsBoolean()
|
||||
txDisabled: boolean;
|
||||
txEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
txPower: number;
|
||||
|
||||
@IsArray()
|
||||
ignoreIncoming: number[];
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { IsBoolean, IsInt } from 'class-validator';
|
||||
|
||||
import type { Protobuf } from "@meshtastic/meshtasticjs";
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export class PositionValidation implements Protobuf.Config_PositionConfig {
|
||||
@IsInt()
|
||||
positionBroadcastSecs: number;
|
||||
|
||||
@IsBoolean()
|
||||
positionBroadcastSmartDisabled: boolean;
|
||||
positionBroadcastSmartEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
fixedPosition: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
gpsDisabled: boolean;
|
||||
gpsEnabled: boolean;
|
||||
|
||||
@IsInt()
|
||||
gpsUpdateInterval: number;
|
||||
|
||||
8
tailwind.config.cjs
Normal file
8
tailwind.config.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user