commit f16a32b63e8677620e9232414f564705e89363bd
Author: Sticks <sticks@teamhydra.dev>
Date:   Sun Jun 9 20:24:22 2024 -0500

    Initial commit

diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..4afbbb9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug Report
+about: Create a bug report to help solve an issue
+title: 'Bug: TITLE'
+labels: New Issue
+assignees: ''
+---
+
+**Describe the issue**
+
+A clear and concise description of what the bug is.
+
+**Expected behavior**
+
+A clear and concise description of what you expected to happen.
+
+**To Reproduce**
+
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+
+**Media**
+
+If applicable, add a screenshot or a video to help explain your problem.
+
+**Needed information (please complete the following information):**
+- **Client Version:**: [e.g. Canary or Release]
+- **Template Version**: [e.g. 3486] Don't know?~~Check the version in your package.json~~
+
+**Additional context**
+Add any other context about the issue here.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4fa52ae
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: Main CI
+on: [push, pull_request]
+jobs:
+  build:
+    name: Build Test
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: web
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Setup node environment
+        uses: actions/setup-node@v2
+        with:
+          node-version: 20.x
+      - name: Get yarn cache directory path
+        id: yarn-cache-dir-path
+        run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
+      - uses: actions/cache@v2
+        id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
+        with:
+          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-yarn-
+      - name: Install deps
+        run: yarn --frozen-lockfile
+      - name: Try build
+        run: yarn build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..6ff9176
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,30 @@
+name: Tagged Release
+on:
+  push:
+    tags:
+      - "v*"
+jobs:
+  create-tagged-release:
+    name: Create Release
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout source
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+          ref: ${{ github.ref }}
+      - name: Get tag
+        run: echo ::set-output name=VERSION_TAG::${GITHUB_REF/refs\/tags\//}
+        id: get_tag
+      - name: 'Setup Node.js'
+        uses: 'actions/setup-node@v1'
+        with:
+          node-version: 20.x
+      - name: Create release
+        uses: marvinpinto/action-automatic-releases@latest
+        with:
+          title: React/Lua Boilerplate - ${{ steps.get_tag.outputs.VERSION_TAG }}
+          repo_token: ${{ secrets.GITHUB_TOKEN }}
+          prerelease: false
+        id: auto_release
+          
\ No newline at end of file
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 @@
+<div align="center">
+    <img href="https://projecterror.dev" width="150" src="https://i.tasoagc.dev/c1pD" alt="Material-UI logo" />
+</div>
+<h1 align="center">FiveM React and Lua Boilerplate</h1>
+
+<div align="center">
+A simple and extendable React (TypeScript) boilerplate designed around the Lua ScRT
+</div>
+
+<div align="center">
+
+[![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)
+</div>
+
+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<string>('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(
+    <div>
+      <h1>Some component</h1>
+      <p>{state}</p>
+    </div>
+  )
+}
+
+```
+
+**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<ReturnData>('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/client.lua b/client/client.lua
new file mode 100644
index 0000000..9bb1eca
--- /dev/null
+++ b/client/client.lua
@@ -0,0 +1,25 @@
+local function toggleNuiFrame(shouldShow)
+  SetNuiFocus(shouldShow, shouldShow)
+  SendReactMessage('setVisible', shouldShow)
+end
+
+RegisterCommand('show-nui', function()
+  toggleNuiFrame(true)
+  debugPrint('Show NUI frame')
+end)
+
+RegisterNUICallback('hideFrame', function(_, cb)
+  toggleNuiFrame(false)
+  debugPrint('Hide NUI frame')
+  cb({})
+end)
+
+RegisterNUICallback('getClientData', function(data, cb)
+  debugPrint('Data sent by React', json.encode(data))
+
+-- Lets send back client coords to the React frame for use
+  local curCoords = GetEntityCoords(PlayerPedId())
+
+  local retData <const> = { x = curCoords.x, y = curCoords.y, z = curCoords.z }
+  cb(retData)
+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 <const> = { ... }
+
+  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/fxmanifest.lua b/fxmanifest.lua
new file mode 100644
index 0000000..53c96b6
--- /dev/null
+++ b/fxmanifest.lua
@@ -0,0 +1,23 @@
+fx_version "cerulean"
+
+description "Basic React (TypeScript) & Lua Game Scripts Boilerplate"
+author "Project Error"
+version '1.0.0'
+repository 'https://github.com/project-error/fivem-react-boilerplate-lua'
+
+lua54 'yes'
+
+games {
+  "gta5",
+  "rdr3"
+}
+
+ui_page 'web/build/index.html'
+
+client_script "client/**/*"
+server_script "server/**/*"
+
+files {
+	'web/build/index.html',
+	'web/build/**/*',
+}
\ 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 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>NUI React Boilerplate</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
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<ReturnClientDataCompProps> = ({
+  data,
+}) => (
+  <>
+    <h5>Returned Data:</h5>
+    <pre>
+      <code>{JSON.stringify(data, null)}</code>
+    </pre>
+  </>
+);
+
+interface ReturnData {
+  x: number;
+  y: number;
+  z: number;
+}
+
+const App: React.FC = () => {
+  const [clientData, setClientData] = useState<ReturnData | null>(null);
+
+  const handleGetClientData = () => {
+    fetchNui<ReturnData>("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 (
+    <div className="nui-wrapper">
+      <div className="popup-thing">
+        <div>
+          <h1>This is the NUI Popup!</h1>
+          <p>Exit with the escape key</p>
+          <button onClick={handleGetClientData}>Get Client Data</button>
+          {clientData && <ReturnClientDataComp data={clientData} />}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+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<T = unknown> {
+  action: string;
+  data: T;
+}
+
+type NuiHandlerSignature<T> = (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 = <T = unknown>(
+  action: string,
+  handler: (data: T) => void,
+) => {
+  const savedHandler: MutableRefObject<NuiHandlerSignature<T>> = useRef(noop);
+
+  // Make sure we handle for a reactive handler
+  useEffect(() => {
+    savedHandler.current = handler;
+  }, [handler]);
+
+  useEffect(() => {
+    const eventListener = (event: MessageEvent<NuiMessageData<T>>) => {
+      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(
+  <React.StrictMode>
+    <VisibilityProvider>
+      <App />
+    </VisibilityProvider>
+  </React.StrictMode>,
+);
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<VisibilityProviderValue | null>(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<boolean>("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 (
+    <VisibilityCtx.Provider
+      value={{
+        visible,
+        setVisible,
+      }}
+    >
+      <div
+        style={{ visibility: visible ? "visible" : "hidden", height: "100%" }}
+      >
+        {children}
+      </div>
+    </VisibilityCtx.Provider>
+  );
+};
+
+export const useVisibility = () =>
+  useContext<VisibilityProviderValue>(
+    VisibilityCtx as Context<VisibilityProviderValue>,
+  );
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<T = unknown> {
+  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 = <P>(events: DebugEvent<P>[], 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<T = unknown>(
+  eventName: string,
+  data?: unknown,
+  mockData?: T,
+): Promise<T> {
+  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 @@
+/// <reference types="vite/client" />
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',
+  },
+});