Merge pull request #12 from overextended/feat/ui

Feat/UI
This commit is contained in:
Linden
2022-04-11 13:55:23 +10:00
committed by GitHub
27 changed files with 12763 additions and 0 deletions

View File

@@ -33,10 +33,14 @@ dependencies {
'/onesync',
}
ui_page 'web/build/index.html'
files {
'init.lua',
'imports/**/client.lua',
'imports/**/shared.lua',
'web/build/index.html',
'web/build/**/*'
}
shared_script 'resource/main.lua'

View File

@@ -0,0 +1,42 @@
local contextMenus = {}
function lib.showContext(id)
local data = contextMenus[id]
SetNuiFocus(true, true)
SendNUIMessage({
action = 'showContext',
data = {
title = data.title,
menu = data.menu,
options = data.options
}
})
end
function lib.registerContext(context)
for k, v in pairs(context) do
if type(k) == 'number' then
contextMenus[v.id] = v
else
contextMenus[context.id] = context
break
end
end
end
RegisterNUICallback('openContext', function(id)
lib.showContext(id)
end)
RegisterNUICallback('clickContext', function(data)
if data.event then TriggerEvent(data.event, data.args) end
if data.serverEvent then TriggerServerEvent(data.serverEvent, data.args) end
SetNuiFocus(false, false)
SendNUIMessage({
action = 'hideContext'
})
end)
RegisterNUICallback('closeContext', function()
SetNuiFocus(false, false)
end)

View File

@@ -0,0 +1,19 @@
--[[```lua
{
id?: string
title?: string
description: string
duration?: number
position?: 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
style?: table
icon?: string
iconColor?: string
}
```]]
---@param data table
function lib.notify(data)
SendNUIMessage({
action = 'customNotify',
data = data
})
end

23
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

24
web/craco.config.js Normal file
View File

@@ -0,0 +1,24 @@
const path = require("path");
module.exports = {
webpack: {
configure: (webpackConfig) => {
// Because CEF has issues with loading source maps properly atm,
// lets use the best we can get in line with `eval-source-map`
if (webpackConfig.mode === 'development' && process.env.IN_GAME_DEV) {
webpackConfig.devtool = 'eval-source-map'
webpackConfig.output.path = path.join(__dirname, 'build')
}
return webpackConfig
}
},
devServer: (devServerConfig) => {
if (process.env.IN_GAME_DEV) {
// Used for in-game dev mode
devServerConfig.devMiddleware.writeToDisk = true
}
return devServerConfig
}
}

66
web/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "web",
"version": "0.1.0",
"homepage": "web/build",
"private": true,
"dependencies": {
"@chakra-ui/react": "^1.8.6",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@testing-library/jest-dom": "^5.16.3",
"@testing-library/react": "^12.1.4",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^26.0.24",
"@types/node": "^16.11.26",
"@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14",
"framer-motion": "^6.2.8",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-markdown": "^8.0.1",
"react-scripts": "5.0.0",
"typescript": "^4.6.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "cross-env PUBLIC_URL=/ craco start",
"start:game": "cross-env IN_GAME_DEV=1 craco start",
"build": "rimraf build && craco build",
"test": "craco test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/core": ">=7.0.0 <8.0.0",
"@babel/plugin-syntax-flow": "^7.14.5",
"@babel/plugin-transform-react-jsx": "^7.14.9",
"@craco/craco": "^6.4.3",
"@testing-library/dom": "^8.11.4",
"autoprefixer": "^10.0.2",
"cross-env": "^7.0.3",
"postcss": "^8.1.0",
"rimraf": "^3.0.2"
}
}

11434
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
web/public/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>NUI React Boilerplate</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

21
web/src/App.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Notifications from "./features/notifications/NotificationWrapper";
import CircleProgressbar from "./features/progress/CircleProgressbar";
import Progressbar from "./features/progress/Progressbar";
import TextUI from "./features/textui/TextUI";
import InputDialog from "./features/dialog/InputDialog";
import ContextMenu from "./features/menu/ContextMenu";
const App: React.FC = () => {
return (
<>
<Progressbar />
<CircleProgressbar />
<Notifications />
<TextUI />
<InputDialog />
<ContextMenu />
</>
);
};
export default App;

View File

@@ -0,0 +1,101 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalFooter,
ModalHeader,
ModalBody,
Box,
Input,
Text,
Button,
} from "@chakra-ui/react";
import React from "react";
import { useNuiEvent } from "../../hooks/useNuiEvent";
// import { debugData } from "../utils/debugData";
import { fetchNui } from "../../utils/fetchNui";
interface Props {
heading: string;
inputs: string[];
}
// debugData([
// {
// action: "openDialog",
// data: {
// heading: "Police locker",
// inputs: ["Locker number", "Locker PIN"],
// },
// },
// ]);
const InputDialog: React.FC = () => {
const [inputOptions, setInputOptions] = React.useState<Props>({
heading: "",
inputs: [""],
});
const [inputData, setInputData] = React.useState<string[]>([]);
const [visible, setVisible] = React.useState(false);
useNuiEvent<Props>("openDialog", (data) => {
setInputOptions(data);
setVisible(true);
});
const handleClose = () => {
setVisible(false);
fetchNui("inputData");
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
index: number
) => {
setInputData((previousData) => {
previousData[index] = e.target.value;
return previousData;
});
};
const handleConfirm = () => {
setVisible(false);
fetchNui("inputData", inputData);
};
return (
<>
<Modal
isOpen={visible}
onClose={handleClose}
isCentered
closeOnEsc
closeOnOverlayClick={false}
size="xs"
>
<ModalOverlay />
<ModalContent>
<ModalHeader textAlign="center">{inputOptions.heading}</ModalHeader>
<ModalBody>
{inputOptions.inputs.map((input: string, index: number) => (
<Box mb={3} key={`input-${index}`}>
<Text>{input}</Text>
<Input onChange={(e) => handleChange(e, index)} />
</Box>
))}
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={handleClose}>
Close
</Button>
<Button colorScheme="blue" onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default InputDialog;

View File

@@ -0,0 +1,132 @@
import { useNuiEvent } from "../../hooks/useNuiEvent";
import { Box, Text, Flex, ScaleFade } from "@chakra-ui/react";
import { debugData } from "../../utils/debugData";
import { useEffect, useState } from "react";
import { ContextMenuProps } from "../../interfaces";
import Item from "./Item";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { fetchNui } from "../../utils/fetchNui";
debugData<ContextMenuProps>([
{
action: "showContext",
data: {
title: "Vehicle garage",
options: {
"Dinka Blista": {
description: "Super cool vehicle",
menu: "some_other_identifier",
metadata: {
Plate: "KLT 192",
Status: "In garage",
Health: "30%",
},
},
"Elegy Nitro": {
description: "Even cooler vehicle",
metadata: ["Plate: JGM 971", "Status: In garage"],
},
Burger: {
description: "Make a delicious burger",
metadata: {
Bun: 3,
Lettuce: 2,
Meat: 1,
Tomato: 1,
Cheese: 2,
},
},
},
},
},
]);
const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>("openContext", id);
};
const ContextMenu: React.FC = () => {
const [visible, setVisible] = useState(false);
const [contextMenu, setContextMenu] = useState<ContextMenuProps>({
title: "",
options: { "": { description: "", metadata: [] } },
});
// Hides the context menu on ESC
useEffect(() => {
if (!visible) return;
const keyHandler = (e: KeyboardEvent) => {
if (["Escape"].includes(e.code)) {
setVisible(false);
fetchNui("closeContext");
}
};
window.addEventListener("keydown", keyHandler);
return () => window.removeEventListener("keydown", keyHandler);
}, [visible]);
useNuiEvent("hideContext", () => setVisible(false));
useNuiEvent<ContextMenuProps>("showContext", async (data) => {
if (visible) {
setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 100));
}
setContextMenu(data);
setVisible(true);
});
return (
<Flex
position="absolute"
w="75%"
h="80%"
justifyContent="flex-end"
alignItems="center"
>
<ScaleFade in={visible} unmountOnExit>
<Box w="xs" h={580}>
<Flex justifyContent="center" alignItems="center" mb={3}>
{contextMenu.menu && (
<Box
borderRadius="md"
bg="gray.800"
h="100%"
flex="1 15%"
textAlign="center"
marginRight={2}
p={2}
_hover={{ bg: "gray.700" }}
transition="300ms"
onClick={() => openMenu(contextMenu.menu)}
>
<FontAwesomeIcon icon="chevron-left" />
</Box>
)}
<Box borderRadius="md" bg="gray.800" flex="1 85%">
<Text
fontFamily="Poppins"
fontSize="md"
p={2}
textAlign="center"
fontWeight="light"
>
{contextMenu.title}
</Text>
</Box>
</Flex>
<Box maxH={560} overflowY="scroll">
{Object.entries(contextMenu.options).map((option, index) => (
<Item option={option} key={`context-item-${index}`} />
))}
</Box>
</Box>
</ScaleFade>
</Flex>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,134 @@
import {
Portal,
Popover,
PopoverTrigger,
PopoverBody,
PopoverContent,
Box,
Text,
Flex,
Spacer,
} from "@chakra-ui/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Option, ContextMenuProps } from "../../interfaces";
import { fetchNui } from "../../utils/fetchNui";
interface DataProps {
event?: string;
serverEvent?: string;
args?: any;
}
const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>("openContext", id);
};
const clickContext = (data: DataProps) => {
fetchNui("clickContext", data);
};
const Item: React.FC<{
option: [string, Option];
}> = ({ option }) => {
return (
<>
<Popover
placement="right-start"
trigger="hover"
eventListeners={{ scroll: true }}
isLazy
>
<PopoverTrigger>
<Box
bg="gray.800"
borderRadius="md"
h="fit-content"
w="100%"
p={2}
mb={1}
fontFamily="Poppins"
fontSize="md"
transition="300ms"
_hover={{ bg: "gray.700" }}
>
<Flex
w="100%"
onClick={() =>
option[1].menu
? openMenu(option[1].menu)
: clickContext({
event: option[1].event,
serverEvent: option[1].serverEvent,
args: option[1].args,
})
}
>
<Box>
<Box paddingBottom={1}>
<Text w="100%" fontWeight="medium">
{option[0]}
</Text>
</Box>
{option[1].description && (
<Box paddingBottom={1}>
<Text>{option[1].description}</Text>
</Box>
)}
</Box>
{option[1].menu && (
<>
<Spacer />
<Box
alignSelf="center"
justifySelf="center"
mr={4}
fontSize="xl"
>
<FontAwesomeIcon icon="chevron-right" />
</Box>
</>
)}
</Flex>
<Portal>
{option[1].metadata && (
<PopoverContent
fontFamily="Poppins"
bg="gray.800"
outline="none"
border="none"
w="fit-content"
maxW="2xs"
>
<PopoverBody>
{Array.isArray(option[1].metadata) ? (
option[1].metadata.map(
(metadata: string, index: number) => (
<Text key={`context-metadata-${index}`}>
{metadata}
</Text>
)
)
) : (
<>
{typeof option[1].metadata === "object" &&
Object.entries(option[1].metadata).map(
(metadata: { [key: string]: any }, index) => (
<Text key={`context-metadata-${index}`}>
{metadata[0]}: {metadata[1]}
</Text>
)
)}
</>
)}
</PopoverBody>
</PopoverContent>
)}
</Portal>
</Box>
</PopoverTrigger>
</Popover>
</>
);
};
export default Item;

