Channel cleanup & sidebar unification

This commit is contained in:
Sacha Weatherstone
2022-01-17 01:35:45 +11:00
parent 568867de27
commit b929ca0136
17 changed files with 612 additions and 481 deletions

View File

@@ -11,7 +11,7 @@ import { requestNotificationPermission } from '@core/utils/notifications';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Messages } from '@pages/Messages';
import { Nodes } from '@pages/Nodes/Index';
import { Nodes } from '@pages/Nodes';
import { NotFound } from '@pages/NotFound';
import { Plugins } from '@pages/Plugins/Index';
import { Settings } from '@pages/settings/Index';
@@ -46,7 +46,6 @@ export const App = (): JSX.Element => {
);
}
// Notification.permission === ''
requestNotificationPermission().catch((e) => {
console.log(e);
});

View File

@@ -1,252 +0,0 @@
import React from 'react';
import { fromByteArray, toByteArray } from 'base64-js';
import { useForm, useWatch } from 'react-hook-form';
import { FaQrcode } from 'react-icons/fa';
import {
FiChevronDown,
FiChevronUp,
FiCode,
FiRotateCcw,
FiSave,
} from 'react-icons/fi';
import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
import JSONPretty from 'react-json-pretty';
import QRCode from 'react-qr-code';
import { Loading } from '@components/generic/Loading';
import { Modal } from '@components/generic/Modal';
import { connection } from '@core/connection';
import { Disclosure } from '@headlessui/react';
import {
Card,
Checkbox,
IconButton,
Input,
Select,
} from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface ChannelProps {
channel: Protobuf.Channel;
}
export const Channel = ({ channel }: ChannelProps): JSX.Element => {
const [loading, setLoading] = React.useState(false);
const [showQr, setShowQr] = React.useState(false);
const [keySize, setKeySize] = React.useState<128 | 256>(256);
const [pskHidden, setPskHidden] = React.useState(true);
const [showDebug, setShowDebug] = React.useState(false);
const { register, handleSubmit, setValue, control, formState, reset } =
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)),
},
});
const watchPsk = useWatch({
control,
name: 'psk',
defaultValue: '',
});
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 (
<>
<Modal
open={showDebug}
onClose={(): void => {
setShowDebug(false);
}}
>
<Card>
<div className="p-10 overflow-y-auto text-left max-h-96">
<JSONPretty data={channel} />
</div>
</Card>
</Modal>
<Modal
open={showQr}
onClose={(): void => {
setShowQr(false);
}}
>
<Card>
<QRCode
className="rounded-md"
value={`https://www.meshtastic.org/d/#${watchPsk}`}
/>
</Card>
</Modal>
<Disclosure
as="div"
className="bg-gray-100 rounded-md dark:bg-secondaryDark"
>
{({ open }): JSX.Element => (
<>
<Disclosure.Button
as="div"
className="relative flex justify-between p-3"
>
<>
<div className="flex my-auto space-x-2">
<div
className={`h-3 my-auto w-3 rounded-full ${
[
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel.role)
? 'bg-green-500'
: 'bg-gray-400'
}`}
/>
<div>
{channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channel.index}`}
</div>
</div>
<div className="flex gap-2">
{open && (
<>
<IconButton
onClick={(e): void => {
e.stopPropagation();
reset();
}}
disabled={loading || !formState.isDirty}
icon={<FiRotateCcw />}
/>
<IconButton
onClick={async (e): Promise<void> => {
e.stopPropagation();
await onSubmit();
}}
disabled={loading || !formState.isDirty}
icon={<FiSave />}
/>
</>
)}
<IconButton
onClick={(e): void => {
e.stopPropagation();
setShowDebug(true);
}}
icon={<FiCode className="w-5 h-5" />}
/>
<IconButton
onClick={(e): void => {
e.stopPropagation();
setShowQr(true);
}}
icon={<FaQrcode />}
/>
<IconButton
icon={open ? <FiChevronUp /> : <FiChevronDown />}
/>
</div>
</>
</Disclosure.Button>
<Disclosure.Panel className="p-2 border-t border-gray-300 dark:border-gray-600">
{loading && <Loading />}
<div className="flex px-2 my-auto">
<form className="w-full gap-3">
{channel.index !== 0 && (
<>
<Checkbox
label="Enabled"
{...register('enabled', { valueAsNumber: true })}
/>
<Input label="Name" {...register('name')} />
</>
)}
<Select
label="Key Size"
options={[
{ name: '128 Bit', value: 128 },
{ name: '256 Bit', value: 256 },
]}
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
/>
<Input
label="Pre-Shared Key"
type={pskHidden ? 'password' : 'text'}
disabled
action={
<>
<IconButton
onClick={(): void => {
setPskHidden(!pskHidden);
}}
icon={
pskHidden ? <MdVisibility /> : <MdVisibilityOff />
}
/>
<IconButton
onClick={(): void => {
const key = new Uint8Array(keySize);
crypto.getRandomValues(key);
setValue('psk', fromByteArray(key));
}}
icon={<MdRefresh />}
/>
</>
}
{...register('psk')}
/>
<Checkbox
label="Uplink Enabled"
{...register('uplinkEnabled')}
/>
<Checkbox
label="Downlink Enabled"
{...register('downlinkEnabled')}
/>
</form>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
</>
);
};

View File

@@ -0,0 +1,46 @@
import type React from 'react';
import { FiX } from 'react-icons/fi';
import { IconButton } from '@meshtastic/components';
export interface SidebarProps {
title: string;
tagline: string;
footer?: JSX.Element;
closeSidebar: () => void;
children: React.ReactNode;
}
export const Sidebar = ({
title,
tagline,
closeSidebar,
children,
}: SidebarProps): JSX.Element => {
return (
<div className="absolute z-50 flex flex-col w-full h-full bg-white border-l border-gray-300 md:z-10 md:max-w-sm md:static min-w-max dark:border-gray-600 dark:bg-secondaryDark">
<div className="p-2">
<div className="flex justify-between">
<div>
<h3 className="text-xs font-medium text-gray-400">{title}</h3>
<h1 className="text-lg font-medium truncate">{tagline}</h1>
</div>
<div className="mb-auto">
<IconButton
onClick={(): void => {
closeSidebar();
}}
icon={<FiX />}
/>
</div>
</div>
</div>
{children ?? (
<div className="flex flex-grow bg-gray-100 dark:bg-primaryDark">
<div className="m-auto text-lg font-medium">Please select item</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,31 @@
import type React from 'react';
import { FiCheck, FiClipboard } from 'react-icons/fi';
import useCopyClipboard from 'react-use-clipboard';
import type { ButtonProps } from '@meshtastic/components';
import { IconButton } from '@meshtastic/components';
export interface CopyButtonProps extends ButtonProps {
data: string;
}
export const CopyButton = ({
data,
...props
}: CopyButtonProps): JSX.Element => {
const [isCopied, setCopied] = useCopyClipboard(data, {
successDuration: 1000,
});
return (
<IconButton
placeholder={``}
onClick={(): void => {
setCopied();
}}
icon={isCopied ? <FiCheck /> : <FiClipboard />}
{...props}
/>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { FiCode, FiMapPin, FiSliders, FiUser } from 'react-icons/fi';
import { IoTelescope } from 'react-icons/io5';
import { DebugPanel } from '@app/components/pages/nodes/panels/DebugPanel';
import { InfoPanel } from '@app/components/pages/nodes/panels/InfoPanel';
import { PositionPanel } from '@app/components/pages/nodes/panels/PositionPanel';
import { Sidebar } from '@components/generic/Sidebar';
import { TabButton } from '@components/TabButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface NodeSidebarProps {
node: Node;
closeSidebar: () => void;
}
export const NodeSidebar = ({
node,
closeSidebar,
}: NodeSidebarProps): JSX.Element => {
return (
<Sidebar
title={node.number.toString()}
tagline={`${node.user?.longName}(${node.user?.shortName})`}
closeSidebar={closeSidebar}
>
<Tab.Group>
<div className="shadow-md">
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
<TabButton>
<FiUser />
</TabButton>
<TabButton>
<FiMapPin />
</TabButton>
<TabButton>
<IoTelescope />
</TabButton>
<TabButton>
<FiSliders />
</TabButton>
<TabButton>
<FiCode />
</TabButton>
</Tab.List>
</div>
<Tab.Panels className="flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
<InfoPanel />
<PositionPanel node={node} />
<Tab.Panel className="p-2">Content 3</Tab.Panel>
<Tab.Panel className="p-2">Remote Administration</Tab.Panel>
<DebugPanel node={node} />
</Tab.Panels>
</Tab.Group>
</Sidebar>
);
};

View File

@@ -0,0 +1,22 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface DebugPanelProps {
node: Node;
}
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</Tab.Panel>
);
};

View File

@@ -0,0 +1,7 @@
import type React from 'react';
import { Tab } from '@headlessui/react';
export const InfoPanel = (): JSX.Element => {
return <Tab.Panel className="p-2">Info</Tab.Panel>;
};

View File

@@ -0,0 +1,33 @@
import type React from 'react';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface PositionPanelProps {
node: Node;
}
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
return (
<Tab.Panel className="p-2">
{node.currentPosition && (
<div className="flex justify-between h-10 px-1 text-gray-500 bg-transparent bg-gray-200 border border-gray-300 rounded-md select-none dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
<div className="px-1 my-auto">
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
<CopyButton
data={
node.currentPosition
? `${node.currentPosition.latitudeI / 1e7},${
node.currentPosition.longitudeI / 1e7
}`
: ''
}
/>
</div>
)}
</Tab.Panel>
);
};

View File

@@ -0,0 +1,60 @@
import type React from 'react';
import { FaQrcode } from 'react-icons/fa';
import { FiCode, FiSliders } from 'react-icons/fi';
import { TabButton } from '@app/components/TabButton';
import { Sidebar } from '@components/generic/Sidebar';
import { Tab } from '@headlessui/react';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { DebugPanel } from './panels/DebugPanel';
import { QRCodePanel } from './panels/QRCodePanel';
import { SettingsPanel } from './panels/SettingsPanel';
export interface ChannelsSidebarProps {
channel?: Protobuf.Channel;
closeSidebar: () => void;
}
export const ChannelsSidebar = ({
channel,
closeSidebar,
}: ChannelsSidebarProps): JSX.Element => {
return (
<Sidebar
title={
channel
? channel.settings?.name.length
? channel.settings.name
: `Channel: ${channel.index}`
: 'Please select channel'
}
tagline={channel ? Protobuf.Channel_Role[channel.role] : '...'}
closeSidebar={closeSidebar}
>
{channel && (
<Tab.Group>
<div className="shadow-md">
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
<TabButton>
<FiSliders />
</TabButton>
<TabButton>
<FaQrcode />
</TabButton>
<TabButton>
<FiCode />
</TabButton>
</Tab.List>
</div>
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
<SettingsPanel channel={channel} />
<QRCodePanel channel={channel} />
<DebugPanel channel={channel} />
</Tab.Panels>
</Tab.Group>
)}
</Sidebar>
);
};

View File

@@ -0,0 +1,22 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface DebugPanelProps {
channel: Protobuf.Channel;
}
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(channel)} />
</div>
<JSONPretty className="max-w-sm" data={channel} />
</Tab.Panel>
);
};

View File

@@ -0,0 +1,23 @@
import type React from 'react';
import QRCode from 'react-qr-code';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface QRCodePanelProps {
channel: Protobuf.Channel;
}
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
return (
<Tab.Panel className="flex flex-grow p-2">
<div className="m-auto">
<QRCode
className="rounded-md"
value={`https://www.meshtastic.org/d/#${channel.index}`}
/>
</div>
</Tab.Panel>
);
};

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { fromByteArray, toByteArray } from 'base64-js';
import { useForm } from 'react-hook-form';
import { FiSave } from 'react-icons/fi';
import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
import { Loading } from '@app/components/generic/Loading';
import { connection } from '@app/core/connection';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface SettingsPanelProps {
channel: Protobuf.Channel;
}
export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
const [loading, setLoading] = React.useState(false);
const [keySize, setKeySize] = React.useState<128 | 256>(256);
const [pskHidden, setPskHidden] = React.useState(true);
const { register, handleSubmit, setValue, formState, reset } = 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)),
},
});
React.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 (
<Tab.Panel className="flex flex-col w-full">
{loading && <Loading />}
<form className="flex-grow gap-3 p-2">
{channel?.index !== 0 && (
<>
<Checkbox
label="Enabled"
{...register('enabled', { valueAsNumber: true })}
/>
<Input label="Name" {...register('name')} />
</>
)}
<Select
label="Key Size"
options={[
{ name: '128 Bit', value: 128 },
{ name: '256 Bit', value: 256 },
]}
value={keySize}
onChange={(e): void => {
setKeySize(parseInt(e.target.value) as 128 | 256);
}}
/>
<Input
label="Pre-Shared Key"
type={pskHidden ? 'password' : 'text'}
disabled
action={
<>
<IconButton
onClick={(): void => {
setPskHidden(!pskHidden);
}}
icon={pskHidden ? <MdVisibility /> : <MdVisibilityOff />}
/>
<IconButton
onClick={(): void => {
const key = new Uint8Array(keySize);
crypto.getRandomValues(key);
setValue('psk', fromByteArray(key));
}}
icon={<MdRefresh />}
/>
</>
}
{...register('psk')}
/>
<Checkbox label="Uplink Enabled" {...register('uplinkEnabled')} />
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
</form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
await onSubmit();
}}
icon={<FiSave />}
/>
</div>
</div>
</Tab.Panel>
);
};

