87 changed files with 2877 additions and 3570 deletions
@ -1 +1,4 @@ |
|||||
{} |
{ |
||||
|
"tabWidth": 2, |
||||
|
"useTabs": false |
||||
|
} |
||||
|
|||||
File diff suppressed because it is too large
@ -0,0 +1,6 @@ |
|||||
|
module.exports = { |
||||
|
plugins: { |
||||
|
tailwindcss: {}, |
||||
|
autoprefixer: {}, |
||||
|
}, |
||||
|
} |
||||
@ -1,20 +1,37 @@ |
|||||
import type React from "react"; |
import type React from "react"; |
||||
|
|
||||
import { Pane } from "evergreen-ui"; |
|
||||
import { MapProvider } from "react-map-gl"; |
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"; |
import { PageRouter } from "./PageRouter.js"; |
||||
|
|
||||
export const App = (): JSX.Element => { |
export const App = (): JSX.Element => { |
||||
|
const { getDevice } = useDeviceStore(); |
||||
|
const { selectedDevice } = useAppStore(); |
||||
|
|
||||
|
const device = getDevice(selectedDevice); |
||||
|
|
||||
return ( |
return ( |
||||
<Pane display="flex"> |
<div className="h-full flex w-full"> |
||||
<AppLayout> |
<DeviceSelector /> |
||||
<MapProvider> |
|
||||
<PageRouter /> |
{device && ( |
||||
</MapProvider> |
<DeviceWrapper device={device}> |
||||
</AppLayout> |
<Sidebar /> |
||||
</Pane> |
<PageNav /> |
||||
|
<MapProvider> |
||||
|
<PageRouter /> |
||||
|
</MapProvider> |
||||
|
</DeviceWrapper> |
||||
|
)} |
||||
|
{selectedDevice === 0 && <NewDevice />} |
||||
|
</div> |
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
|
); |
||||
@ -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> |
||||
|
); |
||||
|
}); |
||||
@ -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> |
||||
|
); |
||||
|
}); |
||||
@ -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,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 DeviceContext = createContext<Device | undefined>(undefined); |
||||
|
|
||||
export const useDevice = (): Device => { |
export const useDevice = (): Device => { |
||||
const context = useContext(DeviceContext); |
const context = useContext(DeviceContext); |
||||
if (context === undefined) { |
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; |
return context; |
||||
}; |
}; |
||||
|
|||||
@ -0,0 +1,3 @@ |
|||||
|
@tailwind base; |
||||
|
@tailwind components; |
||||
|
@tailwind utilities; |
||||
@ -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,17 +1,15 @@ |
|||||
import type React from "react"; |
import type React from "react"; |
||||
|
|
||||
import { Pane } from "evergreen-ui"; |
|
||||
|
|
||||
import { useDevice } from "@core/providers/useDevice.js"; |
import { useDevice } from "@core/providers/useDevice.js"; |
||||
|
|
||||
export const Environment = (): JSX.Element => { |
export const Environment = (): JSX.Element => { |
||||
const { nodes } = useDevice(); |
const { nodes } = useDevice(); |
||||
|
|
||||
return ( |
return ( |
||||
<Pane> |
<div> |
||||
{nodes.map((node, index) => ( |
{nodes.map((node, index) => ( |
||||
<Pane key={index}>{JSON.stringify(node.environmentMetrics)}</Pane> |
<div key={index}>{JSON.stringify(node.environmentMetrics)}</div> |
||||
))} |
))} |
||||
</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> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,8 @@ |
|||||
|
/** @type {import('tailwindcss').Config} */ |
||||
|
module.exports = { |
||||
|
content: ["./index.html", "./src/**/*.{ts,tsx}"], |
||||
|
theme: { |
||||
|
extend: {}, |
||||
|
}, |
||||
|
plugins: [], |
||||
|
}; |
||||
Loading…
Reference in new issue