View File

@@ -0,0 +1,120 @@
import {
useToast,
type ToastPositionWithLogical,
Box,
HStack,
Text,
} from "@chakra-ui/react";
import { useNuiEvent } from "../../hooks/useNuiEvent";
import { debugData } from "../../utils/debugData";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
interface Props {
title?: string;
description: string;
duration?: number;
position?: ToastPositionWithLogical;
status?: "info" | "warning" | "success" | "error";
id?: number;
}
interface CustomProps {
style?: React.CSSProperties;
description: string;
title?: string;
duration?: number;
icon?: IconProp;
iconColor?: string;
position?: ToastPositionWithLogical;
id?: number;
type?: string;
}
debugData<Props>([
{
action: "notify",
data: {
description: "Dunak is nerd",
title: "Dunak",
id: 1,
},
},
]);
debugData<CustomProps>([
{
action: "customNotify",
data: {
description: "Dunak is nerd",
title: "Dunak",
icon: "basket-shopping",
style: {
backgroundColor: "#2D3748",
color: "white",
},
},
},
]);
const Notifications: React.FC = () => {
const toast = useToast();
useNuiEvent<CustomProps>("customNotify", (data) => {
if (data.id && toast.isActive(data.id)) return;
if (!data.icon) {
//@ts-expect-error because amazing types
data.icon =
data.type === "error"
? "fa-circle-xmark"
: data.type === "success"
? "fa-circle-check"
: "fa-circle-info";
}
toast({
duration: data.duration || 3000,
position: data.position || "top-right",
render: () => (
<Box
className={`toast-${data.type || "inform"}`}
style={data.style}
p={2}
borderRadius="sm"
boxShadow="md"
>
<HStack spacing={0}>
{
<FontAwesomeIcon
//@ts-expect-error because amazing types
icon={data.icon}
fontSize="1.3em"
style={{ paddingRight: 8 }}
color={data.iconColor}
/>
}
<Box w="100%">
{data.title && <Text as="b">{data.title}</Text>}
{data.description && <Text>{data.description}</Text>}
</Box>
</HStack>
</Box>
),
});
});
useNuiEvent<Props>("notify", (data) => {
if (data.id && toast.isActive(data.id)) return;
toast({
title: data.title,
description: data.description,
duration: data.duration || 4000,
position: data.position || "top-right",
status: data.status,
});
});
return <></>;
};
export default Notifications;

