From 1531de93bfd99247e7910a4bba67d3da34750faa Mon Sep 17 00:00:00 2001 From: Lorraxs Date: Tue, 5 Dec 2023 13:55:38 +0700 Subject: [PATCH] first commit --- .gitignore | 1 + LICENSE | 19 ++ README.md | 216 +++++++++++++++++++++++ client/impl/newbie.impl.lua | 9 + client/impl/test.impl.lua | 12 ++ client/utils.lua | 30 ++++ config.lua | 3 + fxmanifest.lua | 45 +++++ impl.lua | 124 +++++++++++++ main.lua | 139 +++++++++++++++ server/impl/test.impl.lua | 5 + server/server.lua | 0 web/.eslintrc.cjs | 18 ++ web/.gitignore | 23 +++ web/index.html | 13 ++ web/package.json | 29 +++ web/src/components/App.css | 26 +++ web/src/components/App.tsx | 66 +++++++ web/src/hooks/useNuiEvent.ts | 49 +++++ web/src/index.css | 18 ++ web/src/main.tsx | 13 ++ web/src/providers/VisibilityProvider.tsx | 64 +++++++ web/src/utils/debugData.ts | 30 ++++ web/src/utils/fetchNui.ts | 39 ++++ web/src/utils/misc.ts | 6 + web/src/vite-env.d.ts | 1 + web/tsconfig.json | 21 +++ web/tsconfig.node.json | 8 + web/vite.config.ts | 11 ++ 29 files changed, 1038 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client/impl/newbie.impl.lua create mode 100644 client/impl/test.impl.lua create mode 100644 client/utils.lua create mode 100644 config.lua create mode 100644 fxmanifest.lua create mode 100644 impl.lua create mode 100644 main.lua create mode 100644 server/impl/test.impl.lua create mode 100644 server/server.lua create mode 100644 web/.eslintrc.cjs create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/components/App.css create mode 100644 web/src/components/App.tsx create mode 100644 web/src/hooks/useNuiEvent.ts create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/providers/VisibilityProvider.tsx create mode 100644 web/src/utils/debugData.ts create mode 100644 web/src/utils/fetchNui.ts create mode 100644 web/src/utils/misc.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c657199 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Project Error + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..227d79d --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +
+ Material-UI logo +
+

FiveM React and Lua Boilerplate

+ +
+A simple and extendable React (TypeScript) boilerplate designed around the Lua ScRT +
+ +
+ +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/project-error/pe-utils/master/LICENSE) +![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord) +![David](https://img.shields.io/david/project-error/fivem-react-boilerplate-lua) +[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=project-error/fivem-react-boilerplate-lua)](https://dependabot.com) +
+ +This repository is a basic boilerplate for getting started +with React in NUI. It contains several helpful utilities and +is bootstrapped using `create-react-app`. It is for both browser +and in-game based development workflows. + +For in-game workflows, Utilizing `craco` to override CRA, we can have hot +builds that just require a resource restart instead of a full +production build + +This version of the boilerplate is meant for the CfxLua runtime. + +## Requirements +* [Node > v10.6](https://nodejs.org/en/) +* [Yarn](https://yarnpkg.com/getting-started/install) (Preferred but not required) + +*A basic understanding of the modern web development workflow. If you don't +know this yet, React might not be for you just yet.* + +## Getting Started + +First clone the repository or use the template option and place +it within your `resources` folder + +### Installation + +*The boilerplate was made using `yarn` but is still compatible with +`npm`.* + +Install dependencies by navigating to the `web` folder within +a terminal of your choice and type `npm i` or `yarn`. + +## Features + +This boilerplate comes with some utilities and examples to work off of. + +### Lua Utils + +**SendReactMessage** + +This is a small wrapper for dispatching NUI messages. This is designed +to be used with the `useNuiEvent` React hook. + +Signature +```lua +---@param action string The action you wish to target +---@param data any The data you wish to send along with this action +SendReactMessage(action, data) +``` + +Usage +```lua +SendReactMessage('setVisible', true) +``` + +**debugPrint** + +A debug printing utility that is dependent on a convar, +if the convar is set this will print out to the console. + +The convar is dependent on the name given to the resource. +It follows this format `YOUR_RESOURCE_NAME-debugMode` + +To turn on debugMode add `setr YOUR_RESOURCE_NAME-debugMode 1` to +your server.cfg or use the `setr` console command instead. + +Signature (Replicates `print`) +```lua +---@param ... any[] The arguments you wish to send +debugPrint(...) +``` + +Usage +```lua +debugPrint('wow cool string to print', true, someOtherVar) +``` + +### React Utils + +Signatures are not included for these utilities as the type definitions +are sufficient enough. + +**useNuiEvent** + +This is a custom React hook that is designed to intercept and handle +messages dispatched by the game scripts. This is the primary +way of creating passive listeners. + + +*Note: For now handlers can only be registered a single time. I haven't +come across a personal usecase for a cascading event system* + +**Usage** +```jsx +const MyComp: React.FC = () => { + const [state, setState] = useState('') + + useNuiEvent('myAction', (data) => { + // the first argument to the handler function + // is the data argument sent using SendReactMessage + + // do whatever logic u want here + setState(data) + }) + + return( +
+

Some component

+

{state}

+
+ ) +} + +``` + +**fetchNui** + +This is a simple NUI focused wrapper around the standard `fetch` API. +This is the main way to accomplish active NUI data fetching +or to trigger NUI callbacks in the game scripts. + +When using this, you must always at least callback using `{}` +in the gamescripts. + +*This can be heavily customized to your use case* + +**Usage** +```ts +// First argument is the callback event name. +fetchNui('getClientData').then(retData => { + console.log('Got return data from client scripts:') + console.dir(retData) + setClientData(retData) +}).catch(e => { + console.error('Setting mock data due to error', e) + setClientData({ x: 500, y: 300, z: 200}) +}) +``` + +**debugData** + +This is a function allowing for mocking dispatched game script +actions in a browser environment. It will trigger `useNuiEvent` handlers +as if they were dispatched by the game scripts. **It will only fire if the current +environment is a regular browser and not CEF** + +**Usage** +```ts +// This will target the useNuiEvent hooks registered with `setVisible` +// and pass them the data of `true` +debugData([ + { + action: 'setVisible', + data: true, + } +]) +``` + +**Misc Utils** + +These are small but useful included utilities. + +* `isEnvBrowser()` - Will return a boolean indicating if the current + environment is a regular browser. (Useful for logic in development) + +## Development Workflow + +This boilerplate was designed with development workflow in mind. +It includes some helpful scripts to accomplish that. + +**Hot Builds In-Game** + +When developing in-game, you can use the hot build system by +running the `start:game` script. This is essentially the start +script but it writes to disk. Meaning all that is required is a +resource restart to update the game script + +**Usage** +```sh +# yarn +yarn start:game +# npm +npm run start:game +``` + +**Production Builds** + +When you are done with development phase for your resource. You +must create a production build that is optimized and minimized. + +You can do this by running the following: + +```sh +npm run build +yarn build +``` + +## Additional Notes + +Need further support? Join our [Discord](https://discord.com/invite/HYwBjTbAY5)! diff --git a/client/impl/newbie.impl.lua b/client/impl/newbie.impl.lua new file mode 100644 index 0000000..ce02fb7 --- /dev/null +++ b/client/impl/newbie.impl.lua @@ -0,0 +1,9 @@ +local Impl = NewImpl("Newbie") + +function Impl:Init() + main:LogInfo("%s initialized", self:GetName()) +end + +function Impl:OnReady() + main:LogInfo("%s ready", self:GetName()) +end \ No newline at end of file diff --git a/client/impl/test.impl.lua b/client/impl/test.impl.lua new file mode 100644 index 0000000..c8947fe --- /dev/null +++ b/client/impl/test.impl.lua @@ -0,0 +1,12 @@ +local Impl = NewImpl("Test") + +function Impl:Init() + main:LogInfo("%s initialized", self:GetName()) + self.data = { + test = "test" + } +end + +function Impl:GetData() + return self.data +end \ No newline at end of file diff --git a/client/utils.lua b/client/utils.lua new file mode 100644 index 0000000..d78b5f4 --- /dev/null +++ b/client/utils.lua @@ -0,0 +1,30 @@ +--- A simple wrapper around SendNUIMessage that you can use to +--- dispatch actions to the React frame. +--- +---@param action string The action you wish to target +---@param data any The data you wish to send along with this action +function SendReactMessage(action, data) + SendNUIMessage({ + action = action, + data = data + }) +end + +local currentResourceName = GetCurrentResourceName() + +local debugIsEnabled = GetConvarInt(('%s-debugMode'):format(currentResourceName), 0) == 1 + +--- A simple debug print function that is dependent on a convar +--- will output a nice prettfied message if debugMode is on +function debugPrint(...) + if not debugIsEnabled then return end + local args = { ... } + + local appendStr = '' + for _, v in ipairs(args) do + appendStr = appendStr .. ' ' .. tostring(v) + end + local msgTemplate = '^3[%s]^0%s' + local finalMsg = msgTemplate:format(currentResourceName, appendStr) + print(finalMsg) +end diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..499f985 --- /dev/null +++ b/config.lua @@ -0,0 +1,3 @@ +Config = {} + +Config.Debug = true \ No newline at end of file diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..d898ed9 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,45 @@ +fx_version "cerulean" + +description "Small script for FiveM" +author "Lorraxs" +version '1.0.0' +repository 'https://github.com/Lorraxs/lr_addon' + +dependencies { + '/server:6116', + '/onesync', + 'oxmysql', + 'ox_lib', +} + +lua54 'yes' + +games { + "gta5", + "rdr3" +} + +shared_scripts { + '@ox_lib/init.lua', + '@es_extended/imports.lua', + "main.lua", + "impl.lua", + "config.lua", +} + +ui_page 'web/build/index.html' + +client_scripts { + "client/classes/*", + "client/impl/*" +} +server_script { + '@oxmysql/lib/MySQL.lua', + "server/classes/*", + "server/impl/*" +} + +files { + 'web/build/index.html', + 'web/build/**/*', +} \ No newline at end of file diff --git a/impl.lua b/impl.lua new file mode 100644 index 0000000..52274a7 --- /dev/null +++ b/impl.lua @@ -0,0 +1,124 @@ +Class = {} + +-- default (empty) constructor +function Class:Init(...) end + + +-- create a subclass +function Class:extend(obj) + local obj = obj or {} + + local function copyTable(table, destination) + local table = table or {} + local result = destination or {} + + for k, v in pairs(table) do + if not result[k] then + if type(v) == "table" and k ~= "__index" and k ~= "__newindex" then + result[k] = copyTable(v) + else + result[k] = v + end + end + end + + return result + end + + copyTable(self, obj) + + obj._ = obj._ or {} + + local mt = {} + + -- create new objects directly, like o = Object() + mt.__call = function(self, ...) + return self:new(...) + end + + -- allow for getters and setters + mt.__index = function(table, key) + local val = rawget(table._, key) + if val and type(val) == "table" and (val.get ~= nil or val.value ~= nil) then + if val.get then + if type(val.get) == "function" then + return val.get(table, val.value) + else + return val.get + end + elseif val.value then + return val.value + end + else + return val + end + end + + mt.__newindex = function(table, key, value) + local val = rawget(table._, key) + if val and type(val) == "table" and ((val.set ~= nil and val._ == nil) or val.value ~= nil) then + local v = value + if val.set then + if type(val.set) == "function" then + v = val.set(table, value, val.value) + else + v = val.set + end + end + val.value = v + if val and val.afterSet then val.afterSet(table, v) end + else + table._[key] = value + end + end + + setmetatable(obj, mt) + + return obj +end + +-- set properties outside the constructor or other functions +function Class:set(prop, value) + if not value and type(prop) == "table" then + for k, v in pairs(prop) do + rawset(self._, k, v) + end + else + rawset(self._, prop, value) + end +end + +-- create an instance of an object with constructor parameters +function Class:new(...) + local obj = self:extend({}) + if obj.Init then obj:Init(...) end + return obj +end + + +function class(attr) + attr = attr or {} + return Class:extend(attr) +end + +Impl = class() + +function Impl:GetName() + return self.name +end + +function Impl:Destroy() + self.destroyed = true + main:LogInfo("%s destroyed", self.name) +end + +function Impl:OnReady(...) +end + +function NewImpl(name) + local impl = Impl:extend({ + name = name + }) + main:RegisterImpl(name, impl) + return impl +end \ No newline at end of file diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..00ebb72 --- /dev/null +++ b/main.lua @@ -0,0 +1,139 @@ +Main = {} +if IsDuplicityVersion() then + function GetGameTimer() + return os.clock() * 1000 + end +end +function Main:Init() + local o = {} + setmetatable(o, {__index = Main}) + o.impls = {} + o.lastTimeImplRegistered = 0 + o.ready = false + if not IsDuplicityVersion() then + o.playerId = PlayerId() + o.playerPed = PlayerPedId() + o.playerCoords = GetEntityCoords(o.playerPed) + o.playerHeading = GetEntityHeading(o.playerPed) + o:Thread1() + end + o:Exports() + return o +end +if not IsDuplicityVersion() then + function Main:Thread1() + Citizen.CreateThread(function() + while true do + self.playerId = PlayerId() + self.playerPed = PlayerPedId() + self.playerCoords = GetEntityCoords(self.playerPed) + self.playerHeading = GetEntityHeading(self.playerPed) + Citizen.Wait(1000) + end + end) + end +end + +function Main:LogError(msg, ...) + if not Config.Debug then return end + print(("[^1ERROR^0] " .. msg):format(...)) +end + +function Main:LogWarning(msg, ...) + if not Config.Debug then return end + print(("[^3WARNING^0] " .. msg):format(...)) +end + +function Main:LogSuccess(msg, ...) + if not Config.Debug then return end + print(("[^2INFO^0] " .. msg):format(...)) +end + +function Main:LogInfo(msg, ...) + if not Config.Debug then return end + print(("[^5INFO^0] " .. msg):format(...)) +end + +function Main:CheckValidImpl(name, impl) + if not impl then + self:LogError("Impl %s is nil", name) + return false + end + if not impl.Init then + self:LogError("Impl %s missing Init function", name) + return false + end + return true +end + +function Main:RegisterImpl(name, impl) + if self.impls[name] then + self:LogWarning("Impl %s already registered", name) + return + end + if not self:CheckValidImpl(name, impl) then + return + end + self.impls[name] = impl + self.lastTimeImplRegistered = GetGameTimer() + self:LogSuccess("Impl %s registered", name) + if self.ready then + self.impls[name] = impl(self) + self.impls[name]:OnReady() + end +end + +function Main:InitImpl() + for name, impl in pairs(self.impls) do + self.impls[name] = impl(self) + end + self:LogInfo("All impls initialized") + self.ready = true + for name, impl in pairs(self.impls) do + impl:OnReady() + end +end + +function Main:GetImpl(name) + if not self.impls[name] then + self:LogError("Impl %s not found", name) + return + end + return self.impls[name] +end + +function Main:ImplCall(name, func, ...) + local impl = self:GetImpl(name) + if not impl then + return + end + if not impl[func] then + self:LogError("Impl %s missing function %s - args %s", name, func, json.encode({...})) + return + end + return impl[func](impl, ...) +end + +function Main:ImplInfo() + for name, impl in pairs(self.impls) do + local debug = debug.getinfo(impl.Init, "S") + self:LogInfo("Impl %s - %s", name, debug.short_src) + end +end + +function Main:Exports() + exports("ImplCall", function(name, func, ...) + return self:ImplCall(name, func, ...) + end) +end + +main = Main:Init() + +Citizen.CreateThread(function() + + while GetGameTimer() < main.lastTimeImplRegistered + 1000 do + Citizen.Wait(0) + end + main:InitImpl() + main:ImplInfo() +end) diff --git a/server/impl/test.impl.lua b/server/impl/test.impl.lua new file mode 100644 index 0000000..207f3ac --- /dev/null +++ b/server/impl/test.impl.lua @@ -0,0 +1,5 @@ +local Impl = NewImpl("Test") + +function Impl:OnReady() + main:LogInfo("%s ready", self:GetName()) +end \ No newline at end of file diff --git a/server/server.lua b/server/server.lua new file mode 100644 index 0000000..e69de29 diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..6e8698b --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..317c8cc --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +build +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b216a76 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + NUI React Boilerplate + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..57e471b --- /dev/null +++ b/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "web", + "homepage": "web/build", + "private": true, + "type": "module", + "version": "0.1.0", + "scripts": { + "start": "vite", + "start:game": "vite build --watch", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "@vitejs/plugin-react": "^4.2.0", + "eslint": "^8.54.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/web/src/components/App.css b/web/src/components/App.css new file mode 100644 index 0000000..8f56557 --- /dev/null +++ b/web/src/components/App.css @@ -0,0 +1,26 @@ +.nui-wrapper { + text-align: center; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +pre { + counter-reset:line-numbering; + background:#2c3e50; + padding:12px 0px 14px 0; + color:#ecf0f1; + line-height:140%; +} + +.popup-thing { + background: #282c34; + border-radius: 10px; + width: 500px; + height: 400px; + display: flex; + justify-content: center; + align-items: center; + color: white; +} diff --git a/web/src/components/App.tsx b/web/src/components/App.tsx new file mode 100644 index 0000000..6faaa0d --- /dev/null +++ b/web/src/components/App.tsx @@ -0,0 +1,66 @@ +import React, { useState } from "react"; +import "./App.css"; +import { debugData } from "../utils/debugData"; +import { fetchNui } from "../utils/fetchNui"; + +// This will set the NUI to visible if we are +// developing in browser +debugData([ + { + action: "setVisible", + data: true, + }, +]); + +interface ReturnClientDataCompProps { + data: unknown; +} + +const ReturnClientDataComp: React.FC = ({ + data, +}) => ( + <> +
Returned Data:
+
+      {JSON.stringify(data, null)}
+    
+ +); + +interface ReturnData { + x: number; + y: number; + z: number; +} + +const App: React.FC = () => { + const [clientData, setClientData] = useState(null); + + const handleGetClientData = () => { + fetchNui("getClientData") + .then((retData) => { + console.log("Got return data from client scripts:"); + console.dir(retData); + setClientData(retData); + }) + .catch((e) => { + console.error("Setting mock data due to error", e); + setClientData({ x: 500, y: 300, z: 200 }); + }); + }; + + return ( +
+
+
+

This is the NUI Popup!

+

Exit with the escape key

+ + {clientData && } +
+
+
+ ); +}; + +export default App; diff --git a/web/src/hooks/useNuiEvent.ts b/web/src/hooks/useNuiEvent.ts new file mode 100644 index 0000000..62c7172 --- /dev/null +++ b/web/src/hooks/useNuiEvent.ts @@ -0,0 +1,49 @@ +import { MutableRefObject, useEffect, useRef } from "react"; +import { noop } from "../utils/misc"; + +interface NuiMessageData { + action: string; + data: T; +} + +type NuiHandlerSignature = (data: T) => void; + +/** + * A hook that manage events listeners for receiving data from the client scripts + * @param action The specific `action` that should be listened for. + * @param handler The callback function that will handle data relayed by this hook + * + * @example + * useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => { + * // whatever logic you want + * }) + * + **/ + +export const useNuiEvent = ( + action: string, + handler: (data: T) => void, +) => { + const savedHandler: MutableRefObject> = useRef(noop); + + // Make sure we handle for a reactive handler + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + const eventListener = (event: MessageEvent>) => { + const { action: eventAction, data } = event.data; + + if (savedHandler.current) { + if (eventAction === action) { + savedHandler.current(data); + } + } + }; + + window.addEventListener("message", eventListener); + // Remove Event Listener on component cleanup + return () => window.removeEventListener("message", eventListener); + }, [action]); +}; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..cf3c162 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,18 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + height: 100vh; +} + +#root { + height: 100% +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..5aa0669 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { VisibilityProvider } from './providers/VisibilityProvider'; +import App from './components/App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/web/src/providers/VisibilityProvider.tsx b/web/src/providers/VisibilityProvider.tsx new file mode 100644 index 0000000..d4e270e --- /dev/null +++ b/web/src/providers/VisibilityProvider.tsx @@ -0,0 +1,64 @@ +import React, { + Context, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { useNuiEvent } from "../hooks/useNuiEvent"; +import { fetchNui } from "../utils/fetchNui"; +import { isEnvBrowser } from "../utils/misc"; + +const VisibilityCtx = createContext(null); + +interface VisibilityProviderValue { + setVisible: (visible: boolean) => void; + visible: boolean; +} + +// This should be mounted at the top level of your application, it is currently set to +// apply a CSS visibility value. If this is non-performant, this should be customized. +export const VisibilityProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [visible, setVisible] = useState(false); + + useNuiEvent("setVisible", setVisible); + + // Handle pressing escape/backspace + useEffect(() => { + // Only attach listener when we are visible + if (!visible) return; + + const keyHandler = (e: KeyboardEvent) => { + if (["Backspace", "Escape"].includes(e.code)) { + if (!isEnvBrowser()) fetchNui("hideFrame"); + else setVisible(!visible); + } + }; + + window.addEventListener("keydown", keyHandler); + + return () => window.removeEventListener("keydown", keyHandler); + }, [visible]); + + return ( + +
+ {children} +
+
+ ); +}; + +export const useVisibility = () => + useContext( + VisibilityCtx as Context, + ); diff --git a/web/src/utils/debugData.ts b/web/src/utils/debugData.ts new file mode 100644 index 0000000..0e03d3e --- /dev/null +++ b/web/src/utils/debugData.ts @@ -0,0 +1,30 @@ +import { isEnvBrowser } from "./misc"; + +interface DebugEvent { + action: string; + data: T; +} + +/** + * Emulates dispatching an event using SendNuiMessage in the lua scripts. + * This is used when developing in browser + * + * @param events - The event you want to cover + * @param timer - How long until it should trigger (ms) + */ +export const debugData =