View File

@@ -19,7 +19,7 @@ export const PrimaryTemplate = ({
}: PrimaryTemplateProps): JSX.Element => {
return (
<div className="flex flex-col flex-auto h-full min-w-0">
<div className="flex p-4 bg-white border-b border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
<div className="flex p-2 bg-white border-b border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
<div className="flex-1 min-w-0">
<a className="font-medium whitespace-nowrap text-primary">
{tagline}

View File

@@ -4,17 +4,15 @@ import mapbox from 'mapbox-gl';
import { FiMapPin, FiXCircle } from 'react-icons/fi';
import { Marker } from '@app/components/Map/Marker';
import type { Node } from '@app/core/slices/meshtasticSlice.js';
import type { Node } from '@app/core/slices/meshtasticSlice';
import { Drawer } from '@components/generic/Drawer';
import { Map } from '@components/Map';
import { NodeSidebar } from '@components/pages/nodes/NodeSidebar';
import { useAppSelector } from '@hooks/useAppSelector';
import { useBreakpoint } from '@hooks/useBreakpoint';
import { IconButton } from '@meshtastic/components';
import { NodeCard } from './NodeCard';
import { Sidebar } from './Sidebar';
// const getMarkerRadius = ():number => {}
import { NodeCard } from '../components/pages/nodes/NodeCard';
export const Nodes = (): JSX.Element => {
const myNodeInfo = useAppSelector((state) => state.meshtastic.radio.hardware);
@@ -28,9 +26,9 @@ export const Nodes = (): JSX.Element => {
);
const myNode = nodes.find((node) => node.number === myNodeInfo.myNodeNum);
const [navOpen, setNavOpen] = React.useState(false);
const { breakpoint } = useBreakpoint();
const [navOpen, setNavOpen] = React.useState(false);
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const [selectedNode, setSelectedNode] = React.useState<Node | undefined>();
@@ -119,7 +117,7 @@ export const Nodes = (): JSX.Element => {
<Map />
{sidebarOpen && selectedNode && (
<Sidebar
<NodeSidebar
closeSidebar={(): void => {
setSidebarOpen(false);
}}

View File

@@ -1,119 +0,0 @@
import React from 'react';
import {
FiCheck,
FiClipboard,
FiCode,
FiMapPin,
FiSliders,
FiUser,
FiX,
} from 'react-icons/fi';
import { IoTelescope } from 'react-icons/io5';
import JSONPretty from 'react-json-pretty';
import useCopyClipboard from 'react-use-clipboard';
import { TabButton } from '@app/components/TabButton';
import type { Node } from '@app/core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
import { IconButton } from '@meshtastic/components';
export interface SidebarProps {
node: Node;
closeSidebar: () => void;
}
export const Sidebar = ({ node, closeSidebar }: SidebarProps): JSX.Element => {
const [toCopy, setToCopy] = React.useState<string>('');
const [isCopied, setCopied] = useCopyClipboard(toCopy, {
successDuration: 1000,
});
return (
<div className="absolute z-50 flex flex-col w-full h-full bg-white border-l border-gray-300 md:z-10 md:max-w-sm md:static min-w-max dark:border-gray-600 dark:bg-secondaryDark">
<Tab.Group>
<div className="shadow-md">
<div className="p-2">
<div className="flex justify-between">
<div>
<h3 className="text-xs font-medium text-gray-400">
{node.number}
</h3>
<h1 className="text-lg font-medium truncate">
{node.user?.longName}({node.user?.shortName})
</h1>
</div>
<div className="mb-auto">
<IconButton
onClick={(): void => {
closeSidebar();
}}
icon={<FiX />}
/>
</div>
</div>
</div>
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600">
<TabButton>
<FiUser />
</TabButton>
<TabButton>
<FiMapPin />
</TabButton>
<TabButton>
<IoTelescope />
</TabButton>
<TabButton>
<FiSliders />
</TabButton>
<TabButton>
<FiCode />
</TabButton>
</Tab.List>
</div>
<Tab.Panels className="flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark">
<Tab.Panel className="p-2">Content 1</Tab.Panel>
<Tab.Panel className="p-2">
{node.currentPosition && (
<div className="flex justify-between h-10 px-1 text-gray-500 bg-transparent bg-gray-200 border border-gray-300 rounded-md select-none dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
<div className="px-1 my-auto">
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
<IconButton
placeholder={``}
onClick={(): void => {
setToCopy(
node.currentPosition
? `${node.currentPosition.latitudeI / 1e7},${
node.currentPosition.longitudeI / 1e7
}`
: '',
);
setCopied();
}}
icon={isCopied ? <FiCheck /> : <FiClipboard />}
/>
</div>
)}
</Tab.Panel>
<Tab.Panel className="p-2">Content 3</Tab.Panel>
<Tab.Panel className="p-2">Remote Administration</Tab.Panel>
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<IconButton
onClick={(): void => {
setToCopy(JSON.stringify(node));
setCopied();
}}
icon={isCopied ? <FiCheck /> : <FiClipboard />}
/>
</div>
<JSONPretty className="max-w-sm" data={node} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
};

View File

@@ -1,16 +1,21 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty';
import { FiExternalLink, FiMenu, FiX } from 'react-icons/fi';
import {
RiArrowDownLine,
RiArrowUpDownLine,
RiArrowUpLine,
} from 'react-icons/ri';
import { Channel } from '@components/Channel';
import { Tooltip } from '@app/components/generic/Tooltip';
import type { ChannelData } from '@app/core/slices/meshtasticSlice';
import { FormFooter } from '@components/FormFooter';
import { Cover } from '@components/generic/Cover';
import { Loading } from '@components/generic/Loading';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
import { connection } from '@core/connection';
import { useAppSelector } from '@hooks/useAppSelector';
import { useBreakpoint } from '@hooks/useBreakpoint';
import {
Card,
Checkbox,
@@ -20,6 +25,8 @@ import {
} from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { ChannelsSidebar } from '../../components/pages/settings/channels/ChannelsSidebar';
export interface ChannelsProps {
navOpen?: boolean;
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
@@ -34,10 +41,13 @@ export const Channels = ({
channels.find(
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY,
) ?? channels[0];
const { breakpoint } = useBreakpoint();
const [usePreset, setUsePreset] = React.useState(true);
const [debug, setDebug] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [sidebarOpen, setSidebarOpen] = React.useState(breakpoint !== 'sm');
const [selectedChannel, setSelectedChannel] = React.useState<
ChannelData | undefined
>();
const { register, handleSubmit, reset, formState } = useForm<
DeepOmit<Protobuf.Channel, 'psk'>
@@ -66,102 +76,155 @@ export const Channels = ({
});
return (
<PrimaryTemplate
title="Channels"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
rightButton={
<IconButton
icon={<FiCode className="w-5 h-5" />}
active={debug}
onClick={(): void => {
setDebug(!debug);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<div className="space-y-4">
{adminChannel && (
<Card>
{loading && <Loading />}
<div className="w-full max-w-3xl p-10 md:max-w-xl">
{/* TODO: get gap working */}
<Checkbox
checked={usePreset}
label="Use Presets"
onChange={(e): void => setUsePreset(e.target.checked)}
/>
<form onSubmit={onSubmit}>
{usePreset ? (
<Select
label="Preset"
optionsEnum={Protobuf.ChannelSettings_ModemConfig}
{...register('settings.modemConfig', {
valueAsNumber: true,
})}
/>
) : (
<>
<Input
label="Bandwidth"
type="number"
suffix="MHz"
{...register('settings.bandwidth', {
valueAsNumber: true,
})}
/>
<Input
label="Spread Factor"
type="number"
suffix="CPS"
min={7}
max={12}
{...register('settings.spreadFactor', {
valueAsNumber: true,
})}
/>
<Input
label="Coding Rate"
type="number"
{...register('settings.codingRate', {
valueAsNumber: true,
})}
/>
</>
)}
<Input
label="Transmit Power"
type="number"
suffix="dBm"
{...register('settings.txPower', { valueAsNumber: true })}
<>
<PrimaryTemplate
title="Channels"
tagline="Settings"
leftButton={
<IconButton
icon={<FiMenu className="w-5 h-5" />}
onClick={(): void => {
setNavOpen && setNavOpen(!navOpen);
}}
/>
}
footer={
<FormFooter
dirty={formState.isDirty}
saveAction={onSubmit}
clearAction={reset}
/>
}
>
<div className="space-y-4">
{adminChannel && (
<Card>
{loading && <Loading />}
<div className="w-full max-w-3xl p-10 md:max-w-xl">
{/* TODO: get gap working */}
<Checkbox
checked={usePreset}
label="Use Presets"
onChange={(e): void => setUsePreset(e.target.checked)}
/>
</form>
<form onSubmit={onSubmit}>
{usePreset ? (
<Select
label="Preset"
optionsEnum={Protobuf.ChannelSettings_ModemConfig}
{...register('settings.modemConfig', {
valueAsNumber: true,
})}
/>
) : (
<>
<Input
label="Bandwidth"
type="number"
suffix="MHz"
{...register('settings.bandwidth', {
valueAsNumber: true,
})}
/>
<Input
label="Spread Factor"
type="number"
suffix="CPS"
min={7}
max={12}
{...register('settings.spreadFactor', {
valueAsNumber: true,
})}
/>
<Input
label="Coding Rate"
type="number"
{...register('settings.codingRate', {
valueAsNumber: true,
})}
/>
</>
)}
<Input
label="Transmit Power"
type="number"
suffix="dBm"
{...register('settings.txPower', { valueAsNumber: true })}
/>
</form>
</div>
</Card>
)}
<Card>
<div className="w-full p-4 space-y-2 md:p-10">
{channels.map((channel) => (
<div
key={channel.channel.index}
onClick={(): void => {
setSelectedChannel(channel);
setSidebarOpen(true);
}}
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${
selectedChannel?.channel.index === channel.channel.index
? 'border-primary dark:border-primary'
: ''
}`}
>
<div className="flex my-auto space-x-2">
<div
className={`h-3 my-auto w-3 rounded-full ${
[
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY,
].find((role) => role === channel.channel.role)
? 'bg-green-500'
: 'bg-gray-400'
}`}
/>
<div>
{channel.channel.settings?.name.length
? channel.channel.settings.name
: channel.channel.role === Protobuf.Channel_Role.PRIMARY
? 'Primary'
: `Channel: ${channel.channel.index}`}
</div>
</div>
<div className="flex gap-2">
<Tooltip contents={`MQTT Status`}>
<div className="p-2 rounded-md">
{channel.channel.settings?.uplinkEnabled &&
channel.channel.settings?.downlinkEnabled ? (
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" />
) : channel.channel.settings?.uplinkEnabled ? (
<RiArrowUpLine className="p-0.5 group-active:scale-90" />
) : channel.channel.settings?.downlinkEnabled ? (
<RiArrowDownLine className="p-0.5 group-active:scale-90" />
) : (
<FiX className="p-0.5" />
)}
</div>
</Tooltip>
<IconButton
active={
selectedChannel?.channel.index === channel.channel.index
}
icon={<FiExternalLink />}
/>
</div>
</div>
))}
</div>
</Card>
)}
<Card>
<Cover enabled={debug} content={<JSONPretty data={channels} />} />
<div className="w-full p-4 space-y-2 md:p-10">
{channels.map((channel) => (
<Channel key={channel.channel.index} channel={channel.channel} />
))}
</div>
</Card>
</div>
</PrimaryTemplate>
</div>
</PrimaryTemplate>
{sidebarOpen && (
<ChannelsSidebar
closeSidebar={(): void => {
setSidebarOpen(false);
}}
channel={selectedChannel?.channel}
/>
)}
</>
);
};