Browse Source

WIP

pull/1/head
Sacha Weatherstone 5 years ago
parent
commit
29fcc8051c
  1. 21
      .eslintrc
  2. 2
      README.md
  3. 23
      package.json
  4. 2
      postcss.config.js
  5. 10
      snowpack.config.mjs
  6. 99
      src/App.tsx
  7. 204
      src/components/TestForm.tsx
  8. 12
      src/components/chat/Message.tsx
  9. 72
      src/components/chat/MessageBar.tsx
  10. 52
      src/components/form/Input.tsx
  11. 38
      src/components/form/Select.tsx
  12. 40
      src/components/form/Switch.tsx
  13. 33
      src/components/generic/Blur.tsx
  14. 52
      src/components/generic/Button.tsx
  15. 35
      src/components/generic/Drawer.tsx
  16. 15
      src/components/generic/IconButton.tsx
  17. 50
      src/components/generic/Input.tsx
  18. 82
      src/components/generic/Select.tsx
  19. 33
      src/components/generic/SidebarItem.tsx
  20. 12
      src/components/menu/Logo.tsx
  21. 37
      src/components/menu/MenuButton.tsx
  22. 50
      src/components/menu/MobileNav.tsx
  23. 63
      src/components/menu/Navigation.tsx
  24. 29
      src/components/menu/buttons/DeviceStatusDropdown.tsx
  25. 66
      src/components/menu/buttons/LanguageDropdown.tsx
  26. 14
      src/components/menu/buttons/MobileNavToggle.tsx
  27. 27
      src/components/menu/buttons/ThemeToggle.tsx
  28. 44
      src/components/nodes/NodeDetails.tsx
  29. 18
      src/components/templates/PrimaryTemplate.tsx
  30. 28
      src/core/theme.ts
  31. 2
      src/core/translation.ts
  32. 21
      src/hooks/breakpoint.ts
  33. 4
      src/hooks/redux.ts
  34. 9
      src/index.tsx
  35. 4
      src/pages/About.tsx
  36. 26
      src/pages/Messages.tsx
  37. 45
      src/pages/Nodes.tsx
  38. 92
      src/pages/Nodes/Index.tsx
  39. 32
      src/pages/Nodes/Node.tsx
  40. 160
      src/pages/Settings.tsx
  41. 69
      src/pages/settings/Device.tsx
  42. 101
      src/pages/settings/Index.tsx
  43. 76
      src/pages/settings/Interface.tsx
  44. 47
      src/pages/settings/Radio.tsx
  45. 7
      tsconfig.json
  46. 720
      yarn.lock

21
.eslintrc

@ -1,22 +1,3 @@
{
"root": true,
"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"
}
}
"extends": ["@verypossible/eslint-config/react"]
}

2
README.md

@ -1,4 +1,4 @@
# Meshtastic.js
# Meshtastic Web
[![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)](https://open.vscode.dev/meshtastic/meshtastic-web)

23
package.json

@ -11,14 +11,10 @@
"lint": "eslint 'src/**/*.{ts,tsx}'"
},
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@headlessui/react": "^1.3.0",
"@headlessui/react": "^1.4.0",
"@heroicons/react": "^1.0.1",
"@material-ui/core": "^5.0.0-beta.3",
"@meshtastic/meshtasticjs": "^0.6.16",
"@reduxjs/toolkit": "^1.6.0",
"add": "^2.0.6",
"boring-avatars": "^1.5.8",
"i18next": "^20.3.5",
"i18next-browser-languagedetector": "^6.1.2",
@ -28,8 +24,8 @@
"react-hook-form": "^7.9.0",
"react-i18next": "^11.11.4",
"react-redux": "^7.2.4",
"react-select": "^5.0.0-beta.0",
"type-route": "^0.6.0",
"use-breakpoint": "^2.0.1",
"yarn": "^1.22.11"
},
"devDependencies": {
@ -37,23 +33,24 @@
"@snowpack/plugin-postcss": "^1.4.3",
"@snowpack/plugin-react-refresh": "^2.5.0",
"@snowpack/plugin-typescript": "^1.2.0",
"@types/eslint": "^7.2.13",
"@types/react": "^17.0.13",
"@types/react-dom": "^17.0.8",
"@types/react-redux": "^7.1.16",
"@types/snowpack-env": "^2.3.3",
"@typescript-eslint/eslint-plugin": "^4.28.1",
"@typescript-eslint/parser": "^4.28.1",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.29.1",
"@verypossible/eslint-config": "^1.6.0",
"autoprefixer": "^10.2.6",
"eslint": "^7.29.0",
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-prettier": "^3.4.0",
"eslint-import-resolver-babel-module": "^5.3.1",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-import": "^2.24.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"gzipper": "^5.0.0",
"postcss": "^8.3.5",
"postcss-cli": "^8.3.1",
"prettier": "^2.3.2",
"snowpack": "^3.7.1",
"tailwindcss": "^2.2.4",

2
postcss.config.js

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};

10
snowpack.config.mjs