(events: DebugEvent

[], timer = 1000): void => { + if (import.meta.env.MODE === "development" && isEnvBrowser()) { + for (const event of events) { + setTimeout(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + action: event.action, + data: event.data, + }, + }), + ); + }, timer); + } + } +}; diff --git a/web/src/utils/fetchNui.ts b/web/src/utils/fetchNui.ts new file mode 100644 index 0000000..266b019 --- /dev/null +++ b/web/src/utils/fetchNui.ts @@ -0,0 +1,39 @@ +import { isEnvBrowser } from "./misc"; + +/** + * Simple wrapper around fetch API tailored for CEF/NUI use. This abstraction + * can be extended to include AbortController if needed or if the response isn't + * JSON. Tailor it to your needs. + * + * @param eventName - The endpoint eventname to target + * @param data - Data you wish to send in the NUI Callback + * @param mockData - Mock data to be returned if in the browser + * + * @return returnData - A promise for the data sent back by the NuiCallbacks CB argument + */ + +export async function fetchNui( + eventName: string, + data?: unknown, + mockData?: T, +): Promise { + const options = { + method: "post", + headers: { + "Content-Type": "application/json; charset=UTF-8", + }, + body: JSON.stringify(data), + }; + + if (isEnvBrowser() && mockData) return mockData; + + const resourceName = (window as any).GetParentResourceName + ? (window as any).GetParentResourceName() + : "nui-frame-app"; + + const resp = await fetch(`https://${resourceName}/${eventName}`, options); + + const respFormatted = await resp.json(); + + return respFormatted; +} diff --git a/web/src/utils/misc.ts b/web/src/utils/misc.ts new file mode 100644 index 0000000..f0a087d --- /dev/null +++ b/web/src/utils/misc.ts @@ -0,0 +1,6 @@ +// Will return whether the current environment is in a regular browser +// and not CEF +export const isEnvBrowser = (): boolean => !(window as any).invokeNative; + +// Basic no operation function +export const noop = () => {}; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3d0a51a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..e993792 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "composite": true, + "module": "esnext", + "moduleResolution": "node" + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..724195e --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + base: './', + build: { + outDir: 'build', + }, +});