View File

@@ -0,0 +1,110 @@
import React from "react";
import {
CircularProgress,
CircularProgressLabel,
Flex,
ScaleFade,
} from "@chakra-ui/react";
import { useNuiEvent } from "../../hooks/useNuiEvent";
import { debugData } from "../../utils/debugData";
import { fetchNui } from "../../utils/fetchNui";
interface Props {
duration: number;
position?: "middle" | "bottom";
percent?: boolean;
}
debugData([
{
action: "circleProgress",
data: {
duration: 8000,
},
},
]);
const CircleProgressbar: React.FC = () => {
const [visible, setVisible] = React.useState(false);
const [progressDuration, setProgressDuration] = React.useState(0);
const [position, setPosition] = React.useState<"middle" | "bottom">("middle");
const [value, setValue] = React.useState(0);
const [cancelled, setCancelled] = React.useState(false);
const progressComplete = () => {
setVisible(false);
fetchNui("progressComplete");
};
const progressCancel = () => {
setCancelled(true);
setValue(99); // Sets the final value to 100% kek
setTimeout(() => {
setVisible(false);
}, 2500);
};
useNuiEvent("progressCancel", progressCancel);
useNuiEvent<Props>("circleProgress", (data) => {
if (visible) return;
setCancelled(false);
setVisible(true);
setValue(0);
setProgressDuration(data.duration);
setPosition(data.position || "middle");
const onePercent = data.duration * 0.01;
const updateProgress = setInterval(() => {
setValue((previousValue) => {
const newValue = previousValue + 1;
newValue >= 100 && clearInterval(updateProgress);
return newValue;
});
}, onePercent);
});
return (
<Flex
h={position === "middle" ? "100%" : "20%"}
w="100%"
position="absolute"
bottom="0"
justifyContent="center"
alignItems="center"
>
<ScaleFade in={visible} unmountOnExit>
<CircularProgress
value={value}
size="5rem"
trackColor="rgba(0, 0, 0, 0.6)"
onAnimationEnd={progressComplete}
thickness={6}
color={cancelled ? "rgb(198, 40, 40)" : "white"}
sx={
!cancelled
? {
".chakra-progress__indicator": {
transition: "none !important",
animation: "progress linear forwards !important",
animationDuration: `${progressDuration}ms !important`,
opacity: "1 !important",
},
}
: {
".chakra-progress__indicator": {
transition: "none !important",
strokeDasharray: "264, 0 !important", // sets circle to full
},
}
}
>
<CircularProgressLabel fontFamily="Fira Mono">
{value}%
</CircularProgressLabel>
</CircularProgress>
</ScaleFade>
</Flex>
);
};
export default CircleProgressbar;

