├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── client ├── enums.lua ├── hud.lua ├── main.lua ├── progress.lua ├── prompt.lua ├── settings.lua ├── test.lua ├── toast.lua └── utils.lua ├── config.json ├── fxmanifest.lua ├── package.json ├── update └── main.js ├── web ├── .gitignore ├── .prettierrc ├── README.md ├── craco.config.js ├── package.json ├── public │ ├── assets │ │ └── img │ │ │ └── p.png │ └── index.html ├── src │ ├── components │ │ ├── MainWrapper.tsx │ │ ├── crosshair │ │ │ └── CrosshairManager.tsx │ │ ├── misc │ │ │ ├── CinematicBars.tsx │ │ │ ├── ScreenshotModeManager.tsx │ │ │ └── TextPrompt.tsx │ │ ├── player │ │ │ ├── ArmorCircle.tsx │ │ │ ├── CircleHudWrapper.tsx │ │ │ ├── CircleItem.tsx │ │ │ ├── GenericCircleItem.tsx │ │ │ ├── HealthCircle.tsx │ │ │ └── VoiceCircle.tsx │ │ └── progress │ │ │ └── ProgressBarWrapper.tsx │ ├── config │ │ └── defaultSettings.ts │ ├── features │ │ └── settings │ │ │ ├── components │ │ │ ├── SettingButton.tsx │ │ │ ├── SettingColorPicker.tsx │ │ │ ├── SettingDropdown.tsx │ │ │ ├── SettingInput.tsx │ │ │ ├── SettingSwitch.tsx │ │ │ ├── SettingsModal.tsx │ │ │ └── SettingsSlider.tsx │ │ │ └── pages │ │ │ ├── AdditionalSettings.tsx │ │ │ ├── PerformanceSettings.tsx │ │ │ └── VisualSettings.tsx │ ├── hooks │ │ ├── useExitListener.ts │ │ ├── useHudListener.ts │ │ ├── useHudReady.ts │ │ ├── useKey.ts │ │ └── useNuiEvent.ts │ ├── index.css │ ├── index.tsx │ ├── providers │ │ ├── AlertDialogProvider.tsx │ │ ├── TextPromptProvider.tsx │ │ └── ToastProvider.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── state │ │ ├── base.state.ts │ │ ├── hud.state.ts │ │ └── settings.state.ts │ ├── styles │ │ └── theme.ts │ ├── types │ │ ├── prompt.types.ts │ │ └── settings.types.ts │ └── utils │ │ ├── DebugObserver.ts │ │ ├── debugData.ts │ │ ├── fetchNui.ts │ │ ├── misc.ts │ │ ├── registerBrowserFuncs.ts │ │ └── setClipboard.ts ├── tsconfig.json └── yarn.lock └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to help solve an issue 4 | title: 'Bug: TITLE' 5 | labels: New Issue 6 | assignees: '' 7 | --- 8 | 9 | **Describe the issue** 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behavior** 14 | 15 | A clear and concise description of what you expected to happen. 16 | 17 | **To Reproduce** 18 | 19 | Steps to reproduce the behavior: 20 | 1. Go to '...' 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | 26 | **Media** 27 | 28 | If applicable, add a screenshot or a video to help explain your problem. 29 | 30 | **Needed information (please complete the following information):** 31 | - **Client Version:**: [e.g. Canary or Release] 32 | - **Template Version**: [e.g. 3486] Don't know?~~Check the version in your package.json~~ 33 | 34 | **Additional context** 35 | Add any other context about the issue here. 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Main CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build Test 6 | runs-on: ubuntu-latest 7 | defaults: 8 | run: 9 | working-directory: web 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup node environment 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 14.x 18 | - name: Get yarn cache directory path 19 | id: yarn-cache-dir-path 20 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 21 | - uses: actions/cache@v2 22 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 23 | with: 24 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-yarn- 28 | - name: Install deps 29 | run: yarn --frozen-lockfile 30 | - name: Try build 31 | run: yarn build 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Tagged Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | jobs: 7 | create-tagged-release: 8 | name: Create Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout source 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | ref: ${{ github.ref }} 16 | - name: Get tag 17 | run: echo ::set-output name=VERSION_TAG::${GITHUB_REF/refs\/tags\//} 18 | id: get_tag 19 | - name: 'Setup Node.js' 20 | uses: 'actions/setup-node@v1' 21 | with: 22 | node-version: 14.x 23 | - name: Create release 24 | uses: marvinpinto/action-automatic-releases@latest 25 | with: 26 | title: React/Lua Boilerplate - ${{ steps.get_tag.outputs.VERSION_TAG }} 27 | repo_token: ${{ secrets.GITHUB_TOKEN }} 28 | prerelease: false 29 | id: auto_release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | .yarn.installed 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Project Error 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Material-UI logo 3 |
4 |

FiveM React and Lua Boilerplate

5 | 6 |
7 | A simple and extendable React (TypeScript) boilerplate designed around the Lua ScRT 8 |
9 | 10 |
11 | 12 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/project-error/pe-utils/master/LICENSE) 13 | ![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord) 14 |
15 | 16 | This repository is a basic boilerplate for getting started 17 | with React in NUI. It contains several helpful utilities and 18 | is bootstrapped using `create-react-app`. It is for both browser 19 | and in-game based development workflows. 20 | 21 | For in-game workflows, Utilizing `craco` to override CRA, we can have hot 22 | builds that just require a resource restart instead of a full 23 | production build 24 | 25 | This version of the boilerplate is meant for the CfxLua runtime. 26 | 27 | ## Requirements 28 | * [Node > v10.6](https://nodejs.org/en/) 29 | * [Yarn](https://yarnpkg.com/getting-started/install) (Preferred but not required) 30 | 31 | *A basic understanding of the modern web development workflow. If you don't 32 | know this yet, React might not be for you just yet.* 33 | 34 | ## Getting Started 35 | 36 | First clone the repository or use the template option and place 37 | it within your `resources` folder 38 | 39 | ### Installation 40 | 41 | *The boilerplate was made using `yarn` but is still compatible with 42 | `npm`.* 43 | 44 | Install dependencies by navigating to the `web` folder within 45 | a terminal of your choice and type `npm i` or `yarn`. 46 | 47 | ## Features 48 | 49 | This boilerplate comes with some utilities and examples to work off of. 50 | 51 | ### Lua Utils 52 | 53 | **SendReactMessage** 54 | 55 | This is a small wrapper for dispatching NUI messages. This is designed 56 | to be used with the `useNuiEvent` React hook. 57 | 58 | Signature 59 | ```lua 60 | ---@param action string The action you wish to target 61 | ---@param data any The data you wish to send along with this action 62 | SendReactMessage(action, data) 63 | ``` 64 | 65 | Usage 66 | ```lua 67 | SendReactMessage('setVisible', true) 68 | ``` 69 | 70 | **debugPrint** 71 | 72 | A debug printing utility that is dependent on a convar, 73 | if the convar is set this will print out to the console. 74 | 75 | The convar is dependent on the name given to the resource. 76 | It follows this format `YOUR_RESOURCE_NAME-debugMode` 77 | 78 | To turn on debugMode add `+setr YOUR_RESOURCE_NAME-debugMode 1` to 79 | your server.cfg or use the `setr` console command instead. 80 | 81 | Signature (Replicates `print`) 82 | ```lua 83 | ---@param ... any[] The arguments you wish to send 84 | debugPrint(...) 85 | ``` 86 | 87 | Usage 88 | ```lua 89 | debugPrint('wow cool string to print', true, someOtherVar) 90 | ``` 91 | 92 | ### React Utils 93 | 94 | Signatures are not included for these utilities as the type definitions 95 | are sufficient enough. 96 | 97 | **useNuiEvent** 98 | 99 | This is a custom React hook that is designed to intercept and handle 100 | messages dispatched by the game scripts. This is the primary 101 | way of creating passive listeners. 102 | 103 | 104 | *Note: For now handlers can only be registered a single time. I haven't 105 | come across a personal usecase for a cascading event system* 106 | 107 | **Usage** 108 | ```jsx 109 | const MyComp: React.FC = () => { 110 | const [state, setState] = useState('') 111 | 112 | useNuiEvent('myAction', (data) => { 113 | // the first argument to the handler function 114 | // is the data argument sent using SendReactMessage 115 | 116 | // do whatever logic u want here 117 | setState(data) 118 | }) 119 | 120 | return( 121 |
122 |

Some component

123 |

{state}

