This commit is contained in:
Sacha Weatherstone
2022-02-07 00:00:55 +11:00
parent e43b04990b
commit 8d184578f0
109 changed files with 3495 additions and 3632 deletions
+45 -39
View File
@@ -1,5 +1,7 @@
import React from 'react';
import { AnimatePresence } from 'framer-motion';
import { BLE } from '@components/connection/BLE';
import { HTTP } from '@components/connection/HTTP';
import { Serial } from '@components/connection/Serial';
@@ -46,47 +48,51 @@ export const Connection = (): JSX.Element => {
}, [state.ready, dispatch]);
return (
<Modal
className="w-full max-w-3xl"
open={appState.connectionModalOpen}
onClose={(): void => {
dispatch(closeConnectionModal());
}}
>
<Card>
<div className="w-full max-w-3xl p-10">
{state.deviceStatus === Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
<div className="space-y-2">
<Select
label="Connection Method"
optionsEnum={connType}
value={appState.connType}
onChange={(e): void => {
dispatch(setConnType(parseInt(e.target.value)));
}}
/>
{appState.connType === connType.HTTP && <HTTP />}
{appState.connType === connType.BLE && <BLE />}
{appState.connType === connType.SERIAL && <Serial />}
</div>
) : (
<div>
<span>Connecting...</span>
<AnimatePresence>
{appState.connectionModalOpen && (
<Modal
className="w-full max-w-3xl"
onClose={(): void => {
dispatch(closeConnectionModal());
}}
>
<Card>
<div className="w-full max-w-3xl p-10">
{state.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
<Button
border
onClick={async (): Promise<void> => {
await connection.disconnect();
}}
>
Disconnect
</Button>
Types.DeviceStatusEnum.DEVICE_DISCONNECTED ? (
<div className="space-y-2">
<Select
label="Connection Method"
optionsEnum={connType}
value={appState.connType}
onChange={(e): void => {
dispatch(setConnType(parseInt(e.target.value)));
}}
/>
{appState.connType === connType.HTTP && <HTTP />}
{appState.connType === connType.BLE && <BLE />}
{appState.connType === connType.SERIAL && <Serial />}
</div>
) : (
<div>
<span>Connecting...</span>
{state.deviceStatus ===
Types.DeviceStatusEnum.DEVICE_CONNECTED && (
<Button
border
onClick={async (): Promise<void> => {
await connection.disconnect();
}}
>
Disconnect
</Button>
)}
</div>
)}
</div>
)}
</div>
</Card>
</Modal>
</Card>
</Modal>
)}
</AnimatePresence>
);
};
+3 -3
View File
@@ -16,9 +16,9 @@ export const FormFooter = ({
saveAction,
}: FormFooterProps): JSX.Element => {
return (
<div className="flex float-right gap-2">
<div className="float-right flex gap-2">
<IconButton
icon={<FiXCircle className="w-5 h-5" />}
icon={<FiXCircle className="h-5 w-5" />}
disabled={!dirty}
onClick={(): void => {
clearAction && clearAction();
@@ -29,7 +29,7 @@ export const FormFooter = ({
onClick={(): void => {
saveAction && saveAction();
}}
icon={<FiSave className="w-5 h-5" />}
icon={<FiSave className="h-5 w-5" />}
/>
</div>
);
-43
View File
@@ -1,43 +0,0 @@
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);
};
-90
View File
@@ -1,90 +0,0 @@
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 Map = (): 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 w-full h-full">
<div className="absolute right-0 z-20 p-2 m-4 space-y-2 bg-white border border-gray-300 rounded-md shadow-md dark:bg-primaryDark dark:border-gray-600">
<IconButton
active={mapState.style.title === 'Satellite'}
onClick={(): void => {
ChangeMapStyle('Satellite', MapStyles.Satellite);
}}
icon={<FaGlobeAfrica />}
/>
<div
className={`p-1 -m-1 space-y-2 rounded-md border-gray-300 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="flex-grow w-full h-full" ref={ref} />
</div>
);
};
-28
View File
@@ -1,28 +0,0 @@
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 -1
View File
@@ -11,7 +11,7 @@ import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { useCreateMapbox } from '@hooks/useCreateMapbox';
import { MapStyles } from '../Map/styles';
import { MapStyles } from '../../pages/Map/styles';
import { MapboxContext } from './mapboxContext';
export type MapboxProviderProps = {
+1 -1
View File
@@ -8,5 +8,5 @@ export interface MapboxContextValue {
}
export const MapboxContext = React.createContext<MapboxContextValue>(
{} as MapboxContextValue
{} as MapboxContextValue,
);
-27
View File
@@ -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>
);
};
-56
View File
@@ -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>
);
};
-88
View File
@@ -1,88 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FiSend } from 'react-icons/fi';
import { connection } from '@core/connection';
import { ackMessage } from '@core/slices/meshtasticSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { IconButton, Input, Select } 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 nodes = useAppSelector((state) => state.meshtastic.nodes);
const myNodeNum = useAppSelector(
(state) => state.meshtastic.radio.hardware,
).myNodeNum;
const [currentMessage, setCurrentMessage] = React.useState('');
const [destinationNode, setDestinationNode] =
React.useState<number>(0xffffffff);
const sendMessage = (): void => {
if (ready) {
void connection.sendText(
currentMessage,
destinationNode,
true,
channelIndex--,
(id) => {
dispatch(ackMessage({ channel: channelIndex--, messageId: id }));
return Promise.resolve();
},
);
setCurrentMessage('');
}
};
const { t } = useTranslation();
return (
<div className="flex w-full p-4 mx-auto space-x-2 text-gray-500 bg-gray-50 dark:bg-transparent dark:text-gray-400">
<div className="flex w-full max-w-4xl mx-auto">
<form
className="flex w-full space-x-2"
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<Select
onChange={(e): void => {
setDestinationNode(parseInt(e.target.value));
}}
options={[
{
name: 'All',
value: 0xffffffff,
},
...nodes
.filter((node) => node.number !== myNodeNum)
.map((node) => {
return {
name: node.user ? node.user.shortName : node.number,
value: node.number,
};
}),
]}
/>
<Input
type="text"
minLength={2}
placeholder={`${t('placeholder.message')}...`}
disabled={!ready}
value={currentMessage}
onChange={(e): void => {
setCurrentMessage(e.target.value);
}}
/>
<IconButton icon={<FiSend className="w-5 h-5" />} type="submit" />
</form>
</div>
</div>
);
};
+4 -2
View File
@@ -4,8 +4,9 @@ import { useForm } from 'react-hook-form';
import { FiCheck } from 'react-icons/fi';
import { connType } from '@app/core/slices/appSlice';
import { ble, setConnection } from '@core/connection';
import { setConnection } from '@core/connection';
import { Button, IconButton } from '@meshtastic/components';
import { IBLEConnection } from '@meshtastic/meshtasticjs';
export const BLE = (): JSX.Element => {
const [bleDevices, setBleDevices] = React.useState<BluetoothDevice[]>([]);
@@ -15,6 +16,7 @@ export const BLE = (): JSX.Element => {
}>();
const updateBleDeviceList = React.useCallback(async (): Promise<void> => {
const ble = new IBLEConnection();
const devices = await ble.getDevices();
setBleDevices(devices);
}, []);
@@ -34,7 +36,7 @@ export const BLE = (): JSX.Element => {
onClick={async (): Promise<void> => {
await setConnection(connType.BLE);
}}
className="flex justify-between p-2 bg-gray-700 rounded-md"
className="flex justify-between rounded-md bg-gray-700 p-2"
key={index}
>
<div className="my-auto">{device.name}</div>
+6 -4
View File
@@ -3,10 +3,11 @@ import React from 'react';
import { useForm } from 'react-hook-form';
import { FiCheck } from 'react-icons/fi';
import { serial, setConnection } from '@core/connection';
import { setConnection } from '@core/connection';
import { connType, setConnectionParams } from '@core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { Button, IconButton } from '@meshtastic/components';
import { ISerialConnection } from '@meshtastic/meshtasticjs';
export const Serial = (): JSX.Element => {
const [serialDevices, setSerialDevices] = React.useState<SerialPort[]>([]);
@@ -17,6 +18,7 @@ export const Serial = (): JSX.Element => {
}>();
const updateSerialDeviceList = React.useCallback(async (): Promise<void> => {
const serial = new ISerialConnection();
const devices = await serial.getPorts();
setSerialDevices(devices);
}, []);
@@ -34,10 +36,10 @@ export const Serial = (): JSX.Element => {
{serialDevices.length > 0 ? (
serialDevices.map((device, index) => (
<div
className="flex justify-between p-2 bg-gray-700 rounded-md"
className="flex justify-between rounded-md bg-gray-700 p-2"
key={index}
>
<div className="flex gap-4 my-auto">
<div className="my-auto flex gap-4">
<p>
Vendor: <small>{device.getInfo().usbVendorId}</small>
</p>
@@ -62,7 +64,7 @@ export const Serial = (): JSX.Element => {
</div>
))
) : (
<div className="h-40 border border-gray-300 rounded-md dark:border-gray-600">
<div className="h-40 rounded-md border border-gray-300 dark:border-gray-600">
<p>No previously connected devices found</p>
</div>
)}
+2 -2
View File
@@ -14,14 +14,14 @@ export const Blur = ({
}: BlurProps): JSX.Element => {
return (
<div
className={`absolute inset-0 z-20 w-full h-full transition-opacity ${
className={`absolute inset-0 z-20 h-full w-full transition-opacity ${
disableOnMd ? 'md:hidden' : ''
} ${className}`}
{...props}
>
<div
onClick={onClick}
className={`absolute inset-0 w-full h-full backdrop-filter backdrop-blur-sm ${
className={`absolute inset-0 h-full w-full backdrop-blur-sm backdrop-filter ${
disableOnMd ? 'md:hidden' : ''
}`}
tabIndex={0}
-38
View File
@@ -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>
</>
);
};
+46
View File
@@ -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>
);
};
+13 -13
View File
@@ -1,33 +1,33 @@
import type React from 'react';
import { Dialog } from '@headlessui/react';
import { m } from 'framer-motion';
import { useAppSelector } from '@hooks/useAppSelector';
// import { Backdrop } from './Backdrop';
type DefaultDivProps = JSX.IntrinsicElements['div'];
export interface ModalProps extends DefaultDivProps {
children: React.ReactNode;
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export const Modal = ({
children,
open,
onClose,
children,
className,
...props
}: ModalProps): JSX.Element => {
const darkMode = useAppSelector((state) => state.app.darkMode);
return (
<Dialog
as="div"
<m.div
className={`fixed inset-0 z-30 ${darkMode ? 'dark' : ''}`}
open={open}
onClose={onClose}
onClick={onClose}
>
<Dialog.Overlay className="fixed w-full h-full backdrop-filter backdrop-blur-sm" />
<div className="text-center ">
<m.div className="fixed h-full w-full backdrop-blur-sm backdrop-filter" />
<m.div className="text-center ">
<span
className="inline-block h-screen align-middle "
aria-hidden="true"
@@ -37,7 +37,7 @@ export const Modal = ({
<div className={`inline-block align-middle ${className}`} {...props}>
{children}
</div>
</div>
</Dialog>
</m.div>
</m.div>
);
};
-46
View File
@@ -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>
);
};
-33
View File
@@ -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>
);
};
-14
View File
@@ -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>
);
};
+1 -1
View File
@@ -9,6 +9,6 @@ export const Label = ({ label, error }: LabelProps): JSX.Element => (
<label className="flex py-1 text-xs font-semibold text-gray-500 dark:text-gray-400">
{label}
{error && <span className="ml-2 text-red-500">{error}</span>}
<div className="flex-grow h-0.5 my-auto ml-2 dark:bg-gray-700 bg-gray-300 rounded-full" />
<div className="my-auto ml-2 h-0.5 flex-grow rounded-full bg-gray-300 dark:bg-gray-700" />
</label>
);
@@ -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>
);
};
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
export const ExternalNotificationsDebugPanel = (): JSX.Element => {
const preferences = useAppSelector(
@@ -22,11 +21,11 @@ export const ExternalNotificationsDebugPanel = (): JSX.Element => {
};
return (
<Tab.Panel className="relative">
<>
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(debugData)} />
</div>
<JSONPretty className="max-w-sm" data={debugData} />
</Tab.Panel>
</>
);
};
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
import { Form } from '@app/components/generic/form/Form';
import { connection } from '@app/core/connection';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input } from '@meshtastic/components';
import type { Protobuf } from '@meshtastic/meshtasticjs';
@@ -42,7 +41,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
});
return (
<Tab.Panel className="flex flex-col w-full">
<>
<Form loading={loading}>
<Checkbox
label="Plugin Enabled"
@@ -82,7 +81,7 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
/>
</Form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<div className="ml-auto p-2">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
@@ -92,6 +91,6 @@ export const ExternalNotificationsSettingsPlanel = (): JSX.Element => {
/>
</div>
</div>
</Tab.Panel>
</>
);
};
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
export const RangeTestDebugPanel = (): JSX.Element => {
const preferences = useAppSelector(
@@ -18,11 +17,11 @@ export const RangeTestDebugPanel = (): JSX.Element => {
};
return (
<Tab.Panel className="relative">
<>
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(debugData)} />
</div>
<JSONPretty className="max-w-sm" data={debugData} />
</Tab.Panel>
</>
);
};
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
import { Form } from '@app/components/generic/form/Form';
import { connection } from '@app/core/connection';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input } from '@meshtastic/components';
import type { Protobuf } from '@meshtastic/meshtasticjs';
@@ -42,7 +41,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
});
return (
<Tab.Panel className="flex flex-col w-full">
<>
<Form loading={loading}>
<Checkbox
label="Range Test Plugin Enabled?"
@@ -64,7 +63,7 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
/>
</Form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<div className="ml-auto p-2">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
@@ -74,6 +73,6 @@ export const RangeTestSettingsPanel = (): JSX.Element => {
/>
</div>
</div>
</Tab.Panel>
</>
);
};
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
export const SerialDebugPanel = (): JSX.Element => {
const preferences = useAppSelector(
@@ -21,11 +20,11 @@ export const SerialDebugPanel = (): JSX.Element => {
};
return (
<Tab.Panel className="relative">
<>
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(debugData)} />
</div>
<JSONPretty className="max-w-sm" data={debugData} />
</Tab.Panel>
</>
);
};
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
import { Form } from '@app/components/generic/form/Form';
import { connection } from '@app/core/connection';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input } from '@meshtastic/components';
import type { Protobuf } from '@meshtastic/meshtasticjs';
@@ -42,7 +41,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
});
return (
<Tab.Panel className="flex flex-col w-full">
<>
<Form loading={loading}>
<Checkbox label="Plugin Enabled" {...register('serialpluginEnabled')} />
<Checkbox
@@ -85,7 +84,7 @@ export const SerialSettingsPanel = (): JSX.Element => {
/>
</Form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<div className="ml-auto p-2">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
@@ -95,6 +94,6 @@ export const SerialSettingsPanel = (): JSX.Element => {
/>
</div>
</div>
</Tab.Panel>
</>
);
};
@@ -4,7 +4,6 @@ import JSONPretty from 'react-json-pretty';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
export const StoreForwardDebugPanel = (): JSX.Element => {
const preferences = useAppSelector(
@@ -22,11 +21,11 @@ export const StoreForwardDebugPanel = (): JSX.Element => {
};
return (
<Tab.Panel className="relative">
<>
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(debugData)} />
</div>
<JSONPretty className="max-w-sm" data={debugData} />
</Tab.Panel>
</>
);
};
@@ -6,7 +6,6 @@ import { FiSave } from 'react-icons/fi';
import { Form } from '@app/components/generic/form/Form';
import { connection } from '@app/core/connection';
import { useAppSelector } from '@app/hooks/useAppSelector';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input } from '@meshtastic/components';
import type { Protobuf } from '@meshtastic/meshtasticjs';
@@ -42,7 +41,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
});
return (
<Tab.Panel className="flex flex-col w-full">
<>
<Form loading={loading}>
<Checkbox
label="Plugin Enabled"
@@ -80,7 +79,7 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
/>
</Form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<div className="ml-auto p-2">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
@@ -90,6 +89,6 @@ export const StoreForwardSettingsPanel = (): JSX.Element => {
/>
</div>
</div>
</Tab.Panel>
</>
);
};
@@ -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>
);
})}
</>
);
};
@@ -3,7 +3,6 @@ import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface DebugPanelProps {
@@ -12,11 +11,11 @@ export interface DebugPanelProps {
export const DebugPanel = ({ channel }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<>
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(channel)} />
</div>
<JSONPretty className="max-w-sm" data={channel} />
</Tab.Panel>
</>
);
};
@@ -2,7 +2,6 @@ import type React from 'react';
import QRCode from 'react-qr-code';
import { Tab } from '@headlessui/react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
export interface QRCodePanelProps {
@@ -11,13 +10,11 @@ export interface QRCodePanelProps {
export const QRCodePanel = ({ channel }: QRCodePanelProps): JSX.Element => {
return (
<Tab.Panel className="flex flex-grow p-2">
<div className="m-auto">
<QRCode
className="rounded-md"
value={`https://www.meshtastic.org/d/#${channel.index}`}
/>
</div>
</Tab.Panel>
<div className="m-auto">
<QRCode
className="rounded-md"
value={`https://www.meshtastic.org/d/#${channel.index}`}
/>
</div>
);
};
@@ -7,7 +7,6 @@ import { MdRefresh, MdVisibility, MdVisibilityOff } from 'react-icons/md';
import { Form } from '@app/components/generic/form/Form';
import { connection } from '@app/core/connection';
import { Tab } from '@headlessui/react';
import { Checkbox, IconButton, Input, Select } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs';
@@ -72,7 +71,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
});
return (
<Tab.Panel className="flex flex-col w-full">
<div className="flex w-full flex-col">
<Form loading={loading}>
{channel?.index !== 0 && (
<>
@@ -123,7 +122,7 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
<Checkbox label="Downlink Enabled" {...register('downlinkEnabled')} />
</Form>
<div className="flex w-full bg-white dark:bg-secondaryDark">
<div className="p-2 ml-auto">
<div className="ml-auto p-2">
<IconButton
disabled={!formState.isDirty}
onClick={async (): Promise<void> => {
@@ -133,6 +132,6 @@ export const SettingsPanel = ({ channel }: SettingsPanelProps): JSX.Element => {
/>
</div>
</div>
</Tab.Panel>
</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>
);
};
+38
View File
@@ -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>
);
};
+39
View File
@@ -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>
);
};
+43 -24
View File
@@ -1,10 +1,10 @@
import React from 'react';
import {
FiBell,
FiBluetooth,
FiCpu,
FiGitBranch,
FiMenu,
FiMoon,
FiSun,
FiWifi,
@@ -20,18 +20,15 @@ import {
connType,
openConnectionModal,
setDarkModeEnabled,
toggleMobileNav,
} from '@app/core/slices/appSlice';
import { useAppDispatch } from '@hooks/useAppDispatch';
import { useAppSelector } from '@hooks/useAppSelector';
import { Tooltip } from '@meshtastic/components';
import { Protobuf, Types } from '@meshtastic/meshtasticjs';
import { Tooltip } from '../generic/Tooltip';
import { VersionInfo } from '../modals/VersionInfo';
// export interface BottomNavProps {
// }
export const BottomNav = (): JSX.Element => {
const [showVersionInfo, setShowVersionInfo] = React.useState(false);
const dispatch = useAppDispatch();
@@ -43,11 +40,26 @@ export const BottomNav = (): JSX.Element => {
?.channel.settings;
return (
<div className="flex justify-between bg-white border-t border-gray-300 dark:bg-secondaryDark dark:border-gray-600">
<div className="z-20 flex justify-between border-t border-gray-300 bg-white dark:border-gray-600 dark:bg-secondaryDark">
<div className="flex">
<Tooltip contents={`Connection Status`}>
<Tooltip content="Meshtastic WebUI">
<div className="group flex cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark">
<img
title="Logo"
className="w-5 dark:hidden"
src="/Logo_Black.svg"
/>
<img
title="Logo"
className="hidden w-5 dark:flex"
src="/Logo_White.svg"
/>
</div>
</Tooltip>
<Tooltip content={`Connection Status`}>
<div
className={`flex p-1 cursor-pointer group w-min hover:bg-opacity-80 ${
className={`group flex w-min cursor-pointer p-1 hover:bg-opacity-80 ${
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
@@ -72,7 +84,7 @@ export const BottomNav = (): JSX.Element => {
) : (
<FiWifi className="mr-1 p-0.5 group-active:scale-90" />
)}
<div className="text-xs font-medium truncate group-active:scale-90">
<div className="truncate text-xs font-medium group-active:scale-90">
{meshtasticState.nodes.find(
(node) =>
node.number === meshtasticState.radio.hardware.myNodeNum,
@@ -80,8 +92,8 @@ export const BottomNav = (): JSX.Element => {
</div>
</div>
</Tooltip>
<Tooltip contents={`MQTT Status`}>
<div className="flex p-1 border-r border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark">
<Tooltip content="MQTT Status">
<div className="group flex cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark">
{primaryChannelSettings?.uplinkEnabled &&
primaryChannelSettings?.downlinkEnabled &&
!meshtasticState.radio.preferences.mqttDisabled ? (
@@ -98,7 +110,18 @@ export const BottomNav = (): JSX.Element => {
</div>
</Tooltip>
</div>
<div
onClick={(): void => {
dispatch(toggleMobileNav());
}}
className="group flex w-full cursor-pointer select-none border-r border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark md:hidden"
>
{appState.mobileNavOpen ? (
<FiX className="m-auto p-0.5 group-active:scale-90" />
) : (
<FiMenu className="m-auto p-0.5 group-active:scale-90" />
)}
</div>
<div className="flex">
<VersionInfo
visible={showVersionInfo}
@@ -106,26 +129,22 @@ export const BottomNav = (): JSX.Element => {
setShowVersionInfo(false);
}}
/>
<Tooltip contents={`Current Commit`}>
<Tooltip content={`Current Commit`}>
<div
onClick={(): void => {
setShowVersionInfo(true);
}}
className="flex p-1 border-l border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark"
className="group flex cursor-pointer select-none border-l border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark"
>
<FiGitBranch className="p-0.5 mr-1 group-active:scale-90" />
<FiGitBranch className="mr-1 p-0.5 group-active:scale-90" />
<p className="text-xs opacity-60">{process.env.COMMIT_HASH}</p>
</div>
</Tooltip>
<Tooltip contents={`Notifications`}>
<div className="flex p-1 border-l border-gray-300 cursor-pointer select-none group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark">
<FiBell className="p-0.5 mr-1 group-active:scale-90" />
<p className="text-xs opacity-60">Example Notification</p>
</div>
</Tooltip>
<Tooltip contents={`Toggle Theme`}>
<Tooltip content={`Toggle Theme`}>
<div
className="p-1 border-l border-gray-300 cursor-pointer group dark:border-gray-600 dark:text-white hover:bg-gray-200 dark:hover:bg-primaryDark"
className="group cursor-pointer border-l border-gray-300 p-1 hover:bg-gray-200 dark:border-gray-600 dark:text-white dark:hover:bg-primaryDark"
onClick={(): void => {
dispatch(setDarkModeEnabled(!appState.darkMode));
}}
-14
View File
@@ -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"
/>
</>
);
};
-58
View File
@@ -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>
);
};
+4 -4
View File
@@ -23,7 +23,7 @@ export const DeviceStatus = (): JSX.Element => {
<div className="flex gap-2 px-2">
<div
className={`
my-auto w-2 h-2 rounded-full min-w-[2] ${
my-auto h-2 w-2 min-w-[2] rounded-full ${
[
Types.DeviceStatusEnum.DEVICE_CONNECTED,
Types.DeviceStatusEnum.DEVICE_CONFIGURED,
@@ -44,11 +44,11 @@ export const DeviceStatus = (): JSX.Element => {
)?.user?.longName ?? 'Disconnected'}
</div>
{appState.connType === connType.BLE ? (
<FiBluetooth className="w-5 h-5" />
<FiBluetooth className="h-5 w-5" />
) : appState.connType === connType.SERIAL ? (
<FiCpu className="w-5 h-5" />
<FiCpu className="h-5 w-5" />
) : (
<FiWifi className="w-5 h-5" />
<FiWifi className="h-5 w-5" />
)}
</div>
</Button>
@@ -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>
);
};
+16 -11
View File
@@ -1,5 +1,7 @@
import React from 'react';
import { AnimatePresence } from 'framer-motion';
import { Modal } from '@components/generic/Modal';
import { Card } from '@meshtastic/components';
@@ -41,16 +43,19 @@ export const VersionInfo = ({
// console.log(data);
return (
<Modal
open={visible}
onClose={(): void => {
onclose();
}}
>
<Card>
<div className="w-full max-w-3xl p-10">Version Info</div>
{/* {data?.sha} */}
</Card>
</Modal>
<AnimatePresence>
{visible && (
<Modal
onClose={(): void => {
onclose();
}}
>
<Card>
<div className="w-full max-w-3xl p-10">Version Info</div>
{/* {data?.sha} */}
</Card>
</Modal>
)}
</AnimatePresence>
);
};
-131
View File
@@ -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,22 +0,0 @@
import type React from 'react';
import JSONPretty from 'react-json-pretty';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface DebugPanelProps {
node: Node;
}
export const DebugPanel = ({ node }: DebugPanelProps): JSX.Element => {
return (
<Tab.Panel className="relative">
<div className="fixed right-0 m-2">
<CopyButton data={JSON.stringify(node)} />
</div>
<JSONPretty className="max-w-sm" data={node} />
</Tab.Panel>
);
};
@@ -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,33 +0,0 @@
import type React from 'react';
import { CopyButton } from '@components/menu/buttons/CopyButton';
import type { Node } from '@core/slices/meshtasticSlice';
import { Tab } from '@headlessui/react';
export interface PositionPanelProps {
node: Node;
}
export const PositionPanel = ({ node }: PositionPanelProps): JSX.Element => {
return (
<Tab.Panel className="p-2">
{node.currentPosition && (
<div className="flex justify-between h-10 px-1 text-gray-500 bg-transparent bg-gray-200 border border-gray-300 rounded-md select-none dark:border-gray-600 dark:bg-secondaryDark dark:text-gray-400 ">
<div className="px-1 my-auto">
{(node.currentPosition.latitudeI / 1e7).toPrecision(6)},&nbsp;
{(node.currentPosition?.longitudeI / 1e7).toPrecision(6)}
</div>
<CopyButton
data={
node.currentPosition
? `${node.currentPosition.latitudeI / 1e7},${
node.currentPosition.longitudeI / 1e7
}`
: ''
}
/>
</div>
)}
</Tab.Panel>
);
};
@@ -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>
);
};
-89
View File
@@ -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>
);
};