View File

@@ -0,0 +1,102 @@
import React from "react";
import {
Progress,
ProgressLabel,
Flex,
Box,
ScaleFade,
} from "@chakra-ui/react";
import { useNuiEvent } from "../../hooks/useNuiEvent";
import { debugData } from "../../utils/debugData";
import { fetchNui } from "../../utils/fetchNui";
interface Props {
label: string;
duration: number;
}
debugData([
{
action: "progress",
data: {
label: "Using Lockpick",
duration: 8000,
},
},
]);
const Progressbar: React.FC = () => {
const [visible, setVisible] = React.useState(false);
const [label, setLabel] = React.useState("");
const [duration, setDuration] = React.useState(0);
const [cancelled, setCancelled] = React.useState(false);
const progressComplete = () => {
setVisible(false);
fetchNui("progressComplete");
};
const progressCancel = () => {
setCancelled(true);
setTimeout(() => {
setVisible(false);
}, 2500);
};
useNuiEvent("progressCancel", progressCancel);
useNuiEvent<Props>("progress", (data) => {
if (visible) return;
setCancelled(false);
setVisible(true);
setLabel(data.label);
setDuration(data.duration);
});
return (
<Flex
h="20%"
w="100%"
position="absolute"
bottom="0"
justifyContent="center"
alignItems="center"
>
<Box width="22rem">
<ScaleFade in={visible} unmountOnExit>
<Progress
value={100}
height="2.8rem"
borderRadius="sm"
fontFamily="Inter"
flex="1 1 auto"
boxShadow="lg"
onAnimationEnd={progressComplete}
sx={
!cancelled
? {
// really scuffed solution but works, wonder if there's a better way to do this?
"> div:first-of-type": {
animation: `progress-bar linear ${duration}ms`,
borderRadius: "none",
},
}
: {
"> div:first-of-type": {
width: "100%",
backgroundColor: "rgb(198, 40, 40)",
},
}
}
>
<ProgressLabel fontSize={22} fontWeight="light">
{label}
</ProgressLabel>
</Progress>
</ScaleFade>
</Box>
</Flex>
);
};
export default Progressbar;

View File

@@ -0,0 +1,84 @@
import React from "react";
import { useNuiEvent } from "../../hooks/useNuiEvent";
import { Box, Flex, ScaleFade } from "@chakra-ui/react";
import { debugData } from "../../utils/debugData";
import ReactMarkdown from "react-markdown";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
interface Props {
text: string;
position?: "right-center" | "left-center" | "top-center";
icon?: IconProp;
iconColor?: string;
style?: React.CSSProperties;
}
debugData([
{
action: "textUi",
data: {
text: "[E] - Access locker inventory \n [G] - Do something else",
position: "right-center",
icon: "door-open",
},
},
]);
const TextUI: React.FC = () => {
const [data, setData] = React.useState<Props>({
text: "",
position: "right-center",
});
const [visible, setVisible] = React.useState(false);
useNuiEvent<Props>("textUi", (data) => {
setData(data);
setVisible(true);
});
useNuiEvent("textUiHide", () => setVisible(false));
return (
<Flex
w="100%"
h="100%"
p={3}
position="absolute"
alignItems={data.position === "top-center" ? "baseline" : "center"}
justifyContent={
data.position === "right-center"
? "flex-end"
: data.position === "left-center"
? "flex-start"
: "center"
}
>
<ScaleFade in={visible} unmountOnExit>
<Box
bg="gray.700"
boxShadow="lg"
p={3}
fontFamily="Poppins"
style={data.style}
borderRadius="md"
maxW="xs"
>
<Flex justifyContent="center" alignItems="center">
{data.icon && (
<FontAwesomeIcon
icon={data.icon}
color={data.iconColor}
fontSize="1.4rem"
style={{ paddingRight: 15 }}
/>
)}
<ReactMarkdown>{data.text}</ReactMarkdown>
</Flex>
</Box>
</ScaleFade>
</Flex>
);
};
export default TextUI;