124 |
125 | ) 126 | } 127 | 128 | ``` 129 | 130 | **fetchNui** 131 | 132 | This is a simple NUI focused wrapper around the standard `fetch` API. 133 | This is the main way to accomplish active NUI data fetching 134 | or to trigger NUI callbacks in the game scripts. 135 | 136 | When using this, you must always at least callback using `{}` 137 | in the gamescripts. 138 | 139 | *This can be heavily customized to your use case* 140 | 141 | **Usage** 142 | ```ts 143 | // First argument is the callback event name. 144 | fetchNui('getClientData').then(retData => { 145 | console.log('Got return data from client scripts:') 146 | console.dir(retData) 147 | setClientData(retData) 148 | }).catch(e => { 149 | console.error('Setting mock data due to error', e) 150 | setClientData({ x: 500, y: 300, z: 200}) 151 | }) 152 | ``` 153 | 154 | **debugData** 155 | 156 | This is a function allowing for mocking dispatched game script 157 | actions in a browser environment. It will trigger `useNuiEvent` handlers 158 | as if they were dispatched by the game scripts. **It will only fire if the current 159 | environment is a regular browser and not CEF** 160 | 161 | **Usage** 162 | ```ts 163 | // This will target the useNuiEvent hooks registered with `setVisible` 164 | // and pass them the data of `true` 165 | debugData([ 166 | { 167 | action: 'setVisible', 168 | data: true, 169 | } 170 | ]) 171 | ``` 172 | 173 | **Misc Utils** 174 | 175 | These are small but useful included utilities. 176 | 177 | * `isEnvBrowser()` - Will return a boolean indicating if the current 178 | environment is a regular browser. (Useful for logic in development) 179 | 180 | ## Development Workflow 181 | 182 | This boilerplate was designed with development workflow in mind. 183 | It includes some helpful scripts to accomplish that. 184 | 185 | **Hot Builds In-Game** 186 | 187 | When developing in-game, you can use the hot build system by 188 | running the `start:game` script. This is essentially the start 189 | script but it writes to disk. Meaning all that is required is a 190 | resource restart to update the game script 191 | 192 | **Usage** 193 | ```sh 194 | # yarn 195 | yarn start:game 196 | # npm 197 | npm run start:game 198 | ``` 199 | 200 | **Production Builds** 201 | 202 | When you are done with development phase for your resource. You 203 | must create a production build that is optimized and minimized. 204 | 205 | You can do this by running the following: 206 | 207 | ```sh 208 | npm run build 209 | yarn build 210 | ``` 211 | 212 | ## Additional Notes 213 | 214 | Need further support? Join our [Discord](https://discord.com/invite/HYwBjTbAY5)! 215 | -------------------------------------------------------------------------------- /client/enums.lua: -------------------------------------------------------------------------------- 1 | HudComponentEnum = { 2 | WANTED_STARS = 1, 3 | WEAPON_ICON = 2, 4 | CASH = 3, 5 | MP_CASH = 4, 6 | MP_MESSAGE = 5, 7 | VEHICLE_NAME = 6, 8 | AREA_NAME = 7, 9 | VEHICLE_CLASS = 8, 10 | STREET_NAME = 9, 11 | HELP_TEXT = 10, 12 | FLOATING_HELP_TEXT_1 = 11, 13 | FLOATING_HELP_TEXT_2 = 12, 14 | CASH_CHANGE = 13, 15 | RETICLE = 14, 16 | SUBTITLE_TEXT = 15, 17 | RADIO_STATIONS = 16, 18 | SAVING_GAME = 17, 19 | GAME_STREAM = 18, 20 | WEAPON_WHEEL = 19, 21 | WEAPON_WHEEL_STATS = 20, 22 | HUD_COMPONENTS = 21, 23 | HUD_WEAPONS = 22 24 | } -------------------------------------------------------------------------------- /client/hud.lua: -------------------------------------------------------------------------------- 1 | local DisplayRadar = DisplayRadar 2 | local NetworkIsPlayerTalking = NetworkIsPlayerTalking 3 | local Wait = Wait 4 | local HideHudComponentThisFrame = HideHudComponentThisFrame 5 | local cinematicModeOn = false 6 | local screenshotMode = false 7 | 8 | USER_SETTINGS = nil 9 | 10 | local function hideAllElLoop() 11 | CreateThread(function() 12 | while screenshotMode do 13 | HideHudComponentThisFrame(1) 14 | HideHudComponentThisFrame(2) 15 | HideHudComponentThisFrame(3) 16 | HideHudComponentThisFrame(4) 17 | HideHudComponentThisFrame(6) 18 | HideHudComponentThisFrame(7) 19 | HideHudComponentThisFrame(8) 20 | HideHudComponentThisFrame(9) 21 | HideHudComponentThisFrame(10) 22 | HideHudComponentThisFrame(16) 23 | HideHudComponentThisFrame(19) 24 | HideHudComponentThisFrame(20) 25 | HideHudComponentThisFrame(21) 26 | HideHudComponentThisFrame(22) 27 | Wait(0) 28 | end 29 | end) 30 | end 31 | 32 | RegisterNUICallback('userSettingsUpdated', function(userSettings, cb) 33 | USER_SETTINGS = userSettings 34 | cb({}) 35 | end) 36 | 37 | 38 | RegisterNUICallback('cinematicModeToggle', function(bool, cb) 39 | debugPrint('Cinematic mode toggle > ' .. tostring(bool)) 40 | cinematicModeOn = bool 41 | if bool then 42 | DisplayRadar(false) 43 | hideAllElLoop() 44 | else 45 | DisplayRadar(true) 46 | end 47 | cb({}) 48 | end) 49 | 50 | RegisterNUICallback('screenshotModeToggle', function(bool, cb) 51 | debugPrint('Screenshot mode toggle > ' .. tostring(bool)) 52 | screenshotMode = bool 53 | if bool then 54 | DisplayRadar(false) 55 | else 56 | DisplayRadar(true) 57 | end 58 | cb({}) 59 | end) 60 | 61 | RegisterCommand('cmode', function() 62 | cinematicModeOn = not cinematicModeOn 63 | 64 | SendReactMessage('setCinematicBars', cinematicModeOn) 65 | end) 66 | 67 | RegisterKeyMapping('cmode', 'Toggle cinematic mode', 'keyboard', '') 68 | 69 | 70 | AddEventHandler('pma-voice:setTalkingMode', function(voiceMode) 71 | debugPrint('PMA Voice Range update: ' .. tostring(voiceMode)) 72 | SendReactMessage('setVoiceRange', voiceMode) 73 | end) 74 | 75 | -- Store last talking value in loop 76 | -- so we don't need to continuously send to NUI every iteration 77 | -- we will only send an NUI message if status has changed since last loop 78 | local wasTalking 79 | 80 | -- Talking Thread 81 | CreateThread(function() 82 | while true do 83 | local playerTalking = NetworkIsPlayerTalking(PlayerId()) 84 | 85 | if playerTalking and not wasTalking then 86 | SendReactMessage('setIsTalking', true) 87 | debugPrint('Voice activated > true') 88 | wasTalking = true 89 | elseif not playerTalking and wasTalking then 90 | SendReactMessage('setIsTalking', false) 91 | debugPrint('Voice activated > false') 92 | wasTalking = false 93 | end 94 | Wait(USER_SETTINGS?.voiceUpdateTime or ResourceConfig.voiceUpdateTime) 95 | end 96 | end) 97 | 98 | local lastHealth 99 | local lastArmor 100 | -- Health & Armor Thread 101 | CreateThread(function() 102 | while true do 103 | if nuiIsReady then 104 | local playerPed = PlayerPedId() 105 | local curHealth = GetEntityHealth(playerPed) - 100 106 | local curArmor = GetPedArmour(playerPed) 107 | 108 | if curHealth ~= lastHealth then 109 | SendReactMessage('setHealth', curHealth) 110 | debugPrint('Updating health:' .. tostring(curHealth)) 111 | lastHealth = curHealth 112 | end 113 | 114 | if curArmor ~= lastArmor then 115 | SendReactMessage('setArmor', curArmor) 116 | debugPrint('Updating armor:' .. tostring(curArmor)) 117 | lastArmor = curArmor 118 | end 119 | end 120 | 121 | Wait(USER_SETTINGS?.healthArmorInterval or ResourceConfig.defaultHUDSettings.healthArmorUpdate) 122 | end 123 | end) 124 | 125 | -- Pause menu check thread 126 | local lastPauseStatus = false 127 | CreateThread(function() 128 | while true do 129 | -- Sometimes returning int instead of bool? So we ternary it 130 | local curPauseStatus = IsPauseMenuActive() and true or false 131 | if lastPauseStatus ~= curPauseStatus then 132 | SendReactMessage('setPauseActive', curPauseStatus) 133 | debugPrint('Pause menu > ' .. tostring(curPauseStatus)) 134 | lastPauseStatus = curPauseStatus 135 | end 136 | Wait(500) 137 | end 138 | end) 139 | 140 | --- @class AddCircleOpts 141 | --- @field id string 142 | --- @field iconColor string|nil 143 | --- @field iconName string 144 | --- @field trackColor string|nil 145 | --- @field color string|nil 146 | --- @field min number|nil 147 | --- @field max number|nil 148 | --- @field value number|nil 149 | 150 | --- Adds a new circleHud based on passed opts 151 | --- @param opts AddCircleOpts 152 | local function addCircleHudItem(opts) 153 | SendReactMessage('addCircleItem', opts) 154 | end 155 | 156 | exports('addCircleHudItem', addCircleHudItem) 157 | 158 | --- @class SetCircleHudValueOpts 159 | --- @field value number 160 | --- @field id string 161 | 162 | --- Update an existing circleHud with a new value 163 | --- @param opts SetCircleHudValueOpts 164 | local function setCircleHudValue(opts) 165 | SendReactMessage('setItemValue', opts) 166 | end 167 | 168 | exports('setCircleHudValue', setCircleHudValue) -------------------------------------------------------------------------------- /client/main.lua: -------------------------------------------------------------------------------- 1 | -- Local vars used throughout file 2 | local resourceName = GetCurrentResourceName() 3 | -- Globals used in other files 4 | nuiIsReady = false 5 | ResourceConfig = json.decode(LoadResourceFile(resourceName, 'config.json')) 6 | 7 | RegisterNUICallback('nuiReadyForMessages', function(_, cb) 8 | nuiIsReady = true 9 | debugPrint('NUI sent ready message') 10 | end) 11 | 12 | RegisterNUICallback('requestFocus', function(boolean, cb) 13 | SetNuiFocus(boolean, boolean) 14 | cb({}) 15 | end) -------------------------------------------------------------------------------- /client/progress.lua: -------------------------------------------------------------------------------- 1 | local openProgbars = {} 2 | 3 | RegisterNUICallback('progbar-complete', function(data, cb) 4 | local progbarId = data.progbarId 5 | openProgbars[progbarId]:resolve(true) 6 | SetNuiFocus(false) 7 | cb({}) 8 | end) 9 | 10 | RegisterNUICallback('progbar-cancel', function(data, cb) 11 | local progbarId = data.progbarId 12 | openProgbars[progbarId]:resolve(false) 13 | SetNuiFocus(false) 14 | cb({}) 15 | end) 16 | 17 | -- Probably a good idea to move cancel key handling to game scripts 18 | -- instead of NUI for several reasons 19 | -- * NUI Focus can be taken by another resource 20 | -- * Handling NUI Focus switching can be a pain 21 | 22 | -- Start a new progbar 23 | --[[ 24 | interface ProgBarData { 25 | color: string; 26 | duration: number; 27 | id: string; 28 | isCancellable?: boolean; 29 | disableControls?: boolean; 30 | } 31 | ]] 32 | 33 | local function startProgbar(opts) 34 | SendReactMessage('setProgressBar', opts) 35 | SetNuiFocus(true) 36 | openProgbars[opts.id] = promise.new() 37 | local resp = Citizen.Await(openProgbars[opts.id]) 38 | openProgbars[opts.id] = nil 39 | return resp 40 | end 41 | exports('startProgbar', startProgbar) 42 | 43 | -- Close and resolve tracked progbars 44 | local function closeProgbar() 45 | SendReactMessage('closeProgbar') 46 | 47 | for _, v in ipairs(openProgbars) do 48 | v:resolve(false) 49 | end 50 | 51 | openProgbars = {} 52 | end 53 | exports('closeProgbar', closeProgbar) 54 | -------------------------------------------------------------------------------- /client/prompt.lua: -------------------------------------------------------------------------------- 1 | -- Will track whether prompt is currently open 2 | local promptIsOpen = false 3 | 4 | --[[ 5 | table object: 6 | interface PromptInfo { 7 | placeholder: string; 8 | description: string; 9 | id: string; 10 | title: string; 11 | isClosable?: boolean; 12 | } 13 | ]] 14 | ---@param promptTable table 15 | --- 16 | --- NOTE: Need to handle an export spam triggering many prompts 17 | local function startPrompt(promptTable) 18 | debugPrint('Prompt opened >') 19 | debugPrint(json.encode(promptTable)) 20 | 21 | local p = promise.new() 22 | SendReactMessage('openPrompt', promptTable) 23 | 24 | local cbStr = ('promptNuiResp-%s'):format(promptTable.id) 25 | 26 | promptIsOpen = true 27 | 28 | RegisterRawNuiCallback(cbStr, function(data, cb) 29 | local resp = json.decode(data.body) 30 | 31 | p:resolve(resp) 32 | promptIsOpen = false 33 | cb({ body = '{}' }) 34 | 35 | UnregisterRawNuiCallback(`promptNuiResp-${props.id}`); 36 | end) 37 | 38 | local result = Citizen.Await(p) 39 | 40 | -- Returns two results as ['closed' | 'submitted', content | null] 41 | return result[1], result[2] 42 | end 43 | 44 | exports('startPrompt', startPrompt) 45 | 46 | ---@param promptId string Target prompt to close 47 | function closePrompt(promptId) 48 | local stringPromptId = tostring(promptId) 49 | SendReactMessage('closePrompt', stringPromptId) 50 | debugPrint(('Prompt ID %s | Closed'):format(stringPromptId)) 51 | end 52 | 53 | 54 | exports('closePrompt', closePrompt) 55 | -------------------------------------------------------------------------------- /client/settings.lua: -------------------------------------------------------------------------------- 1 | local settingModalOpen = false 2 | 3 | RegisterCommand('ui-settings', function() 4 | -- Toggle state of modal with command 5 | settingModalOpen = not settingModalOpen 6 | SendReactMessage('setSettingsVisible', settingModalOpen) 7 | end) 8 | 9 | RegisterNUICallback('settingsModalClosed', function(_, cb) 10 | settingModalOpen = false 11 | cb({}) 12 | end) 13 | 14 | -- Always unbound for now 15 | RegisterKeyMapping('ui-settings', 'Opens the UI settings menu', 'KEYBOARD', '') 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/test.lua: -------------------------------------------------------------------------------- 1 | if not ResourceConfig.enableTestCommands then return end 2 | 3 | debugPrint('Registering test commands') 4 | 5 | RegisterCommand('testPrompt', function() 6 | local status, content = exports['pe-ui']:startPrompt({ 7 | placeholder = 'Sent by lua', 8 | description = 'This is the description sent by lua', 9 | id = 'myTestPrompt', 10 | title = 'Wow this has a title!' 11 | }) 12 | 13 | debugPrint('Export result >') 14 | debugPrint('Status | ' ..status) 15 | debugPrint('Content | ' .. tostring(content)) 16 | end) 17 | 18 | RegisterCommand('testPromptClose', function() 19 | local status, content = exports['pe-ui']:startPrompt({ 20 | placeholder = 'Closable Prompt', 21 | description = 'This is a prompt that is closable', 22 | id = 'myTestPrompt', 23 | title = 'Closable Prompt', 24 | isClosable = true 25 | }) 26 | 27 | debugPrint('Export result >') 28 | debugPrint('Status | ' ..status) 29 | debugPrint('Content | ' .. tostring(content)) 30 | end) 31 | 32 | RegisterCommand('testToast', function() 33 | CreateThread(function() 34 | exports['pe-ui']:addToast({ 35 | message = 'This is my toast description', 36 | position = 'top-right', 37 | duration = 5000, 38 | status = 'success' 39 | }) 40 | 41 | exports['pe-ui']:addToast({ 42 | message = 'This is my toast description', 43 | position = 'top-right', 44 | title = 'Title test', 45 | duration = 3000, 46 | status = 'error' 47 | }) 48 | 49 | exports['pe-ui']:addToast({ 50 | message = 'This is my toast description', 51 | position = 'top-right', 52 | title = 'Title test', 53 | duration = 3000, 54 | status = 'info' 55 | }) 56 | 57 | Wait(3000) 58 | 59 | exports['pe-ui']:addToast({ 60 | message = 'This is my toast description', 61 | position = 'top-left', 62 | title = 'Title test', 63 | duration = 3000, 64 | status = 'info' 65 | }) 66 | 67 | Wait(1000) 68 | 69 | exports['pe-ui']:addToast({ 70 | message = 'This is my toast description', 71 | position = 'top', 72 | title = 'Title test', 73 | duration = 3000, 74 | status = 'warning' 75 | }) 76 | 77 | Wait(1000) 78 | 79 | exports['pe-ui']:addToast({ 80 | message = 'This is my toast description', 81 | position = 'bottom', 82 | title = 'Title test', 83 | duration = 3000, 84 | status = 'success' 85 | }) 86 | 87 | Wait(1000) 88 | 89 | exports['pe-ui']:addToast({ 90 | message = 'This is my toast description', 91 | position = 'bottom-right', 92 | title = 'Title test', 93 | duration = 3000, 94 | status = 'error' 95 | }) 96 | end) 97 | end) 98 | 99 | RegisterCommand('testPersistent', function() 100 | exports['pe-ui']:addPersistentToast({ 101 | message = 'Persistent test 1', 102 | position = 'top-right', 103 | id = 'myPersistentNoti', 104 | status = 'error' 105 | }) 106 | 107 | exports['pe-ui']:addPersistentToast({ 108 | message = 'Persistent test 2', 109 | position = 'top', 110 | title = 'This has a title', 111 | id = 'myPersistentNoti2', 112 | status = 'success' 113 | }) 114 | end) 115 | 116 | RegisterCommand('clearPersistent', function() 117 | exports['pe-ui']:clearPersistentToast('myPersistentNoti') 118 | exports['pe-ui']:clearPersistentToast('myPersistentNoti2') 119 | end) 120 | 121 | RegisterCommand('progBar', function() 122 | local isComplete = exports['pe-ui']:startProgbar({ 123 | color = 'green', 124 | id = 'myProgBar', 125 | duration = 10000, 126 | isCancellable = true 127 | }) 128 | debugPrint('Prog bar complete:' .. tostring(isComplete)) 129 | end) 130 | 131 | RegisterCommand('closeProgbar', function() 132 | exports['pe-ui']:closeProgbar() 133 | end) -------------------------------------------------------------------------------- /client/toast.lua: -------------------------------------------------------------------------------- 1 | local persistentToasts = {} 2 | 3 | --[[ 4 | position: "top" | "top-right" | "top-left" | "bottom" | "bottom-right" | "bottom-left"; 5 | status: 'success' | 'error' | 'warning' | 'info' 6 | title: string 7 | description: string 8 | id: string 9 | ]] 10 | 11 | --- @param persistentToastOpts table Persistent toast options 12 | local function addPersistentToast(persistentToastOpts) 13 | if persistentToasts[persistentToastOpts.id] then 14 | return errorPrint(('Persistent Toast with ID (%s) already exists!'):format(persistentToastOpts.id)) 15 | end 16 | 17 | debugPrint('Adding persistent toast > ') 18 | debugPrint(json.encode(persistentToastOpts)) 19 | 20 | SendReactMessage('addPersistentToast', persistentToastOpts) 21 | 22 | persistentToasts[persistentToastOpts.id] = persistentToastOpts 23 | end 24 | 25 | exports('addPersistentToast', addPersistentToast) 26 | 27 | --- @param id string The persistent toast to clear 28 | local function clearPersistentToast(id) 29 | if not persistentToasts[id] then 30 | return errorPrint(('Persistent Toast with ID (%s) does not exist in cache'):format(id)) 31 | end 32 | 33 | SendReactMessage('clearPersistentToast', id) 34 | 35 | -- Clear from table 36 | persistentToasts[id] = nil 37 | end 38 | 39 | exports('clearPersistentToast', clearPersistentToast) 40 | 41 | --[[ 42 | message: string, 43 | status: 'success' | 'error' | 'warning' | 'info' 44 | position: "top" | "top-right" | "top-left" | "bottom" | "bottom-right" | "bottom-left"; 45 | duration: number 46 | title?: string 47 | ]] 48 | --- @param toastOpts table 49 | local function addToast(toastOpts) 50 | debugPrint('Adding new toast >') 51 | debugPrint(json.encode(toastOpts)) 52 | 53 | SendReactMessage('addToast', toastOpts) 54 | end 55 | 56 | exports('addToast', addToast) 57 | 58 | --- Will close all open toasts including persistent ones 59 | --- and wipe from cache 60 | local function closeAllToasts() 61 | debugPrint('Clearing all toasts') 62 | persistentToasts = {} 63 | SendReactMessage('closeAllToasts', {}) 64 | end 65 | 66 | exports('closeAllToasts', closeAllToasts) -------------------------------------------------------------------------------- /client/utils.lua: -------------------------------------------------------------------------------- 1 | --- A simple wrapper around SendNUIMessage that you can use to 2 | --- dispatch actions to the React frame. 3 | --- 4 | ---@param action string The action you wish to target 5 | ---@param data any The data you wish to send along with this action 6 | function SendReactMessage(action, data) 7 | SendNUIMessage({ 8 | action = action, 9 | data = data 10 | }) 11 | end 12 | 13 | local currentResourceName = GetCurrentResourceName() 14 | 15 | local debugIsEnabled = GetConvarInt(('%s-debugMode'):format(currentResourceName), 0) == 1 16 | 17 | --- A simple debug print function that is dependent on a convar 18 | --- will output a nice prettfied message if debugMode is on 19 | function debugPrint(...) 20 | if not debugIsEnabled then return end 21 | local args = { ... } 22 | 23 | local appendStr = '' 24 | for _, v in ipairs(args) do 25 | appendStr = appendStr .. ' ' .. tostring(v) 26 | end 27 | local msgTemplate = '^3[%s]^0%s' 28 | local finalMsg = msgTemplate:format(currentResourceName, appendStr) 29 | print(finalMsg) 30 | end 31 | 32 | function errorPrint(...) 33 | local args = { ... } 34 | 35 | local appendStr = '' 36 | for _, v in ipairs(args) do 37 | appendStr = appendStr .. ' ' .. tostring(v) 38 | end 39 | 40 | local msgTemplate = '^3[%s]^1[ERROR] %s' 41 | local finalMsg = msgTemplate:format(currentResourceName, appendStr) 42 | print(finalMsg) 43 | end -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "updateChecker": false, 3 | "enableTestCommands": true, 4 | "voiceUpdateTime": 150, 5 | "defaultHUDSettings": { 6 | "cinematicBars": false, 7 | "crosshairEnabled": false, 8 | "crosshairColor": "#00ff04", 9 | "crosshairSize": 2, 10 | "statusCirclesLocation": "bottom-right", 11 | "cinematicBarSize": 50, 12 | "healthArmorInterval": 100, 13 | "voiceUpdateInterval": 100, 14 | "screenshotMode": false 15 | }, 16 | "targetFramework": "qb-core" 17 | } -------------------------------------------------------------------------------- /fxmanifest.lua: -------------------------------------------------------------------------------- 1 | fx_version "cerulean" 2 | 3 | description "Basic React (TypeScript) & Lua Game Scripts Boilerplate" 4 | author "Project Error" 5 | version '0.0.1' 6 | repository 'https://github.com/project-error/fivem-react-boilerplate-lua' 7 | 8 | lua54 'yes' 9 | 10 | games { 11 | "gta5", 12 | "rdr3" 13 | } 14 | 15 | ui_page 'web/build/index.html' 16 | 17 | client_scripts { 18 | "client/utils.lua", 19 | "client/main.lua", 20 | "client/**/*" 21 | } 22 | 23 | server_script "update/main.js" 24 | 25 | files { 26 | 'config.json', 27 | 'web/build/index.html', 28 | 'web/build/**/*', 29 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pe-ui", 3 | "description": "", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/project-error/pe-ui.git" 9 | }, 10 | "author": "Project Error", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/project-error/pe-ui/issues" 14 | }, 15 | "dependencies": { 16 | "common-tags": "^1.8.2", 17 | "node-fetch": "^2.6.6", 18 | "semver": "^7.3.5" 19 | }, 20 | "homepage": "https://github.com/project-error/pe-ui#readme" 21 | } 22 | -------------------------------------------------------------------------------- /update/main.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | const semver = require("semver"); 3 | const { stripIndents } = require("common-tags"); 4 | 5 | const GITHUB_USER = "project-error"; 6 | const REPO_NAME = "pe-ui"; 7 | const DEFAULT_BRANCH = "master"; 8 | 9 | const CURRENT_RESOURCE_NAME = GetCurrentResourceName(); 10 | const RESOURCE_PREFIX = `^3[pe-ui]^0`; 11 | const RELEASE_URL = "https://github.com/project-error/pe-ui/releases"; 12 | 13 | const messageTemplates = { 14 | outOfDate: (remoteVersion, localVersion, diffType) => stripIndents` 15 | ^1===============================================================================^0 16 | Your version of ${RESOURCE_PREFIX} is currently ^1outdated^0 17 | The latest version is ^2${remoteVersion}^0, your version is ^1${localVersion}^0 18 | This is considered a ^3"${diffType.toUpperCase()}"^0 version change. 19 | You can find the latest version at ^2${RELEASE_URL}^0 20 | ^1===============================================================================^0 21 | `, 22 | prerelease: (remoteVersion, localVersion) => stripIndents` 23 | ^1===============================================================================^0 24 | You may be using a pre-release version of ${RESOURCE_PREFIX} as your version 25 | is higher than the latest stable GitHub release. 26 | Your version: ^1${localVersion}^0 27 | GitHub version: ^2${remoteVersion}^0 28 | ^1===============================================================================^0 29 | `, 30 | isUpdated: (version) => 31 | `${RESOURCE_PREFIX} (v${version}) is up to date and has started sucessfully!`, 32 | badResponseCode: (respCode) => 33 | `${RESOURCE_PREFIX} ^1There was an error while attempting to check for updates. Code: ${respCode}^0`, 34 | genericError: (e) => 35 | stripIndents` 36 | ^1===============================================================================^0 37 | An unexpected error occured in ${RESOURCE_PREFIX} while checking for updates. 38 | If you see this message consistently, please file a report with the given information. 39 | Error: ^1${e.message}^0 40 | ^1===============================================================================^0 41 | `, 42 | }; 43 | 44 | const getVersionFromRawManifest = (manifestContent) => { 45 | const rawResults = manifestContent.match(/^[\s]*version.*['"]$/m); 46 | if (!rawResults || !rawResults[0]) 47 | throw new Error("Unable to find parse version in fxmanifest"); 48 | 49 | // debugLog("Raw Remote Regex Result:", rawResults); 50 | 51 | // Improve this parsing to be adaptable & maintable for the future 52 | return rawResults[0].split(" ")[1].replace(/["']/g, ""); 53 | }; 54 | 55 | const getVersionFromMetadata = () => { 56 | return GetResourceMetadata(CURRENT_RESOURCE_NAME, "version", 0); 57 | }; 58 | 59 | const fetchManifestVersionFromGitHub = async () => { 60 | try { 61 | const rawRes = await fetch( 62 | `https://raw.githubusercontent.com/${GITHUB_USER}/${REPO_NAME}/${DEFAULT_BRANCH}/fxmanifest.lua` 63 | ); 64 | 65 | const textConversion = await rawRes.text(); 66 | // debugLog("Ret Text:", textConversion); 67 | 68 | return { 69 | version: getVersionFromRawManifest(textConversion), 70 | statusCode: rawRes.status, 71 | }; 72 | } catch (e) { 73 | return { error: e }; 74 | } 75 | }; 76 | 77 | const startUpdateCheck = async () => { 78 | const localVersion = getVersionFromMetadata(); 79 | const { 80 | version: remoteVersion, 81 | error, 82 | statusCode: respStatusCode, 83 | } = await fetchManifestVersionFromGitHub(); 84 | 85 | if (error) { 86 | return console.log(messageTemplates.genericError(error)); 87 | } 88 | 89 | if (!respStatusCode || !remoteVersion) { 90 | return console.log( 91 | messageTemplates.genericError( 92 | new Error( 93 | "The version or response status code is undefined after error checks" 94 | ) 95 | ) 96 | ); 97 | } 98 | 99 | // debugLog("Fetched RemoteVer:", remoteVersion); 100 | // debugLog("Local Ver:", localVersion); 101 | 102 | // This set of conditionals, should handle all the possible returns 103 | // from GH adequately. 104 | 105 | // Non 200 status code handling 106 | if (respStatusCode < 200 || respStatusCode > 200) { 107 | return console.log(messageTemplates.badResponseCode(respStatusCode)); 108 | } 109 | 110 | // Local version is equal to remote 111 | if (semver.eq(localVersion, remoteVersion)) { 112 | return console.log(messageTemplates.isUpdated(localVersion)); 113 | } 114 | 115 | // Local version is below remote 116 | if (semver.lt(localVersion, remoteVersion)) { 117 | // Non-null assert as we have already confirmed that localVersion < remoteVersion 118 | const verDiffType = semver.diff(localVersion, remoteVersion); 119 | 120 | return console.log( 121 | messageTemplates.outOfDate(remoteVersion, localVersion, verDiffType) 122 | ); 123 | } 124 | 125 | // Local version is ahead of remote 126 | if (semver.gt(localVersion, remoteVersion)) { 127 | return console.log( 128 | messageTemplates.prerelease(remoteVersion, localVersion) 129 | ); 130 | } 131 | }; 132 | 133 | const updateCheckDisabled = GetConvarInt("qb-core:disableUpdateCheck", 0) == 1; 134 | 135 | on("onResourceStart", async (resName) => { 136 | if (resName !== CURRENT_RESOURCE_NAME) return; 137 | 138 | if (updateCheckDisabled) { 139 | // debugLog("Update checking disabled by convar"); 140 | return; 141 | } 142 | 143 | // debugLog("Beginning update check"); 144 | await startUpdateCheck(); 145 | }); 146 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "avoid", 4 | "jsxSingleQuote": true 5 | } -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /web/craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | webpack: { 4 | configure: webpackConfig => { 5 | // Because CEF has issues with loading source maps properly atm, 6 | // lets use the best we can get in line with `eval-source-map` 7 | if (webpackConfig.mode === 'development') { 8 | webpackConfig.devtool = 'eval-source-map'; 9 | webpackConfig.output.path = path.join(__dirname, 'build'); 10 | } 11 | 12 | return webpackConfig; 13 | }, 14 | }, 15 | 16 | devServer: devServerConfig => { 17 | if (process.env.IN_GAME_DEV) { 18 | // Used for in-game dev mode 19 | devServerConfig.writeToDisk = true; 20 | } 21 | 22 | return devServerConfig; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "homepage": "web/build", 5 | "private": true, 6 | "dependencies": { 7 | "@chakra-ui/react": "^1.7.2", 8 | "@chakra-ui/theme-tools": "^1.3.1", 9 | "@emotion/react": "^11", 10 | "@emotion/styled": "^11", 11 | "@testing-library/jest-dom": "^5.15.1", 12 | "@testing-library/react": "^12.1.2", 13 | "@testing-library/user-event": "^13.5.0", 14 | "@types/jest": "^26.0.15", 15 | "@types/node": "^16.11.10", 16 | "@types/react": "^17.0.37", 17 | "@types/react-dom": "^17.0.11", 18 | "framer-motion": "^4", 19 | "jsonschema": "tdegrunt/jsonschema", 20 | "node-fetch": "^3.1.1", 21 | "react": "^17.0.2", 22 | "react-dom": "^17.0.2", 23 | "react-icons": "^4.3.1", 24 | "react-scripts": "4.0.3", 25 | "recoil": "^0.5.2", 26 | "typescript": "^4.5.2", 27 | "web-vitals": "^2.1.2" 28 | }, 29 | "scripts": { 30 | "start": "cross-env PUBLIC_URL=/ craco start", 31 | "start:game": "cross-env IN_GAME_DEV=1 craco start", 32 | "build": "rimraf build && craco build", 33 | "test": "craco test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "@craco/craco": "^6.4.3", 56 | "cross-env": "^7.0.3", 57 | "prettier": "^2.5.0", 58 | "rimraf": "^3.0.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /web/public/assets/img/p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/project-error/pe-ui/7bdf99180b3ba11b5aab27b23d7b980e1b00accd/web/public/assets/img/p.png -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NUI React Boilerplate 8 | 9 | 10 | 11 |
12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /web/src/components/MainWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@chakra-ui/react'; 3 | import { TextPrompt } from './misc/TextPrompt'; 4 | import { SettingsModal } from '../features/settings/components/SettingsModal'; 5 | import { CinematicBars } from './misc/CinematicBars'; 6 | import { CircleHudWrapper } from './player/CircleHudWrapper'; 7 | import { useHudListener } from '../hooks/useHudListener'; 8 | import { useHudReady } from '../hooks/useHudReady'; 9 | import { ProgressBarWrapper } from './progress/ProgressBarWrapper'; 10 | import { ScreenshotModeManager } from './misc/ScreenshotModeManager'; 11 | import { CrosshairManager } from './crosshair/CrosshairManager'; 12 | 13 | const MainWrapper: React.FC = () => { 14 | useHudListener(); 15 | useHudReady(); 16 | 17 | return ( 18 | }> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default MainWrapper; 34 | -------------------------------------------------------------------------------- /web/src/components/crosshair/CrosshairManager.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Circle } from '@chakra-ui/react'; 2 | import React from 'react'; 3 | import { useSettingsValue } from '../../state/settings.state'; 4 | 5 | export const CrosshairManager: React.FC = () => { 6 | const { crosshairColor, crosshairEnabled, screenshotMode, crosshairSize } = 7 | useSettingsValue(); 8 | 9 | const shouldShow = crosshairEnabled && !screenshotMode; 10 | 11 | const adjustedSize = crosshairSize / 2; 12 | 13 | return ( 14 | 23 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /web/src/components/misc/CinematicBars.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | import { useSetSettings, useSettingsValue } from '../../state/settings.state'; 3 | import { Box, Slide } from '@chakra-ui/react'; 4 | import { fetchNui } from '../../utils/fetchNui'; 5 | import { useNuiEvent } from '../../hooks/useNuiEvent'; 6 | 7 | // This is the max height both halfs of the cinematic bars can 8 | // add up to in vh units. 9 | const MAX_HEIGHT = 40; 10 | 11 | export const CinematicBars: React.FC = () => { 12 | const { cinematicBarSize, cinematicBars } = useSettingsValue(); 13 | 14 | const setSettings = useSetSettings(); 15 | 16 | useEffect(() => { 17 | fetchNui('cinematicModeToggle', cinematicBars, {}); 18 | }, [cinematicBars]); 19 | 20 | const evenSize = useMemo(() => { 21 | const percentage = cinematicBarSize / 100; 22 | return (percentage * MAX_HEIGHT) / 2; 23 | }, [cinematicBarSize]); 24 | 25 | // Triggered by the command on client side 26 | useNuiEvent('setCinematicBars', toggleOn => { 27 | setSettings(prevSettings => ({ ...prevSettings, cinematicBars: toggleOn })); 28 | }); 29 | 30 | return ( 31 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /web/src/components/misc/ScreenshotModeManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { fetchNui } from '../../utils/fetchNui'; 3 | import { useSettingsValue } from '../../state/settings.state'; 4 | 5 | export const ScreenshotModeManager: React.FC = ({ children }) => { 6 | const { screenshotMode } = useSettingsValue(); 7 | 8 | useEffect(() => { 9 | fetchNui('screenshotModeToggle', screenshotMode, {}); 10 | }, [screenshotMode]); 11 | 12 | return <>{children}; 13 | }; 14 | -------------------------------------------------------------------------------- /web/src/components/misc/TextPrompt.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { 3 | Box, 4 | Button, 5 | Input, 6 | InputGroup, 7 | InputLeftElement, 8 | Modal, 9 | ModalBody, 10 | ModalCloseButton, 11 | ModalContent, 12 | ModalFooter, 13 | ModalHeader, 14 | ModalOverlay, 15 | Text, 16 | } from '@chakra-ui/react'; 17 | import { MdEdit } from 'react-icons/md'; 18 | import { usePromptCtx } from '../../providers/TextPromptProvider'; 19 | 20 | export const TextPrompt: React.FC = () => { 21 | const { handleClosePrompt, promptInfo, visible, handleSubmitPrompt } = 22 | usePromptCtx(); 23 | 24 | const [promptVal, setPromptVal] = useState(''); 25 | 26 | const handleSubmit = (e: React.FormEvent) => { 27 | e.preventDefault(); 28 | handleSubmitPrompt(promptInfo.id, promptVal); 29 | setPromptVal(''); 30 | }; 31 | 32 | const handleClose = () => { 33 | handleClosePrompt(promptInfo.id); 34 | setPromptVal(''); 35 | }; 36 | 37 | const initRef = useRef(null); 38 | 39 | return ( 40 | 49 | 50 |
51 | 52 | {promptInfo.title} 53 | {promptInfo?.isClosable && } 54 | 55 | 56 | {promptInfo.description} 57 | 58 | 59 | } 63 | /> 64 | setPromptVal(e.target.value)} 69 | /> 70 | 71 | 72 | 73 | 80 | {promptInfo?.isClosable && ( 81 | 84 | )} 85 | 86 | 87 |
88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /web/src/components/player/ArmorCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircleItem } from './CircleItem'; 3 | import { BsFillShieldFill } from 'react-icons/bs'; 4 | import { useArmorValue } from '../../state/hud.state'; 5 | 6 | export const ArmorCircle: React.FC = () => { 7 | const armor = useArmorValue(); 8 | 9 | return ( 10 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /web/src/components/player/CircleHudWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, HStack } from '@chakra-ui/react'; 3 | import { VoiceCircle } from './VoiceCircle'; 4 | import { HealthCircle } from './HealthCircle'; 5 | import { ArmorCircle } from './ArmorCircle'; 6 | import { usePauseActiveValue } from '../../state/base.state'; 7 | import { useSettingsValue } from '../../state/settings.state'; 8 | import { GenericCircleItem } from './GenericCircleItem'; 9 | import { useRecoilValue } from 'recoil'; 10 | import { hudStateListIds } from '../../state/hud.state'; 11 | import { ValidStatusLocations } from '../../types/settings.types'; 12 | 13 | interface FlexStyleObj { 14 | justifyContent: string; 15 | alignItems: string; 16 | } 17 | 18 | const determineFlexLayout = (location: ValidStatusLocations): FlexStyleObj => { 19 | switch (location) { 20 | case 'bottom-right': 21 | return { alignItems: 'flex-end', justifyContent: 'flex-end' }; 22 | case 'bottom': 23 | return { justifyContent: 'center', alignItems: 'flex-end' }; 24 | case 'bottom-left': 25 | return { justifyContent: 'flex-start', alignItems: 'flex-end' }; 26 | case 'top': 27 | return { justifyContent: 'center', alignItems: 'flex-start' }; 28 | case 'top-left': 29 | return { justifyContent: 'flex-start', alignItems: 'flex-start' }; 30 | case 'top-right': 31 | return { justifyContent: 'flex-end', alignItems: 'flex-start' }; 32 | } 33 | }; 34 | 35 | export const CircleHudWrapper: React.FC = () => { 36 | const pauseActive = usePauseActiveValue(); 37 | const { cinematicBars, screenshotMode } = useSettingsValue(); 38 | const ids = useRecoilValue(hudStateListIds); 39 | const { statusCirclesLocation } = useSettingsValue(); 40 | 41 | const flexLayout = determineFlexLayout(statusCirclesLocation); 42 | 43 | return ( 44 | 52 | 53 | }> 54 | 55 | 56 | 57 | {ids.map(({ ...props }) => ( 58 | 59 | ))} 60 | 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /web/src/components/player/CircleItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | CircularProgress, 4 | CircularProgressLabel, 5 | CircularProgressProps, 6 | Fade, 7 | Icon, 8 | } from '@chakra-ui/react'; 9 | import { IconType } from 'react-icons'; 10 | 11 | interface CircleItemProps extends CircularProgressProps { 12 | icon: IconType; 13 | iconColor: any; 14 | value: number; 15 | hideWhenZero?: boolean; 16 | } 17 | 18 | export const CircleItem: React.FC = ({ 19 | icon, 20 | value, 21 | iconColor, 22 | hideWhenZero, 23 | ...props 24 | }) => { 25 | const [visible, setVisible] = useState(true); 26 | 27 | const handleAnimationEnd = () => { 28 | if (!hideWhenZero) return; 29 | if (value <= (props?.min || 0)) { 30 | setVisible(false); 31 | } 32 | }; 33 | 34 | useEffect(() => { 35 | if (value > (props?.min || 0) && !visible) { 36 | setVisible(true); 37 | } 38 | }, [props?.min, value, visible]); 39 | 40 | return ( 41 | 49 | 58 | 59 | { 60 | 67 | } 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /web/src/components/player/GenericCircleItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircleItem } from './CircleItem'; 3 | import { circleHudValues, HudStateAtomParam } from '../../state/hud.state'; 4 | import { useRecoilState } from 'recoil'; 5 | import * as faIcons from 'react-icons/fa'; 6 | import { IconType } from 'react-icons'; 7 | import { useNuiEvent } from '../../hooks/useNuiEvent'; 8 | 9 | interface SetCircleItemOpts { 10 | id: string; 11 | value: number; 12 | } 13 | 14 | export const GenericCircleItem: React.FC = ({ 15 | color, 16 | value, 17 | id, 18 | trackColor, 19 | iconName, 20 | iconColor, 21 | max, 22 | min, 23 | }) => { 24 | const [itemVal, setItemVal] = useRecoilState(circleHudValues(id)); 25 | const faIconsTyped = faIcons as Record; 26 | 27 | const icon = faIconsTyped[iconName]; 28 | 29 | useNuiEvent('setItemValue', ({ value, id: tgtId }) => { 30 | if (tgtId !== id) return; 31 | setItemVal(value); 32 | }); 33 | 34 | return ( 35 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /web/src/components/player/HealthCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircleItem } from './CircleItem'; 3 | import { AiFillHeart } from 'react-icons/ai'; 4 | import { useHealthValue } from '../../state/hud.state'; 5 | 6 | export const HealthCircle: React.FC = () => { 7 | const health = useHealthValue(); 8 | 9 | const healthColor = health < 20 ? 'red.400' : 'green.400'; 10 | 11 | return ( 12 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /web/src/components/player/VoiceCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { FaMicrophone } from 'react-icons/fa'; 3 | import { CircleItem } from './CircleItem'; 4 | import { useNuiEvent } from '../../hooks/useNuiEvent'; 5 | 6 | export const VoiceCircle: React.FC = () => { 7 | const [voiceRange, setVoiceRange] = useState(1); 8 | const [isTalking, setIsTalking] = useState(false); 9 | 10 | useNuiEvent('setVoiceRange', setVoiceRange); 11 | useNuiEvent('setIsTalking', setIsTalking); 12 | 13 | const iconColor = isTalking ? 'yellow.200' : 'white'; 14 | 15 | return ( 16 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /web/src/components/progress/ProgressBarWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | CircularProgress, 4 | CircularProgressLabel, 5 | Fade, 6 | } from '@chakra-ui/react'; 7 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 8 | import { useNuiEvent } from '../../hooks/useNuiEvent'; 9 | import { fetchNui } from '../../utils/fetchNui'; 10 | import { useKey } from '../../hooks/useKey'; 11 | 12 | interface ProgBarData { 13 | color: string; 14 | duration: number; 15 | id: string; 16 | isCancellable?: boolean; 17 | disableControls?: boolean; 18 | } 19 | 20 | // const defaultProgState: ProgBarData = { 21 | // color: 'green', 22 | // duration: 5000, 23 | // id: 'nice-one', 24 | // }; 25 | 26 | const TICK_DURATION = 100; 27 | 28 | export const ProgressBarWrapper: React.FC = () => { 29 | const [progState, setProgState] = useState(null); 30 | const [progValue, setProgValue] = useState(0); 31 | const [progVisible, setProgVisible] = useState(true); 32 | const intervalCountRef = useRef(0); 33 | 34 | const resetState = () => { 35 | intervalCountRef.current = 0; 36 | setProgValue(0); 37 | setProgState(null); 38 | }; 39 | 40 | const handleProgComplete = useCallback( 41 | (progState: ProgBarData) => { 42 | if (!progVisible) return; 43 | setProgVisible(false); 44 | fetchNui('progbar-complete', { 45 | progbarId: progState.id, 46 | }); 47 | resetState(); 48 | }, 49 | [progVisible] 50 | ); 51 | 52 | const handleProgCancelled = useCallback((progState: ProgBarData) => { 53 | setProgVisible(false); 54 | fetchNui('progbar-cancel', { 55 | progbarId: progState.id, 56 | }); 57 | resetState(); 58 | }, []); 59 | 60 | const escapeKeyHandler = useCallback( 61 | (e: KeyboardEvent) => { 62 | if (!progVisible || !progState) return; 63 | 64 | if (!progState.isCancellable) return; 65 | 66 | e.preventDefault(); 67 | handleProgCancelled(progState); 68 | }, 69 | [handleProgCancelled, progVisible, progState] 70 | ); 71 | 72 | useEffect(() => { 73 | if (!progState) return; 74 | 75 | const amountOfSteps = progState.duration / TICK_DURATION; 76 | const valuePerStep = TICK_DURATION / amountOfSteps; 77 | 78 | const interval = setInterval(() => { 79 | intervalCountRef.current += 1; 80 | setProgValue(prevState => prevState + valuePerStep); 81 | 82 | if (intervalCountRef.current === amountOfSteps) { 83 | clearInterval(interval); 84 | handleProgComplete(progState); 85 | } 86 | }, TICK_DURATION); 87 | 88 | return () => { 89 | clearInterval(interval); 90 | }; 91 | }, [handleProgComplete, progState]); 92 | 93 | useNuiEvent('setProgressBar', progBarData => { 94 | setProgState(progBarData); 95 | setProgVisible(true); 96 | }); 97 | 98 | useNuiEvent('closeProgbar', () => { 99 | setProgVisible(false); 100 | resetState(); 101 | }); 102 | 103 | useKey('Escape', escapeKeyHandler); 104 | 105 | if (!progState) return null; 106 | 107 | return ( 108 | 116 | 117 | 123 | 124 | {progValue}% 125 | 126 | 127 | 128 | 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /web/src/config/defaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { UserSettings } from '../types/settings.types'; 2 | 3 | // We use these default settings in browser 4 | export const defaultSettings: UserSettings = { 5 | cinematicBars: false, 6 | screenshotMode: false, 7 | crosshairEnabled: false, 8 | crosshairColor: '#00ff04', 9 | crosshairSize: 1, 10 | statusCirclesLocation: 'bottom-right', 11 | voiceUpdateInterval: 100, 12 | cinematicBarSize: 50, 13 | healthArmorInterval: 100, 14 | }; 15 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | ButtonProps, 5 | Center, 6 | Flex, 7 | Heading, 8 | Text, 9 | } from '@chakra-ui/react'; 10 | import React from 'react'; 11 | 12 | interface SettingButtonProps extends ButtonProps { 13 | title: string; 14 | desc: string; 15 | buttonText: string; 16 | handler: () => void; 17 | } 18 | 19 | export const SettingButton: React.FC = ({ 20 | title, 21 | desc, 22 | buttonText, 23 | handler, 24 | ...props 25 | }) => { 26 | return ( 27 | 28 | 29 | 30 | {title} 31 | 32 | {desc} 33 | 34 | 35 |
36 | 39 |
40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Center, Flex, Heading, Text } from '@chakra-ui/react'; 3 | import { noop } from '../../../utils/misc'; 4 | 5 | interface SettingColorPickerProps { 6 | title: string; 7 | desc: string; 8 | value: string; 9 | handler?: (val: string) => void; 10 | } 11 | 12 | export const SettingColorPicker: React.FC = ({ 13 | title, 14 | value, 15 | desc, 16 | handler = noop, 17 | }) => ( 18 | 19 | 20 | 21 | {title} 22 | 23 | {desc} 24 | 25 | 26 |
27 | handler(e.target.value)} 29 | defaultValue={value} 30 | type={'color'} 31 | /> 32 |
33 |
34 |
35 | ); 36 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Button, 5 | Center, 6 | Flex, 7 | Heading, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | Text, 13 | } from '@chakra-ui/react'; 14 | import { noop } from '../../../utils/misc'; 15 | import { FaChevronDown } from 'react-icons/fa'; 16 | 17 | interface SettingDropdownProps { 18 | title: string; 19 | desc: string; 20 | value: string; 21 | options: string[]; 22 | handler?: (val: string) => void; 23 | } 24 | 25 | export const SettingDropdown: React.FC = ({ 26 | title, 27 | value, 28 | desc, 29 | options, 30 | handler = noop, 31 | }) => { 32 | return ( 33 | 34 | 35 | 36 | {title} 37 | 38 | {desc} 39 | 40 | 41 |
42 | 43 | } width='100%'> 44 | {value} 45 | 46 | 47 | {options.map(opt => ( 48 | handler(opt)}> 49 | {opt} 50 | 51 | ))} 52 | 53 | 54 |
55 |
56 |
57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Center, Flex, Heading, Input, Text } from '@chakra-ui/react'; 3 | import { noop } from '../../../utils/misc'; 4 | import { useAlertProvider } from '../../../providers/ToastProvider'; 5 | 6 | interface SettingInputProps { 7 | title: string; 8 | desc: string; 9 | value: number; 10 | handler?: (val: number) => void; 11 | } 12 | 13 | export const SettingInput: React.FC = ({ 14 | title, 15 | value, 16 | desc, 17 | handler = noop, 18 | }) => { 19 | const { addToast } = useAlertProvider(); 20 | 21 | const handleBlur: React.FocusEventHandler = e => { 22 | const parsedInt = parseInt(e.target.value); 23 | if (parsedInt === value) return; 24 | 25 | addToast({ 26 | message: 'Saved new value!', 27 | position: 'bottom', 28 | status: 'success', 29 | }); 30 | 31 | handler(parsedInt); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 38 | {title} 39 | 40 | {desc} 41 | 42 | 43 |
44 | 51 |
52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Center, Flex, Heading, Switch, Text } from '@chakra-ui/react'; 3 | 4 | interface SettingSwitchProps { 5 | title: string; 6 | desc: string; 7 | value: boolean; 8 | handler?: (e: React.ChangeEvent) => void; 9 | } 10 | 11 | export const SettingSwitch: React.FC = ({ 12 | title, 13 | desc, 14 | value, 15 | handler, 16 | }) => ( 17 | 18 | 19 | 20 | {title} 21 | 22 | {desc} 23 | 24 | 25 |
26 | 27 |
28 |
29 |
30 | ); 31 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | Tab, 8 | TabList, 9 | TabPanel, 10 | TabPanels, 11 | Tabs, 12 | } from '@chakra-ui/react'; 13 | import { useNuiEvent } from '../../../hooks/useNuiEvent'; 14 | import { fetchNui } from '../../../utils/fetchNui'; 15 | import { VisualSettings } from '../pages/VisualSettings'; 16 | import { AdditionalSettings } from '../pages/AdditionalSettings'; 17 | import { PerformanceSettings } from '../pages/PerformanceSettings'; 18 | 19 | export const SettingsModal: React.FC = () => { 20 | const [isOpen, setIsOpen] = useState(false); 21 | 22 | useEffect(() => { 23 | fetchNui('requestFocus', isOpen, {}); 24 | }, [isOpen]); 25 | 26 | useNuiEvent('setSettingsVisible', bool => { 27 | setIsOpen(bool); 28 | }); 29 | 30 | const handleClose = () => { 31 | fetchNui('settingsModalClosed', undefined, {}); 32 | setIsOpen(false); 33 | }; 34 | 35 | return ( 36 | 45 | 46 | 47 | 48 | 49 | 50 | Visual Settings 51 | Performance Settings 52 | Additional Settings 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /web/src/features/settings/components/SettingsSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Center, 5 | Flex, 6 | Heading, 7 | Slider, 8 | SliderFilledTrack, 9 | SliderProps, 10 | SliderThumb, 11 | SliderTrack, 12 | Text, 13 | } from '@chakra-ui/react'; 14 | 15 | interface SettingsSliderProps extends SliderProps { 16 | title: string; 17 | desc: string; 18 | value?: number; 19 | handler?: (val: number) => void; 20 | } 21 | 22 | export const SettingsSlider: React.FC = ({ 23 | title, 24 | desc, 25 | value, 26 | handler, 27 | ...props 28 | }) => ( 29 | 30 | 31 | 32 | {title} 33 | 34 | {desc} 35 | 36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 | ); 48 | -------------------------------------------------------------------------------- /web/src/features/settings/pages/AdditionalSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Button, 5 | Center, 6 | Flex, 7 | Heading, 8 | HStack, 9 | Stack, 10 | Text, 11 | } from '@chakra-ui/react'; 12 | import { SettingSwitch } from '../components/SettingSwitch'; 13 | import { 14 | checkIfValid, 15 | mergeSettings, 16 | useResetSettings, 17 | useSettings, 18 | } from '../../../state/settings.state'; 19 | import { SettingColorPicker } from '../components/SettingColorPicker'; 20 | import { SettingsSlider } from '../components/SettingsSlider'; 21 | import { SettingButton } from '../components/SettingButton'; 22 | import { useAlertDialog } from '../../../providers/AlertDialogProvider'; 23 | import { useAlertProvider } from '../../../providers/ToastProvider'; 24 | import { setClipboard } from '../../../utils/setClipboard'; 25 | import { usePromptCtx } from '../../../providers/TextPromptProvider'; 26 | import { UserSettings } from '../../../types/settings.types'; 27 | 28 | const MultiActionSettingItem: React.FC = () => { 29 | const [settings, setSettings] = useSettings(); 30 | const { addToast } = useAlertProvider(); 31 | const { openPrompt } = usePromptCtx(); 32 | 33 | const handleExportClick = () => { 34 | // Indent JSON with two spaces 35 | const settingsJson = JSON.stringify(settings, null, 2); 36 | setClipboard(settingsJson, 'chakra-modal-settings-modal'); 37 | addToast({ message: 'Copied user settings to clipboard!', status: 'info' }); 38 | }; 39 | 40 | const handleImportClick = () => { 41 | openPrompt({ 42 | shouldEmitEvent: false, 43 | runValidator: content => { 44 | try { 45 | const parsedObj = JSON.parse(content); 46 | return checkIfValid(parsedObj); 47 | } catch (e) { 48 | return false; 49 | } 50 | }, 51 | onSubmit: (val: string) => { 52 | const parsedObj: UserSettings = JSON.parse(val); 53 | setSettings(parsedObj); 54 | addToast({ 55 | message: 'Sucessfully updated settings from import', 56 | status: 'success', 57 | }); 58 | }, 59 | id: 'importSettingsPrompt', 60 | title: 'Import Settings', 61 | isClosable: true, 62 | placeholder: 'Settings JSON...', 63 | description: 64 | 'Please enter the exact exported settings JSON, otherwise, you may come across issues.', 65 | }); 66 | }; 67 | 68 | return ( 69 | 70 | 71 | 72 | Export / Import Settings 73 | 74 | Export or import user settings in JSON format 75 | 76 | 77 |
78 | 79 | 82 | 89 | 90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export const AdditionalSettings: React.FC = () => { 97 | const [settings, setSettings] = useSettings(); 98 | const resetSettings = useResetSettings(); 99 | const { openAlertDialog } = useAlertDialog(); 100 | const { addToast } = useAlertProvider(); 101 | 102 | const handleCrosshairToggle = (bool: boolean) => { 103 | setSettings(prevSettings => 104 | mergeSettings(prevSettings, { crosshairEnabled: bool }) 105 | ); 106 | }; 107 | 108 | const handleColorChange = (color: string) => { 109 | setSettings(prevSettings => 110 | mergeSettings(prevSettings, { crosshairColor: color }) 111 | ); 112 | }; 113 | 114 | const handleSizeChange = (size: number) => { 115 | setSettings(prevSettings => 116 | mergeSettings(prevSettings, { crosshairSize: size }) 117 | ); 118 | }; 119 | 120 | const handleResetUserSettings = () => { 121 | openAlertDialog({ 122 | title: 'Reset User Settings', 123 | confirmBtnText: 'Reset Settings', 124 | message: 125 | 'Please confirm that you would like to reset the user settings back to default. (You cannot undo this action)', 126 | onConfirm: () => { 127 | resetSettings(); 128 | addToast({ 129 | message: 'Reset user settings to default!', 130 | status: 'success', 131 | }); 132 | }, 133 | }); 134 | }; 135 | 136 | return ( 137 | 138 | 139 | handleCrosshairToggle(e.target.checked)} 142 | title='Enable Crosshair' 143 | desc='Enable your custom crosshair' 144 | /> 145 | 151 | 159 | 160 | 168 | 169 | 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /web/src/features/settings/pages/PerformanceSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Stack } from '@chakra-ui/react'; 3 | import { mergeSettings, useSettings } from '../../../state/settings.state'; 4 | import { SettingInput } from '../components/SettingInput'; 5 | 6 | export const PerformanceSettings: React.FC = () => { 7 | const [settings, setSettings] = useSettings(); 8 | 9 | const handleArmorIntervalChange = (ms: number) => { 10 | setSettings(prevSettings => 11 | mergeSettings(prevSettings, { healthArmorInterval: ms }) 12 | ); 13 | }; 14 | 15 | const handleVoiceIntervalChange = (ms: number) => { 16 | setSettings(prevSettings => 17 | mergeSettings(prevSettings, { healthArmorInterval: ms }) 18 | ); 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 30 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /web/src/features/settings/pages/VisualSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Stack } from '@chakra-ui/react'; 3 | import { SettingSwitch } from '../components/SettingSwitch'; 4 | import { mergeSettings, useSettings } from '../../../state/settings.state'; 5 | import { SettingDropdown } from '../components/SettingDropdown'; 6 | import { ValidStatusLocations } from '../../../types/settings.types'; 7 | import { SettingsSlider } from '../components/SettingsSlider'; 8 | 9 | export const VisualSettings: React.FC = () => { 10 | const [settings, setSettings] = useSettings(); 11 | 12 | const handleScreenshotToggle = (bool: boolean) => { 13 | setSettings(prevSettings => 14 | mergeSettings(prevSettings, { screenshotMode: bool }) 15 | ); 16 | }; 17 | 18 | const handleStatusLocation = (newLocation: string) => { 19 | setSettings(prevSettings => 20 | mergeSettings(prevSettings, { 21 | statusCirclesLocation: newLocation as ValidStatusLocations, 22 | }) 23 | ); 24 | }; 25 | 26 | const handleCinematicToggle = (bool: boolean) => { 27 | setSettings(prevSettings => 28 | mergeSettings(prevSettings, { cinematicBars: bool }) 29 | ); 30 | }; 31 | 32 | const handleBlackbarSize = (val: number) => { 33 | setSettings(prevSettings => 34 | mergeSettings(prevSettings, { cinematicBarSize: val }) 35 | ); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | handleScreenshotToggle(e.target.checked)} 44 | desc='Enables screenshot mode, disabling all current UI components from showing' 45 | value={settings.screenshotMode} 46 | /> 47 | handleCinematicToggle(e.target.checked)} 52 | /> 53 | 59 | 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /web/src/hooks/useExitListener.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from "react"; 2 | import {noop} from "../utils/misc"; 3 | import {fetchNui} from "../utils/fetchNui"; 4 | 5 | type FrameVisibleSetter = (bool: boolean) => void 6 | 7 | const LISTENED_KEYS = ["Escape", "Backspace"] 8 | 9 | // Basic hook to listen for key presses in NUI in order to exit 10 | export const useExitListener = (visibleSetter: FrameVisibleSetter) => { 11 | const setterRef = useRef(noop) 12 | 13 | useEffect(() => { 14 | setterRef.current = visibleSetter 15 | }, [visibleSetter]) 16 | 17 | useEffect(() => { 18 | const keyHandler = (e: KeyboardEvent) => { 19 | if (LISTENED_KEYS.includes(e.code)) { 20 | setterRef.current(false) 21 | fetchNui('hideFrame') 22 | } 23 | } 24 | 25 | window.addEventListener("keydown", keyHandler) 26 | 27 | return () => window.removeEventListener("keydown", keyHandler) 28 | }, []); 29 | 30 | 31 | } -------------------------------------------------------------------------------- /web/src/hooks/useHudListener.ts: -------------------------------------------------------------------------------- 1 | import { 2 | circleHudValues, 3 | HudStateAtomParam, 4 | hudStateListIds, 5 | useSetArmor, 6 | useSetHealth, 7 | } from '../state/hud.state'; 8 | import { useNuiEvent } from './useNuiEvent'; 9 | import { useSetPauseActive } from '../state/base.state'; 10 | import { useRecoilCallback } from 'recoil'; 11 | 12 | export const useHudListener = () => { 13 | const setHealth = useSetHealth(); 14 | const setArmor = useSetArmor(); 15 | const setPauseStatus = useSetPauseActive(); 16 | 17 | const createHudCircle = useRecoilCallback( 18 | ({ set }) => 19 | (opts: HudStateAtomParam) => { 20 | set(hudStateListIds, curState => [...curState, { ...opts }]); 21 | set(circleHudValues(opts.id), opts.value ?? 100); 22 | }, 23 | [] 24 | ); 25 | 26 | useNuiEvent('setHealth', setHealth); 27 | useNuiEvent('setArmor', setArmor); 28 | useNuiEvent('setPauseActive', setPauseStatus); 29 | useNuiEvent('addCircleItem', createHudCircle); 30 | }; 31 | -------------------------------------------------------------------------------- /web/src/hooks/useHudReady.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { fetchNui } from '../utils/fetchNui'; 3 | 4 | export const useHudReady = () => { 5 | useEffect(() => { 6 | fetchNui('nuiReadyForMessages', undefined, {}); 7 | }, []); 8 | }; 9 | -------------------------------------------------------------------------------- /web/src/hooks/useKey.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export const useKey = (key: string, handler: (e: KeyboardEvent) => void) => { 4 | useEffect(() => { 5 | const downHandler = (e: KeyboardEvent) => { 6 | if (e.key === key) handler(e); 7 | }; 8 | 9 | window.addEventListener('keydown', downHandler); 10 | 11 | return () => window.removeEventListener('keydown', downHandler); 12 | }, [handler, key]); 13 | }; 14 | -------------------------------------------------------------------------------- /web/src/hooks/useNuiEvent.ts: -------------------------------------------------------------------------------- 1 | import {MutableRefObject, useEffect, useRef} from "react"; 2 | import {noop} from "../utils/misc"; 3 | 4 | interface NuiMessageData { 5 | action: string; 6 | data: T; 7 | } 8 | 9 | type NuiHandlerSignature = (data: T) => void; 10 | 11 | /** 12 | * A hook that manage events listeners for receiving data from the client scripts 13 | * @param action The specific `action` that should be listened for. 14 | * @param handler The callback function that will handle data relayed by this hook 15 | * 16 | * @example 17 | * useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => { 18 | * // whatever logic you want 19 | * }) 20 | * 21 | **/ 22 | 23 | export const useNuiEvent = ( 24 | action: string, 25 | handler: (data: T) => void 26 | ) => { 27 | const savedHandler: MutableRefObject> = useRef(noop); 28 | 29 | // When handler value changes set mutable ref to handler val 30 | useEffect(() => { 31 | savedHandler.current = handler; 32 | }, [handler]); 33 | 34 | useEffect(() => { 35 | const eventListener = (event: MessageEvent>) => { 36 | const { action: eventAction, data } = event.data; 37 | 38 | if (savedHandler.current) { 39 | if (eventAction === action) { 40 | savedHandler.current(data); 41 | } 42 | } 43 | }; 44 | 45 | window.addEventListener("message", eventListener); 46 | // Remove Event Listener on component cleanup 47 | return () => window.removeEventListener("message", eventListener); 48 | }, [action]); 49 | }; -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | height: 100vh; 9 | background: none !important; 10 | overflow: hidden; 11 | } 12 | 13 | input[type="color"] { 14 | -webkit-appearance: none; 15 | border: none; 16 | } 17 | input[type="color"]::-webkit-color-swatch-wrapper { 18 | padding: 0; 19 | } 20 | input[type="color"]::-webkit-color-swatch { 21 | border: none; 22 | } 23 | 24 | #root { 25 | height: 100% 26 | } 27 | 28 | code { 29 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 30 | monospace; 31 | } 32 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import MainWrapper from './components/MainWrapper'; 5 | import { ChakraProvider } from '@chakra-ui/react'; 6 | import { customTheme } from './styles/theme'; 7 | import { TextPromptProvider } from './providers/TextPromptProvider'; 8 | import { RecoilRoot } from 'recoil'; 9 | import { registerBrowserFuncs } from './utils/registerBrowserFuncs'; 10 | import { isEnvBrowser } from './utils/misc'; 11 | import { AlertDialogProvider } from './providers/AlertDialogProvider'; 12 | import { ToastProvider } from './providers/ToastProvider'; 13 | 14 | // Register window helper functions in browser 15 | // to replicate lua behavior 16 | registerBrowserFuncs(); 17 | 18 | if (isEnvBrowser() && process.env.NODE_ENV === 'development') { 19 | const root = document.getElementById('root'); 20 | 21 | root!.style.backgroundImage = 'url("build/assets/img/p.png")'; 22 | root!.style.backgroundSize = 'cover'; 23 | root!.style.backgroundRepeat = 'no-repeat'; 24 | root!.style.backgroundPosition = 'center'; 25 | } 26 | 27 | ReactDOM.render( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | , 41 | document.getElementById('root') 42 | ); 43 | -------------------------------------------------------------------------------- /web/src/providers/AlertDialogProvider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogBody, 4 | AlertDialogContent, 5 | AlertDialogFooter, 6 | AlertDialogHeader, 7 | AlertDialogOverlay, 8 | Button, 9 | } from '@chakra-ui/react'; 10 | import React, { 11 | createContext, 12 | useCallback, 13 | useContext, 14 | useRef, 15 | useState, 16 | } from 'react'; 17 | 18 | interface AlertDialogCtxValue { 19 | dialog: OpenAlertOpts | null; 20 | openAlertDialog: (opts: OpenAlertOpts) => void; 21 | } 22 | 23 | export const AlertDialogCtx = createContext(null as any); 24 | 25 | interface OpenAlertOpts { 26 | message: string; 27 | title: string; 28 | confirmBtnText: string; 29 | onConfirm: () => void; 30 | } 31 | 32 | export const AlertDialogProvider: React.FC = ({ children }) => { 33 | const [dialogState, setDialogState] = useState(null); 34 | const [visible, setVisible] = useState(false); 35 | const cancelRef = useRef(null); 36 | 37 | const openAlertDialog = useCallback( 38 | ({ title, message, onConfirm, confirmBtnText }: OpenAlertOpts) => { 39 | setDialogState({ 40 | title, 41 | message, 42 | onConfirm, 43 | confirmBtnText, 44 | }); 45 | 46 | setVisible(true); 47 | }, 48 | [] 49 | ); 50 | 51 | const handleClose = () => { 52 | setVisible(false); 53 | setDialogState(null); 54 | }; 55 | 56 | const handleCleanUp = () => { 57 | dialogState?.onConfirm(); 58 | handleClose(); 59 | }; 60 | 61 | return ( 62 | 68 | 74 | 75 | 76 | 77 | {dialogState?.title} 78 | 79 | 80 | {dialogState?.message} 81 | 82 | 85 | 88 | 89 | 90 | 91 | 92 | {children} 93 | 94 | ); 95 | }; 96 | 97 | export const useAlertDialog = () => useContext(AlertDialogCtx); 98 | -------------------------------------------------------------------------------- /web/src/providers/TextPromptProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Context, 3 | createContext, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useState, 8 | } from 'react'; 9 | 10 | import { fetchNui } from '../utils/fetchNui'; 11 | import { useNuiEvent } from '../hooks/useNuiEvent'; 12 | import { PromptCtxValue, PromptInfo } from '../types/prompt.types'; 13 | import { useAlertProvider } from './ToastProvider'; 14 | 15 | const TextPromptCtx = createContext(null); 16 | 17 | export const usePromptCtx = () => 18 | useContext(TextPromptCtx as Context); 19 | 20 | const defaultPromptValue: PromptInfo = { 21 | placeholder: 'Enter here', 22 | description: 'adadadadadadadadadadadadadadada', 23 | id: '132131', 24 | title: 'adada', 25 | isClosable: true, 26 | }; 27 | 28 | export const TextPromptProvider: React.FC = ({ children }) => { 29 | const [promptVisible, setPromptVisible] = useState(false); 30 | const [promptInfo, setPromptInfo] = useState(defaultPromptValue); 31 | const { addToast } = useAlertProvider(); 32 | 33 | useEffect(() => { 34 | fetchNui('requestFocus', promptVisible, {}); 35 | }, [promptVisible]); 36 | 37 | const openPrompt = useCallback((promptInfo: PromptInfo) => { 38 | setPromptInfo(promptInfo); 39 | setPromptVisible(true); 40 | }, []); 41 | 42 | const handleSubmitPrompt = useCallback( 43 | (promptId: string, content: string) => { 44 | if (promptInfo.runValidator) { 45 | const isValid = promptInfo.runValidator(content); 46 | 47 | if (!isValid) 48 | return addToast({ 49 | message: 'Invalid settings schema detected!', 50 | status: 'error', 51 | }); 52 | } 53 | 54 | if (promptInfo.shouldEmitEvent) { 55 | fetchNui(`promptNuiResp-${promptId}`, ['submitted', content], {}); 56 | } 57 | setPromptVisible(false); 58 | if (promptInfo.onSubmit) promptInfo.onSubmit(content); 59 | 60 | setPromptInfo(defaultPromptValue); 61 | }, 62 | [addToast, promptInfo] 63 | ); 64 | 65 | const handleClosePrompt = useCallback( 66 | (promptId: string) => { 67 | setPromptVisible(false); 68 | setPromptInfo(defaultPromptValue); 69 | if (promptInfo.shouldEmitEvent) { 70 | fetchNui(`promptNuiResp-${promptId}`, ['closed', null], {}); 71 | } 72 | }, 73 | [promptInfo.shouldEmitEvent] 74 | ); 75 | 76 | useNuiEvent('openPrompt', data => { 77 | openPrompt({ ...data, shouldEmitEvent: true }); 78 | }); 79 | 80 | useNuiEvent('closePrompt', handleClosePrompt); 81 | 82 | return ( 83 | 92 | {children} 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /web/src/providers/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext } from 'react'; 2 | import { ToastId, ToastOptions, useToast } from '@chakra-ui/react'; 3 | import { useNuiEvent } from '../hooks/useNuiEvent'; 4 | 5 | export interface ToastOpts { 6 | position?: ToastOptions['position']; 7 | status?: 'success' | 'error' | 'warning' | 'info'; 8 | id: ToastOptions['id']; 9 | message: string; 10 | title?: string; 11 | } 12 | 13 | export interface AddToastOptions { 14 | position?: ToastOptions['position']; 15 | status?: 'success' | 'error' | 'warning' | 'info'; 16 | message: string; 17 | title?: string; 18 | duration?: number; 19 | } 20 | 21 | interface ToastCtxValue { 22 | addPersistentToast: (toastOpts: ToastOpts) => void; 23 | clearPersistentToast: (id: ToastOptions['id']) => void; 24 | addToast: (opts: AddToastOptions) => void; 25 | } 26 | 27 | const ToastCtx = createContext(null); 28 | 29 | // 30 | // debugData([ 31 | // { 32 | // action: 'addPersistentToast', 33 | // data: { 34 | // id: 'niceToast', 35 | // position: 'top-right', 36 | // status: 'error', 37 | // message: 'Uh oh spaghettios', 38 | // }, 39 | // }, 40 | // ]); 41 | 42 | export const ToastProvider: React.FC = ({ children }) => { 43 | const toast = useToast(); 44 | 45 | const addPersistentToast = useCallback( 46 | (toastOpts: ToastOpts) => { 47 | toast({ 48 | id: toastOpts.id, 49 | title: toastOpts.title, 50 | description: toastOpts.message, 51 | position: toastOpts.position, 52 | status: toastOpts.status, 53 | isClosable: false, 54 | duration: null, 55 | }); 56 | }, 57 | [toast] 58 | ); 59 | 60 | const clearPersistentToast = useCallback( 61 | (toastId: ToastId) => { 62 | toast.close(toastId); 63 | }, 64 | [toast] 65 | ); 66 | 67 | const addToast = useCallback( 68 | (toastOpts: AddToastOptions) => { 69 | toast({ 70 | position: toastOpts.position, 71 | isClosable: false, 72 | title: toastOpts.title, 73 | description: toastOpts.message, 74 | status: toastOpts.status, 75 | duration: toastOpts.duration, 76 | }); 77 | }, 78 | [toast] 79 | ); 80 | 81 | const closeAllToasts = useCallback(() => { 82 | toast.closeAll(); 83 | }, [toast]); 84 | 85 | useNuiEvent('addToast', addToast); 86 | useNuiEvent('addPersistentToast', addPersistentToast); 87 | useNuiEvent('clearPersistentToast', clearPersistentToast); 88 | useNuiEvent('closeAllToasts', closeAllToasts); 89 | 90 | return ( 91 | 94 | {children} 95 | 96 | ); 97 | }; 98 | 99 | // @ts-ignore 100 | export const useAlertProvider = () => useContext(ToastCtx); 101 | -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /web/src/state/base.state.ts: -------------------------------------------------------------------------------- 1 | import { atom, useRecoilValue, useSetRecoilState } from 'recoil'; 2 | 3 | const baseState = { 4 | settingsVisible: atom({ 5 | key: 'visible', 6 | default: false, 7 | }), 8 | isPauseActive: atom({ 9 | key: 'pauseActive', 10 | default: false, 11 | }), 12 | }; 13 | 14 | export const useSetSettingsVisible = () => 15 | useSetRecoilState(baseState.settingsVisible); 16 | export const useSettingsVisibleValue = () => 17 | useRecoilValue(baseState.settingsVisible); 18 | 19 | export const usePauseActiveValue = () => 20 | useRecoilValue(baseState.isPauseActive); 21 | export const useSetPauseActive = () => 22 | useSetRecoilState(baseState.isPauseActive); 23 | -------------------------------------------------------------------------------- /web/src/state/hud.state.ts: -------------------------------------------------------------------------------- 1 | import { atom, atomFamily, useRecoilValue, useSetRecoilState } from 'recoil'; 2 | 3 | const hudState = { 4 | health: atom({ 5 | key: 'health', 6 | default: 100, 7 | }), 8 | armor: atom({ 9 | key: 'armor', 10 | default: 100, 11 | }), 12 | }; 13 | 14 | export interface HudStateAtomParam { 15 | id: string; 16 | iconColor?: string; 17 | iconName: string; 18 | trackColor?: string; 19 | color?: string; 20 | min?: number; 21 | max?: number; 22 | value?: number; 23 | } 24 | 25 | export const hudStateListIds = atom({ 26 | key: 'hudCircleAtomsIDs', 27 | default: [], 28 | }); 29 | 30 | export const circleHudValues = atomFamily({ 31 | key: 'hudCircleAtoms', 32 | default: 100, 33 | }); 34 | 35 | export const useHealthValue = () => useRecoilValue(hudState.health); 36 | export const useSetHealth = () => useSetRecoilState(hudState.health); 37 | 38 | export const useArmorValue = () => useRecoilValue(hudState.armor); 39 | export const useSetArmor = () => useSetRecoilState(hudState.armor); 40 | -------------------------------------------------------------------------------- /web/src/state/settings.state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | atom, 3 | DefaultValue, 4 | selector, 5 | useRecoilState, 6 | useRecoilValue, 7 | useResetRecoilState, 8 | useSetRecoilState, 9 | } from 'recoil'; 10 | import { defaultSettings } from '../config/defaultSettings'; 11 | import { getResourceName, isEnvBrowser } from '../utils/misc'; 12 | import { UserSettings } from '../types/settings.types'; 13 | import { fetchNui } from '../utils/fetchNui'; 14 | import { validate } from 'jsonschema'; 15 | 16 | const validSchema = { 17 | id: '/SettingsObject', 18 | type: 'object', 19 | properties: { 20 | cinematicBars: { type: 'boolean', required: true }, 21 | screenshotMode: { type: 'boolean', required: true }, 22 | crosshairEnabled: { type: 'boolean', required: true }, 23 | crosshairColor: { type: 'string', required: true }, 24 | crosshairSize: { type: 'number', required: true }, 25 | statusCirclesLocation: { type: 'string', required: true }, 26 | voiceUpdateInterval: { type: 'number', required: true }, 27 | cinematicBarSize: { type: 'number', required: true }, 28 | healthArmorInterval: { type: 'number', required: true }, 29 | }, 30 | }; 31 | 32 | export const checkIfValid = (settings: unknown): boolean => { 33 | const res = validate(settings, validSchema, { 34 | allowUnknownAttributes: false, 35 | required: true, 36 | }); 37 | 38 | return res.valid; 39 | }; 40 | 41 | // Cant be othered to type this right now, doesnt matter anyways 42 | const localStorageEffect = 43 | (key: string) => 44 | // @ts-ignore 45 | ({ setSelf, onSet }) => { 46 | const savedValue = localStorage.getItem(key); 47 | 48 | if (savedValue !== null) { 49 | setSelf(JSON.parse(savedValue)); 50 | console.log('updating settings in lua'); 51 | fetchNui('userSettingsUpdated', savedValue, {}).catch(); 52 | } 53 | 54 | // @ts-ignore 55 | onSet(newValue => { 56 | if (newValue instanceof DefaultValue) { 57 | localStorage.removeItem(key); 58 | } else { 59 | fetchNui('userSettingsUpdated', newValue, {}).catch(); 60 | localStorage.setItem(key, JSON.stringify(newValue)); 61 | } 62 | }); 63 | }; 64 | 65 | export const mergeSettings = ( 66 | oldSettings: UserSettings, 67 | newSettings: Partial 68 | ) => ({ ...oldSettings, ...newSettings }); 69 | 70 | const currentSettings = atom({ 71 | key: 'userSettings', 72 | effects_UNSTABLE: [ 73 | localStorageEffect('PE-UI'), 74 | ({ onSet }) => { 75 | onSet(settingData => fetchNui('userSettingsUpdated', settingData, {})); 76 | }, 77 | ], 78 | default: selector({ 79 | key: 'defaultUserSettings', 80 | get: async () => { 81 | try { 82 | if (isEnvBrowser()) return defaultSettings; 83 | 84 | const resName = getResourceName(); 85 | const resp = await fetch(`https://cfx-nui-${resName}/config.json`, { 86 | method: 'GET', 87 | }); 88 | const formatResp: UserSettings = (await resp.json()).defaultHUDSettings; 89 | return formatResp; 90 | } catch (e) { 91 | return defaultSettings; 92 | } 93 | }, 94 | }), 95 | }); 96 | 97 | export const useSettings = () => useRecoilState(currentSettings); 98 | export const useSettingsValue = () => useRecoilValue(currentSettings); 99 | export const useSetSettings = () => useSetRecoilState(currentSettings); 100 | 101 | export const useResetSettings = () => useResetRecoilState(currentSettings); 102 | -------------------------------------------------------------------------------- /web/src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme, ThemeConfig } from '@chakra-ui/react'; 2 | 3 | const themeConfig: ThemeConfig = { 4 | initialColorMode: 'dark', 5 | useSystemColorMode: false, 6 | }; 7 | 8 | export const customTheme = extendTheme({ config: themeConfig }); 9 | -------------------------------------------------------------------------------- /web/src/types/prompt.types.ts: -------------------------------------------------------------------------------- 1 | export interface PromptCtxValue { 2 | visible: boolean; 3 | promptInfo: PromptInfo; 4 | openPrompt: (info: PromptInfo) => void; 5 | handleSubmitPrompt: (promptId: string, content: string) => void; 6 | handleClosePrompt: (promptId: string) => void; 7 | } 8 | 9 | export interface PromptInfo { 10 | placeholder?: string; 11 | description: string; 12 | runValidator?: (content: string) => boolean; 13 | onSubmit?: (val: string) => void; 14 | shouldEmitEvent?: boolean; 15 | id: string; 16 | title: string; 17 | isClosable?: boolean; 18 | } 19 | -------------------------------------------------------------------------------- /web/src/types/settings.types.ts: -------------------------------------------------------------------------------- 1 | export type ValidStatusLocations = 2 | | 'bottom' 3 | | 'bottom-left' 4 | | 'bottom-right' 5 | | 'top' 6 | | 'top-left' 7 | | 'top-right'; 8 | 9 | export interface UserSettings { 10 | cinematicBars: boolean; 11 | crosshairColor: string; 12 | crosshairSize: number; 13 | crosshairEnabled: boolean; 14 | cinematicBarSize: number; 15 | screenshotMode: boolean; 16 | statusCirclesLocation: ValidStatusLocations; 17 | voiceUpdateInterval: number; 18 | healthArmorInterval: number; 19 | } 20 | -------------------------------------------------------------------------------- /web/src/utils/DebugObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useRecoilSnapshot } from 'recoil'; 3 | 4 | export function DebugObserver() { 5 | const snapshot = useRecoilSnapshot(); 6 | useEffect(() => { 7 | console.debug('The following atoms were modified:'); 8 | for (const node of snapshot.getNodes_UNSTABLE({ isModified: true })) { 9 | console.debug(node.key, snapshot.getLoadable(node)); 10 | } 11 | }, [snapshot]); 12 | 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/utils/debugData.ts: -------------------------------------------------------------------------------- 1 | import { isEnvBrowser } from './misc'; 2 | 3 | interface DebugEvent { 4 | action: string; 5 | data: T; 6 | } 7 | 8 | /** 9 | * Emulates dispatching an event using SendNuiMessage in the lua scripts. 10 | * This is used when developing in browser 11 | * 12 | * @param events - The event you want to cover 13 | * @param timer - How long until it should trigger (ms) 14 | */ 15 | export const debugData =

(events: DebugEvent

[], timer = 500): void => { 16 | if (process.env.NODE_ENV === 'development' && isEnvBrowser()) { 17 | for (const event of events) { 18 | setTimeout(() => { 19 | window.dispatchEvent( 20 | new MessageEvent('message', { 21 | data: { 22 | action: event.action, 23 | data: event.data, 24 | }, 25 | }) 26 | ); 27 | }, timer); 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /web/src/utils/fetchNui.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple wrapper around fetch API tailored for CEF/NUI use. This abstraction 3 | * can be extended to include AbortController if needed or if the response isn't 4 | * JSON. Tailor it to your needs. 5 | * 6 | * @param eventName - The endpoint eventname to target 7 | * @param data - Data you wish to send in the NUI Callback 8 | * 9 | * @return returnData - A promise for the data sent back by the NuiCallbacks CB argument 10 | */ 11 | import { isEnvBrowser } from './misc'; 12 | 13 | export async function fetchNui( 14 | eventName: string, 15 | data?: any, 16 | mockData?: T 17 | ): Promise { 18 | const options = { 19 | method: 'post', 20 | headers: { 21 | 'Content-Type': 'application/json; charset=UTF-8', 22 | }, 23 | body: JSON.stringify(data), 24 | }; 25 | 26 | if (isEnvBrowser() && mockData !== undefined) { 27 | return mockData; 28 | } 29 | 30 | const resourceName = (window as any).GetParentResourceName 31 | ? (window as any).GetParentResourceName() 32 | : 'nui-frame-app'; 33 | 34 | const resp = await fetch(`https://${resourceName}/${eventName}`, options); 35 | 36 | return await resp.json(); 37 | } 38 | -------------------------------------------------------------------------------- /web/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | // Will return whether the current environment is in a regular browser 2 | // and not CEF 3 | export const isEnvBrowser = (): boolean => !(window as any).invokeNative; 4 | 5 | // Basic no operation function 6 | export const noop = () => {}; 7 | 8 | export const getResourceName = (): string => 9 | (window as any).GetParentResourceName 10 | ? (window as any).GetParentResourceName() 11 | : 'pe-ui'; 12 | 13 | export const Delay = (ms: number) => new Promise(resp => setTimeout(resp, ms)); 14 | -------------------------------------------------------------------------------- /web/src/utils/registerBrowserFuncs.ts: -------------------------------------------------------------------------------- 1 | import { Delay, isEnvBrowser } from './misc'; 2 | import { debugData } from './debugData'; 3 | import { PromptInfo } from '../types/prompt.types'; 4 | import { HudStateAtomParam } from '../state/hud.state'; 5 | import { AddToastOptions, ToastOpts } from '../providers/ToastProvider'; 6 | import { ToastId } from '@chakra-ui/react'; 7 | 8 | (window as any).pe = {}; 9 | 10 | const castWindow = (window as any).pe; 11 | 12 | function dispatchNuiEvent(action: string, data: T) { 13 | window.dispatchEvent( 14 | new MessageEvent('message', { 15 | data: { 16 | action, 17 | data, 18 | }, 19 | }) 20 | ); 21 | } 22 | 23 | // Just a simple function that will attach useful window functions whenever 24 | // in browser (makes it easy to mock Lua actions). 25 | export const registerBrowserFuncs = async () => { 26 | if (!isEnvBrowser()) return; 27 | 28 | castWindow.dispatchNuiEvent = dispatchNuiEvent; 29 | 30 | castWindow.addCircleItem = () => { 31 | debugData([ 32 | { 33 | data: { 34 | id: 'oxygen', 35 | value: 100, 36 | iconName: 'FaSwimmer', 37 | }, 38 | action: 'addCircleItem', 39 | }, 40 | ]); 41 | }; 42 | 43 | castWindow.openSettings = (bool: boolean) => { 44 | dispatchNuiEvent('setSettingsVisible', bool); 45 | }; 46 | 47 | castWindow.setArmor = (amount: number) => { 48 | dispatchNuiEvent('setArmor', amount); 49 | }; 50 | 51 | castWindow.testNotifications = async () => { 52 | dispatchNuiEvent('addToast', { 53 | message: 'This is my toast description', 54 | position: 'top-right', 55 | duration: 5000, 56 | status: 'success', 57 | }); 58 | 59 | dispatchNuiEvent('addToast', { 60 | message: 'This is my toast description', 61 | title: 'Title test', 62 | position: 'top-left', 63 | duration: 5000, 64 | status: 'success', 65 | }); 66 | 67 | dispatchNuiEvent('addToast', { 68 | message: 'This is my toast description', 69 | title: 'Title test', 70 | position: 'top', 71 | duration: 5000, 72 | status: 'error', 73 | }); 74 | 75 | dispatchNuiEvent('addToast', { 76 | message: 'Error test', 77 | title: 'Title test', 78 | position: 'top-right', 79 | duration: 5000, 80 | status: 'error', 81 | }); 82 | 83 | dispatchNuiEvent('addToast', { 84 | message: 'warning test', 85 | title: 'Title test', 86 | position: 'bottom-right', 87 | duration: 5000, 88 | status: 'warning', 89 | }); 90 | 91 | await Delay(3000); 92 | 93 | dispatchNuiEvent('addToast', { 94 | message: 'Error test', 95 | title: 'Title test', 96 | position: 'top-left', 97 | duration: 3000, 98 | status: 'info', 99 | }); 100 | 101 | await Delay(1000); 102 | 103 | dispatchNuiEvent('addToast', { 104 | message: 'Nice test', 105 | title: 'Title test', 106 | position: 'bottom-left', 107 | duration: 3000, 108 | status: 'info', 109 | }); 110 | }; 111 | 112 | castWindow.testPersistentNotis = () => { 113 | dispatchNuiEvent('addPersistentToast', { 114 | id: 'myPersistentNoti', 115 | message: 'Persistent notification test', 116 | position: 'top-right', 117 | }); 118 | 119 | dispatchNuiEvent('addPersistentToast', { 120 | id: 'myPersistentNoti2', 121 | message: 'Persistent notification test', 122 | position: 'top-left', 123 | }); 124 | }; 125 | 126 | castWindow.clearPersistentNotis = () => { 127 | dispatchNuiEvent('clearPersistentToast', 'myPersistentNoti'); 128 | dispatchNuiEvent('clearPersistentToast', 'myPersistentNoti2'); 129 | }; 130 | 131 | castWindow.setHealth = (amount: number) => { 132 | dispatchNuiEvent('setHealth', amount); 133 | }; 134 | 135 | castWindow.toggleOnVoice = (toggledOn: boolean) => { 136 | dispatchNuiEvent('setIsTalking', toggledOn); 137 | }; 138 | 139 | castWindow.switchVoiceMode = (voiceMode: number) => { 140 | dispatchNuiEvent('setVoiceRange', voiceMode); 141 | }; 142 | 143 | castWindow.toggleCMode = (bool: boolean) => { 144 | dispatchNuiEvent('cinematicModeToggle', bool); 145 | }; 146 | 147 | castWindow.openPrompt = (promptData: PromptInfo) => { 148 | dispatchNuiEvent('openPrompt', promptData); 149 | }; 150 | 151 | castWindow.closePrompt = (promptId: string) => { 152 | dispatchNuiEvent('closePrompt', promptId); 153 | }; 154 | 155 | await Delay(100); 156 | 157 | console.log( 158 | '%cBrowser Commands', 159 | 'color: green; font-size: 30px; font-weight: bold;' 160 | ); 161 | 162 | console.log('%cTrigger using pe.FUNC_NAME', 'color: green; font-size: 15px'); 163 | 164 | console.dir(castWindow); 165 | }; 166 | -------------------------------------------------------------------------------- /web/src/utils/setClipboard.ts: -------------------------------------------------------------------------------- 1 | // Until we have access to CEFs ClipboardAPI, we will need 2 | // to use the scuffed execCommand from back in the day 3 | export const setClipboard = (content: string, tgtWrapper?: string) => { 4 | const clipEl = document.createElement('input'); 5 | clipEl.value = content; 6 | clipEl.style.height = '0'; 7 | 8 | const tgt = tgtWrapper 9 | ? document.getElementById(tgtWrapper) ?? document.body 10 | : document.body; 11 | 12 | tgt!.appendChild(clipEl); 13 | clipEl.select(); 14 | document.execCommand('copy'); 15 | }; 16 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "skipDefaultLibCheck": true, 10 | "sourceMap": true, 11 | "downlevelIteration": true, 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "jsx": "react-jsx" 25 | }, 26 | "include": [ 27 | "src" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | common-tags@^1.8.2: 6 | version "1.8.2" 7 | resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" 8 | integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== 9 | 10 | lru-cache@^6.0.0: 11 | version "6.0.0" 12 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" 13 | integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== 14 | dependencies: 15 | yallist "^4.0.0" 16 | 17 | node-fetch@^2.6.6: 18 | version "2.6.7" 19 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" 20 | integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== 21 | dependencies: 22 | whatwg-url "^5.0.0" 23 | 24 | semver@^7.3.5: 25 | version "7.3.5" 26 | resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" 27 | integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== 28 | dependencies: 29 | lru-cache "^6.0.0" 30 | 31 | tr46@~0.0.3: 32 | version "0.0.3" 33 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 34 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 35 | 36 | webidl-conversions@^3.0.0: 37 | version "3.0.1" 38 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 39 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 40 | 41 | whatwg-url@^5.0.0: 42 | version "5.0.0" 43 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 44 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 45 | dependencies: 46 | tr46 "~0.0.3" 47 | webidl-conversions "^3.0.0" 48 | 49 | yallist@^4.0.0: 50 | version "4.0.0" 51 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" 52 | integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 53 | --------------------------------------------------------------------------------