109 changed files with 3495 additions and 3632 deletions
@ -1,3 +1,5 @@ |
|||||
dist |
dist |
||||
node_modules |
node_modules |
||||
.env |
.env |
||||
|
stats.html |
||||
|
.vercel |
||||
|
|||||
@ -1,4 +0,0 @@ |
|||||
{ |
|
||||
"singleQuote": true, |
|
||||
"trailingComma": "all" |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -0,0 +1,6 @@ |
|||||
|
module.exports = { |
||||
|
plugins: [require('prettier-plugin-tailwindcss')], |
||||
|
singleQuote: true, |
||||
|
trailingComma: 'all', |
||||
|
tailwindConfig: './tailwind.config.cjs', |
||||
|
}; |
||||
@ -1,27 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
|
|
||||
export interface TabButtonProps { |
|
||||
children: React.ReactNode; |
|
||||
} |
|
||||
|
|
||||
export const TabButton = ({ children }: TabButtonProps): JSX.Element => { |
|
||||
return ( |
|
||||
<Tab |
|
||||
className={({ selected }): string => |
|
||||
`border-gray-300 hover:border-b-2 dark:border-gray-600 w-full ${ |
|
||||
selected ? 'border-b-2' : 'border-b-0' |
|
||||
} ` |
|
||||
} |
|
||||
> |
|
||||
<div className="my-auto text-gray-500 group dark:text-gray-400"> |
|
||||
<div className="flex p-2 rounded-t-md hover:bg-gray-200 dark:hover:bg-gray-600"> |
|
||||
<div className="m-auto transition duration-200 ease-in-out group-active:scale-90"> |
|
||||
{children} |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</Tab> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,56 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import Avatar from 'boring-avatars'; |
|
||||
|
|
||||
export interface MessageProps { |
|
||||
message: string; |
|
||||
ack: boolean; |
|
||||
isSender: boolean; |
|
||||
rxTime: Date; |
|
||||
senderName: string; |
|
||||
} |
|
||||
|
|
||||
export const Message = ({ |
|
||||
message, |
|
||||
ack, |
|
||||
isSender, |
|
||||
rxTime, |
|
||||
senderName, |
|
||||
}: MessageProps): JSX.Element => { |
|
||||
return ( |
|
||||
<div |
|
||||
className={`flex space-x-2 ${ |
|
||||
!isSender ? 'ml-auto flex-row-reverse' : '' |
|
||||
}`}
|
|
||||
> |
|
||||
<div |
|
||||
className={`shadow-md rounded-full mt-auto ${!isSender ? 'ml-2' : ''}`} |
|
||||
> |
|
||||
<Avatar |
|
||||
size={30} |
|
||||
name={senderName ?? 'UNK'} |
|
||||
variant="beam" |
|
||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']} |
|
||||
/> |
|
||||
</div> |
|
||||
<div> |
|
||||
<div |
|
||||
className={`relative max-w-3/4 px-3 py-2 rounded-t-lg ${ |
|
||||
isSender |
|
||||
? 'bg-gray-500 text-gray-50 rounded-br-lg' |
|
||||
: 'bg-primary text-blue-50 rounded-bl-lg' |
|
||||
} ${ack ? 'animate-none' : 'animate-pulse'}`}
|
|
||||
> |
|
||||
<div className="leading-5 min-w-4">{message}</div> |
|
||||
</div> |
|
||||
<div className="text-xs text-gray-600">{senderName}</div> |
|
||||
</div> |
|
||||
<div className="mt-auto mb-4 mr-3 text-xs font-medium text-secondary dark:text-gray-200"> |
|
||||
{rxTime.toLocaleTimeString(undefined, { |
|
||||
hour: '2-digit', |
|
||||
minute: '2-digit', |
|
||||
})} |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,38 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { Blur } from '@components/generic/Blur'; |
|
||||
|
|
||||
type DefaultAsideProps = JSX.IntrinsicElements['aside']; |
|
||||
|
|
||||
interface DrawerProps extends DefaultAsideProps { |
|
||||
open: boolean; |
|
||||
permenant?: boolean; |
|
||||
onClose: () => void; |
|
||||
} |
|
||||
|
|
||||
export const Drawer = ({ |
|
||||
open, |
|
||||
permenant, |
|
||||
onClose, |
|
||||
className, |
|
||||
children, |
|
||||
|
|
||||
...props |
|
||||
}: DrawerProps): JSX.Element => { |
|
||||
return ( |
|
||||
<> |
|
||||
{open && ( |
|
||||
<Blur className={className} disableOnMd={true} onClick={onClose} /> |
|
||||
)} |
|
||||
|
|
||||
<aside |
|
||||
className={`transform top-0 left-0 bg-white dark:bg-secondaryDark shadow-md max-w-xs w-full border-r dark:border-gray-600 border-gray-300 h-full overflow-auto ease-in-out transition-all duration-300 z-30 ${ |
|
||||
permenant ? '' : 'absolute' |
|
||||
} ${open ? 'translate-x-0' : '-translate-x-full'} ${className}`}
|
|
||||
{...props} |
|
||||
> |
|
||||
{children} |
|
||||
</aside> |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,46 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
|
||||
|
export interface ListItemProps { |
||||
|
selected: boolean; |
||||
|
selectedIcon: JSX.Element; |
||||
|
actions?: JSX.Element; |
||||
|
status: JSX.Element; |
||||
|
onClick?: () => void; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const ListItem = ({ |
||||
|
selected, |
||||
|
selectedIcon, |
||||
|
actions, |
||||
|
status, |
||||
|
onClick, |
||||
|
children, |
||||
|
}: ListItemProps): JSX.Element => { |
||||
|
return ( |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
onClick && onClick(); |
||||
|
}} |
||||
|
className={`flex select-none rounded-md border bg-gray-100 shadow-md dark:bg-primaryDark ${ |
||||
|
selected |
||||
|
? 'border-primary dark:border-primary' |
||||
|
: 'border-gray-100 dark:border-primaryDark' |
||||
|
}`}
|
||||
|
> |
||||
|
<div className="w-3 rounded-l-md bg-green-500" /> |
||||
|
<div className="flex justify-between p-2"> |
||||
|
<div className="my-auto flex space-x-2"> |
||||
|
{status} |
||||
|
<div className="flex gap-2">{children}</div> |
||||
|
</div> |
||||
|
<div className="flex gap-2"> |
||||
|
{actions} |
||||
|
<IconButton active={selected} icon={selectedIcon} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -1,46 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { FiX } from 'react-icons/fi'; |
|
||||
|
|
||||
import { IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
export interface SidebarProps { |
|
||||
title: string; |
|
||||
tagline: string; |
|
||||
footer?: JSX.Element; |
|
||||
closeSidebar: () => void; |
|
||||
children: React.ReactNode; |
|
||||
} |
|
||||
|
|
||||
export const Sidebar = ({ |
|
||||
title, |
|
||||
tagline, |
|
||||
closeSidebar, |
|
||||
children, |
|
||||
}: SidebarProps): JSX.Element => { |
|
||||
return ( |
|
||||
<div className="absolute z-50 flex flex-col w-full h-full bg-white border-l border-gray-300 md:z-10 md:max-w-sm md:static min-w-max dark:border-gray-600 dark:bg-secondaryDark"> |
|
||||
<div className="p-2"> |
|
||||
<div className="flex justify-between"> |
|
||||
<div> |
|
||||
<h3 className="text-xs font-medium text-gray-400">{title}</h3> |
|
||||
<h1 className="text-lg font-medium truncate">{tagline}</h1> |
|
||||
</div> |
|
||||
<div className="mb-auto"> |
|
||||
<IconButton |
|
||||
onClick={(): void => { |
|
||||
closeSidebar(); |
|
||||
}} |
|
||||
icon={<FiX />} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
{children ?? ( |
|
||||
<div className="flex flex-grow bg-gray-50 dark:bg-primaryDark"> |
|
||||
<div className="m-auto text-lg font-medium">Please select item</div> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,33 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
type DefaultDivProps = JSX.IntrinsicElements['div']; |
|
||||
|
|
||||
export interface SidebarItemProps extends DefaultDivProps { |
|
||||
title: string; |
|
||||
description: string; |
|
||||
icon: JSX.Element; |
|
||||
selected?: boolean; |
|
||||
} |
|
||||
|
|
||||
export const SidebarItem = ({ |
|
||||
title, |
|
||||
description, |
|
||||
selected, |
|
||||
icon, |
|
||||
...props |
|
||||
}: SidebarItemProps): JSX.Element => { |
|
||||
return ( |
|
||||
<div |
|
||||
className={`flex p-3 cursor-pointer select-none dark:hover:bg-primaryDark ${ |
|
||||
selected ? 'bg-gray-200 dark:bg-primaryDark' : 'dark:bg-secondaryDark' |
|
||||
}`}
|
|
||||
{...props} |
|
||||
> |
|
||||
<div className="my-auto text-gray-500 dark:text-gray-400">{icon}</div> |
|
||||
<div className="ml-3 text-left"> |
|
||||
<div className="font-medium text-left">{title}</div> |
|
||||
<div className="mt-0.5 text-gray-400 text-sm">{description}</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,14 +0,0 @@ |
|||||
import 'tippy.js/dist/tippy.css'; |
|
||||
|
|
||||
import type React from 'react'; |
|
||||
|
|
||||
import Tippy from '@tippyjs/react'; |
|
||||
|
|
||||
export interface TooltipProps { |
|
||||
children: JSX.Element; |
|
||||
contents: string; |
|
||||
} |
|
||||
|
|
||||
export const Tooltip = ({ children, contents }: TooltipProps): JSX.Element => { |
|
||||
return <Tippy content={contents}>{children}</Tippy>; |
|
||||
}; |
|
||||
@ -1,89 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import type { Noop, RefCallBack } from 'react-hook-form'; |
|
||||
import type { Theme } from 'react-select'; |
|
||||
import ReactSelect from 'react-select'; |
|
||||
|
|
||||
import { bitwiseDecode, bitwiseEncode } from '@app/core/utils/bitwise'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
|
|
||||
import { Label } from './Label'; |
|
||||
|
|
||||
export interface BiwiseSelectProps { |
|
||||
label: string; |
|
||||
error?: string; |
|
||||
value: number; |
|
||||
optionsEnum: { [s: string]: string | number }; |
|
||||
onChange: (...event: unknown[]) => void; |
|
||||
onBlur: Noop; |
|
||||
name: string; |
|
||||
ref: RefCallBack; |
|
||||
} |
|
||||
|
|
||||
export const BitwiseSelect = ({ |
|
||||
label, |
|
||||
error, |
|
||||
value, |
|
||||
optionsEnum, |
|
||||
onChange, |
|
||||
ref, |
|
||||
}: BiwiseSelectProps): JSX.Element => { |
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode); |
|
||||
|
|
||||
return ( |
|
||||
<div className="w-full"> |
|
||||
{label && <Label label={label} error={error} />} |
|
||||
<ReactSelect |
|
||||
ref={ref} |
|
||||
isMulti |
|
||||
// styles={{
|
|
||||
// control: (provided, state) => ({
|
|
||||
// ...provided,
|
|
||||
// // color: state.isFocused ? 'blue' : 'red',
|
|
||||
// // borderColor: state.isFocused ? 'blue' : 'red',
|
|
||||
// }),
|
|
||||
// }}
|
|
||||
theme={(theme): Theme => ({ |
|
||||
...theme, |
|
||||
borderRadius: 7, |
|
||||
colors: { |
|
||||
...theme.colors, |
|
||||
primary: '#67ea94', //focus border color
|
|
||||
// primary75: 'red',
|
|
||||
// primary50: 'red',
|
|
||||
// primary25: 'red',
|
|
||||
// danger: 'red',
|
|
||||
// dangerLight: 'red',
|
|
||||
neutral0: darkMode ? 'rgb(30 41 59)' : 'white', //bg color
|
|
||||
// neutral5: 'red',
|
|
||||
neutral10: darkMode ? 'rgb(75 85 99)' : 'rgb(229 231 235)', //tag bg color
|
|
||||
neutral20: darkMode ? 'rgb(229 231 235)' : 'rgb(156 163 175)', //border color
|
|
||||
neutral30: '#67ea94', //border hover
|
|
||||
// neutral40: 'red',
|
|
||||
// neutral50: 'red',
|
|
||||
// neutral60: 'red',
|
|
||||
// neutral70: 'red',
|
|
||||
neutral80: darkMode ? 'white' : 'black', //tag text color
|
|
||||
// neutral90: 'red',
|
|
||||
}, |
|
||||
})} |
|
||||
value={bitwiseDecode(value, optionsEnum).map((flag) => { |
|
||||
return { |
|
||||
value: flag, |
|
||||
label: (optionsEnum[flag] as string).replace('POS_', ''), |
|
||||
}; |
|
||||
})} |
|
||||
options={Object.entries(optionsEnum) |
|
||||
.filter((value) => typeof value[1] !== 'number') |
|
||||
.filter((value) => parseInt(value[0]) !== optionsEnum.POS_UNDEFINED) |
|
||||
.map((value) => { |
|
||||
return { |
|
||||
value: parseInt(value[0]), |
|
||||
label: value[1].toString().replace('POS_', ''), |
|
||||
}; |
|
||||
})} |
|
||||
onChange={(e): void => onChange(bitwiseEncode(e.map((v) => v.value)))} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,79 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { FiMessageCircle, FiSettings } from 'react-icons/fi'; |
||||
|
import { RiMindMap, RiRoadMapLine } from 'react-icons/ri'; |
||||
|
import { VscExtensions } from 'react-icons/vsc'; |
||||
|
|
||||
|
import { toggleMobileNav } from '@app/core/slices/appSlice.js'; |
||||
|
import { useAppDispatch } from '@app/hooks/useAppDispatch.js'; |
||||
|
import { routes, useRoute } from '@core/router'; |
||||
|
|
||||
|
import { NavLinkButton } from './NavLinkButton'; |
||||
|
|
||||
|
export interface ButtonNavProps { |
||||
|
toggleSettingsOpen: () => void; |
||||
|
} |
||||
|
|
||||
|
export const ButtonNav = ({ |
||||
|
toggleSettingsOpen, |
||||
|
}: ButtonNavProps): JSX.Element => { |
||||
|
const route = useRoute(); |
||||
|
const dispatch = useAppDispatch(); |
||||
|
|
||||
|
return ( |
||||
|
<div className="z-10 flex justify-between border-t border-gray-300 px-6 py-2 dark:border-gray-600 dark:bg-primaryDark"> |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
dispatch(toggleMobileNav()); |
||||
|
}} |
||||
|
> |
||||
|
<NavLinkButton |
||||
|
active={route.name === 'messages'} |
||||
|
link={routes.messages().link} |
||||
|
> |
||||
|
<FiMessageCircle className="h-5 w-5" /> |
||||
|
</NavLinkButton> |
||||
|
</div> |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
dispatch(toggleMobileNav()); |
||||
|
}} |
||||
|
> |
||||
|
<NavLinkButton |
||||
|
active={route.name === 'nodes'} |
||||
|
link={routes.nodes().link} |
||||
|
> |
||||
|
<RiMindMap className="h-5 w-5" /> |
||||
|
</NavLinkButton> |
||||
|
</div> |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
dispatch(toggleMobileNav()); |
||||
|
}} |
||||
|
> |
||||
|
<NavLinkButton active={route.name === 'map'} link={routes.map().link}> |
||||
|
<RiRoadMapLine className="h-5 w-5" /> |
||||
|
</NavLinkButton> |
||||
|
</div> |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
dispatch(toggleMobileNav()); |
||||
|
}} |
||||
|
> |
||||
|
<NavLinkButton |
||||
|
active={route.name === 'extensions'} |
||||
|
link={routes.extensions().link} |
||||
|
> |
||||
|
<VscExtensions className="h-5 w-5" /> |
||||
|
</NavLinkButton> |
||||
|
</div> |
||||
|
<NavLinkButton |
||||
|
action={(): void => { |
||||
|
toggleSettingsOpen(); |
||||
|
}} |
||||
|
> |
||||
|
<FiSettings className="h-5 w-5" /> |
||||
|
</NavLinkButton> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,38 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { m } from 'framer-motion'; |
||||
|
import type { Link } from 'type-route'; |
||||
|
|
||||
|
export interface NavLinkButtonProps { |
||||
|
link?: Link; |
||||
|
active?: boolean; |
||||
|
action?: () => void; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const NavLinkButton = ({ |
||||
|
link, |
||||
|
active, |
||||
|
action, |
||||
|
children, |
||||
|
}: NavLinkButtonProps): JSX.Element => { |
||||
|
return ( |
||||
|
<m.div |
||||
|
whileHover={{ scale: 1.01 }} |
||||
|
whileTap={{ scale: 0.99 }} |
||||
|
animate={active ? 'selected' : 'deselected'} |
||||
|
initial={{ borderColor: '#1C1D23' }} |
||||
|
variants={{ |
||||
|
selected: { borderColor: '#67ea94' }, |
||||
|
deselected: { borderColor: '#1C1D23' }, |
||||
|
}} |
||||
|
className="cursor-pointer rounded-full border-2 p-3 hover:bg-opacity-80 hover:shadow-md dark:bg-secondaryDark dark:text-white" |
||||
|
onClick={(): void => { |
||||
|
action && action(); |
||||
|
}} |
||||
|
{...(link && link)} |
||||
|
> |
||||
|
{children} |
||||
|
</m.div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,160 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
import { FiExternalLink, FiX } from 'react-icons/fi'; |
||||
|
import { |
||||
|
RiArrowDownLine, |
||||
|
RiArrowUpDownLine, |
||||
|
RiArrowUpLine, |
||||
|
} from 'react-icons/ri'; |
||||
|
|
||||
|
import { ListItem } from '@app/components/generic/ListItem'; |
||||
|
import type { ChannelData } from '@app/core/slices/meshtasticSlice'; |
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Input, Select, Tooltip } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const Channels = (): JSX.Element => { |
||||
|
const channels = useAppSelector((state) => state.meshtastic.radio.channels); |
||||
|
const adminChannel = |
||||
|
channels.find( |
||||
|
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY, |
||||
|
) ?? channels[0]; |
||||
|
const [usePreset, setUsePreset] = React.useState(true); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const [selectedChannel, setSelectedChannel] = React.useState< |
||||
|
ChannelData | undefined |
||||
|
>(); |
||||
|
|
||||
|
const { register, handleSubmit, reset, formState } = useForm< |
||||
|
DeepOmit<Protobuf.Channel, 'psk'> |
||||
|
>({ |
||||
|
defaultValues: { |
||||
|
...adminChannel.channel, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const onSubmit = handleSubmit(async (data) => { |
||||
|
setLoading(true); |
||||
|
|
||||
|
const channelData = Protobuf.Channel.create({ |
||||
|
...data, |
||||
|
settings: { |
||||
|
...data.settings, |
||||
|
psk: adminChannel.channel.settings?.psk, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
await connection.setChannel(channelData, (): Promise<void> => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
return Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{adminChannel && ( |
||||
|
<> |
||||
|
<Checkbox |
||||
|
checked={usePreset} |
||||
|
label="Use Presets" |
||||
|
onChange={(e): void => setUsePreset(e.target.checked)} |
||||
|
/> |
||||
|
<form onSubmit={onSubmit}> |
||||
|
{usePreset ? ( |
||||
|
<Select |
||||
|
label="Preset" |
||||
|
optionsEnum={Protobuf.ChannelSettings_ModemConfig} |
||||
|
{...register('settings.modemConfig', { |
||||
|
valueAsNumber: true, |
||||
|
})} |
||||
|
/> |
||||
|
) : ( |
||||
|
<> |
||||
|
<Input |
||||
|
label="Bandwidth" |
||||
|
type="number" |
||||
|
suffix="MHz" |
||||
|
{...register('settings.bandwidth', { |
||||
|
valueAsNumber: true, |
||||
|
})} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Spread Factor" |
||||
|
type="number" |
||||
|
suffix="CPS" |
||||
|
min={7} |
||||
|
max={12} |
||||
|
{...register('settings.spreadFactor', { |
||||
|
valueAsNumber: true, |
||||
|
})} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Coding Rate" |
||||
|
type="number" |
||||
|
{...register('settings.codingRate', { |
||||
|
valueAsNumber: true, |
||||
|
})} |
||||
|
/> |
||||
|
</> |
||||
|
)} |
||||
|
<Input |
||||
|
label="Transmit Power" |
||||
|
type="number" |
||||
|
suffix="dBm" |
||||
|
{...register('settings.txPower', { valueAsNumber: true })} |
||||
|
/> |
||||
|
</form> |
||||
|
</> |
||||
|
)} |
||||
|
{channels.map((channel) => ( |
||||
|
<ListItem |
||||
|
key={channel.channel.index} |
||||
|
onClick={(): void => { |
||||
|
setSelectedChannel(channel); |
||||
|
}} |
||||
|
status={ |
||||
|
<div |
||||
|
className={`my-auto h-3 w-3 rounded-full ${ |
||||
|
[ |
||||
|
Protobuf.Channel_Role.SECONDARY, |
||||
|
Protobuf.Channel_Role.PRIMARY, |
||||
|
].find((role) => role === channel.channel.role) |
||||
|
? 'bg-green-500' |
||||
|
: 'bg-gray-400' |
||||
|
}`}
|
||||
|
/> |
||||
|
} |
||||
|
selected={selectedChannel?.channel.index === channel.channel.index} |
||||
|
selectedIcon={<FiExternalLink />} |
||||
|
actions={ |
||||
|
<Tooltip content={`MQTT Status`}> |
||||
|
<div className="rounded-md p-2"> |
||||
|
{channel.channel.settings?.uplinkEnabled && |
||||
|
channel.channel.settings?.downlinkEnabled ? ( |
||||
|
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" /> |
||||
|
) : channel.channel.settings?.uplinkEnabled ? ( |
||||
|
<RiArrowUpLine className="p-0.5 group-active:scale-90" /> |
||||
|
) : channel.channel.settings?.downlinkEnabled ? ( |
||||
|
<RiArrowDownLine className="p-0.5 group-active:scale-90" /> |
||||
|
) : ( |
||||
|
<FiX className="p-0.5" /> |
||||
|
)} |
||||
|
</div> |
||||
|
</Tooltip> |
||||
|
} |
||||
|
> |
||||
|
<div> |
||||
|
{channel.channel.settings?.name.length |
||||
|
? channel.channel.settings.name |
||||
|
: channel.channel.role === Protobuf.Channel_Role.PRIMARY |
||||
|
? 'Primary' |
||||
|
: `Channel: ${channel.channel.index}`} |
||||
|
</div> |
||||
|
</ListItem> |
||||
|
))} |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,131 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { |
||||
|
FiAlignLeft, |
||||
|
FiBell, |
||||
|
FiFastForward, |
||||
|
FiLayers, |
||||
|
FiLayout, |
||||
|
FiMapPin, |
||||
|
FiPackage, |
||||
|
FiRadio, |
||||
|
FiRss, |
||||
|
FiUser, |
||||
|
FiWifi, |
||||
|
FiZap, |
||||
|
} from 'react-icons/fi'; |
||||
|
|
||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection'; |
||||
|
import { ExternalSection } from '@app/components/layout/Sidebar/sections/ExternalSection'; |
||||
|
import { SidebarOverlay } from '@app/components/layout/Sidebar/sections/SidebarOverlay'; |
||||
|
import { SidebarPrimary } from '@app/components/layout/Sidebar/sections/SidebarPrimary'; |
||||
|
import { Channels } from '@app/components/layout/Sidebar/Settings/Channels'; |
||||
|
import { ExternalNotificationsSettingsPlanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/ExternalNotifications/SettingsPlanel'; |
||||
|
import { RangeTestSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/RangeTest/SettingsPanel'; |
||||
|
import { SerialSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/Serial/SettingsPanel'; |
||||
|
import { StoreForwardSettingsPanel } from '@app/components/layout/Sidebar/Settings/plugins/panels/StoreForward/SettingsPanel'; |
||||
|
import { Position } from '@app/components/layout/Sidebar/Settings/Position'; |
||||
|
import { Power } from '@app/components/layout/Sidebar/Settings/Power'; |
||||
|
import { Radio } from '@app/components/layout/Sidebar/Settings/Radio'; |
||||
|
import { User } from '@app/components/layout/Sidebar/Settings/User'; |
||||
|
import { WiFi } from '@app/components/layout/Sidebar/Settings/WiFi'; |
||||
|
|
||||
|
import { Interface } from './Interface'; |
||||
|
import { ChannelsGroup } from './radio/channels/panels/ChannelsGroup'; |
||||
|
|
||||
|
export interface SettingsProps { |
||||
|
open: boolean; |
||||
|
setOpen: (open: boolean) => void; |
||||
|
} |
||||
|
|
||||
|
export const Settings = ({ open, setOpen }: SettingsProps): JSX.Element => { |
||||
|
const [pluginsOpen, setPluginsOpen] = React.useState(false); |
||||
|
const [channelsOpen, setChannelsOpen] = React.useState(false); |
||||
|
// const { hasGps, hasWifi } = useAppSelector((state) => state.meshtastic.radio.hardware);
|
||||
|
|
||||
|
const hasGps = true; |
||||
|
const hasWifi = true; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<SidebarPrimary |
||||
|
title="Settings" |
||||
|
open={open} |
||||
|
close={(): void => { |
||||
|
setOpen(false); |
||||
|
}} |
||||
|
> |
||||
|
<CollapsibleSection icon={<FiWifi />} title="WiFi & MQTT"> |
||||
|
<WiFi /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection icon={<FiMapPin />} title="Position"> |
||||
|
<Position /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection icon={<FiUser />} title="User"> |
||||
|
<User /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection icon={<FiZap />} title="Power"> |
||||
|
<Power /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection icon={<FiRadio />} title="Radio"> |
||||
|
<Radio /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection icon={<FiLayers />} title="Primary Channel"> |
||||
|
<Channels /> |
||||
|
</CollapsibleSection> |
||||
|
<ExternalSection |
||||
|
onClick={(): void => { |
||||
|
setChannelsOpen(true); |
||||
|
}} |
||||
|
icon={<FiLayers />} |
||||
|
title="Channels" |
||||
|
/> |
||||
|
<ExternalSection |
||||
|
onClick={(): void => { |
||||
|
setPluginsOpen(true); |
||||
|
}} |
||||
|
icon={<FiPackage />} |
||||
|
title="Plugins" |
||||
|
/> |
||||
|
<CollapsibleSection icon={<FiLayout />} title="Interface"> |
||||
|
<Interface /> |
||||
|
</CollapsibleSection> |
||||
|
</SidebarPrimary> |
||||
|
|
||||
|
{/* Plugins */} |
||||
|
<SidebarOverlay |
||||
|
title="Plugins" |
||||
|
open={pluginsOpen} |
||||
|
close={(): void => { |
||||
|
setPluginsOpen(false); |
||||
|
}} |
||||
|
> |
||||
|
<CollapsibleSection title="Range Test" icon={<FiRss />}> |
||||
|
<RangeTestSettingsPanel /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="External Notifications" icon={<FiBell />}> |
||||
|
<ExternalNotificationsSettingsPlanel /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Serial" icon={<FiAlignLeft />}> |
||||
|
<SerialSettingsPanel /> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Store & Forward" icon={<FiFastForward />}> |
||||
|
<StoreForwardSettingsPanel /> |
||||
|
</CollapsibleSection> |
||||
|
</SidebarOverlay> |
||||
|
{/* End Plugins */} |
||||
|
|
||||
|
{/* Channels */} |
||||
|
<SidebarOverlay |
||||
|
title="Channels" |
||||
|
open={channelsOpen} |
||||
|
close={(): void => { |
||||
|
setChannelsOpen(false); |
||||
|
}} |
||||
|
> |
||||
|
<ChannelsGroup /> |
||||
|
</SidebarOverlay> |
||||
|
{/* End Channels */} |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,28 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { Select } from '@meshtastic/components'; |
||||
|
|
||||
|
export const Interface = (): JSX.Element => { |
||||
|
return ( |
||||
|
<Select |
||||
|
label="Language" |
||||
|
options={[ |
||||
|
{ |
||||
|
name: 'English', |
||||
|
value: 'en', |
||||
|
}, |
||||
|
{ |
||||
|
name: '日本', |
||||
|
value: 'jp', |
||||
|
}, |
||||
|
{ |
||||
|
name: 'Português', |
||||
|
value: 'pt', |
||||
|
}, |
||||
|
]} |
||||
|
onChange={(e): void => { |
||||
|
console.log('changed language'); |
||||
|
}} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,140 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { Controller, useForm } from 'react-hook-form'; |
||||
|
import { MultiSelect } from 'react-multi-select-component'; |
||||
|
|
||||
|
import { bitwiseEncode } from '@app/core/utils/bitwise'; |
||||
|
import { Label } from '@components/generic/form/Label'; |
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Input, Select } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const Position = (): JSX.Element => { |
||||
|
const preferences = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.preferences, |
||||
|
); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const { register, handleSubmit, formState, reset, control } = |
||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({ |
||||
|
defaultValues: { |
||||
|
...preferences, |
||||
|
positionBroadcastSecs: |
||||
|
preferences.positionBroadcastSecs === 0 |
||||
|
? preferences.isRouter |
||||
|
? 43200 |
||||
|
: 900 |
||||
|
: preferences.positionBroadcastSecs, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
reset(preferences); |
||||
|
}, [reset, preferences]); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
setLoading(true); |
||||
|
void connection.setPreferences(data, async () => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
await Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
const encode = (enums: Protobuf.PositionFlags[]): number => { |
||||
|
return enums.reduce((acc, curr) => acc | curr, 0); |
||||
|
}; |
||||
|
|
||||
|
const decode = (value: number): Protobuf.PositionFlags[] => { |
||||
|
const enumValues = Object.keys(Protobuf.PositionFlags) |
||||
|
.map(Number) |
||||
|
.filter(Boolean); |
||||
|
|
||||
|
return enumValues.map((b) => value & b).filter(Boolean); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<form className="space-y-2" onSubmit={onSubmit}> |
||||
|
<Input |
||||
|
label="Broadcast Interval" |
||||
|
type="number" |
||||
|
suffix="Seconds" |
||||
|
{...register('positionBroadcastSecs', { valueAsNumber: true })} |
||||
|
/> |
||||
|
|
||||
|
<Controller |
||||
|
name="positionFlags" |
||||
|
control={control} |
||||
|
render={({ field, fieldState }): JSX.Element => { |
||||
|
const { value, onChange, ...rest } = field; |
||||
|
const { error } = fieldState; |
||||
|
const label = 'Position Flags'; |
||||
|
return ( |
||||
|
<div className="w-full"> |
||||
|
{label && <Label label={label} error={error?.message} />} |
||||
|
<MultiSelect |
||||
|
options={Object.entries(Protobuf.PositionFlags) |
||||
|
.filter((value) => typeof value[1] !== 'number') |
||||
|
.filter( |
||||
|
(value) => |
||||
|
parseInt(value[0]) !== |
||||
|
Protobuf.PositionFlags.POS_UNDEFINED, |
||||
|
) |
||||
|
.map((value) => { |
||||
|
return { |
||||
|
value: parseInt(value[0]), |
||||
|
label: value[1].toString().replace('POS_', ''), |
||||
|
}; |
||||
|
})} |
||||
|
value={decode(value).map((flag) => { |
||||
|
return { |
||||
|
value: flag, |
||||
|
label: Protobuf.PositionFlags[flag].replace('POS_', ''), |
||||
|
}; |
||||
|
})} |
||||
|
onChange={(e: { value: number; label: string }[]): void => |
||||
|
onChange(bitwiseEncode(e.map((v) => v.value))) |
||||
|
} |
||||
|
labelledBy="Select" |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
}} |
||||
|
/> |
||||
|
|
||||
|
<Input |
||||
|
label="Position Type (DEBUG)" |
||||
|
type="number" |
||||
|
disabled |
||||
|
{...register('positionFlags', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Checkbox label="Use Fixed Position" {...register('fixedPosition')} /> |
||||
|
<Select |
||||
|
label="Location Sharing" |
||||
|
optionsEnum={Protobuf.LocationSharing} |
||||
|
{...register('locationShare', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Select |
||||
|
label="GPS Mode" |
||||
|
optionsEnum={Protobuf.GpsOperation} |
||||
|
{...register('gpsOperation', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Select |
||||
|
label="Display Format" |
||||
|
optionsEnum={Protobuf.GpsCoordinateFormat} |
||||
|
{...register('gpsFormat', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} /> |
||||
|
<Input |
||||
|
label="Max DOP" |
||||
|
type="number" |
||||
|
{...register('gpsMaxDop', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Last GPS Attempt" |
||||
|
disabled |
||||
|
{...register('gpsAttemptTime', { valueAsNumber: true })} |
||||
|
/> |
||||
|
</form> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,53 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
|
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Select } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const Power = (): JSX.Element => { |
||||
|
const preferences = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.preferences, |
||||
|
); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const { register, handleSubmit, formState, reset } = |
||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({ |
||||
|
defaultValues: { |
||||
|
...preferences, |
||||
|
isLowPower: preferences.isRouter ? true : preferences.isLowPower, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
reset(preferences); |
||||
|
}, [reset, preferences]); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
setLoading(true); |
||||
|
void connection.setPreferences(data, async () => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
await Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
return ( |
||||
|
<form className="space-y-2" onSubmit={onSubmit}> |
||||
|
<Select |
||||
|
label="Charge current" |
||||
|
optionsEnum={Protobuf.ChargeCurrent} |
||||
|
{...register('chargeCurrent', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Checkbox label="Always powered" {...register('isAlwaysPowered')} /> |
||||
|
<Checkbox |
||||
|
label="Powered by low power source (solar)" |
||||
|
disabled={preferences.isRouter} |
||||
|
validationMessage={ |
||||
|
preferences.isRouter ? 'Enabled by default in router mode' : '' |
||||
|
} |
||||
|
{...register('isLowPower')} |
||||
|
/> |
||||
|
</form> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,44 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
|
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Select } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const Radio = (): JSX.Element => { |
||||
|
const preferences = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.preferences, |
||||
|
); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const { register, handleSubmit, formState, reset } = |
||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({ |
||||
|
defaultValues: preferences, |
||||
|
}); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
reset(preferences); |
||||
|
}, [reset, preferences]); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
setLoading(true); |
||||
|
void connection.setPreferences(data, async () => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
await Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
return ( |
||||
|
<form className="space-y-2" onSubmit={onSubmit}> |
||||
|
<Checkbox label="Is Router" {...register('isRouter')} /> |
||||
|
<Select |
||||
|
label="Region" |
||||
|
optionsEnum={Protobuf.RegionCode} |
||||
|
{...register('region', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Checkbox label="Debug Log" {...register('debugLogEnabled')} /> |
||||
|
<Checkbox label="Serial Disabled" {...register('serialDisabled')} /> |
||||
|
</form> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,114 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
import { base16 } from 'rfc4648'; |
||||
|
|
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Input, Select } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const User = (): JSX.Element => { |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const myNodeNum = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.hardware, |
||||
|
).myNodeNum; |
||||
|
const node = useAppSelector((state) => state.meshtastic.nodes).find( |
||||
|
(node) => node.number === myNodeNum, |
||||
|
); |
||||
|
const { register, handleSubmit, formState, reset } = useForm<{ |
||||
|
longName: string; |
||||
|
shortName: string; |
||||
|
isLicensed: boolean; |
||||
|
team: Protobuf.Team; |
||||
|
antAzimuth: number; |
||||
|
antGainDbi: number; |
||||
|
txPowerDbm: number; |
||||
|
}>({ |
||||
|
defaultValues: { |
||||
|
longName: node?.user?.longName, |
||||
|
shortName: node?.user?.shortName, |
||||
|
isLicensed: node?.user?.isLicensed, |
||||
|
team: node?.user?.team, |
||||
|
antAzimuth: node?.user?.antAzimuth, |
||||
|
antGainDbi: node?.user?.antGainDbi, |
||||
|
txPowerDbm: node?.user?.txPowerDbm, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
reset({ |
||||
|
longName: node?.user?.longName, |
||||
|
shortName: node?.user?.shortName, |
||||
|
isLicensed: node?.user?.isLicensed, |
||||
|
team: node?.user?.team, |
||||
|
}); |
||||
|
}, [reset, node]); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
setLoading(true); |
||||
|
|
||||
|
if (node?.user) { |
||||
|
void connection.setOwner({ ...node.user, ...data }, async () => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
await Promise.resolve(); |
||||
|
}); |
||||
|
// TODO: can be removed once getUser is implemented
|
||||
|
// dispatch(
|
||||
|
// addUser({ ...node.user, ...{ data: { ...node.user.data, ...data } } }),
|
||||
|
// );
|
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<form className="space-y-2" onSubmit={onSubmit}> |
||||
|
<Input label="Device ID" value={node?.user?.id} disabled /> |
||||
|
<Input |
||||
|
label="Hardware" |
||||
|
value={ |
||||
|
Protobuf.HardwareModel[ |
||||
|
node?.user?.hwModel ?? Protobuf.HardwareModel.UNSET |
||||
|
] |
||||
|
} |
||||
|
disabled |
||||
|
/> |
||||
|
<Input |
||||
|
label="Mac Address" |
||||
|
defaultValue={ |
||||
|
base16 |
||||
|
.stringify(node?.user?.macaddr ?? []) |
||||
|
.match(/.{1,2}/g) |
||||
|
?.join(':') ?? '' |
||||
|
} |
||||
|
disabled |
||||
|
/> |
||||
|
<Input label="Device Name" {...register('longName')} /> |
||||
|
<Input label="Short Name" maxLength={3} {...register('shortName')} /> |
||||
|
<Checkbox label="Licenced Operator?" {...register('isLicensed')} /> |
||||
|
<Select |
||||
|
label="Team" |
||||
|
optionsEnum={Protobuf.Team} |
||||
|
{...register('team', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Antenna Azimuth" |
||||
|
suffix="°" |
||||
|
type="number" |
||||
|
{...register('antAzimuth', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Antenna Gain" |
||||
|
suffix="dBi" |
||||
|
type="number" |
||||
|
{...register('antGainDbi', { valueAsNumber: true })} |
||||
|
/> |
||||
|
<Input |
||||
|
label="Transmit Power" |
||||
|
suffix="dBm" |
||||
|
type="number" |
||||
|
{...register('txPowerDbm', { valueAsNumber: true })} |
||||
|
/> |
||||
|
</form> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,79 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm, useWatch } from 'react-hook-form'; |
||||
|
|
||||
|
import { connection } from '@core/connection'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { Checkbox, Input } from '@meshtastic/components'; |
||||
|
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export const WiFi = (): JSX.Element => { |
||||
|
const preferences = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.preferences, |
||||
|
); |
||||
|
const [loading, setLoading] = React.useState(false); |
||||
|
const { register, handleSubmit, formState, reset, control } = |
||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({ |
||||
|
defaultValues: preferences, |
||||
|
}); |
||||
|
|
||||
|
const watchWifiApMode = useWatch({ |
||||
|
control, |
||||
|
name: 'wifiApMode', |
||||
|
defaultValue: false, |
||||
|
}); |
||||
|
|
||||
|
const watchMQTTDisabled = useWatch({ |
||||
|
control, |
||||
|
name: 'mqttDisabled', |
||||
|
defaultValue: false, |
||||
|
}); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
reset(preferences); |
||||
|
}, [reset, preferences]); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
setLoading(true); |
||||
|
void connection.setPreferences(data, async () => { |
||||
|
reset({ ...data }); |
||||
|
setLoading(false); |
||||
|
await Promise.resolve(); |
||||
|
}); |
||||
|
}); |
||||
|
return ( |
||||
|
<form className="space-y-2" onSubmit={onSubmit}> |
||||
|
<Checkbox label="Enable WiFi AP" {...register('wifiApMode')} /> |
||||
|
<Input |
||||
|
label="WiFi SSID" |
||||
|
disabled={watchWifiApMode} |
||||
|
{...register('wifiSsid')} |
||||
|
/> |
||||
|
<Input |
||||
|
type="password" |
||||
|
autoComplete="off" |
||||
|
label="WiFi PSK" |
||||
|
disabled={watchWifiApMode} |
||||
|
{...register('wifiPassword')} |
||||
|
/> |
||||
|
<Checkbox label="Disable MQTT" {...register('mqttDisabled')} /> |
||||
|
<Input |
||||
|
label="MQTT Server Address" |
||||
|
disabled={watchMQTTDisabled} |
||||
|
{...register('mqttServer')} |
||||
|
/> |
||||
|
<Input |
||||
|
label="MQTT Username" |
||||
|
disabled={watchMQTTDisabled} |
||||
|
{...register('mqttUsername')} |
||||
|
/> |
||||
|
<Input |
||||
|
label="MQTT Password" |
||||
|
type="password" |
||||
|
autoComplete="off" |
||||
|
disabled={watchMQTTDisabled} |
||||
|
{...register('mqttPassword')} |
||||
|
/> |
||||
|
</form> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,59 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { FaQrcode } from 'react-icons/fa'; |
||||
|
import { FiCode, FiSave } from 'react-icons/fi'; |
||||
|
|
||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection'; |
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector'; |
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
import { SettingsPanel } from './SettingsPanel'; |
||||
|
|
||||
|
export const ChannelsGroup = (): JSX.Element => { |
||||
|
const channels = useAppSelector((state) => state.meshtastic.radio.channels); |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{channels.map((channel) => { |
||||
|
return ( |
||||
|
<div key={channel.channel.index}> |
||||
|
<CollapsibleSection |
||||
|
title={ |
||||
|
channel.channel.settings?.name.length |
||||
|
? channel.channel.settings.name |
||||
|
: channel.channel.role === Protobuf.Channel_Role.PRIMARY |
||||
|
? 'Primary' |
||||
|
: `Channel: ${channel.channel.index}` |
||||
|
} |
||||
|
icon={ |
||||
|
<div |
||||
|
className={`h-3 w-3 rounded-full ${ |
||||
|
channel.channel.role === Protobuf.Channel_Role.PRIMARY |
||||
|
? 'bg-orange-500' |
||||
|
: channel.channel.role === Protobuf.Channel_Role.SECONDARY |
||||
|
? 'bg-green-500' |
||||
|
: 'bg-gray-500' |
||||
|
}`}
|
||||
|
/> |
||||
|
} |
||||
|
actions={ |
||||
|
<> |
||||
|
<IconButton icon={<FiCode />} /> |
||||
|
<IconButton icon={<FaQrcode />} /> |
||||
|
<IconButton icon={<FiSave />} /> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<> |
||||
|
{/* <DebugPanel channel={channel.channel} /> */} |
||||
|
{/* <QRCodePanel channel={channel.channel} /> */} |
||||
|
<SettingsPanel channel={channel.channel} /> |
||||
|
</> |
||||
|
</CollapsibleSection> |
||||
|
</div> |
||||
|
); |
||||
|
})} |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,42 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { m } from 'framer-motion'; |
||||
|
|
||||
|
export interface SidebarItemProps { |
||||
|
selected: boolean; |
||||
|
setSelected: () => void; |
||||
|
actions?: React.ReactNode; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const SidebarItem = ({ |
||||
|
selected, |
||||
|
setSelected, |
||||
|
actions, |
||||
|
children, |
||||
|
}: SidebarItemProps): JSX.Element => { |
||||
|
return ( |
||||
|
<m.div |
||||
|
onClick={(): void => { |
||||
|
setSelected(); |
||||
|
}} |
||||
|
animate={selected ? 'selected' : 'deselected'} |
||||
|
initial={{ borderColor: '#1C1D23' }} |
||||
|
variants={{ |
||||
|
selected: { borderColor: '#67ea94' }, |
||||
|
deselected: { borderColor: '#1C1D23' }, |
||||
|
}} |
||||
|
className="mx-2 flex cursor-pointer select-none rounded-md border-2 p-2 first:mt-2 last:mb-2 dark:bg-secondaryDark" |
||||
|
> |
||||
|
<m.div |
||||
|
className="flex w-full justify-between" |
||||
|
whileHover={{ scale: 1.01 }} |
||||
|
whileTap={{ scale: 0.99 }} |
||||
|
> |
||||
|
<div className="flex gap-2">{children}</div> |
||||
|
|
||||
|
<div className="flex gap-1">{actions}</div> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,38 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector.js'; |
||||
|
|
||||
|
import { ButtonNav } from './ButtonNav'; |
||||
|
import { Settings } from './Settings/Index'; |
||||
|
|
||||
|
export interface SidebarProps { |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const Sidebar = ({ children }: SidebarProps): JSX.Element => { |
||||
|
const [settingsOpen, setSettingsOpen] = React.useState(false); |
||||
|
|
||||
|
const appState = useAppSelector((state) => state.app); |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex flex-grow"> |
||||
|
<div |
||||
|
className={`absolute h-full w-full flex-grow flex-col pb-6 md:relative md:flex md:w-96 md:pb-0 ${ |
||||
|
appState.mobileNavOpen ? 'flex' : 'hidden' |
||||
|
}`}
|
||||
|
> |
||||
|
<div className="flex h-full w-full flex-col shadow-xl dark:bg-primaryDark"> |
||||
|
<div className="relative flex-grow gap-1"> |
||||
|
<div className="absolute h-full w-full">{children}</div> |
||||
|
<Settings open={settingsOpen} setOpen={setSettingsOpen} /> |
||||
|
</div> |
||||
|
<ButtonNav |
||||
|
toggleSettingsOpen={(): void => { |
||||
|
setSettingsOpen(!settingsOpen); |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,73 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { AnimatePresence, m } from 'framer-motion'; |
||||
|
import { FiArrowUp } from 'react-icons/fi'; |
||||
|
|
||||
|
export interface CollapsibleSectionProps { |
||||
|
title: string; |
||||
|
icon?: JSX.Element; |
||||
|
actions?: JSX.Element; |
||||
|
children: JSX.Element; |
||||
|
} |
||||
|
|
||||
|
export const CollapsibleSection = ({ |
||||
|
title, |
||||
|
icon, |
||||
|
actions, |
||||
|
children, |
||||
|
}: CollapsibleSectionProps): JSX.Element => { |
||||
|
const [open, setOpen] = React.useState(false); |
||||
|
const toggleOpen = (): void => setOpen(!open); |
||||
|
return ( |
||||
|
<m.div> |
||||
|
<m.div |
||||
|
layout |
||||
|
className="w-full cursor-pointer select-none overflow-hidden shadow-md dark:bg-secondaryDark dark:text-gray-400" |
||||
|
> |
||||
|
<m.div |
||||
|
layout |
||||
|
onClick={toggleOpen} |
||||
|
whileHover={{ scale: 1.01 }} |
||||
|
whileTap={{ scale: 0.99 }} |
||||
|
className="flex justify-between gap-2 border-b border-primaryDark p-2 text-sm font-medium" |
||||
|
> |
||||
|
<m.div className="flex gap-2 "> |
||||
|
<m.div className="my-auto">{icon}</m.div> |
||||
|
{title} |
||||
|
</m.div> |
||||
|
<m.div |
||||
|
animate={open ? 'open' : 'closed'} |
||||
|
initial={{ rotate: 180 }} |
||||
|
variants={{ |
||||
|
open: { rotate: 0 }, |
||||
|
closed: { rotate: 180 }, |
||||
|
}} |
||||
|
className="my-auto" |
||||
|
> |
||||
|
<FiArrowUp /> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
<AnimatePresence> |
||||
|
{open && ( |
||||
|
<> |
||||
|
{actions && ( |
||||
|
<m.div className="flex justify-end gap-1 rounded-b-md border-x border-b p-1 shadow-inner dark:border-gray-600"> |
||||
|
{actions} |
||||
|
</m.div> |
||||
|
)} |
||||
|
<m.div |
||||
|
className="p-2" |
||||
|
layout |
||||
|
initial={{ opacity: 0 }} |
||||
|
animate={{ opacity: 1 }} |
||||
|
exit={{ opacity: 0 }} |
||||
|
> |
||||
|
{children} |
||||
|
</m.div> |
||||
|
</> |
||||
|
)} |
||||
|
</AnimatePresence> |
||||
|
</m.div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,47 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { m } from 'framer-motion'; |
||||
|
import { FiExternalLink } from 'react-icons/fi'; |
||||
|
|
||||
|
export interface ExternalSectionProps { |
||||
|
title: string; |
||||
|
icon?: JSX.Element; |
||||
|
onClick: () => void; |
||||
|
} |
||||
|
|
||||
|
export const ExternalSection = ({ |
||||
|
title, |
||||
|
icon, |
||||
|
onClick, |
||||
|
}: ExternalSectionProps): JSX.Element => { |
||||
|
const [open, setOpen] = React.useState(false); |
||||
|
const toggleOpen = (): void => setOpen(!open); |
||||
|
return ( |
||||
|
<m.div |
||||
|
onClick={(): void => { |
||||
|
onClick(); |
||||
|
}} |
||||
|
> |
||||
|
<m.div |
||||
|
layout |
||||
|
className="w-full cursor-pointer select-none overflow-hidden shadow-md dark:bg-secondaryDark dark:text-gray-400" |
||||
|
> |
||||
|
<m.div |
||||
|
layout |
||||
|
onClick={toggleOpen} |
||||
|
whileHover={{ scale: 1.01 }} |
||||
|
whileTap={{ scale: 0.99 }} |
||||
|
className="flex justify-between gap-2 border-b border-primaryDark p-2 text-sm font-medium" |
||||
|
> |
||||
|
<m.div className="flex gap-2 "> |
||||
|
<m.div className="my-auto">{icon}</m.div> |
||||
|
{title} |
||||
|
</m.div> |
||||
|
<m.div className="my-auto"> |
||||
|
<FiExternalLink /> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
</m.div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,49 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion'; |
||||
|
import { FiArrowLeft } from 'react-icons/fi'; |
||||
|
|
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
|
||||
|
export interface SidebarOverlayProps { |
||||
|
title: string; |
||||
|
open: boolean; |
||||
|
close: () => void; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const SidebarOverlay = ({ |
||||
|
title, |
||||
|
open, |
||||
|
close, |
||||
|
children, |
||||
|
}: SidebarOverlayProps): JSX.Element => { |
||||
|
return ( |
||||
|
<AnimatePresence> |
||||
|
{open && ( |
||||
|
<m.div |
||||
|
className="absolute z-20 flex h-full w-full flex-col bg-primaryDark" |
||||
|
animate={{ translateX: 0 }} |
||||
|
initial={{ translateX: '-100%' }} |
||||
|
exit={{ translateX: '-100%' }} |
||||
|
transition={{ type: 'just' }} |
||||
|
> |
||||
|
<AnimateSharedLayout> |
||||
|
<div className="flex gap-2 p-2"> |
||||
|
<IconButton |
||||
|
onClick={(): void => { |
||||
|
close(); |
||||
|
}} |
||||
|
icon={<FiArrowLeft />} |
||||
|
/> |
||||
|
<div className="my-auto text-lg font-medium dark:text-white"> |
||||
|
{title} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex-grow overflow-y-auto">{children}</div> |
||||
|
</AnimateSharedLayout> |
||||
|
</m.div> |
||||
|
)} |
||||
|
</AnimatePresence> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,50 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { AnimatePresence, AnimateSharedLayout, m } from 'framer-motion'; |
||||
|
import { FiArrowDown } from 'react-icons/fi'; |
||||
|
|
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
|
||||
|
export interface SidebarPrimaryProps { |
||||
|
title: string; |
||||
|
open: boolean; |
||||
|
close: () => void; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const SidebarPrimary = ({ |
||||
|
title, |
||||
|
open, |
||||
|
close, |
||||
|
children, |
||||
|
}: SidebarPrimaryProps): JSX.Element => { |
||||
|
return ( |
||||
|
<AnimatePresence> |
||||
|
{open && ( |
||||
|
<m.div |
||||
|
className="absolute flex h-full w-full flex-col bg-gray-100 dark:bg-primaryDark" |
||||
|
animate={{ translateY: 0 }} |
||||
|
initial={{ translateY: '100%' }} |
||||
|
exit={{ translateY: '100%' }} |
||||
|
transition={{ type: 'just' }} |
||||
|
> |
||||
|
<AnimateSharedLayout> |
||||
|
<div className="flex gap-2 p-2"> |
||||
|
<IconButton |
||||
|
onClick={(): void => { |
||||
|
close(); |
||||
|
}} |
||||
|
icon={<FiArrowDown />} |
||||
|
/> |
||||
|
<div className="my-auto text-lg font-medium dark:text-white"> |
||||
|
{title} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex-grow overflow-y-auto">{children}</div> |
||||
|
</AnimateSharedLayout> |
||||
|
</m.div> |
||||
|
)} |
||||
|
</AnimatePresence> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,39 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import { ErrorBoundary } from 'react-error-boundary'; |
||||
|
|
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
|
||||
|
import { ErrorFallback } from '../ErrorFallback'; |
||||
|
import { Sidebar } from './Sidebar'; |
||||
|
|
||||
|
export interface LayoutProps { |
||||
|
title: string; |
||||
|
icon: React.ReactNode; |
||||
|
sidebarContents: React.ReactNode; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const Layout = ({ |
||||
|
title, |
||||
|
icon, |
||||
|
sidebarContents, |
||||
|
children, |
||||
|
}: LayoutProps): JSX.Element => { |
||||
|
return ( |
||||
|
<div className="flex w-full bg-gray-100 dark:bg-secondaryDark md:overflow-hidden md:shadow-xl"> |
||||
|
<Sidebar> |
||||
|
<div className="flex gap-2 border-b border-gray-300 p-2 dark:border-gray-600"> |
||||
|
<IconButton icon={icon} /> |
||||
|
<div className="my-auto text-lg font-medium dark:text-white"> |
||||
|
{title} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex flex-col gap-2">{sidebarContents}</div> |
||||
|
</Sidebar> |
||||
|
<ErrorBoundary FallbackComponent={ErrorFallback}> |
||||
|
{children} |
||||
|
</ErrorBoundary> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -1,14 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
export const Logo = (): JSX.Element => { |
|
||||
return ( |
|
||||
<> |
|
||||
<img title="Logo" className="w-16 dark:hidden" src="/Logo_Black.svg" /> |
|
||||
<img |
|
||||
title="Logo" |
|
||||
className="hidden w-16 dark:flex" |
|
||||
src="/Logo_White.svg" |
|
||||
/> |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,58 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { FiGrid, FiMessageSquare, FiSettings } from 'react-icons/fi'; |
|
||||
import type { Link } from 'type-route'; |
|
||||
|
|
||||
import { routes, useRoute } from '@core/router'; |
|
||||
|
|
||||
export const Navigation = (): JSX.Element => { |
|
||||
const route = useRoute(); |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex h-full mt-2 border-t border-l border-r border-gray-300 rounded-t dark:text-white dark:border-gray-600"> |
|
||||
<NavLink |
|
||||
name="Messages" |
|
||||
icon={ |
|
||||
<FiMessageSquare className="w-5 h-5 my-auto group-active:scale-90" /> |
|
||||
} |
|
||||
active={route.name === 'messages'} |
|
||||
link={routes.messages().link} |
|
||||
/> |
|
||||
|
|
||||
<NavLink |
|
||||
name="Nodes" |
|
||||
icon={<FiGrid className="w-5 h-5 my-auto group-active:scale-90" />} |
|
||||
active={route.name === 'nodes'} |
|
||||
link={routes.nodes().link} |
|
||||
/> |
|
||||
|
|
||||
<NavLink |
|
||||
name="Settings" |
|
||||
icon={<FiSettings className="w-5 h-5 my-auto group-active:scale-90" />} |
|
||||
active={route.name === 'settings'} |
|
||||
link={routes.settings().link} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
interface NavLinkProps { |
|
||||
name: string; |
|
||||
icon: JSX.Element; |
|
||||
active: boolean; |
|
||||
link: Link; |
|
||||
} |
|
||||
|
|
||||
const NavLink = ({ name, icon, active, link }: NavLinkProps): JSX.Element => { |
|
||||
return ( |
|
||||
<a |
|
||||
className={`flex h-full gap-1 p-2 cursor-pointer group hover:bg-gray-200 dark:hover:bg-primaryDark ${ |
|
||||
active ? 'dark:bg-primaryDark bg-gray-200' : 'bg-transparent' |
|
||||
}`}
|
|
||||
{...link} |
|
||||
> |
|
||||
{icon} |
|
||||
<div className="hidden my-auto md:flex">{name}</div> |
|
||||
</a> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,22 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { FiMenu } from 'react-icons/fi'; |
|
||||
|
|
||||
import { openMobileNav } from '@core/slices/appSlice'; |
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch'; |
|
||||
import { IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
export const MobileNavToggle = (): JSX.Element => { |
|
||||
const dispatch = useAppDispatch(); |
|
||||
|
|
||||
return ( |
|
||||
<div className="md:hidden"> |
|
||||
<IconButton |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
dispatch(openMobileNav()); |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,79 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { FiBell, FiX } from 'react-icons/fi'; |
|
||||
|
|
||||
import { shift, useFloating } from '@floating-ui/react-dom'; |
|
||||
import { Popover } from '@headlessui/react'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { Button, IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
export const Notifications = (): JSX.Element => { |
|
||||
const [unreadCount, setUnreadCount] = React.useState(0); |
|
||||
const notifications = useAppSelector((state) => state.app.notifications); |
|
||||
|
|
||||
const { x, y, reference, floating, strategy } = useFloating({ |
|
||||
placement: 'bottom', |
|
||||
middleware: [shift()], |
|
||||
}); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
setUnreadCount( |
|
||||
notifications.filter((notification) => !notification.read).length, |
|
||||
); |
|
||||
}, [notifications]); |
|
||||
|
|
||||
return ( |
|
||||
<Popover> |
|
||||
<Popover.Button as="div" className="relative" ref={reference}> |
|
||||
<IconButton icon={<FiBell className="w-5 h-5" />} /> |
|
||||
{unreadCount > 0 && ( |
|
||||
<div className="absolute pointer-events-none top-1 right-1"> |
|
||||
<div className="w-3 h-3 text-xs font-semibold leading-3 text-center text-white bg-orange-500 rounded-full"> |
|
||||
{unreadCount} |
|
||||
</div> |
|
||||
</div> |
|
||||
)} |
|
||||
</Popover.Button> |
|
||||
|
|
||||
<Popover.Panel |
|
||||
ref={floating} |
|
||||
style={{ |
|
||||
position: strategy, |
|
||||
top: y ?? '', |
|
||||
left: x ?? '', |
|
||||
}} |
|
||||
className="fixed z-50 border border-gray-300 rounded-md shadow-md w-72 bg-primaryDark dark:border-gray-600" |
|
||||
> |
|
||||
<div className="divide-y divide-gray-600"> |
|
||||
{notifications.map((notification, index) => ( |
|
||||
<div |
|
||||
key={index} |
|
||||
className={`p-1 flex text-sm justify-between ${ |
|
||||
notification.read |
|
||||
? 'text-gray-600 dark:text-gray-300' |
|
||||
: 'text-gray-900 dark:text-white' |
|
||||
}`}
|
|
||||
> |
|
||||
<div className="my-auto">{notification.icon}</div> |
|
||||
|
|
||||
<div className="my-auto font-light">{notification.title}</div> |
|
||||
|
|
||||
<div className="flex space-x-1"> |
|
||||
{notification.action ? ( |
|
||||
<div className="my-auto w-18"> |
|
||||
<Button border onClick={notification.action.action}> |
|
||||
{notification.action.message} |
|
||||
</Button> |
|
||||
</div> |
|
||||
) : ( |
|
||||
<div className="w-16" /> |
|
||||
)} |
|
||||
<IconButton icon={<FiX />} /> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</Popover.Panel> |
|
||||
</Popover> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,131 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import mapbox from 'mapbox-gl'; |
|
||||
import { FiAlignLeft } from 'react-icons/fi'; |
|
||||
import { |
|
||||
MdAccountCircle, |
|
||||
MdGpsFixed, |
|
||||
MdGpsNotFixed, |
|
||||
MdGpsOff, |
|
||||
} from 'react-icons/md'; |
|
||||
import TimeAgo from 'timeago-react'; |
|
||||
|
|
||||
import type { Node } from '@core/slices/meshtasticSlice'; |
|
||||
import { useMapbox } from '@hooks/useMapbox'; |
|
||||
import { IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
type PositionConfidence = 'high' | 'low' | 'none'; |
|
||||
type NodeAge = 'young' | 'aging' | 'old' | 'dead'; |
|
||||
|
|
||||
export interface NodeCardProps { |
|
||||
node: Node; |
|
||||
isMyNode?: boolean; |
|
||||
setSelected: () => void; |
|
||||
} |
|
||||
|
|
||||
export const NodeCard = ({ |
|
||||
node, |
|
||||
isMyNode, |
|
||||
setSelected, |
|
||||
}: NodeCardProps): JSX.Element => { |
|
||||
const { map } = useMapbox(); |
|
||||
|
|
||||
// React.useEffect(() => {
|
|
||||
// setSnrAverage(
|
|
||||
// node.snr
|
|
||||
// .slice(node.snr.length - 3, node.snr.length)
|
|
||||
// .reduce((a, b) => a + b) / (node.snr.length > 3 ? 3 : node.snr.length),
|
|
||||
// );
|
|
||||
// }, [node.snr]);
|
|
||||
const [PositionConfidence, setPositionConfidence] = |
|
||||
React.useState<PositionConfidence>('none'); |
|
||||
const [age, setAge] = React.useState<NodeAge>('young'); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
setAge( |
|
||||
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15) |
|
||||
? 'young' |
|
||||
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30) |
|
||||
? 'aging' |
|
||||
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 60) |
|
||||
? 'old' |
|
||||
: 'dead', |
|
||||
); |
|
||||
}, [node.lastHeard]); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
setPositionConfidence( |
|
||||
node.currentPosition |
|
||||
? new Date(node.currentPosition.posTimestamp * 1000) > |
|
||||
new Date(new Date().getTime() - 1000 * 60 * 30) |
|
||||
? 'high' |
|
||||
: 'low' |
|
||||
: 'none', |
|
||||
); |
|
||||
}, [node.currentPosition]); |
|
||||
return ( |
|
||||
<div className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700"> |
|
||||
<div className="flex w-full gap-1 p-2 bg-gray-100 rounded-md shadow-md dark:bg-primaryDark"> |
|
||||
{isMyNode ? ( |
|
||||
<MdAccountCircle className="my-auto" /> |
|
||||
) : ( |
|
||||
<div |
|
||||
className={`my-auto w-3 h-3 rounded-full ${ |
|
||||
age === 'young' |
|
||||
? 'bg-green-500' |
|
||||
: age === 'aging' |
|
||||
? 'bg-yellow-500' |
|
||||
: age === 'old' |
|
||||
? 'bg-red-500' |
|
||||
: 'bg-gray-500' |
|
||||
}`}
|
|
||||
/> |
|
||||
)} |
|
||||
<div className="my-auto">{node.user?.longName}</div> |
|
||||
|
|
||||
<div className="my-auto ml-auto text-xs font-semibold"> |
|
||||
{!isMyNode && ( |
|
||||
<span> |
|
||||
{node.lastHeard.getTime() ? ( |
|
||||
<TimeAgo datetime={node.lastHeard} /> |
|
||||
) : ( |
|
||||
'Never' |
|
||||
)} |
|
||||
</span> |
|
||||
)} |
|
||||
</div> |
|
||||
<IconButton |
|
||||
disabled={PositionConfidence === 'none'} |
|
||||
onClick={(e): void => { |
|
||||
e.stopPropagation(); |
|
||||
if (PositionConfidence !== 'none' && node.currentPosition) { |
|
||||
map?.flyTo({ |
|
||||
center: new mapbox.LngLat( |
|
||||
node.currentPosition.longitudeI / 1e7, |
|
||||
node.currentPosition.latitudeI / 1e7, |
|
||||
), |
|
||||
zoom: 16, |
|
||||
}); |
|
||||
} |
|
||||
}} |
|
||||
icon={ |
|
||||
PositionConfidence === 'high' ? ( |
|
||||
<MdGpsFixed /> |
|
||||
) : PositionConfidence === 'low' ? ( |
|
||||
<MdGpsNotFixed /> |
|
||||
) : ( |
|
||||
<MdGpsOff /> |
|
||||
) |
|
||||
} |
|
||||
/> |
|
||||
<IconButton |
|
||||
onClick={(): void => { |
|
||||
setSelected(); |
|
||||
}} |
|
||||
icon={<FiAlignLeft />} |
|
||||
/> |
|
||||
{/* <FiBatteryCharging /> */} |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,59 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { FiCode, FiMapPin, FiSliders, FiUser } from 'react-icons/fi'; |
|
||||
import { IoTelescope } from 'react-icons/io5'; |
|
||||
|
|
||||
import { DebugPanel } from '@app/components/pages/nodes/panels/DebugPanel'; |
|
||||
import { InfoPanel } from '@app/components/pages/nodes/panels/InfoPanel'; |
|
||||
import { PositionPanel } from '@app/components/pages/nodes/panels/PositionPanel'; |
|
||||
import { Sidebar } from '@components/generic/Sidebar'; |
|
||||
import { TabButton } from '@components/TabButton'; |
|
||||
import type { Node } from '@core/slices/meshtasticSlice'; |
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
|
|
||||
export interface NodeSidebarProps { |
|
||||
node: Node; |
|
||||
closeSidebar: () => void; |
|
||||
} |
|
||||
|
|
||||
export const NodeSidebar = ({ |
|
||||
node, |
|
||||
closeSidebar, |
|
||||
}: NodeSidebarProps): JSX.Element => { |
|
||||
return ( |
|
||||
<Sidebar |
|
||||
title={node.number.toString()} |
|
||||
tagline={`${node.user?.longName}(${node.user?.shortName})`} |
|
||||
closeSidebar={closeSidebar} |
|
||||
> |
|
||||
<Tab.Group> |
|
||||
<div className="shadow-md"> |
|
||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600"> |
|
||||
<TabButton> |
|
||||
<FiUser /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FiMapPin /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<IoTelescope /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FiSliders /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FiCode /> |
|
||||
</TabButton> |
|
||||
</Tab.List> |
|
||||
</div> |
|
||||
<Tab.Panels className="flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark"> |
|
||||
<InfoPanel /> |
|
||||
<PositionPanel node={node} /> |
|
||||
<Tab.Panel className="p-2">Content 3</Tab.Panel> |
|
||||
<Tab.Panel className="p-2">Remote Administration</Tab.Panel> |
|
||||
<DebugPanel node={node} /> |
|
||||
</Tab.Panels> |
|
||||
</Tab.Group> |
|
||||
</Sidebar> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,7 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
|
|
||||
export const InfoPanel = (): JSX.Element => { |
|
||||
return <Tab.Panel className="p-2">Info</Tab.Panel>; |
|
||||
}; |
|
||||
@ -1,76 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { FiCode, FiSliders } from 'react-icons/fi'; |
|
||||
|
|
||||
import { TabButton } from '@app/components/TabButton'; |
|
||||
import type { Plugin } from '@app/pages/settings/Plugins'; |
|
||||
import { Sidebar } from '@components/generic/Sidebar'; |
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
|
|
||||
import { ExternalNotificationsDebugPanel } from './panels/ExternalNotifications/DebugPanel'; |
|
||||
import { ExternalNotificationsSettingsPlanel } from './panels/ExternalNotifications/SettingsPlanel'; |
|
||||
import { RangeTestDebugPanel } from './panels/RangeTest/DebugPanel'; |
|
||||
import { RangeTestSettingsPanel } from './panels/RangeTest/SettingsPanel'; |
|
||||
import { SerialDebugPanel } from './panels/Serial/DebugPanel'; |
|
||||
import { SerialSettingsPanel } from './panels/Serial/SettingsPanel'; |
|
||||
import { StoreForwardDebugPanel } from './panels/StoreForward/DebugPanel'; |
|
||||
import { StoreForwardSettingsPanel } from './panels/StoreForward/SettingsPanel'; |
|
||||
|
|
||||
export interface PluginsSidebarProps { |
|
||||
plugin?: Plugin; |
|
||||
closeSidebar: () => void; |
|
||||
} |
|
||||
|
|
||||
export const PluginsSidebar = ({ |
|
||||
plugin, |
|
||||
closeSidebar, |
|
||||
}: PluginsSidebarProps): JSX.Element => { |
|
||||
return ( |
|
||||
<Sidebar |
|
||||
title={plugin ?? 'Please select plugin'} |
|
||||
tagline={plugin ? 'settings' : '...'} |
|
||||
closeSidebar={closeSidebar} |
|
||||
> |
|
||||
{plugin && ( |
|
||||
<Tab.Group> |
|
||||
<div className="shadow-md"> |
|
||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600"> |
|
||||
<TabButton> |
|
||||
<FiSliders /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FiCode /> |
|
||||
</TabButton> |
|
||||
</Tab.List> |
|
||||
</div> |
|
||||
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark"> |
|
||||
{plugin === 'Range Test' && ( |
|
||||
<> |
|
||||
<RangeTestSettingsPanel /> |
|
||||
<RangeTestDebugPanel /> |
|
||||
</> |
|
||||
)} |
|
||||
{plugin === 'External Notifications' && ( |
|
||||
<> |
|
||||
<ExternalNotificationsSettingsPlanel /> |
|
||||
<ExternalNotificationsDebugPanel /> |
|
||||
</> |
|
||||
)} |
|
||||
{plugin === 'Serial' && ( |
|
||||
<> |
|
||||
<SerialSettingsPanel /> |
|
||||
<SerialDebugPanel /> |
|
||||
</> |
|
||||
)} |
|
||||
{plugin === 'Store & Forward' && ( |
|
||||
<> |
|
||||
<StoreForwardSettingsPanel /> |
|
||||
<StoreForwardDebugPanel /> |
|
||||
</> |
|
||||
)} |
|
||||
</Tab.Panels> |
|
||||
</Tab.Group> |
|
||||
)} |
|
||||
</Sidebar> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,60 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { FaQrcode } from 'react-icons/fa'; |
|
||||
import { FiCode, FiSliders } from 'react-icons/fi'; |
|
||||
|
|
||||
import { TabButton } from '@app/components/TabButton'; |
|
||||
import { Sidebar } from '@components/generic/Sidebar'; |
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
import { DebugPanel } from './panels/DebugPanel'; |
|
||||
import { QRCodePanel } from './panels/QRCodePanel'; |
|
||||
import { SettingsPanel } from './panels/SettingsPanel'; |
|
||||
|
|
||||
export interface ChannelsSidebarProps { |
|
||||
channel?: Protobuf.Channel; |
|
||||
closeSidebar: () => void; |
|
||||
} |
|
||||
|
|
||||
export const ChannelsSidebar = ({ |
|
||||
channel, |
|
||||
closeSidebar, |
|
||||
}: ChannelsSidebarProps): JSX.Element => { |
|
||||
return ( |
|
||||
<Sidebar |
|
||||
title={ |
|
||||
channel |
|
||||
? channel.settings?.name.length |
|
||||
? channel.settings.name |
|
||||
: `Channel: ${channel.index}` |
|
||||
: 'Please select channel' |
|
||||
} |
|
||||
tagline={channel ? Protobuf.Channel_Role[channel.role] : '...'} |
|
||||
closeSidebar={closeSidebar} |
|
||||
> |
|
||||
{channel && ( |
|
||||
<Tab.Group> |
|
||||
<div className="shadow-md"> |
|
||||
<Tab.List className="flex justify-between border-b border-gray-300 dark:border-gray-600"> |
|
||||
<TabButton> |
|
||||
<FiSliders /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FaQrcode /> |
|
||||
</TabButton> |
|
||||
<TabButton> |
|
||||
<FiCode /> |
|
||||
</TabButton> |
|
||||
</Tab.List> |
|
||||
</div> |
|
||||
<Tab.Panels className="flex flex-grow overflow-y-auto bg-gray-100 dark:bg-primaryDark"> |
|
||||
<SettingsPanel channel={channel} /> |
|
||||
<QRCodePanel channel={channel} /> |
|
||||
<DebugPanel channel={channel} /> |
|
||||
</Tab.Panels> |
|
||||
</Tab.Group> |
|
||||
)} |
|
||||
</Sidebar> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,89 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { FiMenu, FiXCircle } from 'react-icons/fi'; |
|
||||
|
|
||||
import { Drawer } from '@components/generic/Drawer'; |
|
||||
import type { SidebarItemProps } from '@components/generic/SidebarItem'; |
|
||||
import { SidebarItem } from '@components/generic/SidebarItem'; |
|
||||
import { Tab } from '@headlessui/react'; |
|
||||
import { useBreakpoint } from '@hooks/useBreakpoint'; |
|
||||
import { IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
export interface PageLayoutProps { |
|
||||
title: string; |
|
||||
sidebarItems: SidebarItemProps[]; |
|
||||
panels: JSX.Element[]; |
|
||||
emptyMessage?: string; |
|
||||
} |
|
||||
|
|
||||
export const PageLayout = ({ |
|
||||
title, |
|
||||
sidebarItems, |
|
||||
panels, |
|
||||
emptyMessage, |
|
||||
}: PageLayoutProps): JSX.Element => { |
|
||||
const [navOpen, setNavOpen] = React.useState(false); |
|
||||
|
|
||||
const { breakpoint } = useBreakpoint(); |
|
||||
|
|
||||
return ( |
|
||||
<Tab.Group> |
|
||||
<div className="relative flex w-full dark:text-white"> |
|
||||
<Drawer |
|
||||
open={breakpoint === 'sm' ? navOpen : true} |
|
||||
permenant={breakpoint !== 'sm'} |
|
||||
onClose={(): void => { |
|
||||
setNavOpen(!navOpen); |
|
||||
}} |
|
||||
> |
|
||||
<Tab.List className="flex flex-col border-b border-gray-300 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600"> |
|
||||
<div className="flex items-center justify-between m-4"> |
|
||||
<div className="text-2xl font-extrabold leading-none tracking-tight"> |
|
||||
{title} |
|
||||
</div> |
|
||||
<IconButton icon={<FiMenu />} /> |
|
||||
<div className="md:hidden"> |
|
||||
<IconButton |
|
||||
icon={<FiXCircle className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen(false); |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
{!sidebarItems.length && ( |
|
||||
<span className="p-4 text-sm text-gray-400 dark:text-gray-600"> |
|
||||
{emptyMessage} |
|
||||
</span> |
|
||||
)} |
|
||||
{sidebarItems.map((props, index) => ( |
|
||||
<Tab |
|
||||
key={index} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen(false); |
|
||||
}} |
|
||||
> |
|
||||
{({ selected }): JSX.Element => ( |
|
||||
<SidebarItem {...props} selected={selected} /> |
|
||||
)} |
|
||||
</Tab> |
|
||||
))} |
|
||||
</Tab.List> |
|
||||
</Drawer> |
|
||||
<div className="flex w-full"> |
|
||||
<Tab.Panels className="flex w-full"> |
|
||||
{panels.map((Panel, index) => ( |
|
||||
<Tab.Panel key={index} className="flex w-full"> |
|
||||
{React.cloneElement(Panel, { |
|
||||
key: index, |
|
||||
navOpen: navOpen, |
|
||||
setNavOpen: setNavOpen, |
|
||||
})} |
|
||||
</Tab.Panel> |
|
||||
))} |
|
||||
</Tab.Panels> |
|
||||
</div> |
|
||||
</div> |
|
||||
</Tab.Group> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,46 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
export interface PrimaryTemplateProps { |
|
||||
children: React.ReactNode; |
|
||||
title: string; |
|
||||
tagline: string; |
|
||||
leftButton?: JSX.Element; |
|
||||
rightButton?: JSX.Element; |
|
||||
footer?: JSX.Element; |
|
||||
} |
|
||||
|
|
||||
export const PrimaryTemplate = ({ |
|
||||
children, |
|
||||
title, |
|
||||
tagline, |
|
||||
leftButton, |
|
||||
rightButton, |
|
||||
footer, |
|
||||
}: PrimaryTemplateProps): JSX.Element => { |
|
||||
return ( |
|
||||
<div className="flex flex-col flex-auto h-full min-w-0"> |
|
||||
<div className="flex p-2 bg-white border-b border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark"> |
|
||||
<div className="flex-1 min-w-0"> |
|
||||
<a className="font-medium whitespace-nowrap text-primary"> |
|
||||
{tagline} |
|
||||
</a> |
|
||||
<h2 className="text-3xl font-extrabold leading-7 tracking-tight truncate md:text-4xl md:leading-10 dark:text-white"> |
|
||||
{title} |
|
||||
</h2> |
|
||||
</div> |
|
||||
{rightButton} |
|
||||
</div> |
|
||||
<div className="flex-auto flex-grow py-6 overflow-y-auto bg-white md:p-5 dark:bg-secondaryDark"> |
|
||||
{children} |
|
||||
</div> |
|
||||
{footer && ( |
|
||||
<div className="flex px-4 py-2 bg-white border-t border-gray-300 md:flex-row flex-0 md:items-center md:justify-between md:px-10 dark:border-gray-600 dark:bg-secondaryDark"> |
|
||||
{leftButton && ( |
|
||||
<div className="pr-2 m-auto md:hidden">{leftButton}</div> |
|
||||
)} |
|
||||
<div className="flex-1 min-w-0">{footer}</div> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,21 +0,0 @@ |
|||||
import i18n from 'i18next'; |
|
||||
import detector from 'i18next-browser-languagedetector'; |
|
||||
import { initReactI18next } from 'react-i18next'; |
|
||||
|
|
||||
import { en } from '../translations/en'; |
|
||||
import { jp } from '../translations/jp'; |
|
||||
import { pt } from '../translations/pt'; |
|
||||
|
|
||||
void i18n |
|
||||
.use(detector) |
|
||||
.use(initReactI18next) |
|
||||
.init({ |
|
||||
fallbackLng: 'en', |
|
||||
resources: { |
|
||||
en: { translation: en }, |
|
||||
jp: { translation: jp }, |
|
||||
pt: { translation: pt }, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
export default i18n; |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import { request } from 'graphql-request'; |
||||
|
|
||||
|
export default async function gqlFetcher<JSON>( |
||||
|
url: string, |
||||
|
query?: string, |
||||
|
): Promise<JSON> { |
||||
|
// const res = await fetch(input, init);
|
||||
|
return await request<JSON>(url, query); |
||||
|
} |
||||
@ -1,12 +0,0 @@ |
|||||
export const requestNotificationPermission = async (): Promise<void> => { |
|
||||
if (window.Notification && Notification.permission !== 'denied') { |
|
||||
await Notification.requestPermission(); |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
export const showNotification = (title: string, body: string): void => { |
|
||||
new Notification(title, { |
|
||||
body, |
|
||||
icon: 'android-512.png', |
|
||||
}); |
|
||||
}; |
|
||||
@ -1,21 +0,0 @@ |
|||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */ |
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ |
|
||||
import useBreakpointHook from 'use-breakpoint'; |
|
||||
|
|
||||
const BREAKPOINTS = { |
|
||||
sm: 0, |
|
||||
// => @media (min-width: 640px) { ... }
|
|
||||
|
|
||||
md: 768, |
|
||||
// => @media (min-width: 768px) { ... }
|
|
||||
|
|
||||
lg: 1024, |
|
||||
// => @media (min-width: 1024px) { ... }
|
|
||||
|
|
||||
xl: 1280, |
|
||||
// => @media (min-width: 1280px) { ... }
|
|
||||
|
|
||||
'2xl': 1536, |
|
||||
// => @media (min-width: 1536px) { ... }
|
|
||||
}; |
|
||||
export const useBreakpoint = () => useBreakpointHook(BREAKPOINTS); |
|
||||
@ -0,0 +1,69 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import useSWR from 'swr'; |
||||
|
|
||||
|
import fetcher from '@app/core/utils/fetcher'; |
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector'; |
||||
|
|
||||
|
export interface File { |
||||
|
nameModified: string; |
||||
|
name: string; |
||||
|
size: number; |
||||
|
} |
||||
|
|
||||
|
export interface Files { |
||||
|
data: { |
||||
|
files: File[]; |
||||
|
fileSystem: { |
||||
|
total: number; |
||||
|
used: number; |
||||
|
free: number; |
||||
|
}; |
||||
|
}; |
||||
|
status: string; |
||||
|
} |
||||
|
|
||||
|
export const FileBrowser = (): JSX.Element => { |
||||
|
const connectionParams = useAppSelector( |
||||
|
(state) => state.app.connectionParams, |
||||
|
); |
||||
|
|
||||
|
const { data } = useSWR<Files>( |
||||
|
`${connectionParams.HTTP.tls ? 'https' : 'http'}://${ |
||||
|
connectionParams.HTTP.address |
||||
|
}/json/spiffs/browse/static`,
|
||||
|
fetcher, |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex h-full w-full select-none flex-col gap-4 p-4"> |
||||
|
<div className="w-full flex-grow rounded-md bg-gray-200 dark:bg-primaryDark"> |
||||
|
<div className="flex h-10 w-full rounded-t-md bg-gray-300 px-4 text-lg font-semibold dark:bg-zinc-700 dark:text-white"> |
||||
|
<div className="my-auto w-1/3">FileName</div> |
||||
|
<div className="my-auto w-1/3">Actions</div> |
||||
|
</div> |
||||
|
<div className="px-4"> |
||||
|
{data?.data.files.map((file) => ( |
||||
|
<div |
||||
|
key={file.name} |
||||
|
className="flex h-10 w-full border-b border-gray-500 px-4 font-medium dark:text-white" |
||||
|
> |
||||
|
<div className="my-auto w-1/3"> |
||||
|
<a |
||||
|
target="_blank" |
||||
|
rel="noopener noreferrer" |
||||
|
href={`${connectionParams.HTTP.tls ? 'https' : 'http'}://${ |
||||
|
connectionParams.HTTP.address |
||||
|
}/${file.name.replace('static/', '')}`}
|
||||
|
> |
||||
|
{file.name.replace('static/', '').replace('.gz', '')} |
||||
|
</a> |
||||
|
</div> |
||||
|
<div className="my-auto w-1/3"></div> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,55 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { FiFile, FiInfo } from 'react-icons/fi'; |
||||
|
import { RiPinDistanceFill } from 'react-icons/ri'; |
||||
|
import { VscExtensions } from 'react-icons/vsc'; |
||||
|
|
||||
|
import { Layout } from '@app/components/layout'; |
||||
|
import { ExternalSection } from '@app/components/layout/Sidebar/sections/ExternalSection'; |
||||
|
|
||||
|
import { FileBrowser } from './FileBrowser'; |
||||
|
import { Info } from './Info'; |
||||
|
|
||||
|
export const Extensions = (): JSX.Element => { |
||||
|
const [selectedExtension, setSelectedExtension] = React.useState< |
||||
|
'info' | 'fileBrowser' | 'rangeTest' |
||||
|
>('info'); |
||||
|
|
||||
|
return ( |
||||
|
<Layout |
||||
|
title="Extensions" |
||||
|
icon={<VscExtensions />} |
||||
|
sidebarContents={ |
||||
|
<div className="absolute flex h-full w-full flex-col dark:bg-primaryDark"> |
||||
|
<ExternalSection |
||||
|
onClick={(): void => { |
||||
|
setSelectedExtension('info'); |
||||
|
}} |
||||
|
icon={<FiInfo />} |
||||
|
title="Node Info" |
||||
|
/> |
||||
|
<ExternalSection |
||||
|
onClick={(): void => { |
||||
|
setSelectedExtension('fileBrowser'); |
||||
|
}} |
||||
|
icon={<FiFile />} |
||||
|
title="File Browser" |
||||
|
/> |
||||
|
<ExternalSection |
||||
|
onClick={(): void => { |
||||
|
setSelectedExtension('rangeTest'); |
||||
|
}} |
||||
|
icon={<RiPinDistanceFill />} |
||||
|
title="Range Test" |
||||
|
/> |
||||
|
</div> |
||||
|
} |
||||
|
> |
||||
|
<div className="w-full"> |
||||
|
{selectedExtension === 'info' && <Info />} |
||||
|
|
||||
|
{selectedExtension === 'fileBrowser' && <FileBrowser />} |
||||
|
</div> |
||||
|
</Layout> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,42 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { m } from 'framer-motion'; |
||||
|
import JSONPretty from 'react-json-pretty'; |
||||
|
|
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector.js'; |
||||
|
// eslint-disable-next-line import/no-unresolved
|
||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react'; |
||||
|
|
||||
|
const Hashicon = skypack_hashicon.Hashicon; |
||||
|
// import { Hashicon } from '@emeraldpay/hashicon-react';
|
||||
|
|
||||
|
export const Info = (): JSX.Element => { |
||||
|
const hardwareInfo = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.hardware, |
||||
|
); |
||||
|
const node = useAppSelector((state) => |
||||
|
state.meshtastic.nodes.find( |
||||
|
(node) => node.number === hardwareInfo.myNodeNum, |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex w-full select-none flex-col gap-4 p-4"> |
||||
|
<m.div |
||||
|
whileHover={{ scale: 1.01 }} |
||||
|
className="flex w-full flex-col gap-4 rounded-md p-8 shadow-md dark:bg-primaryDark" |
||||
|
> |
||||
|
<div className="m-auto"> |
||||
|
<Hashicon value={hardwareInfo.myNodeNum.toString()} size={180} /> |
||||
|
</div> |
||||
|
<div className="text-center text-lg font-medium dark:text-white"> |
||||
|
{node?.user?.longName || 'Unknown'} |
||||
|
</div> |
||||
|
</m.div> |
||||
|
|
||||
|
<div className="rounded-md p-8 shadow-md dark:bg-primaryDark"> |
||||
|
<JSONPretty data={hardwareInfo} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,85 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import mapboxgl from 'mapbox-gl'; |
||||
|
import { FiMapPin } from 'react-icons/fi'; |
||||
|
import { RiRoadMapLine } from 'react-icons/ri'; |
||||
|
|
||||
|
import { Layout } from '@app/components/layout'; |
||||
|
import { MapboxProvider } from '@app/components/MapBox/MapboxProvider'; |
||||
|
import type { Node } from '@app/core/slices/meshtasticSlice'; |
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector'; |
||||
|
|
||||
|
import { NodeCard } from '../Nodes/NodeCard'; |
||||
|
import { MapContainer } from './MapContainer'; |
||||
|
import { Marker } from './Marker'; |
||||
|
|
||||
|
export const Map = (): JSX.Element => { |
||||
|
const [selectedNode, setSelectedNode] = React.useState<Node>(); |
||||
|
|
||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
|
const myNodeNum = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.hardware.myNodeNum, |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<MapboxProvider> |
||||
|
{nodes.map((node) => { |
||||
|
return ( |
||||
|
node.currentPosition && ( |
||||
|
<Marker |
||||
|
key={node.number} |
||||
|
center={ |
||||
|
new mapboxgl.LngLat( |
||||
|
node.currentPosition.longitudeI / 1e7, |
||||
|
node.currentPosition.latitudeI / 1e7, |
||||
|
) |
||||
|
} |
||||
|
> |
||||
|
<div |
||||
|
onClick={(): void => { |
||||
|
setSelectedNode(node); |
||||
|
}} |
||||
|
className={`z-50 rounded-full border-2 bg-opacity-30 ${ |
||||
|
node.number === selectedNode?.number |
||||
|
? 'border-green-500 bg-green-500' |
||||
|
: 'border-blue-500 bg-blue-500' |
||||
|
}`}
|
||||
|
> |
||||
|
<div className="m-4 "> |
||||
|
<FiMapPin className="h-5 w-5" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Marker> |
||||
|
) |
||||
|
); |
||||
|
})} |
||||
|
<Layout |
||||
|
title="Nodes" |
||||
|
icon={<RiRoadMapLine />} |
||||
|
sidebarContents={ |
||||
|
<div className="flex flex-col gap-2"> |
||||
|
{!nodes.length && ( |
||||
|
<span className="p-4 text-sm text-gray-400 dark:text-gray-600"> |
||||
|
No nodes found. |
||||
|
</span> |
||||
|
)} |
||||
|
|
||||
|
{nodes.map((node) => ( |
||||
|
<NodeCard |
||||
|
key={node.number} |
||||
|
node={node} |
||||
|
isMyNode={node.number === myNodeNum} |
||||
|
selected={selectedNode?.number === node.number} |
||||
|
setSelected={(): void => { |
||||
|
setSelectedNode(node); |
||||
|
}} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
} |
||||
|
> |
||||
|
<MapContainer /> |
||||
|
</Layout> |
||||
|
</MapboxProvider> |
||||
|
); |
||||
|
}; |
||||
@ -1,63 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { FiHash } from 'react-icons/fi'; |
|
||||
|
|
||||
import { Message } from '@components/chat/Message'; |
|
||||
import { MessageBar } from '@components/chat/MessageBar'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { Select } from '@meshtastic/components'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
export const Messages = (): JSX.Element => { |
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
|
||||
const channels = useAppSelector((state) => state.meshtastic.radio.channels); |
|
||||
const [channelIndex, setChannelIndex] = React.useState(0); |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex flex-col w-full"> |
|
||||
<div className="flex justify-between w-full px-2 border-b border-gray-300 dark:border-gray-600 dark:text-gray-300"> |
|
||||
<div className="flex py-2 my-auto text-sm"> |
|
||||
<FiHash className="w-4 h-4 my-auto mr-1" /> |
|
||||
<Select |
|
||||
options={channels |
|
||||
.filter( |
|
||||
(channel) => |
|
||||
channel.channel.role !== Protobuf.Channel_Role.DISABLED && |
|
||||
channel.channel.settings?.name !== 'admin', |
|
||||
) |
|
||||
.map((channel) => { |
|
||||
return { |
|
||||
name: channel.channel.settings?.name.length |
|
||||
? channel.channel.settings.name |
|
||||
: channel.channel.role === Protobuf.Channel_Role.PRIMARY |
|
||||
? 'Primary' |
|
||||
: `CH: ${channel.channel.index}`, |
|
||||
value: channel.channel.index, |
|
||||
}; |
|
||||
})} |
|
||||
onChange={(e): void => { |
|
||||
setChannelIndex(parseInt(e.target.value)); |
|
||||
}} |
|
||||
small |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="flex flex-col flex-grow p-6 space-y-2 overflow-y-auto bg-white border-b border-gray-300 md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark"> |
|
||||
{channels[channelIndex]?.messages.map((message, index) => ( |
|
||||
<Message |
|
||||
key={index} |
|
||||
isSender={message.isSender} |
|
||||
message={message.message.data} |
|
||||
ack={message.ack} |
|
||||
rxTime={message.received} |
|
||||
senderName={ |
|
||||
nodes.find((node) => node.number === message.message.packet.from) |
|
||||
?.user?.longName ?? 'UNK' |
|
||||
} |
|
||||
/> |
|
||||
))} |
|
||||
</div> |
|
||||
<MessageBar channelIndex={channelIndex} /> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,81 @@ |
|||||
|
import type React from 'react'; |
||||
|
|
||||
|
import type { Node } from '@app/core/slices/meshtasticSlice'; |
||||
|
import { Tooltip } from '@meshtastic/components'; |
||||
|
// eslint-disable-next-line import/no-unresolved
|
||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react'; |
||||
|
|
||||
|
const Hashicon = skypack_hashicon.Hashicon; |
||||
|
|
||||
|
export interface MessageProps { |
||||
|
lastMsgSameUser: boolean; |
||||
|
message: string; |
||||
|
ack: boolean; |
||||
|
rxTime: Date; |
||||
|
sender?: Node; |
||||
|
} |
||||
|
|
||||
|
export const Message = ({ |
||||
|
lastMsgSameUser, |
||||
|
message, |
||||
|
ack, |
||||
|
rxTime, |
||||
|
sender, |
||||
|
}: MessageProps): JSX.Element => { |
||||
|
return ( |
||||
|
<div className="group mb-3 hover:bg-gray-200 dark:hover:bg-primaryDark"> |
||||
|
{lastMsgSameUser ? ( |
||||
|
<div |
||||
|
className={`mx-6 -mt-3 flex gap-2 ${lastMsgSameUser ? '' : 'py-1'}`} |
||||
|
> |
||||
|
<div className="flex"> |
||||
|
<Tooltip content={rxTime.toString()}> |
||||
|
<div className="my-auto ml-auto w-8 pt-1 text-xs text-transparent dark:group-hover:text-gray-400"> |
||||
|
{rxTime |
||||
|
.toLocaleTimeString(undefined, { |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
}) |
||||
|
.replace('AM', '') |
||||
|
.replace('PM', '')} |
||||
|
</div> |
||||
|
</Tooltip> |
||||
|
</div> |
||||
|
<div |
||||
|
className={`my-auto dark:text-gray-300 ${ |
||||
|
ack ? '' : 'animate-pulse dark:text-gray-500' |
||||
|
}`}
|
||||
|
> |
||||
|
{message} |
||||
|
</div> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="mx-6 flex gap-2"> |
||||
|
<div className="my-auto w-8"> |
||||
|
<Hashicon value={(sender?.number ?? 0).toString()} size={32} /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div className="flex gap-2"> |
||||
|
<div className="cursor-default text-sm font-semibold hover:underline dark:text-white"> |
||||
|
{sender?.user?.longName ?? 'UNK'} |
||||
|
</div> |
||||
|
<div className="my-auto text-xs dark:text-gray-400"> |
||||
|
{rxTime.toLocaleTimeString(undefined, { |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
})} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div |
||||
|
className={`dark:text-gray-300 ${ |
||||
|
ack ? '' : 'animate-pulse dark:text-gray-400' |
||||
|
}`}
|
||||
|
> |
||||
|
{message} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,152 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { m } from 'framer-motion'; |
||||
|
import { FiHash, FiMessageCircle, FiSettings } from 'react-icons/fi'; |
||||
|
import { MdPublic } from 'react-icons/md'; |
||||
|
import TimeAgo from 'timeago-react'; |
||||
|
|
||||
|
import { Layout } from '@app/components/layout'; |
||||
|
import { SidebarItem } from '@components/layout/Sidebar/SidebarItem'; |
||||
|
import { useAppSelector } from '@hooks/useAppSelector'; |
||||
|
import { IconButton, Tooltip } from '@meshtastic/components'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
// eslint-disable-next-line import/no-unresolved
|
||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react'; |
||||
|
|
||||
|
import { Message } from './Message'; |
||||
|
import { MessageBar } from './MessageBar'; |
||||
|
|
||||
|
const Hashicon = skypack_hashicon.Hashicon; |
||||
|
|
||||
|
export const Messages = (): JSX.Element => { |
||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
|
const channels = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.channels, |
||||
|
).filter((ch) => ch.channel.role !== Protobuf.Channel_Role.DISABLED); |
||||
|
const [channelIndex, setChannelIndex] = React.useState(0); |
||||
|
const chatRef = React.useRef<HTMLDivElement>(null); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
if (chatRef.current) { |
||||
|
chatRef.current.scrollTop = chatRef.current.scrollHeight; |
||||
|
} |
||||
|
}, [channels]); |
||||
|
|
||||
|
return ( |
||||
|
<Layout |
||||
|
title="Message Groups" |
||||
|
icon={<FiMessageCircle />} |
||||
|
sidebarContents={ |
||||
|
<div className="flex flex-col gap-2"> |
||||
|
{channels.map((channel) => ( |
||||
|
<SidebarItem |
||||
|
key={channel.channel.index} |
||||
|
selected={channelIndex === channel.channel.index} |
||||
|
setSelected={(): void => { |
||||
|
setChannelIndex(channel.channel.index); |
||||
|
}} |
||||
|
actions={<IconButton icon={<FiSettings />} />} |
||||
|
> |
||||
|
<div className="flex h-8 w-8 rounded-full bg-gray-200 dark:bg-primaryDark dark:text-white"> |
||||
|
<div className="m-auto"> |
||||
|
{channel.channel.role === Protobuf.Channel_Role.PRIMARY ? ( |
||||
|
<MdPublic /> |
||||
|
) : ( |
||||
|
<p> |
||||
|
{channel.channel.settings?.name.length |
||||
|
? channel.channel.settings.name |
||||
|
.substring(0, 3) |
||||
|
.toUpperCase() |
||||
|
: `CH: ${channel.channel.index}`} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
{channel.messages.length ? ( |
||||
|
<> |
||||
|
<div className="mx-2 flex h-8"> |
||||
|
{[ |
||||
|
...new Set( |
||||
|
channel.messages.flatMap(({ message }) => [ |
||||
|
message.packet.from, |
||||
|
]), |
||||
|
), |
||||
|
] |
||||
|
.sort() |
||||
|
.map((nodeId) => { |
||||
|
return ( |
||||
|
<Tooltip |
||||
|
key={nodeId} |
||||
|
content={ |
||||
|
nodes.find((node) => node.number === nodeId)?.user |
||||
|
?.longName ?? 'UNK' |
||||
|
} |
||||
|
> |
||||
|
<div className="flex h-full"> |
||||
|
<m.div |
||||
|
whileHover={{ scale: 1.1 }} |
||||
|
className="my-auto -ml-2" |
||||
|
> |
||||
|
<Hashicon value={nodeId.toString()} size={20} /> |
||||
|
</m.div> |
||||
|
</div> |
||||
|
</Tooltip> |
||||
|
); |
||||
|
})} |
||||
|
</div> |
||||
|
<TimeAgo |
||||
|
className="my-auto ml-auto text-xs font-semibold dark:text-gray-400" |
||||
|
datetime={channel.lastChatInterraction} |
||||
|
/> |
||||
|
</> |
||||
|
) : ( |
||||
|
<div className="my-auto dark:text-white">No messages</div> |
||||
|
)} |
||||
|
</SidebarItem> |
||||
|
))} |
||||
|
</div> |
||||
|
} |
||||
|
> |
||||
|
<div className="flex w-full flex-col"> |
||||
|
<div className="flex w-full justify-between border-b border-gray-300 px-2 dark:border-gray-600 dark:text-gray-300"> |
||||
|
<div className="my-auto flex py-2 text-sm"> |
||||
|
<FiHash className="my-auto mr-1 h-4 w-4" /> |
||||
|
<div> |
||||
|
{channels[channelIndex]?.channel.settings?.name.length |
||||
|
? channels[channelIndex]?.channel.settings?.name |
||||
|
: channels[channelIndex]?.channel.role === |
||||
|
Protobuf.Channel_Role.PRIMARY |
||||
|
? 'Primary' |
||||
|
: `Channel: ${channels[channelIndex]?.channel.index}`} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div |
||||
|
ref={chatRef} |
||||
|
className="flex flex-grow flex-col space-y-2 overflow-y-auto border-b border-gray-300 bg-white pb-6 dark:border-gray-600 dark:bg-secondaryDark" |
||||
|
> |
||||
|
<div className="mt-auto"> |
||||
|
{channels[channelIndex]?.messages.map((message, index) => ( |
||||
|
<Message |
||||
|
key={index} |
||||
|
message={message.message.data} |
||||
|
ack={message.ack} |
||||
|
rxTime={message.received} |
||||
|
lastMsgSameUser={ |
||||
|
index === 0 |
||||
|
? false |
||||
|
: channels[channelIndex]?.messages[index - 1].message.packet |
||||
|
.from === message.message.packet.from |
||||
|
} |
||||
|
sender={nodes.find( |
||||
|
(node) => node.number === message.message.packet.from, |
||||
|
)} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
<MessageBar channelIndex={channelIndex} /> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
); |
||||
|
}; |
||||
@ -1,129 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import mapbox from 'mapbox-gl'; |
|
||||
import { FiMapPin, FiXCircle } from 'react-icons/fi'; |
|
||||
|
|
||||
import { Marker } from '@app/components/Map/Marker'; |
|
||||
import type { Node } from '@app/core/slices/meshtasticSlice'; |
|
||||
import { Drawer } from '@components/generic/Drawer'; |
|
||||
import { Map } from '@components/Map'; |
|
||||
import { NodeSidebar } from '@components/pages/nodes/NodeSidebar'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { useBreakpoint } from '@hooks/useBreakpoint'; |
|
||||
import { IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
import { NodeCard } from '../components/pages/nodes/NodeCard'; |
|
||||
|
|
||||
export const Nodes = (): JSX.Element => { |
|
||||
const myNodeInfo = useAppSelector((state) => state.meshtastic.radio.hardware); |
|
||||
|
|
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes) |
|
||||
.slice() |
|
||||
.sort((a, b) => |
|
||||
a.number === myNodeInfo.myNodeNum |
|
||||
? 1 |
|
||||
: b?.lastHeard.getTime() - a?.lastHeard.getTime(), |
|
||||
); |
|
||||
|
|
||||
const myNode = nodes.find((node) => node.number === myNodeInfo.myNodeNum); |
|
||||
|
|
||||
const { breakpoint } = useBreakpoint(); |
|
||||
const [navOpen, setNavOpen] = React.useState(false); |
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(false); |
|
||||
const [selectedNode, setSelectedNode] = React.useState<Node | undefined>(); |
|
||||
|
|
||||
return ( |
|
||||
<div className="relative flex w-full dark:text-white"> |
|
||||
<Drawer |
|
||||
open={breakpoint === 'sm' ? navOpen : true} |
|
||||
permenant={breakpoint !== 'sm'} |
|
||||
onClose={(): void => { |
|
||||
setNavOpen(!navOpen); |
|
||||
}} |
|
||||
> |
|
||||
<div className="flex items-center justify-between m-6 mr-6"> |
|
||||
<div className="text-3xl font-extrabold leading-none tracking-tight"> |
|
||||
Nodes |
|
||||
</div> |
|
||||
<div className="md:hidden"> |
|
||||
<IconButton |
|
||||
icon={<FiXCircle className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen(false); |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
{!nodes.length && ( |
|
||||
<span className="p-4 text-sm text-gray-400 dark:text-gray-600"> |
|
||||
No nodes found. |
|
||||
</span> |
|
||||
)} |
|
||||
{myNode && ( |
|
||||
<NodeCard |
|
||||
node={myNode} |
|
||||
isMyNode={true} |
|
||||
setSelected={(): void => { |
|
||||
setSelectedNode(myNode); |
|
||||
setSidebarOpen(true); |
|
||||
}} |
|
||||
/> |
|
||||
)} |
|
||||
{nodes |
|
||||
.filter((node) => node.number !== myNodeInfo.myNodeNum) |
|
||||
.map((node) => ( |
|
||||
<NodeCard |
|
||||
key={node.number} |
|
||||
node={node} |
|
||||
setSelected={(): void => { |
|
||||
setSelectedNode(node); |
|
||||
setSidebarOpen(true); |
|
||||
}} |
|
||||
/> |
|
||||
))} |
|
||||
</Drawer> |
|
||||
|
|
||||
{nodes.map((node) => { |
|
||||
return ( |
|
||||
node.currentPosition && ( |
|
||||
<Marker |
|
||||
center={ |
|
||||
new mapbox.LngLat( |
|
||||
node.currentPosition.longitudeI / 1e7, |
|
||||
node.currentPosition.latitudeI / 1e7, |
|
||||
) |
|
||||
} |
|
||||
> |
|
||||
<div |
|
||||
onClick={(): void => { |
|
||||
setSelectedNode(node); |
|
||||
setSidebarOpen(true); |
|
||||
}} |
|
||||
className={`z-50 border-2 rounded-full bg-opacity-30 ${ |
|
||||
node.number === selectedNode?.number |
|
||||
? 'bg-green-500 border-green-500' |
|
||||
: 'bg-blue-500 border-blue-500' |
|
||||
}`}
|
|
||||
> |
|
||||
<div className="m-4 "> |
|
||||
<FiMapPin className="w-5 h-5" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</Marker> |
|
||||
) |
|
||||
); |
|
||||
})} |
|
||||
|
|
||||
<Map /> |
|
||||
|
|
||||
{sidebarOpen && selectedNode && ( |
|
||||
<NodeSidebar |
|
||||
closeSidebar={(): void => { |
|
||||
setSidebarOpen(false); |
|
||||
}} |
|
||||
node={selectedNode} |
|
||||
/> |
|
||||
)} |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,141 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import mapbox from 'mapbox-gl'; |
||||
|
import { |
||||
|
FiAlignLeft, |
||||
|
FiCode, |
||||
|
FiMapPin, |
||||
|
FiSliders, |
||||
|
FiUser, |
||||
|
} from 'react-icons/fi'; |
||||
|
import { IoTelescope } from 'react-icons/io5'; |
||||
|
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md'; |
||||
|
import JSONPretty from 'react-json-pretty'; |
||||
|
|
||||
|
import { CollapsibleSection } from '@app/components/layout/Sidebar/sections/CollapsibleSection'; |
||||
|
import { SidebarOverlay } from '@app/components/layout/Sidebar/sections/SidebarOverlay'; |
||||
|
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem'; |
||||
|
import { CopyButton } from '@app/components/menu/buttons/CopyButton'; |
||||
|
import type { Node } from '@core/slices/meshtasticSlice'; |
||||
|
import { useMapbox } from '@hooks/useMapbox'; |
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
// eslint-disable-next-line import/no-unresolved
|
||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react'; |
||||
|
|
||||
|
const Hashicon = skypack_hashicon.Hashicon; |
||||
|
|
||||
|
type PositionConfidence = 'high' | 'low' | 'none'; |
||||
|
|
||||
|
export interface NodeCardProps { |
||||
|
node: Node; |
||||
|
isMyNode: boolean; |
||||
|
selected: boolean; |
||||
|
setSelected: () => void; |
||||
|
} |
||||
|
|
||||
|
export const NodeCard = ({ |
||||
|
node, |
||||
|
isMyNode, |
||||
|
selected, |
||||
|
setSelected, |
||||
|
}: NodeCardProps): JSX.Element => { |
||||
|
const { map } = useMapbox(); |
||||
|
const [infoOpen, setInfoOpen] = React.useState(false); |
||||
|
const [PositionConfidence, setPositionConfidence] = |
||||
|
React.useState<PositionConfidence>('none'); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
setPositionConfidence( |
||||
|
node.currentPosition |
||||
|
? new Date(node.currentPosition.posTimestamp * 1000) > |
||||
|
new Date(new Date().getTime() - 1000 * 60 * 30) |
||||
|
? 'high' |
||||
|
: 'low' |
||||
|
: 'none', |
||||
|
); |
||||
|
}, [node.currentPosition]); |
||||
|
return ( |
||||
|
<SidebarItem |
||||
|
selected={selected} |
||||
|
setSelected={setSelected} |
||||
|
actions={ |
||||
|
<> |
||||
|
<IconButton |
||||
|
disabled={PositionConfidence === 'none'} |
||||
|
onClick={(e): void => { |
||||
|
e.stopPropagation(); |
||||
|
setSelected(); |
||||
|
if (PositionConfidence !== 'none' && node.currentPosition) { |
||||
|
map?.flyTo({ |
||||
|
center: new mapbox.LngLat( |
||||
|
node.currentPosition.longitudeI / 1e7, |
||||
|
node.currentPosition.latitudeI / 1e7, |
||||
|
), |
||||
|
zoom: 16, |
||||
|
}); |
||||
|
} |
||||
|
}} |
||||
|
icon={ |
||||
|
PositionConfidence === 'high' ? ( |
||||
|
<MdGpsFixed /> |
||||
|
) : PositionConfidence === 'low' ? ( |
||||
|
<MdGpsNotFixed /> |
||||
|
) : ( |
||||
|
<MdGpsOff /> |
||||
|
) |
||||
|
} |
||||
|
/> |
||||
|
<IconButton |
||||
|
onClick={(e): void => { |
||||
|
e.stopPropagation(); |
||||
|
setInfoOpen(true); |
||||
|
}} |
||||
|
icon={<FiAlignLeft />} |
||||
|
/> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="flex dark:text-white"> |
||||
|
<div className="m-auto"> |
||||
|
<Hashicon value={node.number.toString()} size={32} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400"> |
||||
|
{node.lastHeard.getTime() |
||||
|
? node.lastHeard.toLocaleTimeString(undefined, { |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
}) |
||||
|
: 'Never'} |
||||
|
</div> |
||||
|
<SidebarOverlay |
||||
|
title={`Node ${node.user?.longName ?? 'UNK'} `} |
||||
|
open={infoOpen} |
||||
|
close={(): void => { |
||||
|
setInfoOpen(false); |
||||
|
}} |
||||
|
> |
||||
|
<CollapsibleSection title="User" icon={<FiUser />}> |
||||
|
<div>Info</div> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Location" icon={<FiMapPin />}> |
||||
|
<div>Info</div> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Line of Sight" icon={<IoTelescope />}> |
||||
|
<div>Info</div> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Administration" icon={<FiSliders />}> |
||||
|
<div>Info</div> |
||||
|
</CollapsibleSection> |
||||
|
<CollapsibleSection title="Debug" icon={<FiCode />}> |
||||
|
<> |
||||
|
<div className="fixed right-0 mr-6"> |
||||
|
<CopyButton data={JSON.stringify(node)} /> |
||||
|
</div> |
||||
|
<JSONPretty className="max-w-sm" data={node} /> |
||||
|
</> |
||||
|
</CollapsibleSection> |
||||
|
</SidebarOverlay> |
||||
|
</SidebarItem> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,129 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import type { Edge, Node } from 'react-flow-renderer'; |
||||
|
import ReactFlow, { Background, Controls, MiniMap } from 'react-flow-renderer'; |
||||
|
import { FiSettings } from 'react-icons/fi'; |
||||
|
import { RiMindMap } from 'react-icons/ri'; |
||||
|
|
||||
|
import { Layout } from '@app/components/layout'; |
||||
|
import { SidebarItem } from '@app/components/layout/Sidebar/SidebarItem'; |
||||
|
import { useAppSelector } from '@app/hooks/useAppSelector'; |
||||
|
import { IconButton } from '@meshtastic/components'; |
||||
|
// eslint-disable-next-line import/no-unresolved
|
||||
|
import skypack_hashicon from '@skypack/@emeraldpay/hashicon-react'; |
||||
|
|
||||
|
const Hashicon = skypack_hashicon.Hashicon; |
||||
|
|
||||
|
export const Nodes = (): JSX.Element => { |
||||
|
const [graphNodes, setGraphNodes] = React.useState<Node[]>([]); |
||||
|
const [graphEdges, setGraphEdges] = React.useState<Edge[]>([]); |
||||
|
const [selected, setSelected] = React.useState<number>(0); |
||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
|
const myNodeNum = useAppSelector( |
||||
|
(state) => state.meshtastic.radio.hardware.myNodeNum, |
||||
|
); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
const tmpNodes: Node[] = []; |
||||
|
// User Terminal
|
||||
|
tmpNodes.push({ |
||||
|
id: '1', |
||||
|
type: 'input', |
||||
|
|
||||
|
data: { label: 'User Terminal' }, |
||||
|
position: { x: 160 + 500, y: 0 + 500 }, |
||||
|
}); |
||||
|
|
||||
|
nodes.map((node, index) => { |
||||
|
tmpNodes.push({ |
||||
|
id: node.number.toString(), |
||||
|
data: { label: node.user?.longName ?? `Unknown ${node.number}` }, |
||||
|
position: { x: index * 160 + 500, y: 100 + 500 }, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
setGraphNodes(tmpNodes); |
||||
|
}, [nodes, myNodeNum]); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
const tmpEdges: Edge[] = []; |
||||
|
|
||||
|
nodes.map((node, index) => { |
||||
|
if (node.number === myNodeNum) { |
||||
|
tmpEdges.push({ |
||||
|
id: `e${1}-${myNodeNum}`, |
||||
|
source: '1', |
||||
|
target: myNodeNum.toString(), |
||||
|
type: 'smoothstep', |
||||
|
style: { |
||||
|
stroke: 'yellow', |
||||
|
strokeWidth: 2, |
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
node.routes.map((route) => { |
||||
|
tmpEdges.push({ |
||||
|
id: `e${route.from}-${route.to}`, |
||||
|
source: node.number.toString(), |
||||
|
target: route.to.toString(), |
||||
|
type: 'smoothstep', |
||||
|
animated: true, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
setGraphEdges(tmpEdges); |
||||
|
}, [nodes, myNodeNum]); |
||||
|
|
||||
|
return ( |
||||
|
<Layout |
||||
|
title="Nodes" |
||||
|
icon={<RiMindMap />} |
||||
|
sidebarContents={ |
||||
|
<> |
||||
|
{nodes.map((node) => ( |
||||
|
<SidebarItem |
||||
|
key={node.number} |
||||
|
selected={node.number === selected} |
||||
|
setSelected={(): void => { |
||||
|
setSelected(node.number); |
||||
|
}} |
||||
|
actions={ |
||||
|
<IconButton |
||||
|
onClick={(e): void => { |
||||
|
e.stopPropagation(); |
||||
|
setSelected(node.number); |
||||
|
}} |
||||
|
icon={<FiSettings />} |
||||
|
/> |
||||
|
} |
||||
|
> |
||||
|
<div className="flex dark:text-white"> |
||||
|
<div className="m-auto"> |
||||
|
<Hashicon value={node.number.toString()} size={32} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="my-auto mr-auto text-xs font-semibold dark:text-gray-400"> |
||||
|
{node.lastHeard.getTime() |
||||
|
? node.lastHeard.toLocaleTimeString(undefined, { |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
}) |
||||
|
: 'Never'} |
||||
|
</div> |
||||
|
</SidebarItem> |
||||
|
))} |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="relative flex h-full w-full"> |
||||
|
<ReactFlow nodes={graphNodes} edges={graphEdges}> |
||||
|
<MiniMap /> |
||||
|
<Controls /> |
||||
|
<Background /> |
||||
|
</ReactFlow> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
); |
||||
|
}; |
||||
@ -1,18 +1,15 @@ |
|||||
import type React from 'react'; |
import type React from 'react'; |
||||
|
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { Card } from '@meshtastic/components'; |
import { Card } from '@meshtastic/components'; |
||||
|
|
||||
export const NotFound = (): JSX.Element => { |
export const NotFound = (): JSX.Element => { |
||||
return ( |
return ( |
||||
<PrimaryTemplate title="Page not found" tagline="404"> |
<Card |
||||
<Card |
title="The requested file or directory could not be found" |
||||
title="The requested file or directory could not be found" |
description="Better luck next time" |
||||
description="Better luck next time" |
> |
||||
> |
<br /> |
||||
<br /> |
<br /> |
||||
<br /> |
</Card> |
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
@ -1,230 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { useForm } from 'react-hook-form'; |
|
||||
import { FiExternalLink, FiMenu, FiX } from 'react-icons/fi'; |
|
||||
import { |
|
||||
RiArrowDownLine, |
|
||||
RiArrowUpDownLine, |
|
||||
RiArrowUpLine, |
|
||||
} from 'react-icons/ri'; |
|
||||
|
|
||||
import { Tooltip } from '@app/components/generic/Tooltip'; |
|
||||
import type { ChannelData } from '@app/core/slices/meshtasticSlice'; |
|
||||
import { FormFooter } from '@components/FormFooter'; |
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { connection } from '@core/connection'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { useBreakpoint } from '@hooks/useBreakpoint'; |
|
||||
import { |
|
||||
Card, |
|
||||
Checkbox, |
|
||||
IconButton, |
|
||||
Input, |
|
||||
Loading, |
|
||||
Select, |
|
||||
} from '@meshtastic/components'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
import { ChannelsSidebar } from '../../components/pages/settings/radio/channels/ChannelsSidebar'; |
|
||||
|
|
||||
export interface ChannelsProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Channels = ({ |
|
||||
navOpen, |
|
||||
setNavOpen, |
|
||||
}: ChannelsProps): JSX.Element => { |
|
||||
const channels = useAppSelector((state) => state.meshtastic.radio.channels); |
|
||||
const adminChannel = |
|
||||
channels.find( |
|
||||
(channel) => channel.channel.role === Protobuf.Channel_Role.PRIMARY, |
|
||||
) ?? channels[0]; |
|
||||
const { breakpoint } = useBreakpoint(); |
|
||||
const [usePreset, setUsePreset] = React.useState(true); |
|
||||
const [loading, setLoading] = React.useState(false); |
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(breakpoint !== 'sm'); |
|
||||
const [selectedChannel, setSelectedChannel] = React.useState< |
|
||||
ChannelData | undefined |
|
||||
>(); |
|
||||
|
|
||||
const { register, handleSubmit, reset, formState } = useForm< |
|
||||
DeepOmit<Protobuf.Channel, 'psk'> |
|
||||
>({ |
|
||||
defaultValues: { |
|
||||
...adminChannel.channel, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const onSubmit = handleSubmit(async (data) => { |
|
||||
setLoading(true); |
|
||||
|
|
||||
const channelData = Protobuf.Channel.create({ |
|
||||
...data, |
|
||||
settings: { |
|
||||
...data.settings, |
|
||||
psk: adminChannel.channel.settings?.psk, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
await connection.setChannel(channelData, (): Promise<void> => { |
|
||||
reset({ ...data }); |
|
||||
setLoading(false); |
|
||||
return Promise.resolve(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
return ( |
|
||||
<> |
|
||||
<PrimaryTemplate |
|
||||
title="Channels" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<IconButton |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
footer={ |
|
||||
<FormFooter |
|
||||
dirty={formState.isDirty} |
|
||||
saveAction={onSubmit} |
|
||||
clearAction={reset} |
|
||||
/> |
|
||||
} |
|
||||
> |
|
||||
<div className="space-y-4"> |
|
||||
{adminChannel && ( |
|
||||
<Card> |
|
||||
{loading && <Loading />} |
|
||||
<div className="w-full max-w-3xl p-10 md:max-w-xl"> |
|
||||
{/* TODO: get gap working */} |
|
||||
<Checkbox |
|
||||
checked={usePreset} |
|
||||
label="Use Presets" |
|
||||
onChange={(e): void => setUsePreset(e.target.checked)} |
|
||||
/> |
|
||||
<form onSubmit={onSubmit}> |
|
||||
{usePreset ? ( |
|
||||
<Select |
|
||||
label="Preset" |
|
||||
optionsEnum={Protobuf.ChannelSettings_ModemConfig} |
|
||||
{...register('settings.modemConfig', { |
|
||||
valueAsNumber: true, |
|
||||
})} |
|
||||
/> |
|
||||
) : ( |
|
||||
<> |
|
||||
<Input |
|
||||
label="Bandwidth" |
|
||||
type="number" |
|
||||
suffix="MHz" |
|
||||
{...register('settings.bandwidth', { |
|
||||
valueAsNumber: true, |
|
||||
})} |
|
||||
/> |
|
||||
<Input |
|
||||
label="Spread Factor" |
|
||||
type="number" |
|
||||
suffix="CPS" |
|
||||
min={7} |
|
||||
max={12} |
|
||||
{...register('settings.spreadFactor', { |
|
||||
valueAsNumber: true, |
|
||||
})} |
|
||||
/> |
|
||||
<Input |
|
||||
label="Coding Rate" |
|
||||
type="number" |
|
||||
{...register('settings.codingRate', { |
|
||||
valueAsNumber: true, |
|
||||
})} |
|
||||
/> |
|
||||
</> |
|
||||
)} |
|
||||
<Input |
|
||||
label="Transmit Power" |
|
||||
type="number" |
|
||||
suffix="dBm" |
|
||||
{...register('settings.txPower', { valueAsNumber: true })} |
|
||||
/> |
|
||||
</form> |
|
||||
</div> |
|
||||
</Card> |
|
||||
)} |
|
||||
<Card> |
|
||||
<div className="w-full p-4 space-y-2 md:p-10"> |
|
||||
{channels.map((channel) => ( |
|
||||
<div |
|
||||
key={channel.channel.index} |
|
||||
onClick={(): void => { |
|
||||
setSelectedChannel(channel); |
|
||||
setSidebarOpen(true); |
|
||||
}} |
|
||||
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${ |
|
||||
selectedChannel?.channel.index === channel.channel.index |
|
||||
? 'border-primary dark:border-primary' |
|
||||
: '' |
|
||||
}`}
|
|
||||
> |
|
||||
<div className="flex my-auto space-x-2"> |
|
||||
<div |
|
||||
className={`h-3 my-auto w-3 rounded-full ${ |
|
||||
[ |
|
||||
Protobuf.Channel_Role.SECONDARY, |
|
||||
Protobuf.Channel_Role.PRIMARY, |
|
||||
].find((role) => role === channel.channel.role) |
|
||||
? 'bg-green-500' |
|
||||
: 'bg-gray-400' |
|
||||
}`}
|
|
||||
/> |
|
||||
<div> |
|
||||
{channel.channel.settings?.name.length |
|
||||
? channel.channel.settings.name |
|
||||
: channel.channel.role === Protobuf.Channel_Role.PRIMARY |
|
||||
? 'Primary' |
|
||||
: `Channel: ${channel.channel.index}`} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="flex gap-2"> |
|
||||
<Tooltip contents={`MQTT Status`}> |
|
||||
<div className="p-2 rounded-md"> |
|
||||
{channel.channel.settings?.uplinkEnabled && |
|
||||
channel.channel.settings?.downlinkEnabled ? ( |
|
||||
<RiArrowUpDownLine className="p-0.5 group-active:scale-90" /> |
|
||||
) : channel.channel.settings?.uplinkEnabled ? ( |
|
||||
<RiArrowUpLine className="p-0.5 group-active:scale-90" /> |
|
||||
) : channel.channel.settings?.downlinkEnabled ? ( |
|
||||
<RiArrowDownLine className="p-0.5 group-active:scale-90" /> |
|
||||
) : ( |
|
||||
<FiX className="p-0.5" /> |
|
||||
)} |
|
||||
</div> |
|
||||
</Tooltip> |
|
||||
<IconButton |
|
||||
active={ |
|
||||
selectedChannel?.channel.index === channel.channel.index |
|
||||
} |
|
||||
icon={<FiExternalLink />} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</Card> |
|
||||
</div> |
|
||||
</PrimaryTemplate> |
|
||||
{sidebarOpen && ( |
|
||||
<ChannelsSidebar |
|
||||
closeSidebar={(): void => { |
|
||||
setSidebarOpen(false); |
|
||||
}} |
|
||||
channel={selectedChannel?.channel} |
|
||||
/> |
|
||||
)} |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,98 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { |
|
||||
FiLayers, |
|
||||
FiLayout, |
|
||||
FiMapPin, |
|
||||
FiPackage, |
|
||||
FiRadio, |
|
||||
FiUser, |
|
||||
FiWifi, |
|
||||
FiZap, |
|
||||
} from 'react-icons/fi'; |
|
||||
|
|
||||
import type { SidebarItemProps } from '@app/components/generic/SidebarItem'; |
|
||||
import { PageLayout } from '@components/templates/PageLayout'; |
|
||||
|
|
||||
import { Channels } from './Channels'; |
|
||||
import { Interface } from './Interface'; |
|
||||
import { Plugins } from './Plugins'; |
|
||||
import { Position } from './Position'; |
|
||||
import { Power } from './Power'; |
|
||||
import { Radio } from './Radio'; |
|
||||
import { User } from './User'; |
|
||||
import { WiFi } from './WiFi'; |
|
||||
|
|
||||
export const Settings = (): JSX.Element => { |
|
||||
// const { hasGps, hasWifi } = useAppSelector((state) => state.meshtastic.radio.hardware);
|
|
||||
|
|
||||
const hasGps = true; |
|
||||
const hasWifi = true; |
|
||||
|
|
||||
const panels: JSX.Element[] = [ |
|
||||
<User key={4} />, |
|
||||
<Power key={5} />, |
|
||||
<Radio key={6} />, |
|
||||
<Channels key={7} />, |
|
||||
<Plugins key={8} />, |
|
||||
<Interface key={9} />, |
|
||||
]; |
|
||||
|
|
||||
const sidebarItems: SidebarItemProps[] = [ |
|
||||
{ |
|
||||
title: 'User', |
|
||||
description: 'Device name and details', |
|
||||
icon: <FiUser className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'Power', |
|
||||
description: 'Power and sleep settings', |
|
||||
icon: <FiZap className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'Radio', |
|
||||
description: 'LoRa settings', |
|
||||
icon: <FiRadio className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'Channels', |
|
||||
description: 'Manage channels', |
|
||||
icon: <FiLayers className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'Plugins', |
|
||||
description: 'Plugins', |
|
||||
icon: <FiPackage className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
{ |
|
||||
title: 'Interface', |
|
||||
description: 'Language and UI settings', |
|
||||
icon: <FiLayout className="flex-shrink-0 w-6 h-6" />, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
if (hasGps) { |
|
||||
panels.unshift(<Position key={3} />); |
|
||||
sidebarItems.unshift({ |
|
||||
title: 'Position', |
|
||||
description: 'Position settings and flags', |
|
||||
icon: <FiMapPin className="flex-shrink-0 w-6 h-6" />, |
|
||||
}); |
|
||||
} |
|
||||
if (hasWifi) { |
|
||||
panels.unshift(<WiFi key={2} />); |
|
||||
sidebarItems.unshift({ |
|
||||
title: 'WiFi & MQTT', |
|
||||
description: 'WiFi & MQTT settings', |
|
||||
icon: <FiWifi className="flex-shrink-0 w-6 h-6" />, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
console.log(panels); |
|
||||
}, [hasGps, hasWifi]); |
|
||||
|
|
||||
return ( |
|
||||
<PageLayout title="Settings" sidebarItems={sidebarItems} panels={panels} /> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,73 +0,0 @@ |
|||||
import type React from 'react'; |
|
||||
|
|
||||
import { useTranslation } from 'react-i18next'; |
|
||||
import { FiMenu, FiSave } from 'react-icons/fi'; |
|
||||
|
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import i18n from '@core/translation'; |
|
||||
import { Button, Card, Select } from '@meshtastic/components'; |
|
||||
|
|
||||
export interface InterfaceProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Interface = ({ |
|
||||
navOpen, |
|
||||
setNavOpen, |
|
||||
}: InterfaceProps): JSX.Element => { |
|
||||
const { t } = useTranslation(); |
|
||||
|
|
||||
return ( |
|
||||
<PrimaryTemplate |
|
||||
title="Interface" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<Button |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
footer={ |
|
||||
<Button |
|
||||
className="px-10 ml-auto" |
|
||||
icon={<FiSave className="w-5 h-5" />} |
|
||||
active |
|
||||
border |
|
||||
> |
|
||||
{t('strings.save_changes')} |
|
||||
</Button> |
|
||||
} |
|
||||
> |
|
||||
<Card |
|
||||
title="Basic settings" |
|
||||
description="Device name and user parameters" |
|
||||
> |
|
||||
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl"> |
|
||||
<Select |
|
||||
label="Language" |
|
||||
options={[ |
|
||||
{ |
|
||||
name: 'English', |
|
||||
value: 'en', |
|
||||
}, |
|
||||
{ |
|
||||
name: '日本', |
|
||||
value: 'jp', |
|
||||
}, |
|
||||
{ |
|
||||
name: 'Português', |
|
||||
value: 'pt', |
|
||||
}, |
|
||||
]} |
|
||||
onChange={(e): void => { |
|
||||
void i18n.changeLanguage(e.target.value); |
|
||||
}} |
|
||||
/> |
|
||||
</div> |
|
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,133 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { |
|
||||
FiAlignLeft, |
|
||||
FiBell, |
|
||||
FiExternalLink, |
|
||||
FiFastForward, |
|
||||
FiMenu, |
|
||||
FiRss, |
|
||||
} from 'react-icons/fi'; |
|
||||
|
|
||||
import { PluginsSidebar } from '@app/components/pages/settings/plugins/PluginsSidebar'; |
|
||||
import { useAppSelector } from '@app/hooks/useAppSelector'; |
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { Button, Card, IconButton } from '@meshtastic/components'; |
|
||||
|
|
||||
export type Plugin = |
|
||||
| 'Range Test' |
|
||||
| 'External Notifications' |
|
||||
| 'Serial' |
|
||||
| 'Store & Forward'; |
|
||||
|
|
||||
export interface PluginsProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Plugins = ({ navOpen, setNavOpen }: PluginsProps): JSX.Element => { |
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(false); |
|
||||
const [selectedPlugin, setSelectedPlugin] = React.useState< |
|
||||
Plugin | undefined |
|
||||
>(); |
|
||||
const preferences = useAppSelector( |
|
||||
(state) => state.meshtastic.radio.preferences, |
|
||||
); |
|
||||
|
|
||||
const plugins: { |
|
||||
name: Plugin; |
|
||||
description: string; |
|
||||
enabled: boolean; |
|
||||
icon: JSX.Element; |
|
||||
}[] = [ |
|
||||
{ |
|
||||
name: 'Range Test', |
|
||||
description: 'Test the range of your Meshtastic node', |
|
||||
enabled: preferences.rangeTestPluginEnabled, |
|
||||
icon: <FiRss />, |
|
||||
}, |
|
||||
{ |
|
||||
name: 'External Notifications', |
|
||||
description: 'External hardware alerts', |
|
||||
enabled: preferences.extNotificationPluginEnabled, |
|
||||
icon: <FiBell />, |
|
||||
}, |
|
||||
{ |
|
||||
name: 'Serial', |
|
||||
description: 'Send serial data over the mesh', |
|
||||
enabled: preferences.serialpluginEnabled, |
|
||||
icon: <FiAlignLeft />, |
|
||||
}, |
|
||||
{ |
|
||||
name: 'Store & Forward', |
|
||||
description: 'Retrive message history', |
|
||||
enabled: preferences.storeForwardPluginEnabled, |
|
||||
icon: <FiFastForward />, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
return ( |
|
||||
<> |
|
||||
<PrimaryTemplate |
|
||||
title="Plugins" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<Button |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
> |
|
||||
<Card |
|
||||
title="Basic settings" |
|
||||
description="Device name and user parameters" |
|
||||
> |
|
||||
<div className="w-full max-w-3xl p-10 space-y-2 md:max-w-xl"> |
|
||||
{plugins.map((plugin, index) => ( |
|
||||
<div |
|
||||
key={index} |
|
||||
onClick={(): void => { |
|
||||
setSelectedPlugin(plugin.name); |
|
||||
setSidebarOpen(true); |
|
||||
}} |
|
||||
className={`flex justify-between p-2 border border-gray-300 dark:border-gray-600 bg-gray-100 rounded-md dark:bg-secondaryDark shadow-md ${ |
|
||||
selectedPlugin === plugin.name |
|
||||
? 'border-primary dark:border-primary' |
|
||||
: '' |
|
||||
}`}
|
|
||||
> |
|
||||
<div className="flex my-auto space-x-2"> |
|
||||
<div |
|
||||
className={`h-3 my-auto w-3 rounded-full ${ |
|
||||
plugin.enabled ? 'bg-green-500' : 'bg-gray-400' |
|
||||
}`}
|
|
||||
/> |
|
||||
<div className="flex gap-2"> |
|
||||
<div className="my-auto">{plugin.icon}</div> |
|
||||
{plugin.name} |
|
||||
</div> |
|
||||
</div> |
|
||||
<div className="flex gap-2"> |
|
||||
<IconButton |
|
||||
active={plugin.name === selectedPlugin} |
|
||||
icon={<FiExternalLink />} |
|
||||
/> |
|
||||
</div> |
|
||||
</div> |
|
||||
))} |
|
||||
</div> |
|
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
{sidebarOpen && ( |
|
||||
<PluginsSidebar |
|
||||
closeSidebar={(): void => { |
|
||||
setSidebarOpen(false); |
|
||||
}} |
|
||||
plugin={selectedPlugin} |
|
||||
/> |
|
||||
)} |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,236 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { Controller, useForm } from 'react-hook-form'; |
|
||||
import { FiCode, FiMenu } from 'react-icons/fi'; |
|
||||
import JSONPretty from 'react-json-pretty'; |
|
||||
import type { Theme } from 'react-select'; |
|
||||
import ReactSelect from 'react-select'; |
|
||||
|
|
||||
import { FormFooter } from '@components/FormFooter'; |
|
||||
import { Cover } from '@components/generic/Cover'; |
|
||||
import { Label } from '@components/generic/form/Label'; |
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { connection } from '@core/connection'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { |
|
||||
Card, |
|
||||
Checkbox, |
|
||||
IconButton, |
|
||||
Input, |
|
||||
Select, |
|
||||
} from '@meshtastic/components'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
export interface PositionProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Position = ({ |
|
||||
navOpen, |
|
||||
setNavOpen, |
|
||||
}: PositionProps): JSX.Element => { |
|
||||
const preferences = useAppSelector( |
|
||||
(state) => state.meshtastic.radio.preferences, |
|
||||
); |
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode); |
|
||||
const [debug, setDebug] = React.useState(false); |
|
||||
const [loading, setLoading] = React.useState(false); |
|
||||
const { register, handleSubmit, formState, reset, control } = |
|
||||
useForm<Protobuf.RadioConfig_UserPreferences>({ |
|
||||
defaultValues: { |
|
||||
...preferences, |
|
||||
positionBroadcastSecs: |
|
||||
preferences.positionBroadcastSecs === 0 |
|
||||
? preferences.isRouter |
|
||||
? 43200 |
|
||||
: 900 |
|
||||
: preferences.positionBroadcastSecs, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
// const watchPsk = useWatch({
|
|
||||
// control,
|
|
||||
// name: 'positionFlags',
|
|
||||
// defaultValue: 0,
|
|
||||
// });
|
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
reset(preferences); |
|
||||
}, [reset, preferences]); |
|
||||
|
|
||||
const onSubmit = handleSubmit((data) => { |
|
||||
setLoading(true); |
|
||||
void connection.setPreferences(data, async () => { |
|
||||
reset({ ...data }); |
|
||||
setLoading(false); |
|
||||
await Promise.resolve(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
const encode = (enums: Protobuf.PositionFlags[]): number => { |
|
||||
return enums.reduce((acc, curr) => acc | curr, 0); |
|
||||
}; |
|
||||
|
|
||||
const decode = (value: number): Protobuf.PositionFlags[] => { |
|
||||
const enumValues = Object.keys(Protobuf.PositionFlags) |
|
||||
.map(Number) |
|
||||
.filter(Boolean); |
|
||||
|
|
||||
return enumValues.map((b) => value & b).filter(Boolean); |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<PrimaryTemplate |
|
||||
title="Position" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<IconButton |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
rightButton={ |
|
||||
<IconButton |
|
||||
icon={<FiCode className="w-5 h-5" />} |
|
||||
active={debug} |
|
||||
onClick={(): void => { |
|
||||
setDebug(!debug); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
footer={ |
|
||||
<FormFooter |
|
||||
dirty={formState.isDirty} |
|
||||
saveAction={onSubmit} |
|
||||
clearAction={reset} |
|
||||
/> |
|
||||
} |
|
||||
> |
|
||||
<Card loading={loading}> |
|
||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} /> |
|
||||
<div className="w-full max-w-3xl p-10 md:max-w-xl"> |
|
||||
<form className="space-y-2" onSubmit={onSubmit}> |
|
||||
<Input |
|
||||
label="Broadcast Interval" |
|
||||
type="number" |
|
||||
suffix="Seconds" |
|
||||
{...register('positionBroadcastSecs', { valueAsNumber: true })} |
|
||||
/> |
|
||||
|
|
||||
<Controller |
|
||||
name="positionFlags" |
|
||||
control={control} |
|
||||
render={({ field, fieldState }): JSX.Element => { |
|
||||
const { value, onChange, ...rest } = field; |
|
||||
const { error } = fieldState; |
|
||||
const label = 'Position Flags'; |
|
||||
return ( |
|
||||
<div className="w-full"> |
|
||||
{label && <Label label={label} error={error?.message} />} |
|
||||
<ReactSelect |
|
||||
{...rest} |
|
||||
isMulti |
|
||||
theme={(theme): Theme => ({ |
|
||||
...theme, |
|
||||
borderRadius: 7, |
|
||||
colors: { |
|
||||
...theme.colors, |
|
||||
primary: '#67ea94', //focus border color
|
|
||||
// primary75: 'red',
|
|
||||
// primary50: 'red',
|
|
||||
// primary25: 'red',
|
|
||||
// danger: 'red',
|
|
||||
// dangerLight: 'red',
|
|
||||
neutral0: darkMode ? 'rgb(30 41 59)' : 'white', //bg color
|
|
||||
// neutral5: 'red',
|
|
||||
neutral10: darkMode |
|
||||
? 'rgb(75 85 99)' |
|
||||
: 'rgb(229 231 235)', //tag bg color
|
|
||||
neutral20: darkMode |
|
||||
? 'rgb(229 231 235)' |
|
||||
: 'rgb(156 163 175)', //border color
|
|
||||
neutral30: '#67ea94', //border hover
|
|
||||
// neutral40: 'red',
|
|
||||
// neutral50: 'red',
|
|
||||
// neutral60: 'red',
|
|
||||
// neutral70: 'red',
|
|
||||
neutral80: darkMode ? 'white' : 'black', //tag text color
|
|
||||
// neutral90: 'red',
|
|
||||
}, |
|
||||
})} |
|
||||
value={decode(value).map((flag) => { |
|
||||
return { |
|
||||
value: flag, |
|
||||
label: Protobuf.PositionFlags[flag].replace( |
|
||||
'POS_', |
|
||||
'', |
|
||||
), |
|
||||
}; |
|
||||
})} |
|
||||
options={Object.entries(Protobuf.PositionFlags) |
|
||||
.filter((value) => typeof value[1] !== 'number') |
|
||||
.filter( |
|
||||
(value) => |
|
||||
parseInt(value[0]) !== |
|
||||
Protobuf.PositionFlags.POS_UNDEFINED, |
|
||||
) |
|
||||
.map((value) => { |
|
||||
return { |
|
||||
value: parseInt(value[0]), |
|
||||
label: value[1].toString().replace('POS_', ''), |
|
||||
}; |
|
||||
})} |
|
||||
onChange={(e): void => |
|
||||
onChange(encode(e.map((v) => v.value))) |
|
||||
} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
}} |
|
||||
/> |
|
||||
|
|
||||
<Input |
|
||||
label="Position Type (DEBUG)" |
|
||||
type="number" |
|
||||
disabled |
|
||||
{...register('positionFlags', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Checkbox |
|
||||
label="Use Fixed Position" |
|
||||
{...register('fixedPosition')} |
|
||||
/> |
|
||||
<Select |
|
||||
label="Location Sharing" |
|
||||
optionsEnum={Protobuf.LocationSharing} |
|
||||
{...register('locationShare', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Select |
|
||||
label="GPS Mode" |
|
||||
optionsEnum={Protobuf.GpsOperation} |
|
||||
{...register('gpsOperation', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Select |
|
||||
label="Display Format" |
|
||||
optionsEnum={Protobuf.GpsCoordinateFormat} |
|
||||
{...register('gpsFormat', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Checkbox label="Accept 2D Fix" {...register('gpsAccept2D')} /> |
|
||||
<Input |
|
||||
label="Max DOP" |
|
||||
type="number" |
|
||||
{...register('gpsMaxDop', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Input |
|
||||
label="Last GPS Attempt" |
|
||||
disabled |
|
||||
{...register('gpsAttemptTime', { valueAsNumber: true })} |
|
||||
/> |
|
||||
</form> |
|
||||
</div> |
|
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,98 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { useForm } from 'react-hook-form'; |
|
||||
import { FiCode, FiMenu } from 'react-icons/fi'; |
|
||||
import JSONPretty from 'react-json-pretty'; |
|
||||
|
|
||||
import { FormFooter } from '@components/FormFooter'; |
|
||||
import { Cover } from '@components/generic/Cover'; |
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { connection } from '@core/connection'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
export interface PowerProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Power = ({ navOpen, setNavOpen }: PowerProps): JSX.Element => { |
|
||||
const preferences = useAppSelector( |
|
||||
(state) => state.meshtastic.radio.preferences, |
|
||||
); |
|
||||
const [debug, setDebug] = React.useState(false); |
|
||||
const [loading, setLoading] = React.useState(false); |
|
||||
const { register, handleSubmit, formState, reset } = |
|
||||
useForm<Protobuf.RadioConfig_UserPreferences>({ |
|
||||
defaultValues: { |
|
||||
...preferences, |
|
||||
isLowPower: preferences.isRouter ? true : preferences.isLowPower, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
reset(preferences); |
|
||||
}, [reset, preferences]); |
|
||||
|
|
||||
const onSubmit = handleSubmit((data) => { |
|
||||
setLoading(true); |
|
||||
void connection.setPreferences(data, async () => { |
|
||||
reset({ ...data }); |
|
||||
setLoading(false); |
|
||||
await Promise.resolve(); |
|
||||
}); |
|
||||
}); |
|
||||
return ( |
|
||||
<PrimaryTemplate |
|
||||
title="Power" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<IconButton |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
rightButton={ |
|
||||
<IconButton |
|
||||
icon={<FiCode className="w-5 h-5" />} |
|
||||
active={debug} |
|
||||
onClick={(): void => { |
|
||||
setDebug(!debug); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
footer={ |
|
||||
<FormFooter |
|
||||
dirty={formState.isDirty} |
|
||||
saveAction={onSubmit} |
|
||||
clearAction={reset} |
|
||||
/> |
|
||||
} |
|
||||
> |
|
||||
<Card loading={loading}> |
|
||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} /> |
|
||||
<div className="w-full max-w-3xl p-10 md:max-w-xl"> |
|
||||
<form className="space-y-2" onSubmit={onSubmit}> |
|
||||
<Select |
|
||||
label="Charge current" |
|
||||
optionsEnum={Protobuf.ChargeCurrent} |
|
||||
{...register('chargeCurrent', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Checkbox label="Always powered" {...register('isAlwaysPowered')} /> |
|
||||
<Checkbox |
|
||||
label="Powered by low power source (solar)" |
|
||||
disabled={preferences.isRouter} |
|
||||
validationMessage={ |
|
||||
preferences.isRouter ? 'Enabled by default in router mode' : '' |
|
||||
} |
|
||||
{...register('isLowPower')} |
|
||||
/> |
|
||||
</form> |
|
||||
</div> |
|
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,89 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { useForm } from 'react-hook-form'; |
|
||||
import { FiCode, FiMenu } from 'react-icons/fi'; |
|
||||
import JSONPretty from 'react-json-pretty'; |
|
||||
|
|
||||
import { FormFooter } from '@components/FormFooter'; |
|
||||
import { Cover } from '@components/generic/Cover'; |
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|
||||
import { connection } from '@core/connection'; |
|
||||
import { useAppSelector } from '@hooks/useAppSelector'; |
|
||||
import { Card, Checkbox, IconButton, Select } from '@meshtastic/components'; |
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
export interface RadioProps { |
|
||||
navOpen?: boolean; |
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
} |
|
||||
|
|
||||
export const Radio = ({ navOpen, setNavOpen }: RadioProps): JSX.Element => { |
|
||||
const preferences = useAppSelector( |
|
||||
(state) => state.meshtastic.radio.preferences, |
|
||||
); |
|
||||
const [debug, setDebug] = React.useState(false); |
|
||||
const [loading, setLoading] = React.useState(false); |
|
||||
const { register, handleSubmit, formState, reset } = |
|
||||
useForm<Protobuf.RadioConfig_UserPreferences>({ |
|
||||
defaultValues: preferences, |
|
||||
}); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
reset(preferences); |
|
||||
}, [reset, preferences]); |
|
||||
|
|
||||
const onSubmit = handleSubmit((data) => { |
|
||||
setLoading(true); |
|
||||
void connection.setPreferences(data, async () => { |
|
||||
reset({ ...data }); |
|
||||
setLoading(false); |
|
||||
await Promise.resolve(); |
|
||||
}); |
|
||||
}); |
|
||||
return ( |
|
||||
<PrimaryTemplate |
|
||||
title="Radio" |
|
||||
tagline="Settings" |
|
||||
leftButton={ |
|
||||
<IconButton |
|
||||
icon={<FiMenu className="w-5 h-5" />} |
|
||||
onClick={(): void => { |
|
||||
setNavOpen && setNavOpen(!navOpen); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
rightButton={ |
|
||||
<IconButton |
|
||||
icon={<FiCode className="w-5 h-5" />} |
|
||||
active={debug} |
|
||||
onClick={(): void => { |
|
||||
setDebug(!debug); |
|
||||
}} |
|
||||
/> |
|
||||
} |
|
||||
footer={ |
|
||||
<FormFooter |
|
||||
dirty={formState.isDirty} |
|
||||
saveAction={onSubmit} |
|
||||
clearAction={reset} |
|
||||
/> |
|
||||
} |
|
||||
> |
|
||||
<Card loading={loading}> |
|
||||
<Cover enabled={debug} content={<JSONPretty data={preferences} />} /> |
|
||||
<div className="w-full max-w-3xl p-10 md:max-w-xl"> |
|
||||
<form className="space-y-2" onSubmit={onSubmit}> |
|
||||
<Checkbox label="Is Router" {...register('isRouter')} /> |
|
||||
<Select |
|
||||
label="Region" |
|
||||
optionsEnum={Protobuf.RegionCode} |
|
||||
{...register('region', { valueAsNumber: true })} |
|
||||
/> |
|
||||
<Checkbox label="Debug Log" {...register('debugLogEnabled')} /> |
|
||||
<Checkbox label="Serial Disabled" {...register('serialDisabled')} /> |
|
||||
</form> |
|
||||
</div> |
|
||||
</Card> |
|
||||
</PrimaryTemplate> |
|
||||
); |
|
||||
}; |
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue