Browse Source
* WIP * WIP * WIP * WIP * Draw improvements, disabled user zooming * Update status indicator * Add dynamic channel name with icon * Add more settings * WIP * WIPpull/1/head
committed by
GitHub
70 changed files with 1916 additions and 1644 deletions
@ -1,22 +1,3 @@ |
|||||
{ |
{ |
||||
"root": true, |
"extends": ["@verypossible/eslint-config/react"] |
||||
"parser": "@typescript-eslint/parser", |
|
||||
"plugins": ["@typescript-eslint"], |
|
||||
"extends": [ |
|
||||
"eslint:recommended", |
|
||||
"plugin:@typescript-eslint/recommended", |
|
||||
"plugin:react-hooks/recommended", |
|
||||
"plugin:react/recommended", |
|
||||
"plugin:import/recommended", |
|
||||
"plugin:import/typescript", |
|
||||
"plugin:prettier/recommended" |
|
||||
], |
|
||||
"rules": { |
|
||||
"@typescript-eslint/consistent-type-imports": "error" |
|
||||
}, |
|
||||
"settings": { |
|
||||
"react": { |
|
||||
"version": "detect" |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
|
|||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@ -1,63 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
|
|
||||
import { useTranslation } from 'react-i18next'; |
|
||||
|
|
||||
import type { Types } from '@meshtastic/meshtasticjs'; |
|
||||
|
|
||||
import { ChatMessage } from './components/ChatMessage'; |
|
||||
import { MessageBox } from './components/MessageBox'; |
|
||||
import { Sidebar } from './components/Sidebar'; |
|
||||
import { connection } from './connection'; |
|
||||
|
|
||||
export const Main = (): JSX.Element => { |
|
||||
const [messages, setMessages] = React.useState< |
|
||||
{ message: Types.TextPacket; ack: boolean }[] |
|
||||
>([]); |
|
||||
const { t } = useTranslation(); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
connection.onTextPacket.subscribe((message) => { |
|
||||
setMessages((messages) => [ |
|
||||
...messages, |
|
||||
{ message: message, ack: false }, |
|
||||
]); |
|
||||
}); |
|
||||
}, []); |
|
||||
|
|
||||
React.useEffect(() => { |
|
||||
connection.onRoutingPacket.subscribe((routingPacket) => { |
|
||||
setMessages( |
|
||||
messages.map((message) => { |
|
||||
return routingPacket.packet.payloadVariant.oneofKind === 'decoded' && |
|
||||
message.message.packet.id === |
|
||||
routingPacket.packet.payloadVariant.decoded.requestId |
|
||||
? { |
|
||||
ack: true, |
|
||||
message: message.message, |
|
||||
} |
|
||||
: message; |
|
||||
}), |
|
||||
); |
|
||||
}); |
|
||||
}, [messages]); |
|
||||
|
|
||||
return ( |
|
||||
<div className="flex flex-col md:flex-row flex-grow m-3 space-y-2 md:space-y-0 space-x-0 md:space-x-2"> |
|
||||
<div className="flex flex-col flex-grow container mx-auto"> |
|
||||
<div className="flex flex-col flex-grow py-6 space-y-2"> |
|
||||
{messages.length ? ( |
|
||||
messages.map((message, Main) => ( |
|
||||
<ChatMessage key={Main} message={message} /> |
|
||||
)) |
|
||||
) : ( |
|
||||
<div className="m-auto text-2xl text-gray-500"> |
|
||||
{t('placeholder.no_messages')} |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
<MessageBox /> |
|
||||
</div> |
|
||||
<Sidebar /> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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} />; |
|
||||
})} |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -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...'} |
|
||||
/> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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')} |
|
||||
/> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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} /> |
|
||||
))} |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
|
||||
); |
|
||||
}; |
|
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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" |
||||
|
/> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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)); |
||||
|
}} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,8 @@ |
|||||
|
import { createRouter, defineRoute } from 'type-route'; |
||||
|
|
||||
|
export const { RouteProvider, useRoute, routes } = createRouter({ |
||||
|
messages: defineRoute('/'), |
||||
|
nodes: defineRoute('/nodes'), |
||||
|
settings: defineRoute('/settings'), |
||||
|
about: defineRoute('/about'), |
||||
|
}); |
||||
@ -0,0 +1,45 @@ |
|||||
|
import type { PayloadAction } from '@reduxjs/toolkit'; |
||||
|
import { createSlice } from '@reduxjs/toolkit'; |
||||
|
|
||||
|
export type currentPageName = 'messages' | 'settings'; |
||||
|
|
||||
|
interface AppState { |
||||
|
mobileNavOpen: boolean; |
||||
|
darkMode: boolean; |
||||
|
currentPage: currentPageName; |
||||
|
} |
||||
|
|
||||
|
const initialState: AppState = { |
||||
|
mobileNavOpen: false, |
||||
|
darkMode: localStorage.getItem('darkMode') === 'true' ?? false, |
||||
|
currentPage: 'messages', |
||||
|
}; |
||||
|
|
||||
|
export const appSlice = createSlice({ |
||||
|
name: 'app', |
||||
|
initialState, |
||||
|
reducers: { |
||||
|
openMobileNav(state) { |
||||
|
state.mobileNavOpen = true; |
||||
|
}, |
||||
|
closeMobileNav(state) { |
||||
|
state.mobileNavOpen = false; |
||||
|
}, |
||||
|
setDarkModeEnabled(state, action: PayloadAction<boolean>) { |
||||
|
localStorage.setItem('darkMode', String(action.payload)); |
||||
|
state.darkMode = action.payload; |
||||
|
}, |
||||
|
setCurrentPage(state, action: PayloadAction<currentPageName>) { |
||||
|
state.currentPage = action.payload; |
||||
|
}, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
export const { |
||||
|
openMobileNav, |
||||
|
closeMobileNav, |
||||
|
setDarkModeEnabled, |
||||
|
setCurrentPage, |
||||
|
} = appSlice.actions; |
||||
|
|
||||
|
export default appSlice.reducer; |
||||
@ -0,0 +1,21 @@ |
|||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */ |
||||
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ |
||||
|
import useBreakpointHook from 'use-breakpoint'; |
||||
|
|
||||
|
const BREAKPOINTS = { |
||||
|
sm: 640, |
||||
|
// => @media (min-width: 640px) { ... }
|
||||
|
|
||||
|
md: 768, |
||||
|
// => @media (min-width: 768px) { ... }
|
||||
|
|
||||
|
lg: 1024, |
||||
|
// => @media (min-width: 1024px) { ... }
|
||||
|
|
||||
|
xl: 1280, |
||||
|
// => @media (min-width: 1280px) { ... }
|
||||
|
|
||||
|
'2xl': 1536, |
||||
|
// => @media (min-width: 1536px) { ... }
|
||||
|
}; |
||||
|
export const useBreakpoint = () => useBreakpointHook(BREAKPOINTS); |
||||
@ -1,7 +1,9 @@ |
|||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */ |
||||
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ |
||||
import type { TypedUseSelectorHook } from 'react-redux'; |
import type { TypedUseSelectorHook } from 'react-redux'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
import { useDispatch, useSelector } from 'react-redux'; |
||||
|
|
||||
import type { AppDispatch, RootState } from '../store'; |
import type { AppDispatch, RootState } from '@core/store'; |
||||
|
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>(); |
export const useAppDispatch = () => useDispatch<AppDispatch>(); |
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; |
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; |
||||
|
|||||
@ -0,0 +1,11 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
||||
|
|
||||
|
export const About = (): JSX.Element => { |
||||
|
return ( |
||||
|
<PrimaryTemplate title="meshtastic-web" tagline="About"> |
||||
|
<p>Content</p> |
||||
|
</PrimaryTemplate> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,55 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { Message } from '@components/chat/Message'; |
||||
|
import { MessageBar } from '@components/chat/MessageBar'; |
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { HashtagIcon, MapIcon, UsersIcon } from '@heroicons/react/outline'; |
||||
|
import { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
import { useAppSelector } from '../hooks/redux'; |
||||
|
|
||||
|
export const Messages = (): JSX.Element => { |
||||
|
const messages = useAppSelector((state) => state.meshtastic.messages); |
||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
|
const channels = useAppSelector((state) => state.meshtastic.channels); |
||||
|
|
||||
|
const channelName = (): string => { |
||||
|
const name = |
||||
|
channels.find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY) |
||||
|
?.settings?.name ?? 'Unknown'; |
||||
|
|
||||
|
return name.length ? name : 'Default'; |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex flex-col w-full"> |
||||
|
<div className="flex justify-between w-full px-2 border-b dark:border-gray-600 dark:text-gray-300"> |
||||
|
<div className="flex my-auto text-sm"> |
||||
|
<HashtagIcon className="w-4 h-4 my-auto" /> |
||||
|
{channelName()} |
||||
|
</div> |
||||
|
<div className="flex"> |
||||
|
<Button icon={<MapIcon className="w-5 h-5" />} circle /> |
||||
|
|
||||
|
<Button icon={<UsersIcon className="w-5 h-5" />} circle /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex flex-col flex-grow p-6 space-y-2 overflow-y-auto bg-white border-b md:py-8 md:px-10 dark:border-gray-600 dark:bg-secondaryDark"> |
||||
|
{messages.map((message, index) => ( |
||||
|
<Message |
||||
|
key={index} |
||||
|
isSender={message.isSender} |
||||
|
message={message.message.data} |
||||
|
ack={message.ack} |
||||
|
rxTime={new Date()} |
||||
|
senderName={ |
||||
|
nodes.find((node) => node.num === message.message.packet.from) |
||||
|
?.user?.longName ?? 'UNK' |
||||
|
} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
<MessageBar /> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,92 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import Avatar from 'boring-avatars'; |
||||
|
|
||||
|
import { useBreakpoint } from '@app/hooks/breakpoint'; |
||||
|
import { useAppSelector } from '@app/hooks/redux'; |
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { Drawer } from '@components/generic/Drawer'; |
||||
|
import { SidebarItem } from '@components/generic/SidebarItem'; |
||||
|
import { Tab } from '@headlessui/react'; |
||||
|
import { XCircleIcon } from '@heroicons/react/outline'; |
||||
|
|
||||
|
import { Node } from './Node'; |
||||
|
|
||||
|
export const Nodes = (): JSX.Element => { |
||||
|
const [navOpen, setNavOpen] = React.useState(false); |
||||
|
|
||||
|
const { breakpoint } = useBreakpoint(); |
||||
|
|
||||
|
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
|
|
||||
|
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 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600"> |
||||
|
<div className="flex items-center justify-between m-8 mr-6 md:my-10"> |
||||
|
<div className="text-4xl font-extrabold leading-none tracking-tight"> |
||||
|
Nodes |
||||
|
</div> |
||||
|
<div className="md:hidden"> |
||||
|
<Button |
||||
|
icon={<XCircleIcon className="w-5 h-5" />} |
||||
|
circle |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(false); |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{nodes.map((node) => ( |
||||
|
<Tab |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(false); |
||||
|
}} |
||||
|
key={node.num} |
||||
|
> |
||||
|
{({ selected }): JSX.Element => ( |
||||
|
<SidebarItem |
||||
|
title={node.user?.longName ?? node.num.toString()} |
||||
|
description="Node info" |
||||
|
selected={selected} |
||||
|
icon={ |
||||
|
<Avatar |
||||
|
size={30} |
||||
|
name={node.user?.longName ?? node.num.toString()} |
||||
|
variant="beam" |
||||
|
colors={[ |
||||
|
'#213435', |
||||
|
'#46685B', |
||||
|
'#648A64', |
||||
|
'#A6B985', |
||||
|
'#E1E3AC', |
||||
|
]} |
||||
|
/> |
||||
|
} |
||||
|
/> |
||||
|
)} |
||||
|
</Tab> |
||||
|
))} |
||||
|
</Tab.List> |
||||
|
</Drawer> |
||||
|
<div className="w-full"> |
||||
|
<Tab.Panels> |
||||
|
{nodes.map((node) => ( |
||||
|
<Tab.Panel key={node.num}> |
||||
|
<Node navOpen={navOpen} setNavOpen={setNavOpen} node={node} /> |
||||
|
</Tab.Panel> |
||||
|
))} |
||||
|
</Tab.Panels> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Tab.Group> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,32 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
||||
|
import { MenuIcon } from '@heroicons/react/outline'; |
||||
|
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export interface NodeProps { |
||||
|
navOpen: boolean; |
||||
|
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>; |
||||
|
node: Protobuf.NodeInfo; |
||||
|
} |
||||
|
|
||||
|
export const Node = ({ navOpen, setNavOpen, node }: NodeProps): JSX.Element => { |
||||
|
return ( |
||||
|
<PrimaryTemplate |
||||
|
title={node.user?.longName ?? node.num.toString()} |
||||
|
tagline="Node" |
||||
|
button={ |
||||
|
<Button |
||||
|
icon={<MenuIcon className="w-5 h-5" />} |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(!navOpen); |
||||
|
}} |
||||
|
circle |
||||
|
/> |
||||
|
} |
||||
|
> |
||||
|
<div className="w-full max-w-3xl space-y-2 md:max-w-xl">Content</div> |
||||
|
</PrimaryTemplate> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,69 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useForm } from 'react-hook-form'; |
||||
|
import { useTranslation } from 'react-i18next'; |
||||
|
|
||||
|
import { connection } from '@app/core/connection'; |
||||
|
import { useAppSelector } from '@app/hooks/redux'; |
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { Input } from '@components/generic/Input'; |
||||
|
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
||||
|
import { MenuIcon, SaveIcon } from '@heroicons/react/outline'; |
||||
|
import type { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
|
export interface DeviceProps { |
||||
|
navOpen: boolean; |
||||
|
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>; |
||||
|
} |
||||
|
|
||||
|
export const Device = ({ navOpen, setNavOpen }: DeviceProps): JSX.Element => { |
||||
|
const { t } = useTranslation(); |
||||
|
const radioConfig = useAppSelector((state) => state.meshtastic.preferences); |
||||
|
|
||||
|
const { register, handleSubmit, formState } = |
||||
|
useForm<Protobuf.RadioConfig_UserPreferences>({ |
||||
|
defaultValues: radioConfig, |
||||
|
}); |
||||
|
|
||||
|
const onSubmit = handleSubmit((data) => { |
||||
|
void connection.setPreferences(data); |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<PrimaryTemplate |
||||
|
title="Device" |
||||
|
tagline="Settings" |
||||
|
button={ |
||||
|
<Button |
||||
|
icon={<MenuIcon className="w-5 h-5" />} |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(!navOpen); |
||||
|
}} |
||||
|
circle |
||||
|
/> |
||||
|
} |
||||
|
footer={ |
||||
|
<Button |
||||
|
className="px-10 ml-auto" |
||||
|
icon={<SaveIcon className="w-5 h-5" />} |
||||
|
disabled={!formState.isDirty} |
||||
|
active |
||||
|
border |
||||
|
> |
||||
|
{t('strings.save_changes')} |
||||
|
</Button> |
||||
|
} |
||||
|
> |
||||
|
<div className="w-full max-w-3xl space-y-2 md:max-w-xl"> |
||||
|
<form onSubmit={onSubmit}> |
||||
|
<Input label={t('strings.wifi_ssid')} {...register('wifiSsid')} /> |
||||
|
<Input |
||||
|
type="password" |
||||
|
label={t('strings.wifi_psk')} |
||||
|
{...register('wifiPassword')} |
||||
|
/> |
||||
|
</form> |
||||
|
</div> |
||||
|
</PrimaryTemplate> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,101 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useBreakpoint } from '@app/hooks/breakpoint'; |
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { Drawer } from '@components/generic/Drawer'; |
||||
|
import { SidebarItem } from '@components/generic/SidebarItem'; |
||||
|
import { Tab } from '@headlessui/react'; |
||||
|
import { |
||||
|
CollectionIcon, |
||||
|
DeviceMobileIcon, |
||||
|
WifiIcon, |
||||
|
XCircleIcon, |
||||
|
} from '@heroicons/react/outline'; |
||||
|
|
||||
|
import { Device } from './Device'; |
||||
|
import { Interface } from './Interface'; |
||||
|
import { Radio } from './Radio'; |
||||
|
|
||||
|
export const Settings = (): 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 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600"> |
||||
|
<div className="flex items-center justify-between m-8 mr-6 md:my-10"> |
||||
|
<div className="text-4xl font-extrabold leading-none tracking-tight"> |
||||
|
Settings |
||||
|
</div> |
||||
|
<div className="md:hidden"> |
||||
|
<Button |
||||
|
icon={<XCircleIcon className="w-5 h-5" />} |
||||
|
circle |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(false); |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
<Tab |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(false); |
||||
|
}} |
||||
|
> |
||||
|
{({ selected }): JSX.Element => ( |
||||
|
<SidebarItem |
||||
|
title="Device" |
||||
|
description="Device settings, such as device name and wifi settings" |
||||
|
selected={selected} |
||||
|
icon={<DeviceMobileIcon className="flex-shrink-0 w-6 h-6" />} |
||||
|
/> |
||||
|
)} |
||||
|
</Tab> |
||||
|
<Tab> |
||||
|
{({ selected }): JSX.Element => ( |
||||
|
<SidebarItem |
||||
|
title="Radio" |
||||
|
description="Adjust radio power and frequency settings" |
||||
|
selected={selected} |
||||
|
icon={<WifiIcon className="flex-shrink-0 w-6 h-6" />} |
||||
|
/> |
||||
|
)} |
||||
|
</Tab> |
||||
|
<Tab> |
||||
|
{({ selected }): JSX.Element => ( |
||||
|
<SidebarItem |
||||
|
title="Interface" |
||||
|
description="Change language and other UI settings" |
||||
|
selected={selected} |
||||
|
icon={<CollectionIcon className="flex-shrink-0 w-6 h-6" />} |
||||
|
/> |
||||
|
)} |
||||
|
</Tab> |
||||
|
</Tab.List> |
||||
|
</Drawer> |
||||
|
<div className="flex w-full"> |
||||
|
<Tab.Panels className="flex w-full"> |
||||
|
<Tab.Panel className="flex w-full"> |
||||
|
<Device navOpen={navOpen} setNavOpen={setNavOpen} /> |
||||
|
</Tab.Panel> |
||||
|
<Tab.Panel className="flex w-full"> |
||||
|
<Radio navOpen={navOpen} setNavOpen={setNavOpen} /> |
||||
|
</Tab.Panel> |
||||
|
<Tab.Panel className="flex w-full"> |
||||
|
<Interface navOpen={navOpen} setNavOpen={setNavOpen} /> |
||||
|
</Tab.Panel> |
||||
|
</Tab.Panels> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Tab.Group> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,76 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { Jp, Pt, Us } from 'react-flags-select'; |
||||
|
import { useTranslation } from 'react-i18next'; |
||||
|
|
||||
|
import { Select } from '@app/components/generic/Select'; |
||||
|
import i18n from '@app/core/translation'; |
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
||||
|
import { MenuIcon, SaveIcon } from '@heroicons/react/outline'; |
||||
|
|
||||
|
export interface InterfaceProps { |
||||
|
navOpen: boolean; |
||||
|
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>; |
||||
|
} |
||||
|
|
||||
|
export const Interface = ({ |
||||
|
navOpen, |
||||
|
setNavOpen, |
||||
|
}: InterfaceProps): JSX.Element => { |
||||
|
const { t } = useTranslation(); |
||||
|
|
||||
|
return ( |
||||
|
<PrimaryTemplate |
||||
|
title="Interface" |
||||
|
tagline="Settings" |
||||
|
button={ |
||||
|
<Button |
||||
|
icon={<MenuIcon className="w-5 h-5" />} |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(!navOpen); |
||||
|
}} |
||||
|
circle |
||||
|
/> |
||||
|
} |
||||
|
footer={ |
||||
|
<Button |
||||
|
className="px-10 ml-auto" |
||||
|
icon={<SaveIcon className="w-5 h-5" />} |
||||
|
active |
||||
|
border |
||||
|
> |
||||
|
{t('strings.save_changes')} |
||||
|
</Button> |
||||
|
} |
||||
|
> |
||||
|
<div className="w-full max-w-3xl space-y-2 md:max-w-xl"> |
||||
|
<Select |
||||
|
label="Language" |
||||
|
value={i18n.language} |
||||
|
onChange={(value): void => { |
||||
|
void i18n.changeLanguage(value); |
||||
|
}} |
||||
|
id="aaa" |
||||
|
options={[ |
||||
|
{ |
||||
|
name: 'English', |
||||
|
value: 'en', |
||||
|
icon: <Us className="w-6" />, |
||||
|
}, |
||||
|
{ |
||||
|
name: 'Português', |
||||
|
value: 'pt', |
||||
|
icon: <Pt className="w-6" />, |
||||
|
}, |
||||
|
{ |
||||
|
name: 'Japanese', |
||||
|
value: 'jp', |
||||
|
icon: <Jp className="w-6" />, |
||||
|
}, |
||||
|
]} |
||||
|
/> |
||||
|
</div> |
||||
|
</PrimaryTemplate> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,47 @@ |
|||||
|
import React from 'react'; |
||||
|
|
||||
|
import { useTranslation } from 'react-i18next'; |
||||
|
|
||||
|
import { Button } from '@components/generic/Button'; |
||||
|
import { Input } from '@components/generic/Input'; |
||||
|
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
||||
|
import { MenuIcon, SaveIcon } from '@heroicons/react/outline'; |
||||
|
|
||||
|
export interface RadioProps { |
||||
|
navOpen: boolean; |
||||
|
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>; |
||||
|
} |
||||
|
|
||||
|
export const Radio = ({ navOpen, setNavOpen }: RadioProps): JSX.Element => { |
||||
|
const { t } = useTranslation(); |
||||
|
|
||||
|
return ( |
||||
|
<PrimaryTemplate |
||||
|
title="Radio" |
||||
|
tagline="Settings" |
||||
|
button={ |
||||
|
<Button |
||||
|
icon={<MenuIcon className="w-5 h-5" />} |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(!navOpen); |
||||
|
}} |
||||
|
circle |
||||
|
/> |
||||
|
} |
||||
|
footer={ |
||||
|
<Button |
||||
|
className="px-10 ml-auto" |
||||
|
icon={<SaveIcon className="w-5 h-5" />} |
||||
|
active |
||||
|
border |
||||
|
> |
||||
|
{t('strings.save_changes')} |
||||
|
</Button> |
||||
|
} |
||||
|
> |
||||
|
<div className="w-full max-w-3xl space-y-2 md:max-w-xl"> |
||||
|
<Input label="test" /> |
||||
|
</div> |
||||
|
</PrimaryTemplate> |
||||
|
); |
||||
|
}; |
||||
@ -1,31 +0,0 @@ |
|||||
import { createSlice } from '@reduxjs/toolkit'; |
|
||||
|
|
||||
interface AppState { |
|
||||
sidebarOpen: boolean; |
|
||||
darkMode: boolean; |
|
||||
} |
|
||||
|
|
||||
const initialState: AppState = { |
|
||||
sidebarOpen: true, |
|
||||
darkMode: false, |
|
||||
}; |
|
||||
|
|
||||
export const appSlice = createSlice({ |
|
||||
name: 'app', |
|
||||
initialState, |
|
||||
reducers: { |
|
||||
openSidebar(state) { |
|
||||
state.sidebarOpen = true; |
|
||||
}, |
|
||||
closeSidebar(state) { |
|
||||
state.sidebarOpen = false; |
|
||||
}, |
|
||||
toggleSidebar(state) { |
|
||||
state.sidebarOpen = !state.sidebarOpen; |
|
||||
}, |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
export const { openSidebar, closeSidebar, toggleSidebar } = appSlice.actions; |
|
||||
|
|
||||
export default appSlice.reducer; |
|
||||
@ -1,22 +0,0 @@ |
|||||
{ |
|
||||
"errors": {}, |
|
||||
"placeholder": { |
|
||||
"message": "Enter Message", |
|
||||
"no_messages": "No messages yet", |
|
||||
"no_nodes": "No nodes found" |
|
||||
}, |
|
||||
"strings": { |
|
||||
"nodes": "Nodes", |
|
||||
"color_scheme": "Color scheme", |
|
||||
"language": "Language", |
|
||||
"device_region": "Device region", |
|
||||
"wifi_ssid": "WiFi SSID", |
|
||||
"wifi_psk": "WiFi PSK", |
|
||||
"save_changes": "Save changes" |
|
||||
}, |
|
||||
"settings": { |
|
||||
"ui": "UI Settings", |
|
||||
"device": "Device Settings", |
|
||||
"channel": "Channels" |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,22 @@ |
|||||
|
export const en = { |
||||
|
errors: {}, |
||||
|
placeholder: { |
||||
|
message: 'Enter Message', |
||||
|
no_messages: 'No messages yet', |
||||
|
no_nodes: 'No nodes found', |
||||
|
}, |
||||
|
strings: { |
||||
|
nodes: 'Nodes', |
||||
|
color_scheme: 'Color scheme', |
||||
|
language: 'Language', |
||||
|
device_region: 'Device region', |
||||
|
wifi_ssid: 'WiFi SSID', |
||||
|
wifi_psk: 'WiFi PSK', |
||||
|
save_changes: 'Save changes', |
||||
|
}, |
||||
|
settings: { |
||||
|
ui: 'UI Settings', |
||||
|
device: 'Device Settings', |
||||
|
channel: 'Channels', |
||||
|
}, |
||||
|
}; |
||||
@ -1,22 +0,0 @@ |
|||||
{ |
|
||||
"errors": {}, |
|
||||
"placeholder": { |
|
||||
"message": "メッセージを入力してください", |
|
||||
"no_messages": "まだメッセージはありません", |
|
||||
"no_nodes": "ノードが見つかりません" |
|
||||
}, |
|
||||
"strings": { |
|
||||
"nodes": "ノード", |
|
||||
"color_scheme": "カラースキーム", |
|
||||
"language": "言語", |
|
||||
"device_region": "デバイスリージョン", |
|
||||
"wifi_ssid": "WiFi名", |
|
||||
"wifi_psk": "WiFiパスワード", |
|
||||
"save_changes": "変更内容を保存" |
|
||||
}, |
|
||||
"settings": { |
|
||||
"ui": "UI設定", |
|
||||
"device": "デバイスの設定", |
|
||||
"channel": "#################" |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,22 @@ |
|||||
|
export const jp = { |
||||
|
errors: {}, |
||||
|
placeholder: { |
||||
|
message: 'メッセージを入力してください', |
||||
|
no_messages: 'まだメッセージはありません', |
||||
|
no_nodes: 'ノードが見つかりません', |
||||
|
}, |
||||
|
strings: { |
||||
|
nodes: 'ノード', |
||||
|
color_scheme: 'カラースキーム', |
||||
|
language: '言語', |
||||
|
device_region: 'デバイスリージョン', |
||||
|
wifi_ssid: 'WiFi名', |
||||
|
wifi_psk: 'WiFiパスワード', |
||||
|
save_changes: '変更内容を保存', |
||||
|
}, |
||||
|
settings: { |
||||
|
ui: 'UI設定', |
||||
|
device: 'デバイスの設定', |
||||
|
channel: '#################', |
||||
|
}, |
||||
|
}; |
||||
@ -1,22 +0,0 @@ |
|||||
{ |
|
||||
"errors": {}, |
|
||||
"placeholder": { |
|
||||
"message": "Entre mensagem", |
|
||||
"no_messages": "Não a mensagens ainda", |
|
||||
"no_nodes": "Nenhum nó foi encontrado" |
|
||||
}, |
|
||||
"strings": { |
|
||||
"nodes": "Nós", |
|
||||
"color_scheme": "Esquema de cores", |
|
||||
"language": "Idioma", |
|
||||
"device_region": "Região do dispositivo", |
|
||||
"wifi_ssid": "Nome do WiFi", |
|
||||
"wifi_psk": "Senha do WiFi", |
|
||||
"save_changes": "Salvar alterações" |
|
||||
}, |
|
||||
"settings": { |
|
||||
"ui": "Configurações da Interface", |
|
||||
"device": "Configurações do dispositivo", |
|
||||
"channel": "Canais" |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,22 @@ |
|||||
|
export const pt = { |
||||
|
errors: {}, |
||||
|
placeholder: { |
||||
|
message: 'Entre mensagem', |
||||
|
no_messages: 'Não a mensagens ainda', |
||||
|
no_nodes: 'Nenhum nó foi encontrado', |
||||
|
}, |
||||
|
strings: { |
||||
|
nodes: 'Nós', |
||||
|
color_scheme: 'Esquema de cores', |
||||
|
language: 'Idioma', |
||||
|
device_region: 'Região do dispositivo', |
||||
|
wifi_ssid: 'Nome do WiFi', |
||||
|
wifi_psk: 'Senha do WiFi', |
||||
|
save_changes: 'Salvar alterações', |
||||
|
}, |
||||
|
settings: { |
||||
|
ui: 'Configurações da Interface', |
||||
|
device: 'Configurações do dispositivo', |
||||
|
channel: 'Canais', |
||||
|
}, |
||||
|
}; |
||||
@ -1,12 +1,19 @@ |
|||||
module.exports = { |
module.exports = { |
||||
mode: 'jit', |
mode: 'jit', |
||||
purge: ['./public/**/*.html', './src/**/*.tsx'], |
purge: ['./public/**/*.html', './src/**/*.tsx'], |
||||
darkMode: false, // or 'media' or 'class'
|
darkMode: 'class', // or 'media' or 'class'
|
||||
theme: { |
theme: { |
||||
extend: {}, |
fontFamily: { |
||||
}, |
sans: 'Roboto', |
||||
variants: { |
}, |
||||
extend: {}, |
extend: { |
||||
|
colors: { |
||||
|
primary: '#67ea94', |
||||
|
primaryDark: '#1E293B', |
||||
|
secondaryDark: '#0F172A', |
||||
|
}, |
||||
|
}, |
||||
}, |
}, |
||||
|
variants: {}, |
||||
plugins: [], |
plugins: [], |
||||
}; |
}; |
||||
|
|||||
File diff suppressed because it is too large
Loading…
Reference in new issue