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 { Pane } from "evergreen-ui"; |
|||
import { MapProvider } from "react-map-gl"; |
|||
|
|||
import { AppLayout } from "@components/layout/AppLayout.js"; |
|||
import { useAppStore } from "@app/core/stores/appStore.js"; |
|||
import { DeviceWrapper } from "@app/DeviceWrapper.js"; |
|||
import { useDeviceStore } from "@core/stores/deviceStore.js"; |
|||
|
|||
import { DeviceSelector } from "./components/DeviceSelector.js"; |
|||
import { NewDevice } from "./components/NewDevice.js"; |
|||
import { PageNav } from "./components/PageNav.js"; |
|||
import { Sidebar } from "./components/Sidebar.js"; |
|||
import { PageRouter } from "./PageRouter.js"; |
|||
|
|||
export const App = (): JSX.Element => { |
|||
const { getDevice } = useDeviceStore(); |
|||
const { selectedDevice } = useAppStore(); |
|||
|
|||
const device = getDevice(selectedDevice); |
|||
|
|||
return ( |
|||
<Pane display="flex"> |
|||
<AppLayout> |
|||
<MapProvider> |
|||
<PageRouter /> |
|||
</MapProvider> |
|||
</AppLayout> |
|||
</Pane> |
|||
<div className="h-full flex w-full"> |
|||
<DeviceSelector /> |
|||
|
|||
{device && ( |
|||
<DeviceWrapper device={device}> |
|||
<Sidebar /> |
|||
<PageNav /> |
|||
<MapProvider> |
|||
<PageRouter /> |
|||
</MapProvider> |
|||
</DeviceWrapper> |
|||
)} |
|||
{selectedDevice === 0 && <NewDevice />} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
@ -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 useDevice = (): Device => { |
|||
const context = useContext(DeviceContext); |
|||
if (context === undefined) { |
|||
throw new Error("useDevice must be used within a ConnectionProvider"); |
|||
throw new Error("useDevice must be used within a DeviceProvider"); |
|||
} |
|||
return context; |
|||
}; |
|||
|
|||
@ -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 { Pane } from "evergreen-ui"; |
|||
|
|||
import { useDevice } from "@core/providers/useDevice.js"; |
|||
|
|||
export const Environment = (): JSX.Element => { |
|||
const { nodes } = useDevice(); |
|||
|
|||
return ( |
|||
<Pane> |
|||
<div> |
|||
{nodes.map((node, index) => ( |
|||
<Pane key={index}>{JSON.stringify(node.environmentMetrics)}</Pane> |
|||
<div key={index}>{JSON.stringify(node.environmentMetrics)}</div> |
|||
))} |
|||
</Pane> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
@ -1,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