View File

@@ -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 = any>(
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]);
};

64
web/src/index.css Normal file
View File

@@ -0,0 +1,64 @@
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@200;400;500;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap");
@import url("https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap");
body {
background: none !important;
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;
user-select: none;
overflow: hidden !important;
}
#root {
height: 100%;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
@keyframes progress {
0% {
stroke-dasharray: 0, 264;
}
100% {
stroke-dasharray: 264, 0;
}
}
@keyframes progress-bar {
from {
width: 0%;
}
to {
width: 100%;
}
}
::-webkit-scrollbar {
background-color: transparent;
padding: 0;
margin: 0;
width: 0;
height: 0;
}
.toast-inform {
background-color: #2980b9;
}
.toast-success {
background-color: #27ae60;
}
.toast-error {
background-color: #c0392b;
}

43
web/src/index.tsx Normal file
View File

@@ -0,0 +1,43 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import { VisibilityProvider } from "./providers/VisibilityProvider";
import { ChakraProvider } from "@chakra-ui/react";
import { theme } from "./theme";
import { debugData } from "./utils/debugData";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { far } from "@fortawesome/free-regular-svg-icons";
import { fab } from "@fortawesome/free-brands-svg-icons";
import { library } from "@fortawesome/fontawesome-svg-core";
import { isEnvBrowser } from "./utils/misc";
library.add(fas, far, fab);
if (isEnvBrowser()) {
const root = document.getElementById("root");
// https://i.imgur.com/iPTAdYV.png - Night time img
root!.style.backgroundImage = 'url("https://i.imgur.com/3pzRj9n.png")';
root!.style.backgroundSize = "cover";
root!.style.backgroundRepeat = "no-repeat";
root!.style.backgroundPosition = "center";
}
debugData([
{
action: "setVisible",
data: true,
},
]);
ReactDOM.render(
<React.StrictMode>
<VisibilityProvider>
<ChakraProvider theme={theme}>
<App />
</ChakraProvider>
</VisibilityProvider>
</React.StrictMode>,
document.getElementById("root")
);

View File

@@ -0,0 +1,18 @@
export interface Option {
menu?: string;
description?: string;
metadata?: string[] | { [key: string]: any };
event?: string;
serverEvent?: string;
args?: any;
}
export interface Options {
[key: string]: Option;
}
export interface ContextMenuProps {
title: string;
menu?: string;
options: Options;
}

View File

@@ -0,0 +1,37 @@
import React, { Context, createContext, useContext, useState } from "react";
import { useNuiEvent } from "../hooks/useNuiEvent";
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 }) => {
const [visible, setVisible] = useState(true);
useNuiEvent<boolean>("setVisible", setVisible);
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>
);

1
web/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

22
web/src/theme/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { extendTheme, type ThemeConfig } from "@chakra-ui/react";
const config: ThemeConfig = {
initialColorMode: 'dark',
useSystemColorMode: false
}
export const theme = extendTheme({
config,
components: {
Progress: {
baseStyle: {
filledTrack: {
bg: 'green.400'
},
track: {
bg: 'rgba(0, 0, 0, 0.6)'
}
}
},
}
})

View File

@@ -0,0 +1,30 @@
import {isEnvBrowser} from "./misc";
interface DebugEvent<T = any> {
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 (process.env.NODE_ENV === "development" && isEnvBrowser()) {
for (const event of events) {
setTimeout(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
action: event.action,
data: event.data,
},
})
);
}, timer);
}
}
};

28
web/src/utils/fetchNui.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 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
*
* @return returnData - A promise for the data sent back by the NuiCallbacks CB argument
*/
export async function fetchNui<T = any>(eventName: string, data?: any): Promise<T> {
const options = {
method: 'post',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(data),
};
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
}

6
web/src/utils/misc.ts Normal file
View File

@@ -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 = () => {}

26
web/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}