@ -16,6 +16,16 @@ export default {
},
],
],
alias: {
// Type 1: Package Import Alias
// "lodash": "lodash-es",
// Type 2: Local Directory Import Alias (relative to cwd)
'@app': './src',
'@pages': './src/pages',
'@components': './src/components',
'@core': './src/core',
'@static': './src/static',
},
routes: [
/* Enable an SPA Fallback in development: */
// {"match": "routes", "src": ".*", "dest": "/index.html"},

99
src/App.tsx

@ -1,17 +1,14 @@
import React from 'react';
import { ThemeProvider } from '@material-ui/system';
import { Protobuf, SettingsManager, Types } from '@meshtastic/meshtasticjs';
import { DeviceStatusDropdown } from './components/menu/buttons/DeviceStatusDropdown';
import { LanguageDropdown } from './components/menu/buttons/LanguageDropdown';
import { MobileNavToggle } from './components/menu/buttons/MobileNavToggle';
import { ThemeToggle } from './components/menu/buttons/ThemeToggle';
import { Logo } from './components/menu/Logo';
import { MobileNav } from './components/menu/MobileNav';
import { Navigation } from './components/menu/Navigation';
import { connection } from './core/connection';
import { useRoute } from './core/router';
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
import { DeviceStatusDropdown } from '@components/menu/buttons/DeviceStatusDropdown';
import { MobileNavToggle } from '@components/menu/buttons/MobileNavToggle';
import { ThemeToggle } from '@components/menu/buttons/ThemeToggle';
import { Logo } from '@components/menu/Logo';
import { MobileNav } from '@components/menu/MobileNav';
import { Navigation } from '@components/menu/Navigation';
import { connection } from '@core/connection';
import { useRoute } from '@core/router';
import {
ackMessage,
addChannel,
@ -22,13 +19,12 @@ import {
setMyNodeInfo,
setPreferences,
setReady,
} from './core/slices/meshtasticSlice';
import { theme } from './core/theme';
import { useAppDispatch, useAppSelector } from './hooks/redux';
import { About } from './pages/About';
import { Messages } from './pages/Messages';
import { Nodes } from './pages/Nodes';
import { Settings } from './pages/Settings';
} from '@core/slices/meshtasticSlice';
import { Protobuf, SettingsManager, Types } from '@meshtastic/meshtasticjs';
import { About } from '@pages/About';
import { Messages } from '@pages/Messages';
import { Nodes } from '@pages/Nodes/Index';
import { Settings } from '@pages/settings/Index';
const App = (): JSX.Element => {
const dispatch = useAppDispatch();
@ -44,12 +40,14 @@ const App = (): JSX.Element => {
React.useEffect(() => {
SettingsManager.debugMode = Protobuf.LogRecord_Level.TRACE;
connection.connect({
void connection.connect({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
address: hostOverrideEnabled
? hostOverride
: import.meta.env.NODE_ENV === 'production'
? window.location.hostname
: import.meta.env.SNOWPACK_PUBLIC_DEVICE_IP,
: import.meta.env.SNOWPACK_PUBLIC_DEVICE_IP ??
'http://meshtastic.local',
receiveBatchRequests: false,
tls: false,
fetchInterval: 2000,
@ -116,7 +114,7 @@ const App = (): JSX.Element => {
}
});
return () => {
return (): void => {
connection.onDeviceStatus.cancelAll();
connection.onMyNodeInfo.cancelAll();
connection.onNodeInfoPacket.cancelAll();
@ -128,41 +126,40 @@ const App = (): JSX.Element => {
}, [dispatch, myNodeInfo.myNodeNum]);
return (
<ThemeProvider theme={theme(darkMode)}>
<div className={`h-screen w-screen ${darkMode ? 'dark' : ''}`}>
<div className="flex flex-col h-full w-full bg-gray-200 dark:bg-primaryDark">
<div className="flex flex-shrink-0 w-full overflow-hidden bg-primary dark:bg-primary">
<div className="w-full sm:py-3 sm:m-8 sm:mb-0 md:mt-12 md:mx-8 md:pt-4 md:pb-3 sm:rounded-t-xl border-b dark:border-gray-600 sm:shadow-md overflow-hidden bg-white dark:bg-primaryDark">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
<div className="hidden md:flex">
<Logo />
</div>
<MobileNavToggle />
<div className="flex items-center space-x-2">
<DeviceStatusDropdown />
<LanguageDropdown />
<ThemeToggle />
</div>
<div
className={`h-screen w-screen ${darkMode ? 'dark rs-theme-dark' : ''}`}
>
<div className="flex flex-col h-full bg-gray-200 dark:bg-primaryDark">
<div className="flex flex-shrink-0 overflow-hidden bg-primary dark:bg-primary">
<div className="w-full overflow-hidden bg-white border-b md:mt-12 md:mx-8 md:pt-4 md:pb-3 md:rounded-t-xl dark:border-gray-600 md:shadow-md dark:bg-primaryDark">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
<div className="hidden md:flex">
<Logo />
</div>
<Navigation />
</div>
</div>
<MobileNav />
<div className="flex flex-grow min-h-0 w-full sm:px-8 sm:mb-8">
<div className="flex w-full sm:shadow-xl sm:overflow-hidden bg-gray-100 dark:bg-secondaryDark sm:rounded-b-xl">
{route.name === 'messages' && <Messages />}
{route.name === 'nodes' && <Nodes />}
{route.name === 'settings' && <Settings />}
{route.name === 'about' && <About />}
{route.name === false && 'Not Found'}
<MobileNavToggle />
<div className="flex items-center space-x-2">
<DeviceStatusDropdown />
{/* <LanguageDropdown /> */}
<ThemeToggle />
</div>
</div>
<Navigation className="hidden md:flex" />
</div>
</div>
<MobileNav />
<div className="flex flex-grow w-full min-h-0 md:px-8 md:mb-8">
<div className="flex w-full bg-gray-100 md:shadow-xl md:overflow-hidden dark:bg-secondaryDark md:rounded-b-xl">
{route.name === 'messages' && <Messages />}
{route.name === 'nodes' && <Nodes />}
{route.name === 'settings' && <Settings />}
{route.name === 'about' && <About />}
{route.name === false && 'Not Found'}
</div>
</div>
</div>
</ThemeProvider>
</div>
);
};

204
src/components/TestForm.tsx

@ -1,204 +0,0 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import Button from '@material-ui/core/Button';
import Checkbox from '@material-ui/core/Checkbox';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Radio from '@material-ui/core/Radio';
import RadioGroup from '@material-ui/core/RadioGroup';
import Switch from '@material-ui/core/Switch';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
let renderCount = 0;
const options = [
{
value: 'chocolate',
label: 'Chocolate',
},
{
value: 'strawberry',
label: 'Strawberry',
},
{
value: 'vanilla',
label: 'Vanilla',
},
];
const defaultValues = {
Native: '',
TextField: '',
Select: '',
ReactSelect: '',
Checkbox: false,
switch: false,
RadioGroup: '',
};
export const TestForm = () => {
const { handleSubmit, register, reset, control, watch } = useForm({
defaultValues,
mode: 'onChange',
});
renderCount++;
const data = watch();
return (
<div className="flex w-full max-w-screen-md justify-start items-start">
<form
className="w-1/2"
onSubmit={handleSubmit((data) => console.info(data))}
>
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
Native Input:
</Typography>
<input
className="border-1 outline-none rounded-8 p-8"
{...register('Native')}
/>
</div>
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
MUI Checkbox
</Typography>
<Controller
name="Checkbox"
control={control}
defaultValue={false}
render={({ field: { onChange, value } }) => (
<Checkbox
checked={value}
onChange={(ev) => onChange(ev.target.checked)}
/>
)}
/>
</div>
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
Radio Group
</Typography>
<Controller
render={({ field }) => (
<RadioGroup {...field} aria-label="gender" name="gender1">
<FormControlLabel
value="female"
control={<Radio />}
label="Female"
/>
<FormControlLabel
value="male"
control={<Radio />}
label="Male"
/>
</RadioGroup>
)}
name="RadioGroup"
control={control}
/>
</div>
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
MUI TextField
</Typography>
<Controller
render={({ field }) => <TextField {...field} variant="outlined" />}
name="TextField"
control={control}
/>
</div>
{/* <div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
MUI Select
</Typography>
<Controller
render={({ field }) => (
<Select {...field} variant="outlined">
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
<MenuItem value={30}>Thirty</MenuItem>
</Select>
)}
name="Select"
control={control}
/>
</div> */}
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
MUI Switch
</Typography>
<Controller
name="switch"
control={control}
defaultValue={false}
render={({ field: { onChange, value } }) => (
<Switch
checked={value}
onChange={(ev) => onChange(ev.target.checked)}
/>
)}
/>
</div>
<div className="mt-48 mb-16">
<Typography className="mb-24 font-medium text-14">
React Select
</Typography>
{/* <Controller
render={({ field }) => <ReactSelect {...field} />}
options={options}
name="ReactSelect"
isClearable
control={control}
onChange={([selected]) => {
return { value: selected };
}}
/> */}
</div>
<div className="flex my-48 items-center">
<Button
className="mx-8"
variant="contained"
color="secondary"
type="submit"
>
Submit
</Button>
<Button
className="mx-8"
type="button"
onClick={() => {
reset(defaultValues);
}}
>
Reset Form
</Button>
</div>
</form>
<div className="w-1/2 my-48 p-24">
<pre className="language-js p-24 w-400">
{JSON.stringify(data, null, 2)}
</pre>
<Typography
className="mt-16 font-medium text-12 italic"
color="textSecondary"
>
Render Count: {renderCount}
</Typography>
</div>
</div>
);
};

12
src/components/chat/Message.tsx

@ -19,9 +19,13 @@ export const Message = ({
}: MessageProps): JSX.Element => {
return (
<div
className={`flex space-x-2 ${!isSender && 'ml-auto flex-row-reverse'}`}
className={`flex space-x-2 ${
!isSender ? 'ml-auto flex-row-reverse' : ''
}`}
>
<div className={`shadow-md rounded-full mt-auto ${!isSender && 'ml-2'}`}>
<div
className={`shadow-md rounded-full mt-auto ${!isSender ? 'ml-2' : ''}`}
>
<Avatar
size={30}
name={senderName ?? 'UNK'}
@ -37,11 +41,11 @@ export const Message = ({
: 'bg-primary text-blue-50 rounded-bl-lg'
} ${ack ? 'animate-none' : 'animate-pulse'}`}
>
<div className="min-w-4 leading-5">{message}</div>
<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 text-xs font-medium text-secondary mr-3 dark:text-gray-200">
<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',

72
src/components/chat/MessageBar.tsx

@ -2,59 +2,57 @@ 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';
import { connection } from '../../core/connection';
import { useAppSelector } from '../../hooks/redux';
import { IconButton } from '../generic/IconButton';
export const MessageBar = (): JSX.Element => {
const ready = useAppSelector((state) => state.meshtastic.ready);
const [currentMessage, setCurrentMessage] = React.useState('');
const sendMessage = () => {
const sendMessage = (): void => {
if (ready) {
connection.sendText(currentMessage, undefined, true);
void connection.sendText(currentMessage, undefined, true);
setCurrentMessage('');
}
};
const { t } = useTranslation();
return (
<div className="flex p-4 bg-gray-50 dark:bg-transparent space-x-2 text-gray-500 dark:text-gray-400">
<div className="flex">
<IconButton>
<EmojiHappyIcon className="w-6 h-6" />
</IconButton>
<IconButton>
<PaperClipIcon className="w-6 h-6" />
</IconButton>
</div>
<form
className="flex w-full space-x-2"
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
>
<input
type="text"
minLength={2}
placeholder={`${t('placeholder.message')}...`}
disabled={!ready}
value={currentMessage}
onChange={(e) => {
setCurrentMessage(e.target.value);
<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();
}}
className="focus:outline-none h-10 w-full resize-none rounded-full border border-gray-300 dark:bg-gray-900 px-4"
/>
<IconButton type="submit">
<PaperAirplaneIcon className="w-6 h-6 rotate-90" />
</IconButton>
</form>
>
<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>
);
};

52
src/components/form/Input.tsx

@ -1,52 +0,0 @@
import React from 'react';
type DefaultInputProps = JSX.IntrinsicElements['input'];
export interface InputProps {
valid?: boolean;
validationMessage?: string;
icon?: JSX.Element;
label: string;
}
export const Input = React.forwardRef<
HTMLInputElement,
InputProps & DefaultInputProps
>(function Input(
{
valid,
validationMessage,
icon,
label,
id,
...props
}: InputProps & DefaultInputProps,
ref,
) {
return (
<div className="space-y-1">
<label htmlFor={id} className="block text-sm font-medium dark:text-white">
{label}
</label>
<div className="relative">
{icon && (
<div className="flex absolute inset-y-0 left-0 px-3 items-center pointer-events-none">
{React.cloneElement(icon, {
className: 'w-5 h-5 text-gray-500 dark:text-gray-600',
})}
</div>
)}
<input
ref={ref}
{...props}
className={`block w-full h-11 rounded-md border shadow-sm focus:outline-none focus:border-primary dark:focus:border-primary 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>
);
});

38
src/components/form/Select.tsx

@ -1,38 +0,0 @@
import React from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import ReactSelect from 'react-select';
interface SelectProps<T> extends UseControllerProps<T> {
label: string;
options: {
value: string;
label: string;
}[];
}
export const Select = <T extends FieldValues>({
name,
control,
label,
options,
}: SelectProps<T>): JSX.Element => {
return (
<Controller
name={name}
control={control}
rules={{
required: 'This is required',
}}
render={({ field: { onChange, value, name } }) => (
<div className="space-y-1">
<span className="block text-sm font-medium dark:text-white">
{label}
</span>
<ReactSelect options={options} />
</div>
)}
/>
);
};

40
src/components/form/Switch.tsx

@ -1,40 +0,0 @@
import React from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import MaterialSwitch from '@material-ui/core/Switch';
interface SwitchProps<T> extends UseControllerProps<T> {
label: string;
}
export const Switch = <T extends FieldValues>({
name,
control,
label,
}: SwitchProps<T>): JSX.Element => {
return (
<Controller
name={name}
control={control}
rules={{
required: 'This is required',
}}
render={({ field: { onChange, value, name } }) => (
<div className="flex flex-col">
<span className="block text-sm font-medium dark:text-white">
{label}
</span>
<div className="relative w-14 mr-2 ml-auto select-none">
<MaterialSwitch
id={name}
checked={value}
onChange={(ev) => onChange(ev.target.checked)}
/>
</div>
</div>
)}
/>
);
};

33
src/components/generic/Blur.tsx

@ -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>
);
};

52
src/components/generic/Button.tsx

@ -1,28 +1,50 @@
import React from 'react';
import MaterialButton from '@material-ui/core/Button';
import type { ButtonProps as MaterialButtonProps } from '@material-ui/core/Button/Button';
type DefaultButtonProps = JSX.IntrinsicElements['button'];
interface LocalButtonProps {
text: string;
icon?: JSX.Element;
circle?: boolean;
active?: boolean;
border?: boolean;
}
export type ButtonProps = MaterialButtonProps & LocalButtonProps;
export type ButtonProps = LocalButtonProps & DefaultButtonProps;
export const Button = ({ text, icon, ...props }: ButtonProps): JSX.Element => {
export const Button = ({
icon,
circle,
className,
active,
border,
disabled,
children,
...props
}: ButtonProps): JSX.Element => {
return (
<MaterialButton
<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}
className="dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div className="flex p-3">
{icon &&
React.cloneElement(icon, {
className: 'h-6 w-6 mr-3 text-gray-500 dark:text-gray-400',
})}
<span>{text}</span>
</div>
</MaterialButton>
{icon && (
<div
className={`text-gray-500 dark:text-gray-400 ${
circle ? 'mx-auto' : ''
}`}
>
{icon}
</div>
)}
<span>{children}</span>
</button>
);
};

35
src/components/generic/Drawer.tsx

@ -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>
</>
);
};

15
src/components/generic/IconButton.tsx

@ -1,15 +0,0 @@
import React from 'react';
import MaterialIconButton from '@material-ui/core/IconButton';
import type { IconButtonProps } from '@material-ui/core/IconButton/IconButton';
export const IconButton = ({
children,
...props
}: IconButtonProps): JSX.Element => {
return (
<MaterialIconButton {...props} className="text-gray-500 dark:text-gray-400">
{children}
</MaterialIconButton>
);
};

50
src/components/generic/Input.tsx

@ -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
src/components/generic/Select.tsx

@ -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
src/components/generic/SidebarItem.tsx

@ -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>
);
};

12
src/components/menu/Logo.tsx

@ -3,8 +3,16 @@ import React from 'react';
export const Logo = (): JSX.Element => {
return (
<>
<img className="w-16 dark:hidden" src="Mesh_Logo_Black.svg" />
<img className="hidden dark:flex w-16" src="Mesh_Logo_White.svg" />
<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"
/>
</>
);
};

37
src/components/menu/MenuButton.tsx

@ -1,37 +0,0 @@
import React from 'react';
import type { Link } from 'type-route';
interface MenuButtonProps {
icon: JSX.Element;
text: string;
link: Link;
clickAction?: () => void;
}
export const MenuButton = ({
icon,
text,
link,
clickAction,
}: MenuButtonProps): JSX.Element => {
return (
<div
onClick={() => {
if (clickAction) {
clickAction();
}
}}
>
<a
{...link}
className="flex text-sm h-12 items-center dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer px-3 select-none"
>
{React.cloneElement(icon, {
className: 'h-6 w-6 mr-3 text-gray-500 dark:text-gray-400',
})}
<span className="">{text}</span>
</a>
</div>
);
};

50
src/components/menu/MobileNav.tsx

@ -1,18 +1,11 @@
import React from 'react';
import {
AnnotationIcon,
CogIcon,
InformationCircleIcon,
ViewGridIcon,
} from '@heroicons/react/outline';
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer/SwipeableDrawer';
import { useAppDispatch, useAppSelector } from '@app/hooks/redux';
import { Drawer } from '@components/generic/Drawer';
import { closeMobileNav } from '@core/slices/appSlice';
import { routes } from '../../core/router';
import { closeMobileNav, openMobileNav } from '../../core/slices/appSlice';
import { useAppDispatch, useAppSelector } from '../../hooks/redux';
import { Button } from '../generic/Button';
import { Logo } from './Logo';
import { Navigation } from './Navigation';
export const MobileNav = (): JSX.Element => {
const dispatch = useAppDispatch();
@ -20,41 +13,22 @@ export const MobileNav = (): JSX.Element => {
const mobileNavOpen = useAppSelector((state) => state.app.mobileNavOpen);
return (
<SwipeableDrawer
<Drawer
open={mobileNavOpen}
anchor="left"
onClose={() => {
onClose={(): void => {
dispatch(closeMobileNav());
}}
onOpen={() => {
dispatch(openMobileNav());
}}
>
<div className="flex flex-col dark:bg-secondaryDark h-full">
<div className="flex flex-col w-64">
<div className="m-auto my-6">
<Logo />
</div>
<Button
icon={<AnnotationIcon />}
text={'Messages'}
{...routes.messages().link}
/>
<Button
icon={<ViewGridIcon />}
text={'Nodes'}
{...routes.nodes().link}
/>
<Button
icon={<CogIcon />}
text={'Settings'}
{...routes.settings().link}
/>
<Button
icon={<InformationCircleIcon />}
text={'About'}
{...routes.about().link}
<Navigation
onClick={(): void => {
dispatch(closeMobileNav());
}}
/>
</div>
</SwipeableDrawer>
</Drawer>
);
};

63
src/components/menu/Navigation.tsx

@ -1,5 +1,7 @@
import React from 'react';
import { Button } from '@components/generic/Button';
import { routes, useRoute } from '@core/router';
import {
AnnotationIcon,
CogIcon,
@ -7,33 +9,60 @@ import {
ViewGridIcon,
} from '@heroicons/react/outline';
import { routes } from '../../core/router';
import { Button } from '../generic/Button';
type DefaultDivProps = JSX.IntrinsicElements['div'];
export const Navigation = (): JSX.Element => {
export type NavigationProps = DefaultDivProps;
export const Navigation = ({
onClick,
className,
...props
}: NavigationProps): JSX.Element => {
const route = useRoute();
return (
<div className="hidden md:flex flex-auto flex-0 relative items-center h-16 px-4 ">
<div className="flex items-center">
<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 />}
text={'Messages'}
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 />}
text={'Nodes'}
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 />}
text={'Settings'}
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 />}
text={'About'}
icon={<InformationCircleIcon className="w-6 h-6" />}
className="w-full md:w-auto"
active={route.name === 'about'}
{...routes.about().link}
/>
>
About
</Button>
</div>
</div>
);

29
src/components/menu/buttons/DeviceStatusDropdown.tsx

@ -1,29 +1,20 @@
import React from 'react';
import { useAppSelector } from '@app/hooks/redux';
import { Button } from '@components/generic/Button';
import { SwitchVerticalIcon } from '@heroicons/react/outline';
import { useAppSelector } from '../../../hooks/redux';
import { IconButton } from '../../generic/IconButton';
export const DeviceStatusDropdown = (): JSX.Element => {
const ready = useAppSelector((state) => state.meshtastic.ready);
const deviceStatus = useAppSelector((state) => state.meshtastic.deviceStatus);
return (
<IconButton>
<SwitchVerticalIcon className={`h-6 w-6 ${!ready && 'animate-pulse'}`} />
{/* <div
className={`flex w-6 h-6 rounded-full animate-pulse shadow-md ${
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> */}
</IconButton>
<Button
icon={
<SwitchVerticalIcon
className={`h-6 w-6 ${!ready ? 'animate-pulse' : ''}`}
/>
}
circle
/>
);
};

66
src/components/menu/buttons/LanguageDropdown.tsx

@ -1,66 +0,0 @@
import React from 'react';
import { Jp, Pt, Us } from 'react-flags-select';
import { Menu } from '@headlessui/react';
import i18n from '../../../core/translation';
import { useAppDispatch } from '../../../hooks/redux';
import { IconButton } from '../../generic/IconButton';
export const LanguageDropdown = (): JSX.Element => {
const dispatch = useAppDispatch();
const languages = [
{
name: 'English',
value: 'en',
flag: <Us className="w-6" />,
},
{
name: 'Português',
value: 'pt',
flag: <Pt className="w-6" />,
},
{
name: 'Japanese',
value: 'jp',
flag: <Jp className="w-6" />,
},
];
return (
<Menu as="div" className="w-10 h-10">
<div className="absolute">
<IconButton>
<Menu.Button as="div">
<Us className="w-6 shadow rounded-sm" />
</Menu.Button>
</IconButton>
<Menu.Items className="z-20 absolute right-0 bg-white dark:bg-secondaryDark border dark:border-gray-600 divide-y divide-gray-200 dark:divide-gray-600 rounded-md shadow-md focus:outline-none">
{languages.map((language, index) => (
<Menu.Item
key={index}
onClick={() => {
i18n.changeLanguage(language.value);
}}
>
{({ active }) => (
<button
className={`dark:text-white first:rounded-t-md last:rounded-b-md space-x-2 group flex items-center w-full px-2 py-2 text-sm ${
active && 'bg-gray-200 dark:bg-gray-800'
}`}
>
{language.flag}
<p className="font-medium">{language.name}</p>
</button>
)}
</Menu.Item>
))}
{/* ... */}
</Menu.Items>
</div>
</Menu>
);
};

14
src/components/menu/buttons/MobileNavToggle.tsx

@ -1,23 +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 { openMobileNav } from '../../../core/slices/appSlice';
import { useAppDispatch } from '../../../hooks/redux';
import { IconButton } from '../../generic/IconButton';
export const MobileNavToggle = (): JSX.Element => {
const dispatch = useAppDispatch();
return (
<div className="md:hidden">
<IconButton
onClick={() => {
<Button
icon={<MenuIcon className="w-5 h-5" />}
onClick={(): void => {
dispatch(openMobileNav());
}}
>
<MenuIcon className="h-6 w-6" />
</IconButton>
circle
/>
</div>
);
};

27
src/components/menu/buttons/ThemeToggle.tsx

@ -1,26 +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';
import { setDarkModeEnabled } from '../../../core/slices/appSlice';
import { useAppDispatch, useAppSelector } from '../../../hooks/redux';
import { IconButton } from '../../generic/IconButton';
export const ThemeToggle = (): JSX.Element => {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.app.darkMode);
return (
<IconButton
onClick={() => {
<Button
icon={
darkMode ? (
<SunIcon className="w-5 h-5" />
) : (
<MoonIcon className="w-5 h-5" />
)
}
circle
onClick={(): void => {
dispatch(setDarkModeEnabled(!darkMode));
}}
>
{darkMode ? (
<SunIcon className="h-6 w-6" />
) : (
<MoonIcon className="h-6 w-6" />
)}
</IconButton>
/>
);
};

44
src/components/nodes/NodeDetails.tsx

@ -1,44 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { XCircleIcon } from '@heroicons/react/outline';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { connection } from '../../core/connection';
export interface NodeProps {
node: Protobuf.NodeInfo;
onClose: () => void;
}
export const NodeDetails = ({ node }: NodeProps): JSX.Element => {
const { register, handleSubmit } = useForm<Protobuf.User>({
defaultValues: node.user,
});
const onSubmit = handleSubmit((data) => {
console.log(data);
connection.setOwner(data);
});
return (
<div>
<div className="flex dark:bg-primaryDark p-2 rounded-t-md justify-between border-b dark:border-gray-600 dark:text-white">
<div>{node.user?.longName ?? node.num}</div>
<XCircleIcon className="h-5 w-5 dark:text-white my-auto" />
</div>
<div>
<form onSubmit={onSubmit}>
{/* <Input label="Node Name" {...register('longName', {})} /> */}
<button
type="submit"
className="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"
>
Save
</button>
</form>
</div>
</div>
);
};

18
src/components/templates/PrimaryTemplate.tsx

@ -4,16 +4,21 @@ 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 flex-col sm:flex-row flex-0 sm:items-center sm:justify-between p-6 sm:py-8 sm:px-10 border-b dark:border-gray-600 bg-white dark:bg-secondaryDark">
<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>
@ -21,13 +26,20 @@ export const PrimaryTemplate = ({
</div>
</div>
<div className="mt-2">
<h2 className="text-3xl md:text-4xl font-extrabold tracking-tight leading-7 sm:leading-10 truncate dark:text-white">
<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 p-6 sm:p-10 ">{children}</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>
);
};

28
src/core/theme.ts

@ -1,28 +0,0 @@
import type { Theme } from '@material-ui/core';
import { createTheme } from '@material-ui/core/styles';
export const theme = (darkMode: boolean): Theme => {
return createTheme(
darkMode
? {
palette: {
mode: 'dark',
primary: {
main: '#67ea94',
},
background: {
default: '#0F172A',
paper: '#0F172A',
},
},
}
: {
palette: {
mode: 'light',
primary: {
main: '#67ea94',
},
},
},
);
};

2
src/core/translation.ts

@ -6,7 +6,7 @@ import { en } from '../translations/en';
import { jp } from '../translations/jp';
import { pt } from '../translations/pt';
i18n
void i18n
.use(detector)
.use(initReactI18next)
.init({

21
src/hooks/breakpoint.ts

@ -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);

4
src/hooks/redux.ts

@ -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 { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from '../core/store';
import type { AppDispatch, RootState } from '@core/store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

9
src/index.tsx

@ -1,14 +1,15 @@
import './index.css';
import './core/translation';
import '@app/index.css';
import '@core/translation';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { RouteProvider } from '@core/router';
import { store } from '@core/store';
import App from './App';
import { RouteProvider } from './core/router';
import { store } from './core/store';
ReactDOM.render(
<React.StrictMode>

4
src/pages/About.tsx

@ -1,13 +1,11 @@
import React from 'react';
import { PrimaryTemplate } from '../components/templates/PrimaryTemplate';
import { TestForm } from '../components/TestForm';
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate';
export const About = (): JSX.Element => {
return (
<PrimaryTemplate title="meshtastic-web" tagline="About">
<p>Content</p>
<TestForm />
</PrimaryTemplate>
);
};

26
src/pages/Messages.tsx

@ -1,11 +1,11 @@
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 { Message } from '../components/chat/Message';
import { MessageBar } from '../components/chat/MessageBar';
import { IconButton } from '../components/generic/IconButton';
import { useAppSelector } from '../hooks/redux';
export const Messages = (): JSX.Element => {
@ -13,7 +13,7 @@ export const Messages = (): JSX.Element => {
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const channels = useAppSelector((state) => state.meshtastic.channels);
const channelName = () => {
const channelName = (): string => {
const name =
channels.find((channel) => channel.role === Protobuf.Channel_Role.PRIMARY)
?.settings?.name ?? 'Unknown';
@ -23,21 +23,18 @@ export const Messages = (): JSX.Element => {
return (
<div className="flex flex-col w-full">
<div className="flex justify-between w-full border-b dark:border-gray-600 dark:text-gray-300 px-2">
<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="h-4 w-4 my-auto" />
<HashtagIcon className="w-4 h-4 my-auto" />
{channelName()}
</div>
<div className="flex">
<IconButton>
<MapIcon className="w-6 h-6" />
</IconButton>
<IconButton>
<UsersIcon className="w-6 h-6" />
</IconButton>
<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 p-6 sm:py-8 sm:px-10 border-b dark:border-gray-600 bg-white dark:bg-secondaryDark flex-grow overflow-y-auto space-y-2">
<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}
@ -56,6 +53,3 @@ export const Messages = (): JSX.Element => {
</div>
);
};
<IconButton>
<UsersIcon className="w-6 h-6" />
</IconButton>;

45
src/pages/Nodes.tsx

@ -1,45 +0,0 @@
import React from 'react';
import type { Protobuf } from '@meshtastic/meshtasticjs';
import { Node } from '../components/nodes/Node';
import { NodeDetails } from '../components/nodes/NodeDetails';
import { PrimaryTemplate } from '../components/templates/PrimaryTemplate';
import { useAppSelector } from '../hooks/redux';
export const Nodes = (): JSX.Element => {
const nodes = useAppSelector((state) => state.meshtastic.nodes);
const [currentNode, setCurrentNode] = React.useState<
Protobuf.NodeInfo | undefined
>();
return (
<PrimaryTemplate title="Administration" tagline="Node">
<div className="flex w-full space-x-5">
<div className="w-1/3">
{nodes.map((node) => (
<Node
key={node.num}
node={node}
onClick={() => {
setCurrentNode(node);
}}
/>
))}
</div>
<div className="w-2/3">
{currentNode ? (
<NodeDetails
onClose={() => {
setCurrentNode(undefined);
}}
node={currentNode}
/>
) : (
<div>Node not selected</div>
)}
</div>
</div>
</PrimaryTemplate>
);
};

92
src/pages/Nodes/Index.tsx

@ -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>
);
};

32
src/pages/Nodes/Node.tsx

@ -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>
);
};

160
src/pages/Settings.tsx

@ -1,160 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Protobuf } from '@meshtastic/meshtasticjs';
import { Input } from '../components/form/Input';
import { Select } from '../components/form/Select';
import { Switch } from '../components/form/Switch';
import { PrimaryTemplate } from '../components/templates/PrimaryTemplate';
import { connection } from '../core/connection';
import {
setHostOverride,
setHostOverrideEnabled,
} from '../core/slices/meshtasticSlice';
import { useAppDispatch, useAppSelector } from '../hooks/redux';
export const Settings = (): JSX.Element => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const radioConfig = useAppSelector((state) => state.meshtastic.preferences);
const hostOverride = useAppSelector((state) => state.meshtastic.hostOverride);
const hostOverrideEnabled = useAppSelector(
(state) => state.meshtastic.hostOverrideEnabled,
);
const { register, handleSubmit, control } =
useForm<Protobuf.RadioConfig_UserPreferences>({
defaultValues: radioConfig,
});
const onSubmit = handleSubmit((data) => {
console.log(data);
connection.setPreferences(data);
});
const [localHostOverride, setLocalHostOverride] =
React.useState(hostOverride);
const [localHostOverrideEnabled, setLocalHostOverrideEnabled] =
React.useState(hostOverrideEnabled);
return (
<PrimaryTemplate title="Settings" tagline="Device">
<form onSubmit={onSubmit}>
<div className="space-y-4">
<div className="flex pb-2 dark:text-white border-b dark:border-gray-600">
<div className="w-1/3 text-lg">WiFi</div>
<div className="space-y-2 w-full max-w-xs">
<Input
label={t('strings.wifi_ssid')}
{...register('wifiSsid', {})}
type="text"
valid={true}
/>
<Input
label={t('strings.wifi_psk')}
{...register('wifiPassword', {})}
type="password"
valid={true}
/>
</div>
</div>
<div className="flex pb-2 dark:text-white border-b dark:border-gray-600">
<div className="w-1/3 text-lg">Node</div>
<div className="space-y-2 w-full max-w-xs">
<Switch
name={'isRouter'}
control={control}
label={'Is Router Node?'}
/>
<Switch
name={'isLowPower'}
control={control}
label={'Is Low Power?'}
/>
<Switch
name={'fixedPosition'}
control={control}
label={'Has Fixed Position?'}
/>
<Switch
name={'serialDisabled'}
control={control}
label={'Is Serial Disabled?'}
/>
<Switch
name={'mqttDisabled'}
control={control}
label={'Is MQTT Disabled?'}
/>
<Switch
name={'debugLogEnabled'}
control={control}
label={'Is Debug Log Enabled?'}
/>
<Select
name={'region'}
control={control}
label="Region"
options={(() => {
return Object.keys(Protobuf.RegionCode)
.filter((value) => isNaN(Number(value)) === false)
.map((key) => {
return {
value: key,
label: Protobuf.RegionCode[parseInt(key)],
};
});
})()}
/>
</div>
</div>
<div className="flex pb-2 dark:text-white border-b dark:border-gray-600">
<div className="w-1/3 text-lg">Client</div>
<div className="space-y-2 w-full max-w-xs">
{/* <Toggle
label={'Enable host override'}
checked={localHostOverrideEnabled}
onChange={(event) => {
console.log(event.target.checked);
setLocalHostOverrideEnabled(event.target.checked);
}}
/> */}
<Input
label={'Host override'}
placeholder={'meshtastic.local'}
value={localHostOverride}
onChange={(event) => {
setLocalHostOverride(event.target.value);
}}
type="text"
valid={true}
disabled={!localHostOverrideEnabled}
/>
</div>
</div>
</div>
<button
type="submit"
onClick={() => {
dispatch(setHostOverride(localHostOverride));
dispatch(setHostOverrideEnabled(localHostOverrideEnabled));
}}
className="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"
>
{t('strings.save_changes')}
</button>
</form>
</PrimaryTemplate>
);
};

69
src/pages/settings/Device.tsx

@ -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>
);
};

101
src/pages/settings/Index.tsx

@ -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>
);
};

76
src/pages/settings/Interface.tsx

@ -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>
);
};

47
src/pages/settings/Radio.tsx

@ -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>
);
};

7
tsconfig.json

@ -12,10 +12,17 @@
// Add this line to get types for streaming imports (packageOptions.source="remote"):
// "*": [".snowpack/types/*"]
// More info: https://www.snowpack.dev/guides/streaming-imports
"@app/*": ["./src/*"],
"@pages/*": ["./src/pages/*"],
"@components/*": ["./src/components/*"],
"@core/*": ["./src/core/*"],
"@static/*": ["./src/static/*"]
},
/* noEmit - Snowpack builds (emits) files, not tsc. */
"noEmit": true,
/* Additional Options */
"importHelpers": true,
"removeComments": true,
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true,

720
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save