UI Overhaul (#3)

* WIP

* WIP

* WIP

* WIP

* Draw improvements, disabled user zooming

* Update status indicator

* Add dynamic channel name with icon

* Add more settings

* WIP

* WIP
This commit is contained in:
Sacha Weatherstone
2021-08-15 23:41:14 +10:00
committed by GitHub
parent f898b1dd49
commit e14c628ecf
70 changed files with 1916 additions and 1644 deletions
-73
View File
@@ -1,73 +0,0 @@
import React from 'react';
import Avatar from 'boring-avatars';
import {
CheckCircleIcon,
DotsCircleHorizontalIcon,
} from '@heroicons/react/outline';
import type { Types } from '@meshtastic/meshtasticjs';
import { useAppSelector } from '../hooks/redux';
interface ChatMessageProps {
message: { message: Types.TextPacket; ack: boolean };
}
export const ChatMessage = (props: ChatMessageProps): JSX.Element => {
const myNodeInfo = useAppSelector((state) => state.meshtastic.myNodeInfo);
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const node = nodes.find((node) => {
return node.num === props.message.message.packet.from;
});
return (
<div className="flex items-end">
<Avatar
size={40}
name={node?.user?.longName ?? 'UNK'}
variant="beam"
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
/>
<div className="flex flex-col container px-2 items-start">
<React.Suspense
fallback={
<div className="flex border-b border-gray-300">
<div className="m-auto p-3 text-gray-500">Loading</div>
</div>
}
>
<div
className={`px-4 py-2 rounded-3xl shadow-md ${
props.message.message.packet.from !== myNodeInfo.myNodeNum
? 'bg-gray-300'
: 'bg-green-200'
}`}
>
<div className="flex text-xs text-gray-500 space-x-1">
<div className="font-medium">{node?.user?.longName ?? 'UNK'}</div>
<p>-</p>
<div className="underline">
{new Date(
props.message.message.packet.rxTime > 0
? props.message.message.packet.rxTime
: Date.now(),
).toLocaleString()}
</div>
</div>
<div className="flex justify-between text-gray-600">
<span className="inline-block">{props.message.message.data}</span>
{node?.num === myNodeInfo.myNodeNum &&
(props.message.ack ? (
<CheckCircleIcon className="my-auto w-5 h-5" />
) : (
<DotsCircleHorizontalIcon className="my-auto animate-pulse w-5 h-5" />
))}
</div>
</div>
</React.Suspense>
</div>
</div>
);
};
-64
View File
@@ -1,64 +0,0 @@
import React from 'react';
import {
DeviceMobileIcon,
StatusOfflineIcon,
StatusOnlineIcon,
} from '@heroicons/react/outline';
import { Types } from '@meshtastic/meshtasticjs';
import { useAppSelector } from '../hooks/redux';
import { Logo } from './Logo';
export const Header = (): JSX.Element => {
const deviceStatus = useAppSelector((state) => state.meshtastic.deviceStatus);
const ready = useAppSelector((state) => state.meshtastic.ready);
const lastMeshInterraction = useAppSelector(
(state) => state.meshtastic.lastMeshInterraction,
);
return (
<nav className="select-none w-full shadow-md">
<div className="flex w-full container mx-auto justify-between px-6 py-4">
<Logo />
<div></div>
<div className="flex space-x-2 items-center">
<div className="flex">
<div
className={`w-5 h-5 rounded-full ${
new Date(lastMeshInterraction) < new Date(Date.now() - 40000)
? 'bg-red-400 animate-pulse'
: new Date(lastMeshInterraction) <
new Date(Date.now() - 20000)
? 'bg-yellow-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
{new Date(lastMeshInterraction) > new Date(Date.now() - 40000) ? (
<StatusOnlineIcon className="m-auto ml-1 h-5 w-5" />
) : (
<StatusOfflineIcon className="m-auto ml-1 h-5 w-5" />
)}
</div>
<div className="flex">
<div
className={`w-5 h-5 rounded-full ${
deviceStatus <= Types.DeviceStatusEnum.DEVICE_DISCONNECTED
? 'bg-red-400 animate-pulse'
: deviceStatus <= Types.DeviceStatusEnum.DEVICE_CONFIGURING &&
!ready
? 'bg-yellow-400 animate-pulse'
: ready
? 'bg-green-400'
: 'bg-gray-400'
}`}
></div>
<DeviceMobileIcon className="m-auto ml-1 w-5 h-5" />
</div>
</div>
</div>
</nav>
);
};
-84
View File
@@ -1,84 +0,0 @@
import React from 'react';
export const Logo = (): JSX.Element => {
return (
<svg
height="30"
width="200"
viewBox="0 0 1115 116"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style={{
fillRule: 'evenodd',
clipRule: 'evenodd',
strokeLinejoin: 'round',
strokeMiterlimit: 1.5,
}}
>
<g transform="matrix(1.05081,0,0,0.602459,-74.3378,-345.171)">
<g>
<g transform="matrix(0.973838,0,0,1.69858,-1.56777,-229.112)">
<path
d="M81.582,577.266L148.536,478.804"
style={{ fill: 'none', stroke: 'black', strokeWidth: '14.11px' }}
/>
</g>
<g transform="matrix(0.961342,0,0,1.67678,882.45,-216.54)">
<path
d="M81.582,577.266L148.536,478.804"
style={{ fill: 'none', stroke: 'black', strokeWidth: '14.86px' }}
/>
</g>
<g transform="matrix(12.0448,0,0,21.0595,-7445.39,-7644.88)">
<text
x="640.988px"
y="399.072px"
style={{
fontFamily: 'ArialMT, Arial, sans-serif',
fontSize: '12px',
}}
>
ESHT
</text>
</g>
<g transform="matrix(0.977299,0,0,1.70462,-43.6432,50.5292)">
<path
d="M187.032,410.85L250.896,317.192L314.907,410.702"
style={{ fill: 'none', stroke: 'black', strokeWidth: '13.85px' }}
/>
</g>
<g transform="matrix(0.977299,0,0,1.70462,468.182,53.0697)">
<path
d="M187.032,410.85L250.896,317.192L314.907,410.702"
style={{ fill: 'none', stroke: 'black', strokeWidth: '13.85px' }}
/>
</g>
<g transform="matrix(0.571939,0,0,1,784.482,759.924)">
<text
x="0px"
y="0px"
style={{
fontFamily: 'ArialMT, Arial, sans-serif',
fontSize: '252.715px',
}}
>
ST
</text>
</g>
<g transform="matrix(0.571939,0,0,1,1030.51,760.498)">
<text
x="0px"
y="0px"
style={{
fontFamily: 'ArialMT, Arial, sans-serif',
fontSize: '252.715px',
}}
>
C
</text>
</g>
</g>
</g>
</svg>
);
};
-69
View File
@@ -1,69 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { MenuIcon, PaperAirplaneIcon } from '@heroicons/react/outline';
import { connection } from '../connection';
import { useAppDispatch, useAppSelector } from '../hooks/redux';
import { toggleSidebar } from '../slices/appSlice';
export const MessageBox = (): JSX.Element => {
const ready = useAppSelector((state) => state.meshtastic.ready);
const [currentMessage, setCurrentMessage] = React.useState('');
const sendMessage = () => {
if (ready) {
connection.sendText(currentMessage, undefined, true);
setCurrentMessage('');
}
};
const { t } = useTranslation();
const dispatch = useAppDispatch();
return (
<div className="flex text-lg font-medium space-x-2 md:space-x-0 w-full">
<motion.button
initial={{}}
whileHover={{
backgroundColor: 'rgba(229, 231, 235)',
}}
className="flex h-14 w-14 text-xl hover:text-gray-500 text-gray-400 rounded-full border shadow-md focus:outline-none cursor-pointer md:hidden"
onClick={() => {
dispatch(toggleSidebar());
}}
>
<MenuIcon className="m-auto h-6 w-6" />
</motion.button>
<form
className="flex flex-wrap relative w-full"
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
>
{ready}
<input
type="text"
placeholder={`${t('placeholder.no_messages')}...`}
disabled={!ready}
value={currentMessage}
onChange={(e) => {
setCurrentMessage(e.target.value);
}}
className={`p-3 placeholder-gray-400 text-gray-700 relative rounded-3xl border shadow-md focus:outline-none w-full pr-10 ${
ready ? 'cursor-text' : 'cursor-not-allowed'
}`}
/>
<span className="flex z-10 h-full text-gray-400 absolute w-8 right-1">
<PaperAirplaneIcon
onClick={sendMessage}
className={`text-xl hover:text-gray-500 h-6 w-6 my-auto ${
ready ? 'cursor-pointer' : 'cursor-not-allowed'
}`}
/>
</span>
</form>
</div>
);
};
-38
View File
@@ -1,38 +0,0 @@
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { useAppSelector } from '../hooks/redux';
import { Channels } from './Sidebar/Channels/Index';
import { Device } from './Sidebar/Device/Index';
import { Nodes } from './Sidebar/Nodes/Index';
import { UI } from './Sidebar/UI/Index';
export const Sidebar = (): JSX.Element => {
const sidebarOpen = useAppSelector((state) => state.app.sidebarOpen);
return (
<AnimatePresence>
{sidebarOpen && (
<motion.div
initial={{
height: 0,
}}
animate={{
height: 'auto',
}}
exit={{
height: 0,
}}
className="flex flex-col rounded-3xl md:ml-0 shadow-md border w-full md:max-w-sm"
>
<Nodes />
<Device />
<Channels />
<div className="flex-grow border-b"></div>
<UI />
</motion.div>
)}
</AnimatePresence>
);
};
-114
View File
@@ -1,114 +0,0 @@
import React from 'react';
import { Disclosure } from '@headlessui/react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/outline';
import { Protobuf } from '@meshtastic/meshtasticjs';
export interface ChannelProps {
channel: Protobuf.Channel;
}
export const Channel = (props: ChannelProps): JSX.Element => {
return (
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
<div className="flex ml-4">
{open ? (
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
) : (
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
)}
{props.channel.index} -{' '}
{Protobuf.Channel_Role[props.channel.role]}
</div>
</Disclosure.Button>
<Disclosure.Panel>
<div className="w-full bg-gray-100 px-2">
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Bandwidth:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.bandwidth}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Channel Number:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.channelNum}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Coding Rate:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.codingRate}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>ID:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.id}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Modem Config:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.modemConfig
? Protobuf.ChannelSettings_ModemConfig[
props.channel.settings.modemConfig
]
: null}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Name:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.name}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>PSK:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.psk.toLocaleString()}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Spread Factor:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.spreadFactor}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Tx Power:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.txPower}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Uplink:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.uplinkEnabled ? 'true' : 'false'}
</code>
</div>
<div className="flex justify-between border-b hover:bg-gray-200">
<p>Downlink:</p>
<code className="bg-gray-200 rounded-full px-2">
{props.channel.settings?.downlinkEnabled ? 'true' : 'false'}
</code>
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};
@@ -1,19 +0,0 @@
import React from 'react';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { useAppSelector } from '../../../hooks/redux';
import { Channel } from './Channel';
export const ChannelList = (): JSX.Element => {
const channels = useAppSelector((state) => state.meshtastic.channels);
return (
<>
{channels.map((channel, index) => {
if (channel.role !== Protobuf.Channel_Role.DISABLED)
return <Channel key={index} channel={channel} />;
})}
</>
);
};
-20
View File
@@ -1,20 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { HashtagIcon } from '@heroicons/react/outline';
import { Dropdown } from '../../basic/Dropdown';
import { ChannelList } from './ChannelList';
export const Channels = (): JSX.Element => {
const { t } = useTranslation();
return (
<Dropdown
icon={<HashtagIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
title={t('settings.channel')}
content={<ChannelList />}
fallbackMessage={'Loading...'}
/>
);
};
-20
View File
@@ -1,20 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AdjustmentsIcon } from '@heroicons/react/outline';
import { Dropdown } from '../../basic/Dropdown';
import { Settings } from './Settings';
export const Device = (): JSX.Element => {
const { t } = useTranslation();
return (
<Dropdown
icon={<AdjustmentsIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
title={t('settings.device')}
content={<Settings />}
fallbackMessage={'Loading...'}
/>
);
};
@@ -1,85 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { SaveIcon } from '@heroicons/react/outline';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { connection } from '../../../connection';
import { useAppSelector } from '../../../hooks/redux';
export const Settings = (): JSX.Element => {
const { t } = useTranslation();
const preferences = useAppSelector((state) => state.meshtastic.preferences);
const { register, handleSubmit } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: preferences,
});
const onSubmit = handleSubmit((data) => connection.setPreferences(data));
return (
<form onSubmit={onSubmit}>
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
<div className="my-auto">{t('strings.device_region')}</div>
<div className="flex shadow-md rounded-3xl ml-2">
<select
{...register('region', {
valueAsNumber: true,
})}
>
<option value={Protobuf.RegionCode.ANZ}>
{Protobuf.RegionCode[Protobuf.RegionCode.ANZ]}
</option>
<option value={Protobuf.RegionCode.CN}>
{Protobuf.RegionCode[Protobuf.RegionCode.CN]}
</option>
<option value={Protobuf.RegionCode.EU433}>
{Protobuf.RegionCode[Protobuf.RegionCode.EU433]}
</option>
<option value={Protobuf.RegionCode.EU865}>
{Protobuf.RegionCode[Protobuf.RegionCode.EU865]}
</option>
<option value={Protobuf.RegionCode.JP}>
{Protobuf.RegionCode[Protobuf.RegionCode.JP]}
</option>
<option value={Protobuf.RegionCode.KR}>
{Protobuf.RegionCode[Protobuf.RegionCode.KR]}
</option>
<option value={Protobuf.RegionCode.TW}>
{Protobuf.RegionCode[Protobuf.RegionCode.TW]}
</option>
<option value={Protobuf.RegionCode.US}>
{Protobuf.RegionCode[Protobuf.RegionCode.US]}
</option>
<option value={Protobuf.RegionCode.Unset}>
{Protobuf.RegionCode[Protobuf.RegionCode.Unset]}
</option>
</select>
</div>
</div>
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
<div className="my-auto">{t('strings.wifi_ssid')}</div>
<div className="flex shadow-md rounded-3xl ml-2">
<input {...register('wifiSsid', {})} type="text" />
</div>
</div>
<div className="flex bg-gray-50 whitespace-nowrap p-3 justify-between border-b">
<div className="my-auto">{t('strings.wifi_psk')}</div>
<div className="flex shadow-md rounded-3xl ml-2">
<input {...register('wifiPassword', {})} type="password" />
</div>
</div>
<div className="flex bg-gray-100 group p-1 cursor-pointer hover:bg-gray-200 border-b">
<button
type="submit"
className="flex m-auto font-medium group-hover:text-gray-700"
>
<SaveIcon className="m-auto mr-2 group-hover:text-gray-700 w-5 h-5" />
{t('strings.save_changes')}
</button>
</div>
</form>
);
};
-20
View File
@@ -1,20 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { UsersIcon } from '@heroicons/react/outline';
import { Dropdown } from '../../basic/Dropdown';
import { NodeList } from './NodeList';
export const Nodes = (): JSX.Element => {
const { t } = useTranslation();
return (
<Dropdown
icon={<UsersIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
title={t('strings.nodes')}
content={<NodeList />}
fallbackMessage={t('placeholder.no_messages')}
/>
);
};
-94
View File
@@ -1,94 +0,0 @@
import React from 'react';
import Avatar from 'boring-avatars';
import { Disclosure } from '@headlessui/react';
import {
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
FlagIcon,
GlobeIcon,
LightningBoltIcon,
} from '@heroicons/react/outline';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { useAppSelector } from '../../../hooks/redux';
export interface NodeProps {
node: Protobuf.NodeInfo;
}
export const Node = (props: NodeProps): JSX.Element => {
const myNodeInfo = useAppSelector((state) => state.meshtastic.myNodeInfo);
return (
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
<div className="flex ml-4">
{open ? (
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
) : (
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
)}
<div className="relative">
{props.node.num === myNodeInfo.myNodeNum ? (
<FlagIcon className="absolute -right-1 -top-2 text-yellow-500 my-auto w-4 h-4" />
) : null}
<Avatar
size={30}
name={props.node.user?.longName ?? 'Unknown'}
variant="beam"
colors={[
'#213435',
'#46685B',
'#648A64',
'#A6B985',
'#E1E3AC',
]}
/>
</div>
{props.node.user?.longName}
</div>
</Disclosure.Button>
<Disclosure.Panel>
<div className="border-b bg-gray-100 px-2">
<p>{props.node.snr}</p>
<p>
{`Last heard: ${
props.node?.lastHeard
? new Date(props.node.lastHeard).toLocaleString()
: 'Unknown'
}`}{' '}
{}
</p>
<div className="flex">
<GlobeIcon className="my-auto mr-2 w-5 h-5" />
<p>
{props.node.position?.latitudeI &&
props.node.position?.longitudeI
? `${props.node.position.latitudeI / 1e7},
${props.node.position.longitudeI / 1e7}`
: 'Unknown'}
, El:
{props.node.position?.altitude}
</p>
</div>
<div className="flex">
<ClockIcon className="my-auto mr-2 w-5 h-5" />
<p>{props.node.position?.time}</p>
</div>
<div className="flex">
<LightningBoltIcon className="my-auto mr-2 w-5 h-5" />
<p>{props.node.position?.batteryLevel}</p>
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};
-16
View File
@@ -1,16 +0,0 @@
import React from 'react';
import { useAppSelector } from '../../../hooks/redux';
import { Node } from './Node';
export const NodeList = (): JSX.Element => {
const nodes = useAppSelector((state) => state.meshtastic.nodes);
return (
<>
{nodes.map((node, index) => (
<Node key={index} node={node} />
))}
</>
);
};
-21
View File
@@ -1,21 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CogIcon } from '@heroicons/react/outline';
import { Dropdown } from '../../basic/Dropdown';
import { Translations } from './Translations';
export const UI = (): JSX.Element => {
const { t } = useTranslation();
return (
<Dropdown
icon={<CogIcon className="my-auto text-gray-600 mr-2 w-5 h-5" />}
title={t('settings.ui')}
content={<Translations />}
fallbackMessage={'Loading...'}
/>
);
};
@@ -1,65 +0,0 @@
import React from 'react';
import { Br, Jp, Us } from 'react-flags-select';
import { useTranslation } from 'react-i18next';
import { Disclosure } from '@headlessui/react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/outline';
export const Translations = (): JSX.Element => {
const { t } = useTranslation();
return (
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="flex bg-gray-50 w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer">
<div className="flex ml-4">
{open ? (
<ChevronDownIcon className="my-auto w-5 h-5 mr-2" />
) : (
<ChevronRightIcon className="my-auto w-5 h-5 mr-2" />
)}
{t('strings.language')}
{/* <div className="my-auto">
{language === LanguageEnum.ENGLISH ? (
<Us className="ml-2 w-8" />
) : language === LanguageEnum.JAPANESE ? (
<Jp className="ml-2 w-8" />
) : language === LanguageEnum.PORTUGUESE ? (
<Br className="ml-2 w-8" />
) : null}
</div> */}
</div>
</Disclosure.Button>
<Disclosure.Panel>
<div
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
onClick={() => {
// setLanguage(LanguageEnum.ENGLISH);
}}
>
English <Us className="w-8 my-auto" />
</div>
<div
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
onClick={() => {
// setLanguage(LanguageEnum.PORTUGUESE);
}}
>
Português <Br className="w-8 my-auto" />
</div>
<div
className="flex bg-gray-100 hover:bg-gray-200 cursor-pointer justify-between p-2"
onClick={() => {
// setLanguage(LanguageEnum.JAPANESE);
}}
>
日本語 <Jp className="w-8 my-auto" />
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};
-73
View File
@@ -1,73 +0,0 @@
import React from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Disclosure } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/outline';
interface DropdownProps {
icon: JSX.Element;
title: string;
content: JSX.Element;
fallbackMessage: string;
}
export const Dropdown = (props: DropdownProps): JSX.Element => {
return (
<Disclosure>
{({ open }) => (
<>
<Disclosure.Button className="flex w-full text-lg font-medium justify-between p-3 border-b hover:bg-gray-200 cursor-pointer first:rounded-t-3xl last:rounded-b-3xl">
<div className="flex">
<motion.div
className="my-auto mr-2"
variants={{
initial: { rotate: -90 },
animate: {
rotate: 0,
},
}}
initial="initial"
animate={open ? 'animate' : 'initial'}
>
<ChevronDownIcon className="w-5 h-5" />
</motion.div>
{props.icon}
{props.title}
</div>
</Disclosure.Button>
<AnimatePresence>
{open && (
<Disclosure.Panel
as={motion.div}
initial={{
height: 0,
}}
animate={{
height: 'auto',
}}
exit={{
height: 0,
}}
className="shadow-inner"
>
<React.Suspense
fallback={
<div className="flex border-b border-gray-300">
<div className="m-auto p-3 text-gray-500">
{props.fallbackMessage}
</div>
</div>
}
>
{props.content}
</React.Suspense>
</Disclosure.Panel>
)}
</AnimatePresence>
</>
)}
</Disclosure>
);
};
-31
View File
@@ -1,31 +0,0 @@
import React from 'react';
import { Switch } from '@headlessui/react';
interface ToggleSwitchProps {
active: boolean;
}
export const ToggleSwitch = (props: ToggleSwitchProps): JSX.Element => {
const [active, setActive] = React.useState(false);
React.useEffect(() => {
setActive(props.active);
}, []);
return (
<Switch
checked={active}
onChange={setActive}
className={`w-12 h-6 flex items-center bg-gray-300 rounded-full p-1 duration-300 ease-in-out my-auto ${
active ? 'bg-green-400' : null
}`}
>
<span
className={`bg-white w-4 h-4 rounded-full shadow-md transform duration-300 ease-in-out ${
active ? 'translate-x-6' : null
}`}
></span>
</Switch>
);
};
+56
View File
@@ -0,0 +1,56 @@
import 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>
);
};
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@app/hooks/redux';
import { Button } from '@components/generic/Button';
import { Input } from '@components/generic/Input';
import { connection } from '@core/connection';
import {
EmojiHappyIcon,
PaperAirplaneIcon,
PaperClipIcon,
} from '@heroicons/react/outline';
export const MessageBar = (): JSX.Element => {
const ready = useAppSelector((state) => state.meshtastic.ready);
const [currentMessage, setCurrentMessage] = React.useState('');
const sendMessage = (): void => {
if (ready) {
void connection.sendText(currentMessage, undefined, true);
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">
<div className="flex">
<Button icon={<EmojiHappyIcon className="w-5 h-5" />} circle />
<Button icon={<PaperClipIcon className="w-5 h-5" />} circle />
</div>
<form
className="flex w-full space-x-2"
onSubmit={(e): void => {
e.preventDefault();
sendMessage();
}}
>
<Input
type="text"
minLength={2}
placeholder={`${t('placeholder.message')}...`}
disabled={!ready}
value={currentMessage}
onChange={(e): void => {
setCurrentMessage(e.target.value);
}}
/>
<Button
icon={<PaperAirplaneIcon className="w-5 h-5" />}
type="submit"
circle
/>
</form>
</div>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
type DefaultDivProps = JSX.IntrinsicElements['div'];
interface LocalBlurProps {
disableOnMd?: boolean;
}
export type BlurProps = LocalBlurProps & DefaultDivProps;
export const Blur = ({
disableOnMd,
className,
onClick,
...props
}: BlurProps): JSX.Element => {
return (
<div
className={`absolute inset-0 z-10 w-full h-full transition-opacity ${
disableOnMd ? 'md:hidden' : 'test'
} ${className}`}
{...props}
>
<div
onClick={onClick}
className={`absolute inset-0 w-full h-full backdrop-filter backdrop-blur-sm ${
disableOnMd ? 'md:hidden' : 'test'
}`}
tabIndex={0}
></div>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
type DefaultButtonProps = JSX.IntrinsicElements['button'];
interface LocalButtonProps {
icon?: JSX.Element;
circle?: boolean;
active?: boolean;
border?: boolean;
}
export type ButtonProps = LocalButtonProps & DefaultButtonProps;
export const Button = ({
icon,
circle,
className,
active,
border,
disabled,
children,
...props
}: ButtonProps): JSX.Element => {
return (
<button
className={`items-center select-none flex dark:text-white ${
active && !disabled ? 'bg-gray-100 dark:bg-gray-700' : ''
} ${
circle ? 'rounded-full h-10 w-10' : 'rounded-md p-3 space-x-3 text-sm'
} ${
disabled
? 'cursor-not-allowed dark:bg-primaryDark bg-white'
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md'
} ${border ? 'border dark:border-gray-600' : ''} ${className}`}
{...props}
>
{icon && (
<div
className={`text-gray-500 dark:text-gray-400 ${
circle ? 'mx-auto' : ''
}`}
>
{icon}
</div>
)}
<span>{children}</span>
</button>
);
};
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { Blur } from '@components/generic/Blur';
type DefaultAsideProps = JSX.IntrinsicElements['aside'];
interface LocalDrawerProps {
open: boolean;
permenant?: boolean;
onClose: () => void;
}
export type DrawerProps = LocalDrawerProps & DefaultAsideProps;
export const Drawer = ({
open,
permenant,
onClose,
children,
...props
}: DrawerProps): JSX.Element => {
return (
<>
{open && <Blur 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 h-full overflow-auto ease-in-out transition-all duration-300 z-30 ${
permenant ? '' : 'absolute'
} ${open ? 'translate-x-0' : '-translate-x-full'}`}
{...props}
>
{children}
</aside>
</>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
type DefaultInputProps = JSX.IntrinsicElements['input'];
interface LocalInputProps {
icon?: JSX.Element;
label?: string;
valid?: boolean;
validationMessage?: string;
}
export type InputProps = LocalInputProps & DefaultInputProps;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
function Input(
{ icon, label, valid, validationMessage, id, ...props }: InputProps,
ref,
) {
return (
<div className="w-full">
<label
htmlFor={id}
className="block text-sm font-medium dark:text-white"
>
{label}
</label>
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 flex items-center px-3 pointer-events-none">
{React.cloneElement(icon, {
className: 'w-5 h-5 text-gray-500 dark:text-gray-600',
})}
</div>
)}
<input
id={id}
ref={ref}
{...props}
className={`block w-full h-11 rounded-md border shadow-sm focus:outline-none focus:border-primary dark:focus:border-primary bg-white dark:bg-secondaryDark dark:border-gray-600 dark:text-white ${
icon ? 'pl-9' : 'pl-2'
}`}
/>
</div>
{!valid && (
<div className="text-sm text-gray-600">{validationMessage}</div>
)}
</div>
);
},
);
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { Listbox } from '@headlessui/react';
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid';
export interface SelectProps {
label: string;
options: {
name: string;
value: string;
icon: JSX.Element;
}[];
id: string;
value: string;
onChange: (value: string) => void;
}
export const Select = ({
label,
options,
id,
value,
onChange,
}: SelectProps): JSX.Element => {
return (
<div className="w-full">
<label htmlFor={id} className="block text-sm font-medium dark:text-white">
{label}
</label>
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative w-full text-left bg-white border rounded-md shadow-sm h-11 focus:outline-none focus:border-primary dark:focus:border-primary dark:bg-secondaryDark dark:border-gray-600 dark:text-white">
<span className="block truncate">{value}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon
className="w-5 h-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Listbox.Options className="absolute w-full bg-white border rounded-md shadow-sm focus:outline-none dark:bg-secondaryDark dark:border-gray-600 dark:text-white">
{options.map((option) => (
<Listbox.Option
key={option.value}
className={({ active }): string =>
`cursor-default select-none relative py-2 pl-10 pr-4 first:rounded-t-md last:rounded-b-md dark:text-white ${
active ? 'bg-gray-200 dark:bg-primaryDark' : 'text-gray-900'
}`
}
value={option.value}
>
{({ selected, active }): JSX.Element => (
<>
<span
className={`${
selected ? 'font-medium' : 'font-normal'
} block truncate`}
>
{option.name}
</span>
{selected ? (
<span
className={`${
active ? 'text-amber-600' : 'text-amber-600'
}
absolute inset-y-0 left-0 flex items-center pl-3`}
>
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
type DefaultDivProps = JSX.IntrinsicElements['div'];
interface LocalSidebarItemProps {
title: string;
description: string;
selected: boolean;
icon: JSX.Element;
}
export type SidebarItemProps = LocalSidebarItemProps & DefaultDivProps;
export const SidebarItem = ({
title,
description,
selected,
icon,
}: SidebarItemProps): JSX.Element => {
return (
<div
className={`flex p-5 cursor-pointer select-none dark:hover:bg-primaryDark ${
selected ? 'bg-gray-200 dark:bg-primaryDark' : 'dark:bg-secondaryDark'
}`}
>
<div className="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>
);
};
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
export const Logo = (): JSX.Element => {
return (
<>
<img
title="Logo"
className="w-16 dark:hidden"
src="Mesh_Logo_Black.svg"
/>
<img
title="Logo"
className="hidden w-16 dark:flex"
src="Mesh_Logo_White.svg"
/>
</>
);
};
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
import { Drawer } from '@components/generic/Drawer';
import { closeMobileNav } from '@core/slices/appSlice';
import { Logo } from './Logo';
import { Navigation } from './Navigation';
export const MobileNav = (): JSX.Element => {
const dispatch = useAppDispatch();
const mobileNavOpen = useAppSelector((state) => state.app.mobileNavOpen);
return (
<Drawer
open={mobileNavOpen}
onClose={(): void => {
dispatch(closeMobileNav());
}}
>
<div className="flex flex-col w-64">
<div className="m-auto my-6">
<Logo />
</div>
<Navigation
onClick={(): void => {
dispatch(closeMobileNav());
}}
/>
</div>
</Drawer>
);
};
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { Button } from '@components/generic/Button';
import { routes, useRoute } from '@core/router';
import {
AnnotationIcon,
CogIcon,
InformationCircleIcon,
ViewGridIcon,
} from '@heroicons/react/outline';
type DefaultDivProps = JSX.IntrinsicElements['div'];
export type NavigationProps = DefaultDivProps;
export const Navigation = ({
onClick,
className,
...props
}: NavigationProps): JSX.Element => {
const route = useRoute();
return (
<div
className={`h-16 px-4 md:space-x-2 space-y-2 md:space-y-0 ${className}`}
{...props}
>
<div onClick={onClick}>
<Button
icon={<AnnotationIcon className="w-6 h-6" />}
active={route.name === 'messages'}
className="w-full md:w-auto"
{...routes.messages().link}
>
Messages
</Button>
</div>
<div onClick={onClick}>
<Button
icon={<ViewGridIcon className="w-6 h-6" />}
className="w-full md:w-auto"
active={route.name === 'nodes'}
{...routes.nodes().link}
>
Nodes
</Button>
</div>
<div onClick={onClick}>
<Button
icon={<CogIcon className="w-6 h-6" />}
className="w-full md:w-auto"
active={route.name === 'settings'}
{...routes.settings().link}
>
Settings
</Button>
</div>
<div onClick={onClick}>
<Button
icon={<InformationCircleIcon className="w-6 h-6" />}
className="w-full md:w-auto"
active={route.name === 'about'}
{...routes.about().link}
>
About
</Button>
</div>
</div>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
import { useAppSelector } from '@app/hooks/redux';
import { Button } from '@components/generic/Button';
import { SwitchVerticalIcon } from '@heroicons/react/outline';
export const DeviceStatusDropdown = (): JSX.Element => {
const ready = useAppSelector((state) => state.meshtastic.ready);
return (
<Button
icon={
<SwitchVerticalIcon
className={`h-6 w-6 ${!ready ? 'animate-pulse' : ''}`}
/>
}
circle
/>
);
};
@@ -0,0 +1,23 @@
import React from 'react';
import { Button } from '@components/generic/Button';
import { openMobileNav } from '@core/slices/appSlice';
import { MenuIcon } from '@heroicons/react/outline';
import { useAppDispatch } from '../../../hooks/redux';
export const MobileNavToggle = (): JSX.Element => {
const dispatch = useAppDispatch();
return (
<div className="md:hidden">
<Button
icon={<MenuIcon className="w-5 h-5" />}
onClick={(): void => {
dispatch(openMobileNav());
}}
circle
/>
</div>
);
};
@@ -0,0 +1,27 @@
import React from 'react';
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
import { Button } from '@components/generic/Button';
import { setDarkModeEnabled } from '@core/slices/appSlice';
import { MoonIcon, SunIcon } from '@heroicons/react/outline';
export const ThemeToggle = (): JSX.Element => {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.app.darkMode);
return (
<Button
icon={
darkMode ? (
<SunIcon className="w-5 h-5" />
) : (
<MoonIcon className="w-5 h-5" />
)
}
circle
onClick={(): void => {
dispatch(setDarkModeEnabled(!darkMode));
}}
/>
);
};
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import Avatar from 'boring-avatars';
import type { Protobuf } from '@meshtastic/meshtasticjs';
type DefaultDivProps = JSX.IntrinsicElements['div'];
export interface NodeProps {
node: Protobuf.NodeInfo;
}
export const Node = ({
node,
...props
}: NodeProps & DefaultDivProps): JSX.Element => {
return (
<div
{...props}
className="flex space-x-4 items-center w-full rounded-md dark:bg-primaryDark shadow-md border dark:border-gray-600 p-2 mt-6 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-900"
>
<Avatar
size={30}
name={node.user?.longName ?? 'UNK'}
variant="beam"
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']}
/>
<div>{node.user?.longName}</div>
</div>
);
};
@@ -0,0 +1,45 @@
import React from 'react';
export interface PrimaryTemplateProps {
children: React.ReactNode;
title: string;
tagline: string;
button?: JSX.Element;
footer?: JSX.Element;
}
export const PrimaryTemplate = ({
children,
title,
tagline,
button,
footer,
}: PrimaryTemplateProps): JSX.Element => {
return (
<div className="flex flex-col flex-auto min-w-0">
<div className="flex p-6 bg-white border-b md:flex-row flex-0 md:items-center md:justify-between md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
{button && <div className="pr-2 m-auto md:hidden">{button}</div>}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center font-medium">
<div>
<a className="whitespace-nowrap text-primary">{tagline}</a>
</div>
</div>
<div className="mt-2">
<h2 className="text-3xl font-extrabold leading-7 tracking-tight truncate md:text-4xl md:leading-10 dark:text-white">
{title}
</h2>
</div>
</div>
</div>
<div className="flex-auto flex-grow p-6 md:p-10">{children}</div>
{footer && (
<div className="flex p-6 bg-white border-t md:flex-row flex-0 md:items-center md:justify-between md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark">
{button && <div className="pr-2 m-auto md:hidden">{button}</div>}
<div className="flex-1 min-w-0">{footer}</div>
</div>
)}
</div>
);
};