Rebuild
This commit is contained in:
@@ -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,90 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa';
|
||||
import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
setExaggeration,
|
||||
setHillShade,
|
||||
setMapStyle,
|
||||
} from '@core/slices/mapSlice';
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
import { useMapbox } from '@hooks/useMapbox';
|
||||
import { IconButton } from '@meshtastic/components';
|
||||
|
||||
import type { MapStyle } from './styles';
|
||||
import { MapStyles } from './styles';
|
||||
|
||||
export const MapContainer = (): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
const darkMode = useAppSelector((state) => state.app.darkMode);
|
||||
|
||||
const mapState = useAppSelector((state) => state.map);
|
||||
|
||||
const { ref } = useMapbox();
|
||||
|
||||
const ChangeMapStyle = React.useCallback(
|
||||
(styleName: string, style: MapStyle) => {
|
||||
dispatch(
|
||||
setMapStyle(
|
||||
mapState.style.title === styleName
|
||||
? darkMode
|
||||
? MapStyles.Dark
|
||||
: MapStyles.Light
|
||||
: style,
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch, darkMode, mapState.style.title],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="absolute right-0 z-20 m-4 space-y-2 rounded-md border border-gray-300 bg-white p-2 shadow-md dark:border-gray-600 dark:bg-primaryDark">
|
||||
<IconButton
|
||||
active={mapState.style.title === 'Satellite'}
|
||||
onClick={(): void => {
|
||||
ChangeMapStyle('Satellite', MapStyles.Satellite);
|
||||
}}
|
||||
icon={<FaGlobeAfrica />}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`-m-1 space-y-2 rounded-md border-gray-300 p-1 dark:border-gray-600 ${
|
||||
mapState.style.title === 'Outdoors' ? 'border' : ''
|
||||
}`}
|
||||
>
|
||||
<IconButton
|
||||
active={mapState.style.title === 'Outdoors'}
|
||||
onClick={(): void => {
|
||||
ChangeMapStyle('Outdoors', MapStyles.Outdoors);
|
||||
}}
|
||||
icon={<FaDirections />}
|
||||
/>
|
||||
{mapState.style.title === 'Outdoors' && (
|
||||
<IconButton
|
||||
active={mapState.hillShade}
|
||||
onClick={(): void => {
|
||||
dispatch(setHillShade(!mapState.hillShade));
|
||||
}}
|
||||
icon={<MdWbShade />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="text-gray-400 dark:text-gray-200" />
|
||||
<IconButton
|
||||
active={mapState.exaggeration}
|
||||
onClick={(): void => {
|
||||
dispatch(setExaggeration(!mapState.exaggeration));
|
||||
}}
|
||||
icon={<FaMountain />}
|
||||
/>
|
||||
<IconButton icon={<MdFullscreen />} />
|
||||
<IconButton icon={<MdRadar />} />
|
||||
</div>
|
||||
<div className="h-full w-full flex-grow" ref={ref} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import mapbox from 'mapbox-gl';
|
||||
|
||||
import { useMapbox } from '@hooks/useMapbox';
|
||||
|
||||
export interface MarkerProps extends Omit<mapbox.MarkerOptions, 'element'> {
|
||||
children?: React.ReactNode;
|
||||
center: mapbox.LngLatLike;
|
||||
}
|
||||
|
||||
export const Marker = ({
|
||||
children,
|
||||
center,
|
||||
...props
|
||||
}: MarkerProps): JSX.Element => {
|
||||
const { map } = useMapbox();
|
||||
const ref = React.useRef<HTMLDivElement>(document.createElement('div'));
|
||||
|
||||
const addMarker = React.useCallback((): void => {
|
||||
if (map) {
|
||||
const marker = new mapbox.Marker(ref.current, props).setLngLat(center);
|
||||
marker.addTo(map);
|
||||
}
|
||||
}, [map, center, props]);
|
||||
|
||||
React.useEffect(() => {
|
||||
map?.on('load', () => {
|
||||
addMarker();
|
||||
});
|
||||
}, [addMarker, map]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (map?.loaded()) {
|
||||
addMarker();
|
||||
}
|
||||
}, [addMarker, map]);
|
||||
|
||||
<div ref={ref}>{children}</div>;
|
||||
|
||||
return ReactDOM.createPortal(children, ref.current);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface MapStyle {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const MapStyles = {
|
||||
Streets: {
|
||||
title: 'Streets',
|
||||
url: 'mapbox://styles/mapbox/streets-v11?optimize=true',
|
||||
} as MapStyle,
|
||||
Outdoors: {
|
||||
title: 'Outdoors',
|
||||
url: 'mapbox://styles/mapbox/outdoors-v11?optimize=true',
|
||||
} as MapStyle,
|
||||
|
||||
Light: {
|
||||
title: 'Light',
|
||||
url: 'mapbox://styles/mapbox/light-v10?optimize=true',
|
||||
} as MapStyle,
|
||||
Dark: {
|
||||
title: 'Dark',
|
||||
url: 'mapbox://styles/sachaw/ckwzwm92e1oep14pjunjqlqbo?optimize=true',
|
||||
} as MapStyle,
|
||||
Satellite: {
|
||||
title: 'Satellite',
|
||||
url: 'mapbox://styles/mapbox/satellite-v9?optimize=true',
|
||||
} as MapStyle,
|
||||
};
|
||||
@@ -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,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { connection } from '@core/connection';
|
||||
import { ackMessage } from '@core/slices/meshtasticSlice';
|
||||
import { useAppDispatch } from '@hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@hooks/useAppSelector';
|
||||
import { Input } from '@meshtastic/components';
|
||||
|
||||
export interface MessageBarProps {
|
||||
channelIndex: number;
|
||||
}
|
||||
|
||||
export const MessageBar = ({ channelIndex }: MessageBarProps): JSX.Element => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ready = useAppSelector((state) => state.meshtastic.ready);
|
||||
|
||||
const [currentMessage, setCurrentMessage] = React.useState('');
|
||||
const sendMessage = (): void => {
|
||||
if (ready) {
|
||||
void connection.sendText(
|
||||
currentMessage,
|
||||
undefined,
|
||||
true,
|
||||
channelIndex--,
|
||||
(id) => {
|
||||
dispatch(ackMessage({ channel: channelIndex--, messageId: id }));
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mx-auto flex w-full space-x-2 bg-gray-50 p-4 text-gray-500 dark:bg-transparent dark:text-gray-400">
|
||||
<div className="mx-auto flex w-full max-w-4xl">
|
||||
<form
|
||||
className="flex w-full space-x-2"
|
||||
onSubmit={(e): void => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
minLength={2}
|
||||
placeholder="Enter Message"
|
||||
disabled={!ready}
|
||||
value={currentMessage}
|
||||
onChange={(e): void => {
|
||||
setCurrentMessage(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
|
||||
import JSONPretty from 'react-json-pretty';
|
||||
|
||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import type { Node } from '@core/slices/meshtasticSlice';
|
||||
|
||||
export interface DebugPanelProps {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="fixed right-0 m-2">
|
||||
<CopyButton data={JSON.stringify(node)} />
|
||||
</div>
|
||||
<JSONPretty className="max-w-sm" data={node} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { CopyButton } from '@components/menu/buttons/CopyButton';
|
||||
import type { Node } from '@core/slices/meshtasticSlice';
|
||||
|
||||
export interface PositionPanelProps {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
|
||||
return (
|
||||
<div className="p-2">
|
||||
{node.currentPosition && (
|
||||
<div className="flex h-10 select-none justify-between rounded-md border border-gray-300 bg-transparent bg-gray-200 px-1 text-gray-500 dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
|
||||
<div className="my-auto px-1">
|
||||
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},
|
||||
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
|
||||
</div>
|
||||
<CopyButton
|
||||
data={
|
||||
node.currentPosition
|
||||
? `${node.currentPosition.latitudeI / 1e7},${
|
||||
node.currentPosition.longitudeI / 1e7
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-10
@@ -1,18 +1,15 @@
|
||||
import type React from 'react';
|
||||
|
||||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
|
||||
import { Card } from '@meshtastic/components';
|
||||
|
||||
export const NotFound = (): JSX.Element => {
|
||||
return (
|
||||
<PrimaryTemplate title="Page not found" tagline="404">
|
||||
<Card
|
||||
title="The requested file or directory could not be found"
|
||||
description="Better luck next time"
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
</Card>
|
||||
</PrimaryTemplate>
|
||||
<Card
|
||||
title="The requested file or directory could not be found"
|
||||
description="Better luck next time"
|
||||
>
|
||||
<br />
|
||||
<br />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,169 +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 { base16 } from 'rfc4648';
|
||||
|
||||
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,
|
||||
Input,
|
||||
Select,
|
||||
} from '@meshtastic/components';
|
||||
import { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface UserProps {
|
||||
navOpen?: boolean;
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const User = ({ navOpen, setNavOpen }: UserProps): JSX.Element => {
|
||||
const [debug, setDebug] = React.useState(false);
|
||||
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 (
|
||||
<PrimaryTemplate
|
||||
title="User"
|
||||
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={node?.user} />} />
|
||||
<div className="w-full max-w-3xl p-10 md:max-w-xl">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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, Input } from '@meshtastic/components';
|
||||
import type { Protobuf } from '@meshtastic/meshtasticjs';
|
||||
|
||||
export interface WiFiProps {
|
||||
navOpen?: boolean;
|
||||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const WiFi = ({ navOpen, setNavOpen }: WiFiProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
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, 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 (
|
||||
<PrimaryTemplate
|
||||
title="WiFi & MQTT"
|
||||
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="Enable WiFi AP" {...register('wifiApMode')} />
|
||||
<Input
|
||||
label={t('strings.wifi_ssid')}
|
||||
disabled={watchWifiApMode}
|
||||
{...register('wifiSsid')}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label={t('strings.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>
|
||||
</div>
|
||||
</Card>
|
||||
</PrimaryTemplate>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user