Merge branch 'v3'

This commit is contained in:
Luke
2023-02-17 15:54:20 +01:00
86 changed files with 3629 additions and 4077 deletions

View File

@@ -7,7 +7,7 @@ rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aw
--[[ Resource Information ]]--
name 'ox_lib'
author 'Linden'
author 'Overextended'
version '2.21.0'
license 'LGPL-3.0-or-later'
repository 'https://github.com/overextended/ox_lib'
@@ -41,6 +41,7 @@ client_scripts {
'imports/callback/client.lua',
'imports/requestModel/client.lua',
'imports/requestAnimDict/client.lua',
'imports/addKeybind/client.lua',
'resource/**/client.lua',
'resource/**/client/*.lua'
}

View File

@@ -1,54 +0,0 @@
if cache.game == 'redm' then return end
if not lib.player then lib.player() end
return function(resource)
local ESX = exports[resource]:getSharedObject()
RegisterNetEvent('esx:playerLoaded', function(xPlayer)
ESX.PlayerData = xPlayer
end)
RegisterNetEvent('esx:setJob', function(job)
ESX.PlayerData.job = job
end)
local CPlayer = lib.getPlayer()
function lib.getPlayer()
return setmetatable({
id = cache.playerId,
serverId = cache.serverId,
}, CPlayer)
end
function CPlayer:hasGroup(filter)
local data = ESX.PlayerData
local type = type(filter)
if type == 'string' then
if data.job.name == filter then
return data.job.name, data.job.grade
end
else
local tabletype = table.type(filter)
if tabletype == 'hash' then
local grade = filter[data.job.name]
if grade and grade <= data.job.grade then
return data.job.name, data.job.grade
end
elseif tabletype == 'array' then
for i = 1, #filter do
if data.job.name == filter[i] then
return data.job.name, data.job.grade
end
end
end
end
end
player = lib.getPlayer()
return ESX
end

View File

@@ -1,47 +0,0 @@
if cache.game == 'redm' then return end
if not lib.player then lib.player() end
return function(resource)
local ESX = exports[resource]:getSharedObject()
-- Eventually add some functions here to simplify the creation of framework-agnostic resources.
local CPlayer = lib.getPlayer()
function lib.getPlayer(player)
player = type(player) == 'table' and player.playerId or ESX.GetPlayerFromId(player)
if not player then
error(("'%s' is not a valid player"):format(player))
end
return setmetatable(player, CPlayer)
end
function CPlayer:hasGroup(filter)
local type = type(filter)
if type == 'string' then
if self.job.name == filter then
return self.job.name, self.job.grade
end
else
local tabletype = table.type(filter)
if tabletype == 'hash' then
local grade = filter[self.job.name]
if grade and grade <= self.job.grade then
return self.job.name, self.job.grade
end
elseif tabletype == 'array' then
for i = 1, #filter do
if self.job.name == filter[i] then
return self.job.name, self.job.grade
end
end
end
end
end
return ESX
end

View File

@@ -1,73 +0,0 @@
if cache.game == 'redm' then return end
if not lib.player then lib.player() end
return function(resource)
local QBCore = exports[resource]:GetCoreObject()
local PlayerData
RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function()
PlayerData = QBCore.Functions.GetPlayerData()
end)
RegisterNetEvent('QBCore:Client:OnJobUpdate', function(job)
PlayerData.job = job
end)
RegisterNetEvent('QBCore:Client:OnGangUpdate', function(gang)
PlayerData.gang = gang
end)
local CPlayer = lib.getPlayer()
function lib.getPlayer()
return setmetatable({
id = cache.playerId,
serverId = cache.serverId,
}, CPlayer)
end
local groups = { 'job', 'gang' }
function CPlayer:hasGroup(filter)
local type = type(filter)
if type == 'string' then
for i = 1, #groups do
local data = PlayerData[groups[i]]
if data.name == filter then
return data.name, data.grade.level
end
end
else
local tabletype = table.type(filter)
if tabletype == 'hash' then
for i = 1, #groups do
local data = PlayerData[groups[i]]
local grade = filter[data.name]
if grade and grade <= data.grade.level then
return data.name, data.grade.level
end
end
elseif tabletype == 'array' then
for i = 1, #filter do
local group = filter[i]
for j = 1, #groups do
local data = PlayerData[groups[j]]
if data.name == group then
return data.name, data.grade.level
end
end
end
end
end
end
player = lib.getPlayer()
return QBCore
end

View File

@@ -1,62 +0,0 @@
if cache.game == 'redm' then return end
if not lib.player then lib.player() end
return function(resource)
local QBCore = exports[resource]:GetCoreObject()
-- Eventually add some functions here to simplify the creation of framework-agnostic resources.
local CPlayer = lib.getPlayer()
function lib.getPlayer(player)
player = type(player) == 'table' and player.playerId or QBCore.Functions.GetPlayer(player)
if not player then
error(("'%s' is not a valid player"):format(player))
end
return setmetatable(player, CPlayer)
end
local groups = { 'job', 'gang' }
function CPlayer:hasGroup(filter)
local type = type(filter)
if type == 'string' then
for i = 1, #groups do
local data = self.PlayerData[groups[i]]
if data.name == filter then
return data.name, data.grade.level
end
end
else
local tabletype = table.type(filter)
if tabletype == 'hash' then
for i = 1, #groups do
local data = self.PlayerData[groups[i]]
local grade = filter[data.name]
if grade and grade <= data.grade.level then
return data.name, data.grade.level
end
end
elseif tabletype == 'array' then
for i = 1, #filter do
local group = filter[i]
for j = 1, #groups do
local data = self.PlayerData[groups[j]]
if data.name == group then
return data.name, data.grade.level
end
end
end
end
end
end
return QBCore
end

View File

@@ -1,81 +0,0 @@
if cache.game == 'redm' then return end
--[[
This module was experimental and won't be worked on or used further.
May be removed in the future.
]]
local Core = {
Ox = 'ox_core',
QB = 'qb-core',
ESX = 'es_extended',
}
---@deprecated
function lib.getCore()
print('^3lib.getCore will be removed in a future update (v3.0.0).^0')
local result
Citizen.CreateThreadNow(function()
local framework = GetConvar('framework', '')
if framework == '' then
framework = GetResourceState(Core.Ox):find('start') and Core.Ox
or GetResourceState(Core.QB):find('start') and Core.QB
or GetResourceState(Core.ESX):find('start') and Core.ESX
if not framework then
error('Unable to determine framework (convar is not set, or resource was renamed)')
end
end
local success
local import
local resource
if framework == Core.Ox then
import = ('imports/%s.lua'):format(lib.context)
resource = Core.Ox
else
import = ('imports/getCore/%s/%s.lua'):format(framework, lib.context)
resource = lib.name
end
success, result = pcall(LoadResourceFile, resource, import)
if not result then
error(("Unable to load '@%s/%s'"):format(resource, import))
end
if not success then
error(result and result or ("Unable to load '@%s/%s'"):format(resource, import), 0)
end
success, result = load(result, ('@@%s/%s'):format(resource, import))
if not success then
error(result, 0)
end
success, result = pcall(success, framework)
if not success then error(result) end
if framework == Core.Ox then
---@diagnostic disable-next-line: undefined-global
result = Ox
end
if not result then
error(('no loader exists for %s'):format(framework))
elseif type(result) == 'function' then
result = result(framework)
end
result.resource = framework
end)
return result
end
---@diagnostic disable-next-line: deprecated
return lib.getCore

View File

@@ -1,35 +0,0 @@
--[[
This module was experimental and won't be worked on or used further.
May be removed in the future.
]]
local CPlayer = {}
function CPlayer:__index(index, ...)
local method = CPlayer[index]
if method then
return function(...)
return method(self, ...)
end
end
end
function CPlayer:getCoords(update)
if update or not self.coords then
self.coords = GetEntityCoords(cache.ped)
end
return self.coords
end
function CPlayer:getDistance(coords)
return #(self:getCoords() - coords)
end
---@deprecated
function lib.getPlayer()
return CPlayer
end
return lib.getPlayer

View File

@@ -1,40 +0,0 @@
--[[
This module was experimental and won't be worked on or used further.
May be removed in the future.
]]
local CPlayer = {}
function CPlayer:__index(index, ...)
local method = CPlayer[index]
if method then
return function(...)
return method(self, ...)
end
end
end
function CPlayer:getCoords(update)
if update or not self.coords then
self.coords = GetEntityCoords(self.getPed())
end
return self.coords
end
function CPlayer:getDistance(coords)
return #(self:getCoords() - coords)
end
function CPlayer:getPed()
self.ped = GetPlayerPed(self.source)
return self.ped
end
---@deprecated
function lib.getPlayer()
return CPlayer
end
return lib.getPlayer

View File

@@ -76,8 +76,6 @@ end
lib = setmetatable({
name = ox_lib,
---@deprecated
service = context,
context = context,
exports = {},
onCache = function(key, cb)

View File

@@ -7,6 +7,7 @@ export * from './interface/notify';
export * from './interface/progress';
export * from './interface/textui';
export * from './interface/skillcheck';
export * from './interface/radial';
export * from './streaming';
export * from './vehicleProperties';

View File

@@ -2,6 +2,8 @@ interface AlertDialogProps {
header: string;
content: string;
centered?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
overflow?: boolean;
cancel?: boolean;
labels?: {
cancel?: string;

View File

@@ -1,14 +1,17 @@
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
interface ContextMenuItem {
title?: string;
menu?: string;
title?: string;
description?: string;
arrow?: boolean;
image?: string;
icon?: IconName | [IconPrefix, IconName];
iconColor?: string;
progress?: number;
colorScheme?: string;
onSelect?: (args: any) => void;
arrow?: boolean;
description?: string;
metadata?: string | { [key: string]: any } | string[];
metadata?: string[] | { [key: string]: any } | { label: string; value: any; progress?: number }[];
disabled?: boolean;
event?: string;
serverEvent?: string;

View File

@@ -1,7 +1,19 @@
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
// Should really be improved at some point to only display properties depending on the input type
interface InputDialogRowProps {
type: 'input' | 'number' | 'checkbox' | 'select' | 'slider';
type:
| 'input'
| 'number'
| 'checkbox'
| 'select'
| 'multi-select'
| 'slider'
| 'color'
| 'date'
| 'date-range'
| 'time'
| 'text-area';
label: string;
options?: { value: string; label: string; default?: string }[];
password?: boolean;
@@ -13,13 +25,19 @@ interface InputDialogRowProps {
checked?: boolean;
min?: number;
max?: number;
autosize?: boolean;
step?: number;
required?: boolean;
format?: string;
description?: string;
}
type inputDialog = (
heading: string,
rows: string[] | InputDialogRowProps[]
rows: string[] | InputDialogRowProps[],
options: {
allowCancel?: boolean;
}
) => Promise<Array<string | number | boolean> | undefined>;
export const inputDialog: inputDialog = async (heading, rows) => await exports.ox_lib.inputDialog(heading, rows);

View File

@@ -1,11 +1,19 @@
import { CSSProperties } from 'react';
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
type NotificationPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left';
type NotificationPosition =
| 'top'
| 'top-right'
| 'top-left'
| 'bottom'
| 'bottom-right'
| 'bottom-left'
| 'center-right'
| 'center-left';
type NotificationType = 'inform' | 'error' | 'success';
interface NotifyProps {
id?: string;
id?: string | number;
title?: string;
description?: string;
duration?: number;
@@ -18,6 +26,7 @@ interface NotifyProps {
export const notify = (data: NotifyProps): void => exports.ox_lib.notify(data);
// Keep for backwards compat with v2
interface DefaultNotifyProps {
title?: string;
description?: string;

View File

@@ -0,0 +1,18 @@
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
type RadialItem = {
id: string;
label: string;
icon: IconName | [IconPrefix, IconName];
onSelect?: () => void;
menu?: string;
};
export const addRadialItem = (items: RadialItem | RadialItem[]) => exports.ox_lib.addRadialItem(items);
export const removeRadialItem = (item: string) => exports.ox_lib.removeRadialItem(item);
export const registerRadial = (radial: { id: string; items: Omit<RadialItem, 'id'>[] }) =>
exports.ox_lib.registerRadial(radial);
export const hideRadial = () => exports.ox_lib.hideRadial();

View File

@@ -1,4 +1,4 @@
type SkillCheckDifficulty = 'easy' | 'medium' | 'hard' | { areaSize: number; speedMultiplier: number };
export const skillCheck = (difficulty: SkillCheckDifficulty | SkillCheckDifficulty[]) =>
export const skillCheck = (difficulty: SkillCheckDifficulty | SkillCheckDifficulty[], inputs?: string[]) =>
exports.ox_lib.skillCheck(difficulty);

View File

@@ -27,10 +27,10 @@
"@citizenfx/server": "2.0.5885-1",
"@fortawesome/fontawesome-common-types": "6.1.1",
"@types/node": "16.9.1",
"@types/react": "^18.0.20",
"typescript": "^4.8.3"
"@types/react": "^18.0.26",
"typescript": "^4.9.4"
},
"devDependencies": {
"prettier": "^2.7.1"
"prettier": "^2.8.1"
}
}

24
package/pnpm-lock.yaml generated
View File

@@ -5,20 +5,20 @@ specifiers:
'@citizenfx/server': 2.0.5885-1
'@fortawesome/fontawesome-common-types': 6.1.1
'@types/node': 16.9.1
'@types/react': ^18.0.20
prettier: ^2.7.1
typescript: ^4.8.3
'@types/react': ^18.0.26
prettier: ^2.8.1
typescript: ^4.9.4
dependencies:
'@citizenfx/client': 2.0.5885-1
'@citizenfx/server': 2.0.5885-1
'@fortawesome/fontawesome-common-types': 6.1.1
'@types/node': 16.9.1
'@types/react': 18.0.20
typescript: 4.8.3
'@types/react': 18.0.26
typescript: 4.9.4
devDependencies:
prettier: 2.7.1
prettier: 2.8.1
packages:
@@ -44,8 +44,8 @@ packages:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
dev: false
/@types/react/18.0.20:
resolution: {integrity: sha512-MWul1teSPxujEHVwZl4a5HxQ9vVNsjTchVA+xRqv/VYGCuKGAU6UhfrTdF5aBefwD1BHUD8i/zq+O/vyCm/FrA==}
/@types/react/18.0.26:
resolution: {integrity: sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==}
dependencies:
'@types/prop-types': 15.7.5
'@types/scheduler': 0.16.2
@@ -60,14 +60,14 @@ packages:
resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==}
dev: false
/prettier/2.7.1:
resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==}
/prettier/2.8.1:
resolution: {integrity: sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==}
engines: {node: '>=10.13.0'}
hasBin: true
dev: true
/typescript/4.8.3:
resolution: {integrity: sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==}
/typescript/4.9.4:
resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: false

View File

@@ -10,3 +10,10 @@ function RegisterCommand(commandName, callback, restricted)
end
end)
end
RegisterNUICallback('getConfig', function(_, cb)
cb({
primaryColor = GetConvar('ox:primaryColor', 'blue'),
primaryShade = GetConvarInt('ox:primaryShade', 8)
})
end)

View File

@@ -10,6 +10,8 @@ end
---@field header string;
---@field content string;
---@field centered? boolean?;
---@field size? 'xs' | 'sm' | 'md' | 'lg' | 'xl';
---@field overflow? boolean?;
---@field cancel? boolean?;
---@field labels? {cancel?: string, confirm?: string}

View File

@@ -1,7 +1,7 @@
local input
---@class InputDialogRowProps
---@field type 'input' | 'number' | 'checkbox' | 'select' | 'slider'
---@field type 'input' | 'number' | 'checkbox' | 'select' | 'slider' | 'multi-select' | 'date' | 'date-range' | 'time' | 'textarea'
---@field label string
---@field options? { value: string, label: string, default?: string }[]
---@field password? boolean
@@ -14,12 +14,20 @@ local input
---@field min? number
---@field max? number
---@field step? number
---@field autosize? boolean
---@field required? boolean
---@field format? string
---@field clearable? string
---@field description? string
---@class InputDialogOptionsProps
---@field allowCancel? boolean
---@param heading string
---@param rows string[] | InputDialogRowProps[]
---@param options InputDialogOptionsProps[]
---@return string[] | number[] | boolean[] | nil
function lib.inputDialog(heading, rows)
function lib.inputDialog(heading, rows, options)
if input then return end
input = promise.new()
@@ -35,7 +43,8 @@ function lib.inputDialog(heading, rows)
action = 'openDialog',
data = {
heading = heading,
rows = rows
rows = rows,
options = options
}
})

View File

@@ -1,4 +1,4 @@
---@alias NotificationPosition 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
---@alias NotificationPosition 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left' | 'center-right' | 'center-left'
---@alias NotificationType 'inform' | 'error' | 'success'
---@class NotifyProps
@@ -15,7 +15,7 @@
---@param data NotifyProps
function lib.notify(data)
SendNUIMessage({
action = 'customNotify',
action = 'notify',
data = data
})
end
@@ -30,10 +30,10 @@ end
---@param data DefaultNotifyProps
function lib.defaultNotify(data)
SendNUIMessage({
action = 'notify',
data = data
})
-- Backwards compat for v3
data.type = data.status
if data.type == 'info' or data.type == 'warning' then data.type = 'inform' end
return lib.notify(data)
end
RegisterNetEvent('ox_lib:notify', lib.notify)

View File

@@ -198,8 +198,6 @@ end
function lib.cancelProgress()
if not progress then
error('No progress bar is active')
elseif not progress.canCancel then
error('Progress bar cannot be cancelled')
end
progress = false

View File

@@ -0,0 +1,173 @@
local isOpen = false
local menus = {}
local menuItems = {}
local currentRadial = nil
---@class RadialMenuItem
---@field id string
---@field icon string
---@field label string
---@field menu? string
---@field onSelect? function
---@class RadialMenuProps
---@field id string
---@field items RadialMenuItem[]
---Registers a radial sub menu with predefined options.
---@param radial RadialMenuProps
function lib.registerRadial(radial)
menus[radial.id] = radial
end
---Open a registered radial submenu with the given id.
---@param id string
local function showRadial(id)
local radial = menus[id]
if not radial then return error('No radial menu with such id found.') end
currentRadial = radial
SendNUIMessage({
action = 'openRadialMenu',
data = {
items = radial.items,
sub = true
}
})
end
function lib.hideRadial()
if not isOpen then return end
SendNUIMessage({
action = 'openRadialMenu',
data = false
})
SetNuiFocus(false, false)
isOpen = false
currentRadial = nil
end
---Registers an item or array of items in the global radial menu.
---@param items RadialMenuItem | RadialMenuItem[]
function lib.addRadialItem(items)
local menuSize = #menuItems
local invokingResource = GetInvokingResource()
if table.type(items) == 'array' then
for i = 1, #items do
local item = items[i]
item.resource = invokingResource
menuSize += 1
menuItems[menuSize] = item
end
else
items.resource = invokingResource
menuItems[menuSize + 1] = items
end
if isOpen then
SendNUIMessage({
action = 'refreshItems',
data = menuItems
})
end
end
---Removes an item from the global radial menu with the given id.
---@param id string
function lib.removeRadialItem(id)
for i = 1, #menuItems do
local item = menuItems[i]
if item.id == id then
table.remove(menuItems, i)
break
end
end
if isOpen then
SendNUIMessage({
action = 'refreshItems',
data = menuItems
})
end
end
RegisterNUICallback('radialClick', function(index, cb)
cb(1)
local item = not currentRadial and menuItems[index + 1] or currentRadial.items[index + 1]
if item.onSelect then item.onSelect() end
if item.menu then return showRadial(item.menu) end
lib.hideRadial()
end)
RegisterNUICallback('radialBack', function(_, cb)
cb(1)
if currentRadial.menu then
return showRadial(currentRadial.menu)
end
currentRadial = nil
SendNUIMessage({
action = 'openRadialMenu',
data = {
items = menuItems
}
})
end)
RegisterNUICallback('radialClose', function(_, cb)
cb(1)
if not isOpen then return end
SetNuiFocus(false, false)
isOpen = false
currentRadial = nil
end)
lib.addKeybind({
name = 'ox_lib-radial',
description = 'Open radial menu',
defaultKey = 'z',
onPressed = function()
if isOpen or #menuItems == 0 or IsNuiFocused() or IsPauseMenuActive() then return end
isOpen = true
SendNUIMessage({
action = 'openRadialMenu',
data = {
items = menuItems
}
})
SetNuiFocus(true, true)
SetNuiFocusKeepInput(true)
SetCursorLocation(0.5, 0.5)
while isOpen do
DisablePlayerFiring(cache.playerId, true)
DisableControlAction(0, 1, true)
DisableControlAction(0, 2, true)
Wait(0)
end
end,
onReleased = lib.hideRadial,
})
AddEventHandler('onClientResourceStop', function(resource)
for i = #menuItems, 1, -1 do
local item = menuItems[i]
if item.resource == resource then
table.remove(menuItems, i)
end
end
end)

View File

@@ -4,15 +4,19 @@ local skillcheck
---@alias SkillCheckDifficulity 'easy' | 'medium' | 'hard' | { areaSize: number, speedMultiplier: number }
---@param difficulty SkillCheckDifficulity | SkillCheckDifficulity[]
---@param inputs string[]
---@return boolean?
function lib.skillCheck(difficulty)
function lib.skillCheck(difficulty, inputs)
if skillcheck then return end
skillcheck = promise:new()
SetNuiFocus(true, false)
SendNUIMessage({
action = 'startSkillCheck',
data = difficulty
data = {
difficulty = difficulty,
inputs = inputs
}
})
return Citizen.Await(skillcheck)

View File

@@ -1,33 +1,31 @@
{
"name": "web",
"name": "ox_lib",
"version": "0.1.0",
"homepage": "web/build",
"private": true,
"dependencies": {
"@chakra-ui/react": "2.3.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": "^18.0.24",
"@types/react-dom": "^18.0.8",
"@vitejs/plugin-react": "^1.3.2",
"@emotion/react": "^11.10.5",
"@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.1.19",
"@mantine/core": "^5.10.0",
"@mantine/dates": "^5.10.0",
"@mantine/hooks": "^5.10.0",
"@vitejs/plugin-react": "^3.0.1",
"dayjs": "^1.11.7",
"focus-trap-react": "^9.0.2",
"framer-motion": "^6.2.8",
"framer-motion": "^8.0.2",
"prettier": "^2.7.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.41.3",
"react-hot-toast": "^2.4.0",
"react-markdown": "^8.0.1",
"remark-gfm": "^3.0.1",
"typescript": "^4.6.3",
"vite": "^2.9.13",
"vite": "^4.0.4",
"web-vitals": "^2.1.4"
},
"scripts": {
@@ -36,33 +34,23 @@
"build": "tsc && vite build",
"preview": "vite preview"
},
"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",
"@testing-library/dom": "^8.11.4",
"autoprefixer": "^10.0.2",
"cross-env": "^7.0.3",
"csstype": "^3.0.10",
"postcss": "^8.1.0",
"prop-types": "^15.8.1",
"rimraf": "^3.0.2"
"rimraf": "^3.0.2",
"@types/node": "^16.11.26",
"@types/react": "^18.0.24",
"@types/react-dom": "^18.0.8"
},
"pnpm": {
"patchedDependencies": {
"react-hot-toast@2.4.0": "patches/react-hot-toast@2.4.0.patch"
}
}
}

File diff suppressed because one or more lines are too long

3682
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,14 @@ import ListMenu from './features/menu/list';
import Dev from './features/dev';
import { isEnvBrowser } from './utils/misc';
import SkillCheck from './features/skillcheck';
import RadialMenu from './features/menu/radial';
import { theme } from './theme';
import { MantineProvider } from '@mantine/core';
import { useConfig } from './providers/ConfigProvider';
const App: React.FC = () => {
const { config } = useConfig();
useNuiEvent('setClipboard', (data: string) => {
setClipboard(data);
});
@@ -21,7 +27,7 @@ const App: React.FC = () => {
fetchNui('init');
return (
<>
<MantineProvider withNormalizeCSS withGlobalStyles theme={{ ...theme, ...config }}>
<Progressbar />
<CircleProgressbar />
<Notifications />
@@ -30,9 +36,10 @@ const App: React.FC = () => {
<AlertDialog />
<ContextMenu />
<ListMenu />
<RadialMenu />
<SkillCheck />
{isEnvBrowser() && <Dev />}
</>
</MantineProvider>
);
};

View File

@@ -1,5 +1,5 @@
import { debugData } from '../../../utils/debugData';
import { AlertProps } from '../../dialog/AlertDialog';
import { AlertProps } from '../../../typings';
export const debugAlert = () => {
debugData<AlertProps>([
@@ -9,11 +9,13 @@ export const debugAlert = () => {
header: 'Hello there',
content: 'General kenobi \n Markdown works',
centered: true,
size: 'lg',
overflow: true,
cancel: true,
labels: {
confirm: 'Ok',
cancel: 'Not ok',
},
// labels: {
// confirm: 'Ok',
// cancel: 'Not ok',
// },
},
},
]);

View File

@@ -1,4 +1,4 @@
import { ContextMenuProps } from '../../../interfaces/context';
import { ContextMenuProps } from '../../../typings';
import { debugData } from '../../../utils/debugData';
export const debugContext = () => {
@@ -9,6 +9,32 @@ export const debugContext = () => {
title: 'Vehicle garage',
options: [
{ title: 'Empty button' },
{
title: 'Karin Kuruma',
image: 'https://cdn.discordapp.com/attachments/1063098499027173461/1064276343585505330/screenshot.jpg',
arrow: true,
colorScheme: 'blue',
metadata: [
{
['label']: 'Body',
['value']: '55%',
['progress']: 55,
},
{
['label']: 'Engine',
['value']: '100%',
['progress']: 100,
},
{
['label']: 'Oil',
['progress']: 11,
},
{
['label']: 'Fuel',
['progress']: 87,
},
],
},
{
title: 'Example button',
description: 'Example button description',
@@ -30,7 +56,7 @@ export const debugContext = () => {
progress: 80,
icon: 'car-side',
metadata: [{ label: 'Durability', value: '80%' }],
colorScheme: 'blue'
colorScheme: 'blue',
},
{
title: 'Menu button',

View File

@@ -1,5 +1,5 @@
import { debugData } from '../../../utils/debugData';
import { InputProps } from '../../dialog/InputDialog';
import type { InputProps } from '../../../typings';
export const debugInput = () => {
debugData<InputProps>([
@@ -14,6 +14,12 @@ export const debugInput = () => {
placeholder: '420',
description: 'Description that tells you what this input field does',
},
{
type: 'time',
format: '12',
label: 'Locker Time',
description: 'Description that tells you what this input field does',
},
{ type: 'checkbox', label: 'Some checkbox' },
{ type: 'input', label: 'Locker PIN', password: true, icon: 'lock' },
{ type: 'checkbox', label: 'Some other checkbox', checked: true },

View File

@@ -1,5 +1,5 @@
import { debugData } from '../../../utils/debugData';
import { MenuSettings } from '../../menu/list';
import { MenuSettings } from '../../../typings';
export const debugMenu = () => {
debugData<MenuSettings>([
@@ -34,7 +34,7 @@ export const debugMenu = () => {
icon: 'car-side',
description: 'Durability: 80%',
colorScheme: 'blue',
iconColor: '#55778d'
iconColor: '#55778d',
},
{ label: 'Option 1' },
{ label: 'Option 2' },

View File

@@ -1,30 +1,47 @@
import { CustomNotificationProps, NotificationProps } from '../../notifications/NotificationWrapper';
import { NotificationProps } from '../../../typings';
import { debugData } from '../../../utils/debugData';
export const debugNotification = () => {
export const debugCustomNotification = () => {
debugData<NotificationProps>([
{
action: 'notify',
data: {
description: 'Dunak is nerd',
title: 'Dunak',
id: 1,
title: 'Success',
description: 'Notification description',
type: 'success',
id: 'pogchamp',
},
},
]);
};
export const debugCustomNotification = () => {
debugData<CustomNotificationProps>([
debugData<NotificationProps>([
{
action: 'customNotify',
action: 'notify',
data: {
description: 'Dunak is nerd',
icon: 'basket-shopping',
style: {
backgroundColor: '#2D3748',
color: 'white',
},
title: 'Success',
description: 'Notification description',
type: 'success',
id: 'pogchamp',
},
},
]);
debugData<NotificationProps>([
{
action: 'notify',
data: {
title: 'Error',
description: 'Notification description',
type: 'error',
},
},
]);
debugData<NotificationProps>([
{
action: 'notify',
data: {
title: 'Custom icon success',
description: 'Notification description',
type: 'success',
icon: 'microchip',
},
},
]);

View File

@@ -1,5 +1,5 @@
import { debugData } from '../../../utils/debugData';
import { ProgressbarProps } from '../../progress/Progressbar';
import { ProgressbarProps } from '../../../typings';
export const debugProgressbar = () => {
debugData<ProgressbarProps>([

View File

@@ -0,0 +1,19 @@
import { debugData } from '../../../utils/debugData';
import type { MenuItem } from '../../../typings';
export const debugRadial = () => {
debugData<{ items: MenuItem[]; sub?: boolean }>([
{
action: 'openRadialMenu',
data: {
items: [
{ icon: 'palette', label: 'Paint' },
{ icon: 'warehouse', label: 'Garage' },
{ icon: 'palette', label: 'Quite long text' },
{ icon: 'palette', label: 'Paint' },
{ icon: 'warehouse', label: 'Garage' },
],
},
},
]);
};

View File

@@ -1,11 +1,14 @@
import { debugData } from '../../../utils/debugData';
import { GameDifficulty } from '../../skillcheck';
import { GameDifficulty } from '../../../typings';
export const debugSkillCheck = () => {
debugData<GameDifficulty | GameDifficulty[]>([
debugData<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }>([
{
action: 'startSkillCheck',
data: ['easy', 'easy', 'hard'],
data: {
difficulty: ['easy', 'easy', 'hard'],
inputs: ['W', 'A', 'S', 'D'],
},
},
]);
};

View File

@@ -1,4 +1,4 @@
import { TextUiProps } from '../../textui/TextUI';
import { TextUiProps } from '../../../typings';
import { debugData } from '../../../utils/debugData';
export const debugTextUI = () => {

View File

@@ -1,91 +1,75 @@
import {
Button,
Drawer,
DrawerBody,
DrawerContent,
DrawerHeader,
DrawerOverlay,
IconButton,
Tooltip,
VStack,
Divider,
useDisclosure,
} from '@chakra-ui/react';
import { ActionIcon, Tooltip, Drawer, Stack, Divider, Button } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { debugAlert } from './debug/alert';
import { debugContext } from './debug/context';
import { debugInput } from './debug/input';
import { debugMenu } from './debug/menu';
import { debugCustomNotification, debugNotification } from './debug/notification';
import { debugCustomNotification } from './debug/notification';
import { debugCircleProgressbar, debugProgressbar } from './debug/progress';
import { debugTextUI } from './debug/textui';
import { debugSkillCheck } from './debug/skillcheck';
import { useState } from 'react';
import { debugRadial } from './debug/radial';
const Dev: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [opened, setOpened] = useState(false);
return (
<>
<Tooltip label="Developer drawer">
<IconButton
position="absolute"
bottom={0}
right={0}
mr={20}
mb={20}
borderRadius="50%"
icon={<FontAwesomeIcon icon="wrench" fixedWidth size="lg" />}
colorScheme="orange"
size="lg"
aria-label="Dev tools"
onClick={() => onOpen()}
/>
<Tooltip label="Developer drawer" position="bottom">
<ActionIcon
onClick={() => setOpened(true)}
radius="xl"
variant="filled"
color="orange"
sx={{ position: 'absolute', bottom: 0, right: 0, width: 50, height: 50 }}
size="xl"
mr={50}
mb={50}
>
<FontAwesomeIcon icon="wrench" fontSize={24} />
</ActionIcon>
</Tooltip>
<Drawer placement="left" onClose={onClose} isOpen={isOpen}>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Developer drawer</DrawerHeader>
<DrawerBody>
<VStack>
<Divider />
<Button width="full" onClick={() => debugInput()}>
Open input dialog
</Button>
<Button width="full" onClick={() => debugAlert()}>
Open alert dialog
</Button>
<Divider />
<Button width="full" onClick={() => debugContext()}>
Open context menu
</Button>
<Button width="full" onClick={() => debugMenu()}>
Open list menu
</Button>
<Divider />
<Button width="full" onClick={() => debugCustomNotification()}>
Send custom notification
</Button>
<Button width="full" onClick={() => debugNotification()}>
Send default notification
</Button>
<Divider />
<Button width="full" onClick={() => debugProgressbar()}>
Activate progress bar
</Button>
<Button width="full" onClick={() => debugCircleProgressbar()}>
Activate progress circle
</Button>
<Divider />
<Button width="full" onClick={() => debugTextUI()}>
Show TextUI
</Button>
<Divider />
<Button width="full" onClick={() => debugSkillCheck()}>
Run skill check
</Button>
</VStack>
</DrawerBody>
</DrawerContent>
<Drawer position="left" onClose={() => setOpened(false)} opened={opened} title="Developer drawer" padding="xl">
<Stack>
<Divider />
<Button fullWidth onClick={() => debugInput()}>
Open input dialog
</Button>
<Button fullWidth onClick={() => debugAlert()}>
Open alert dialog
</Button>
<Divider />
<Button fullWidth onClick={() => debugContext()}>
Open context menu
</Button>
<Button fullWidth onClick={() => debugMenu()}>
Open list menu
</Button>
<Button fullWidth onClick={() => debugRadial()}>
Open radial menu
</Button>
<Divider />
<Button fullWidth onClick={() => debugCustomNotification()}>
Send notification
</Button>
<Divider />
<Button fullWidth onClick={() => debugProgressbar()}>
Activate progress bar
</Button>
<Button fullWidth onClick={() => debugCircleProgressbar()}>
Activate progress circle
</Button>
<Divider />
<Button fullWidth onClick={() => debugTextUI()}>
Show TextUI
</Button>
<Divider />
<Button fullWidth onClick={() => debugSkillCheck()}>
Run skill check
</Button>
</Stack>
</Drawer>
</>
);

View File

@@ -1,83 +1,72 @@
import {
AlertDialog as Dialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
useDisclosure,
Button,
} from '@chakra-ui/react';
import { useRef, useState } from 'react';
import { Modal, Button, Stack, Group, useMantineTheme } from '@mantine/core';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui';
import { useLocales } from '../../providers/LocaleProvider';
export interface AlertProps {
header: string;
content: string;
centered?: boolean;
cancel?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
}
import remarkGfm from 'remark-gfm';
import type { AlertProps } from '../../typings';
const AlertDialog: React.FC = () => {
const { locale } = useLocales();
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef(null);
const theme = useMantineTheme();
const [opened, setOpened] = useState(false);
const [dialogData, setDialogData] = useState<AlertProps>({
header: '',
content: '',
});
const closeAlert = (button: string) => {
onClose();
setOpened(false);
fetchNui('closeAlert', button);
};
useNuiEvent('sendAlert', (data: AlertProps) => {
setDialogData(data);
onOpen();
setOpened(true);
});
useNuiEvent('closeAlertDialog', () => {
onClose();
setOpened(false);
});
return (
<>
<Dialog
leastDestructiveRef={cancelRef}
onClose={onClose}
isOpen={isOpen}
isCentered={dialogData.centered}
closeOnOverlayClick={false}
onEsc={() => closeAlert('cancel')}
<Modal
opened={opened}
centered={dialogData.centered}
size={dialogData.size || 'md'}
overflow={dialogData.overflow ? 'inside' : 'outside'}
closeOnClickOutside={false}
onClose={() => {
setOpened(false);
closeAlert('cancel');
}}
withCloseButton={false}
overlayOpacity={0.5}
exitTransitionDuration={150}
transition="fade"
title={<ReactMarkdown>{dialogData.header}</ReactMarkdown>}
>
<AlertDialogOverlay />
<AlertDialogContent fontFamily="Inter">
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{dialogData.header}
</AlertDialogHeader>
<AlertDialogBody>
<ReactMarkdown>{dialogData.content}</ReactMarkdown>
</AlertDialogBody>
<AlertDialogFooter>
<Stack>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{dialogData.content}</ReactMarkdown>
<Group position="right" spacing={10}>
{dialogData.cancel && (
<Button onClick={() => closeAlert('cancel')} mr={3}>
<Button uppercase variant="default" onClick={() => closeAlert('cancel')} mr={3}>
{dialogData.labels?.cancel || locale.ui.cancel}
</Button>
)}
<Button colorScheme={dialogData.cancel ? 'blue' : undefined} onClick={() => closeAlert('confirm')}>
<Button
uppercase
variant={dialogData.cancel ? 'light' : 'default'}
color={dialogData.cancel ? theme.primaryColor : undefined}
onClick={() => closeAlert('confirm')}
>
{dialogData.labels?.confirm || locale.ui.confirm}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</Dialog>
</Group>
</Stack>
</Modal>
</>
);
};

View File

@@ -1,103 +1,165 @@
import { Modal, ModalOverlay, ModalContent, ModalFooter, ModalHeader, ModalBody, Button } from '@chakra-ui/react';
import { Group, Modal, Button, Stack } from '@mantine/core';
import React from 'react';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { useLocales } from '../../providers/LocaleProvider';
import { fetchNui } from '../../utils/fetchNui';
import { IInput, ICheckbox, ISelect, INumber, ISlider } from '../../interfaces/dialog';
import { OptionValue } from '../../typings';
import InputField from './components/fields/input';
import CheckboxField from './components/fields/checkbox';
import SelectField from './components/fields/select';
import NumberField from './components/fields/number';
import SliderField from './components/fields/slider';
import { useFieldArray, useForm } from 'react-hook-form';
import ColorField from './components/fields/color';
import DateField from './components/fields/date';
import TextareaField from './components/fields/textarea';
import TimeField from './components/fields/time';
import type { InputProps } from '../../typings';
export interface InputProps {
heading: string;
rows: Array<IInput | ICheckbox | ISelect | INumber | ISlider>;
}
export type FormValues = {
test: {
value: any;
}[];
};
const InputDialog: React.FC = () => {
const [fields, setFields] = React.useState<InputProps>({
heading: '',
rows: [{ type: 'input', label: '' }],
});
const [inputData, setInputData] = React.useState<Array<string | number | boolean>>([]);
const [passwordStates, setPasswordStates] = React.useState<boolean[]>([]);
const [visible, setVisible] = React.useState(false);
const { locale } = useLocales();
const handlePasswordStates = (index: number) => {
setPasswordStates({
...passwordStates,
[index]: !passwordStates[index],
});
};
const form = useForm<{ test: { value: any }[] }>({});
const fieldForm = useFieldArray({
control: form.control,
name: 'test',
});
useNuiEvent<InputProps>('openDialog', (data) => {
setPasswordStates([]);
setFields(data);
setInputData([]);
setVisible(true);
data.rows.forEach((row, index) => {
fieldForm.insert(
index,
{
value:
row.type !== 'checkbox'
? row.type === 'date' || row.type === 'date-range' || row.type === 'time'
? // Set date to current one if default is set to true
row.default === true
? new Date().getTime()
: Array.isArray(row.default)
? row.default.map((date) => new Date(date).getTime())
: row.default && new Date(row.default).getTime()
: row.default
: row.checked,
} || { value: null }
);
// Backwards compat with new Select data type
if (row.type === 'select' || row.type === 'multi-select') {
row.options = row.options.map((option) =>
!option.label ? { ...option, label: option.value } : option
) as Array<OptionValue>;
}
});
});
useNuiEvent('closeInputDialog', () => {
setVisible(false);
});
const handleClose = () => {
const handleClose = async () => {
setVisible(false);
fetchNui('inputData');
await new Promise((resolve) => setTimeout(resolve, 200));
form.reset();
fieldForm.remove();
};
const handleChange = (value: string | number | boolean, index: number) => {
setInputData((previousData) => {
previousData[index] = value;
return previousData;
});
};
const handleConfirm = () => {
const onSubmit = form.handleSubmit(async (data) => {
setVisible(false);
fetchNui('inputData', inputData);
};
const values: any[] = [];
Object.values(data.test).forEach((obj: { value: any }) => values.push(obj.value));
console.log(values);
fetchNui('inputData', values);
await new Promise((resolve) => setTimeout(resolve, 200));
form.reset();
fieldForm.remove();
});
return (
<>
<Modal isOpen={visible} onClose={handleClose} isCentered closeOnEsc closeOnOverlayClick={false} size="xs">
<ModalOverlay />
<ModalContent
onKeyDown={(e) => {
if (e.key === 'Enter' && visible) return handleConfirm();
}}
>
<ModalHeader textAlign="center">{fields.heading}</ModalHeader>
<ModalBody fontFamily="Poppins" textAlign="left">
{fields.rows.map((row: IInput | ICheckbox | ISelect | INumber | ISlider, index) => (
<React.Fragment key={`row-${index}-${row.type}-${row.label}`}>
{row.type === 'input' && (
<InputField
row={row}
index={index}
handleChange={handleChange}
passwordStates={passwordStates}
handlePasswordStates={handlePasswordStates}
/>
)}
{row.type === 'checkbox' && <CheckboxField row={row} index={index} handleChange={handleChange} />}
{row.type === 'select' && <SelectField row={row} index={index} handleChange={handleChange} />}
{row.type === 'number' && <NumberField row={row} index={index} handleChange={handleChange} />}
{row.type === 'slider' && <SliderField row={row} index={index} handleChange={handleChange} />}
</React.Fragment>
))}
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={handleClose}>
{locale.ui.close}
</Button>
<Button colorScheme="blue" onClick={handleConfirm}>
{locale.ui.confirm}
</Button>
</ModalFooter>
</ModalContent>
<Modal
opened={visible}
onClose={handleClose}
centered
closeOnEscape={fields.options?.allowCancel !== false}
closeOnClickOutside={false}
size="xs"
styles={{ title: { textAlign: 'center', width: '100%', fontSize: 18 } }}
title={fields.heading}
withCloseButton={false}
overlayOpacity={0.5}
transition="fade"
exitTransitionDuration={150}
>
<form onSubmit={onSubmit}>
<Stack>
{fieldForm.fields.map((item, index) => {
const row = fields.rows[index];
return (
<React.Fragment key={item.id}>
{row.type === 'input' && (
<InputField
register={form.register(`test.${index}.value`, { required: row.required })}
row={row}
index={index}
/>
)}
{row.type === 'checkbox' && (
<CheckboxField
register={form.register(`test.${index}.value`, { required: row.required })}
row={row}
index={index}
/>
)}
{(row.type === 'select' || row.type === 'multi-select') && (
<SelectField row={row} index={index} control={form.control} />
)}
{row.type === 'number' && <NumberField control={form.control} row={row} index={index} />}
{row.type === 'slider' && <SliderField control={form.control} row={row} index={index} />}
{row.type === 'color' && <ColorField control={form.control} row={row} index={index} />}
{row.type === 'time' && <TimeField control={form.control} row={row} index={index} />}
{row.type === 'date' || row.type === 'date-range' ? (
<DateField control={form.control} row={row} index={index} />
) : null}
{row.type === 'textarea' && (
<TextareaField
register={form.register(`test.${index}.value`, { required: row.required })}
row={row}
index={index}
/>
)}
</React.Fragment>
);
})}
<Group position="right" spacing={10}>
<Button
uppercase
variant="default"
onClick={handleClose}
mr={3}
disabled={fields.options?.allowCancel === false}
>
{locale.ui.cancel}
</Button>
<Button uppercase variant="light" type="submit">
{locale.ui.confirm}
</Button>
</Group>
</Stack>
</form>
</Modal>
</>
);

View File

@@ -1,23 +0,0 @@
import { Box, Tooltip } from '@chakra-ui/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const InfoTooltip: React.FC<{ description: string }> = ({ description }) => {
return (
<Tooltip label={description} placement="top" hasArrow arrowSize={4} maxW={220}>
<Box
borderRadius="full"
bg="whiteAlpha.200"
p={0.5}
w={18}
h={18}
display="flex"
alignItems="center"
justifyContent="center"
>
<FontAwesomeIcon icon="question" fixedWidth fontSize={10} />
</Box>
</Tooltip>
);
};
export default InfoTooltip;

View File

@@ -1,13 +0,0 @@
import { HStack, Text } from '@chakra-ui/react';
import InfoTooltip from './InfoTooltip';
const Label: React.FC<{ label: string; description?: string }> = ({ label, description }) => {
return (
<HStack spacing={1}>
<Text>{label}</Text>
{description && <InfoTooltip description={description} />}
</HStack>
);
};
export default Label;

View File

@@ -1,30 +1,22 @@
import { Box, Checkbox, HStack, Text } from '@chakra-ui/react';
import { useEffect } from 'react';
import { ICheckbox } from '../../../../interfaces/dialog';
import Label from '../Label';
import { Checkbox } from '@mantine/core';
import { ICheckbox } from '../../../../typings/dialog';
import { UseFormRegisterReturn } from 'react-hook-form';
interface Props {
row: ICheckbox;
index: number;
handleChange: (value: boolean, index: number) => void;
register: UseFormRegisterReturn;
}
const CheckboxField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.checked) props.handleChange(props.row.checked, props.index);
}, []);
return (
<>
<Box mb={3}>
<Checkbox
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.handleChange(e.target.checked, props.index)}
defaultChecked={props.row.checked}
>
<Label label={props.row.label} description={props.row.description} />
</Checkbox>
</Box>
</>
<Checkbox
{...props.register}
sx={{ display: 'flex' }}
required={props.row.required}
label={props.row.label}
defaultChecked={props.row.checked}
/>
);
};

View File

@@ -0,0 +1,40 @@
import { IColorInput } from '../../../../typings/dialog';
import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
import { ColorInput } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
interface Props {
row: IColorInput;
index: number;
control: Control<FormValues>;
}
const ColorField: React.FC<Props> = (props) => {
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
defaultValue: props.row.default,
rules: { required: props.row.required },
});
return (
<ColorInput
withEyeDropper={false}
value={controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={controller.field.onChange}
label={props.row.label}
description={props.row.description}
disabled={props.row.disabled}
defaultValue={props.row.default}
format={props.row.format}
withAsterisk={props.row.required}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
/>
);
};
export default ColorField;

View File

@@ -0,0 +1,73 @@
import { IDateInput } from '../../../../typings/dialog';
import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
import { DatePicker, DateRangePicker, TimeInput } from '@mantine/dates';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
interface Props {
row: IDateInput;
index: number;
control: Control<FormValues>;
}
const DateField: React.FC<Props> = (props) => {
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
rules: { required: props.row.required },
});
return (
<>
{props.row.type === 'date' && (
<DatePicker
value={controller.field.value ? new Date(controller.field.value) : controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
// Workaround to use timestamp instead of Date object in values
onChange={(date) => controller.field.onChange(date ? date.getTime() : null)}
label={props.row.label}
description={props.row.description}
placeholder={props.row.format}
disabled={props.row.disabled}
inputFormat={props.row.format}
withAsterisk={props.row.required}
clearable={props.row.clearable}
icon={props.row.icon && <FontAwesomeIcon fixedWidth icon={props.row.icon} />}
minDate={props.row.min ? new Date(props.row.min) : undefined}
maxDate={props.row.max ? new Date(props.row.max) : undefined}
/>
)}
{props.row.type === 'date-range' && (
<DateRangePicker
value={
controller.field.value
? controller.field.value[0]
? controller.field.value.map((date: Date) => new Date(date))
: controller.field.value
: controller.field.value
}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={(dates) =>
controller.field.onChange(dates.map((date: Date | null) => (date ? date.getTime() : null)))
}
label={props.row.label}
description={props.row.description}
placeholder={props.row.format}
disabled={props.row.disabled}
inputFormat={props.row.format}
withAsterisk={props.row.required}
clearable={props.row.clearable}
icon={props.row.icon && <FontAwesomeIcon fixedWidth icon={props.row.icon} />}
minDate={props.row.min ? new Date(props.row.min) : undefined}
maxDate={props.row.max ? new Date(props.row.max) : undefined}
/>
)}
</>
);
};
export default DateField;

View File

@@ -1,53 +1,58 @@
import { Box, InputGroup, InputLeftElement, InputRightElement, Input } from '@chakra-ui/react';
import { createStyles, PasswordInput, TextInput } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect } from 'react';
import { IInput } from '../../../../interfaces/dialog';
import Label from '../Label';
import React from 'react';
import { IInput } from '../../../../typings/dialog';
import { UseFormRegisterReturn } from 'react-hook-form';
interface Props {
register: UseFormRegisterReturn;
row: IInput;
index: number;
handleChange: (value: string, index: number) => void;
passwordStates: boolean[];
handlePasswordStates: (index: number) => void;
}
const useStyles = createStyles((theme) => ({
eyeIcon: {
color: theme.colors.dark[2],
},
}));
const InputField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.default) props.handleChange(props.row.default, props.index);
}, []);
const { classes } = useStyles();
return (
<>
<Box mb={3} textAlign="left">
<Label label={props.row.label} description={props.row.description} />
<InputGroup>
{props.row.icon && (
<InputLeftElement pointerEvents="none" children={<FontAwesomeIcon icon={props.row.icon} fixedWidth />} />
)}
<Input
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.handleChange(e.target.value, props.index)}
placeholder={props.row.placeholder}
defaultValue={props.row.default}
type={!props.row.password || props.passwordStates[props.index] ? 'text' : 'password'}
isDisabled={props.row.disabled}
/>
{props.row.password && (
<InputRightElement
{!props.row.password ? (
<TextInput
{...props.register}
defaultValue={props.row.default}
label={props.row.label}
description={props.row.description}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
placeholder={props.row.placeholder}
disabled={props.row.disabled}
withAsterisk={props.row.required}
/>
) : (
<PasswordInput
{...props.register}
defaultValue={props.row.default}
label={props.row.label}
description={props.row.description}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
placeholder={props.row.placeholder}
disabled={props.row.disabled}
withAsterisk={props.row.required}
visibilityToggleIcon={({ reveal, size }) => (
<FontAwesomeIcon
icon={reveal ? 'eye-slash' : 'eye'}
fontSize={size}
cursor="pointer"
onClick={() => props.handlePasswordStates(props.index)}
children={
<FontAwesomeIcon
fixedWidth
icon={props.passwordStates[props.index] ? 'eye' : 'eye-slash'}
fontSize="1em"
style={{ paddingRight: 8 }}
/>
}
className={classes.eyeIcon}
fixedWidth
/>
)}
</InputGroup>
</Box>
/>
)}
</>
);
};

View File

@@ -1,52 +1,39 @@
import {
Box,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
InputLeftElement,
InputGroup,
} from '@chakra-ui/react';
import { useEffect } from 'react';
import { INumber } from '../../../../interfaces/dialog';
import { NumberInput } from '@mantine/core';
import { INumber } from '../../../../typings/dialog';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Label from '../Label';
import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
interface Props {
row: INumber;
index: number;
handleChange: (value: number, index: number) => void;
control: Control<FormValues>;
}
const NumberField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.default) props.handleChange(props.row.default, props.index);
}, []);
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
defaultValue: props.row.default,
rules: { required: props.row.required },
});
return (
<Box mb={3}>
<Label label={props.row.label} description={props.row.description} />
<InputGroup>
<NumberInput
onChange={(val: string) => props.handleChange(+val, props.index)}
defaultValue={props.row.default}
min={props.row.min}
max={props.row.max}
isDisabled={props.row.disabled}
w="100%"
>
{props.row.icon && (
<InputLeftElement pointerEvents="none" children={<FontAwesomeIcon icon={props.row.icon} fixedWidth />} />
)}
<NumberInputField placeholder={props.row.placeholder} pl={props.row.icon ? '40px' : undefined} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</InputGroup>
</Box>
<NumberInput
value={controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={controller.field.onChange}
label={props.row.label}
description={props.row.description}
defaultValue={props.row.default}
min={props.row.min}
max={props.row.max}
disabled={props.row.disabled}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
withAsterisk={props.row.required}
/>
);
};

View File

@@ -1,45 +1,60 @@
import { Box, Select } from '@chakra-ui/react';
import { useEffect } from 'react';
import { ISelect } from '../../../../interfaces/dialog';
import { MultiSelect, Select } from '@mantine/core';
import { ISelect } from '../../../../typings/dialog';
import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
interface Props {
row: ISelect;
index: number;
handleChange: (value: string, index: number) => void;
control: Control<FormValues>;
}
const SelectField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.default) {
props.row.options?.map((option) => {
if (props.row.default === option.value) {
props.handleChange(option.value, props.index);
}
});
}
}, []);
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
defaultValue: props.row.default || props.row.type !== 'multi-select' ? props.row.options[0].value : undefined,
rules: { required: props.row.required },
});
return (
<>
<Box mb={3}>
{props.row.type === 'select' ? (
<Select
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => props.handleChange(e.target.value, props.index)}
defaultValue={props.row.default || ''}
isDisabled={props.row.disabled}
>
{/* Hacky workaround for selectable placeholder issue */}
{!props.row.default && (
<option value="" hidden disabled>
{props.row.label}
</option>
data={props.row.options}
value={controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={controller.field.onChange}
disabled={props.row.disabled}
label={props.row.label}
description={props.row.description}
withAsterisk={props.row.required}
clearable={props.row.clearable}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
/>
) : (
<>
{props.row.type === 'multi-select' && (
<MultiSelect
data={props.row.options}
value={controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={controller.field.onChange}
disabled={props.row.disabled}
label={props.row.label}
description={props.row.description}
withAsterisk={props.row.required}
clearable={props.row.clearable}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
/>
)}
{props.row.options?.map((option, index) => (
<option key={`option-${index}`} value={option.value}>
{option.label || option.value}
</option>
))}
</Select>
</Box>
</>
)}
</>
);
};

View File

@@ -1,47 +1,42 @@
import { Box, Text, Slider, SliderTrack, SliderFilledTrack, SliderThumb, HStack, Tooltip } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { ISlider } from '../../../../interfaces/dialog';
import Label from '../Label';
import { Box, Slider, Text } from '@mantine/core';
import { ISlider } from '../../../../typings/dialog';
import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
interface Props {
row: ISlider;
index: number;
handleChange: (value: number, index: number) => void;
control: Control<FormValues>;
}
const SliderField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.default || props.row.min) props.handleChange(props.row.default || props.row.min!, props.index);
}, []);
const [sliderValue, setSliderValue] = useState(props.row.default || props.row.min || 0);
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
defaultValue: props.row.default || props.row.min || 0,
});
return (
<>
<Box mb={3}>
<Label label={props.row.label} description={props.row.description} />
<Slider
onChangeEnd={(val: number) => props.handleChange(val, props.index)}
onChange={(val: number) => setSliderValue(val)}
defaultValue={props.row.default || props.row.min || 0}
min={props.row.min}
max={props.row.max}
step={props.row.step}
isDisabled={props.row.disabled}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<Tooltip hasArrow label={sliderValue} placement="bottom" gutter={10}>
<SliderThumb />
</Tooltip>
</Slider>
<HStack justifyContent="space-between">
<Text fontSize="sm">{props.row.min || 0}</Text>
<Text fontSize="sm">{props.row.max || 100}</Text>
</HStack>
</Box>
</>
<Box>
<Text sx={{ fontSize: 14, fontWeight: 500 }}>{props.row.label}</Text>
<Slider
mb={10}
value={controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={controller.field.onChange}
defaultValue={props.row.default || props.row.min || 0}
min={props.row.min}
max={props.row.max}
step={props.row.step}
disabled={props.row.disabled}
marks={[
{ value: props.row.min || 0, label: props.row.min || 0 },
{ value: props.row.max || 100, label: props.row.max || 100 },
]}
/>
</Box>
);
};

View File

@@ -0,0 +1,31 @@
import { Textarea } from '@mantine/core';
import { UseFormRegisterReturn } from 'react-hook-form';
import { ITextarea } from '../../../../typings/dialog';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
interface Props {
register: UseFormRegisterReturn;
row: ITextarea;
index: number;
}
const TextareaField: React.FC<Props> = (props) => {
return (
<Textarea
{...props.register}
defaultValue={props.row.default}
label={props.row.label}
description={props.row.description}
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
placeholder={props.row.placeholder}
disabled={props.row.disabled}
withAsterisk={props.row.required}
autosize={props.row.autosize}
minRows={props.row.min}
maxRows={props.row.max}
/>
);
};
export default TextareaField;

View File

@@ -0,0 +1,38 @@
import { TimeInput } from '@mantine/dates';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Control, useController } from 'react-hook-form';
import { ITimeInput } from '../../../../typings/dialog';
import { FormValues } from '../../InputDialog';
interface Props {
row: ITimeInput;
index: number;
control: Control<FormValues>;
}
const TimeField: React.FC<Props> = (props) => {
const controller = useController({
name: `test.${props.index}.value`,
control: props.control,
rules: { required: props.row.required },
});
return (
<TimeInput
value={controller.field.value ? new Date(controller.field.value) : controller.field.value}
name={controller.field.name}
ref={controller.field.ref}
onBlur={controller.field.onBlur}
onChange={(date) => controller.field.onChange(date ? date.getTime() : null)}
label={props.row.label}
description={props.row.description}
disabled={props.row.disabled}
format={props.row.format || '12'}
withAsterisk={props.row.required}
clearable={props.row.clearable}
icon={props.row.icon && <FontAwesomeIcon fixedWidth icon={props.row.icon} />}
/>
);
};
export default TimeField;

View File

@@ -1,11 +1,12 @@
import { useNuiEvent } from '../../../hooks/useNuiEvent';
import { Box, Text, Flex, ScaleFade } from '@chakra-ui/react';
import { Box, Stack, Text, Flex } from '@mantine/core';
import { useEffect, useState } from 'react';
import { ContextMenuProps } from '../../../interfaces/context';
import Item from './Item';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ContextMenuProps } from '../../../typings';
import ContextButton from './components/ContextButton';
import { fetchNui } from '../../../utils/fetchNui';
import ReactMarkdown from 'react-markdown';
import HeaderButton from './components/HeaderButton';
import ScaleFade from '../../../transitions/ScaleFade';
const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>('openContext', { id: id, back: true });
@@ -49,64 +50,28 @@ const ContextMenu: React.FC = () => {
});
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 && (
<Flex
borderRadius="md"
bg="gray.800"
flex="1 15%"
alignSelf="stretch"
textAlign="center"
justifyContent="center"
alignItems="center"
marginRight={2}
p={2}
_hover={{ bg: 'gray.700' }}
transition="300ms"
onClick={() => openMenu(contextMenu.menu)}
>
<FontAwesomeIcon icon="chevron-left" />
</Flex>
)}
<Box borderRadius="md" bg="gray.800" flex="1 85%">
<Text fontFamily="Poppins" fontSize="md" p={2} textAlign="center" fontWeight="light">
<ReactMarkdown>{contextMenu.title}</ReactMarkdown>
</Text>
</Box>
<Flex
borderRadius="md"
as="button"
bg={contextMenu.canClose === false ? 'gray.600' : 'gray.800'}
flex="1 15%"
alignSelf="stretch"
textAlign="center"
justifyContent="center"
alignItems="center"
marginLeft={2}
p={2}
cursor={contextMenu.canClose === false ? 'not-allowed' : undefined}
_hover={{ bg: contextMenu.canClose === false ? undefined : 'gray.700' }}
transition="300ms"
onClick={() => closeContext()}
>
<FontAwesomeIcon
icon="xmark"
fontSize={20}
color={contextMenu.canClose === false ? '#718096' : undefined}
/>
</Flex>
</Flex>
<Box maxH={560} overflowY="scroll">
{Object.entries(contextMenu.options).map((option, index) => (
<Item option={option} key={`context-item-${index}`} />
))}
<Box sx={{ position: 'absolute', top: '15%', right: '25%' }} w={320} h={580}>
<ScaleFade visible={visible}>
<Flex justify="center" align="center" mb={10} gap={6}>
{contextMenu.menu && (
<HeaderButton icon="chevron-left" iconSize={16} handleClick={() => openMenu(contextMenu.menu)} />
)}
<Box sx={{ borderRadius: 4, flex: '1 85%' }} bg="dark.6">
<Text color="dark.0" p={6} align="center">
<ReactMarkdown>{contextMenu.title}</ReactMarkdown>
</Text>
</Box>
<HeaderButton icon="xmark" canClose={contextMenu.canClose} iconSize={18} handleClick={closeContext} />
</Flex>
<Box sx={{ height: 560, overflowY: 'scroll' }}>
<Stack spacing={3}>
{Object.entries(contextMenu.options).map((option, index) => (
<ContextButton option={option} key={`context-item-${index}`} />
))}
</Stack>
</Box>
</ScaleFade>
</Flex>
</Box>
);
};

View File

@@ -1,142 +0,0 @@
import {
Portal,
Popover,
PopoverTrigger,
PopoverBody,
PopoverContent,
Box,
Text,
Flex,
Spacer,
Image,
HStack,
Progress,
} from '@chakra-ui/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ReactMarkdown from 'react-markdown';
import { Option, ContextMenuProps } from '../../../interfaces/context';
import { fetchNui } from '../../../utils/fetchNui';
const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>('openContext', { id: id, back: false });
};
const clickContext = (id: string) => {
fetchNui('clickContext', id);
};
const Item: React.FC<{
option: [string, Option];
}> = ({ option }) => {
const button = option[1];
const buttonKey = option[0];
return (
<>
<Popover placement="right-start" trigger="hover" eventListeners={{ scroll: true }} isLazy>
<PopoverTrigger>
<Box
bg={button.disabled ? 'gray.600' : 'gray.800'}
borderRadius="md"
h="fit-content"
w="100%"
p={2}
mb={1}
fontFamily="Poppins"
fontSize="md"
transition="300ms"
_hover={{ bg: !button.disabled && 'gray.700' }}
cursor={(button.disabled && 'not-allowed') || undefined}
>
<Flex
w="100%"
alignItems="center"
color={button.disabled ? '#718096' : undefined}
onClick={() =>
!button.disabled ? (button.menu ? openMenu(button.menu) : clickContext(buttonKey)) : null
}
>
{button?.icon && (
<FontAwesomeIcon
fixedWidth
icon={button.icon}
fontSize={20}
style={{
marginRight: 10,
justifySelf: 'center',
color: button.iconColor,
}}
/>
)}
<Box w="100%">
<Box>
<Text w="100%" fontWeight="medium" color={button.disabled ? '#718096' : undefined}>
<ReactMarkdown>{button.title ? button.title : buttonKey}</ReactMarkdown>
</Text>
</Box>
{button.description && (
<Box paddingBottom={1} color={button.disabled ? '#718096' : undefined}>
<Text><ReactMarkdown>{button.description}</ReactMarkdown></Text>
</Box>
)}
{button?.progress && (
<Progress
value={button.progress}
size="sm"
colorScheme={button.colorScheme || 'gray'}
borderRadius="md"
marginRight="5px"
/>
)}
</Box>
{(button.menu || button.arrow) && button.arrow !== false && (
<>
<Spacer />
<Box alignSelf="center" justifySelf="center" mr={4} fontSize="xl">
<FontAwesomeIcon icon="chevron-right" />
</Box>
</>
)}
</Flex>
<Portal>
{!button.disabled && (button.metadata || button.image) && (
<PopoverContent
fontFamily="Poppins"
bg="gray.800"
outline="none"
border="none"
w="fit-content"
maxW="2xs"
>
<PopoverBody>
<>
{button.image && <Image src={button.image} />}
{Array.isArray(button.metadata) ? (
button.metadata.map((metadata: string | { label: string; value: any }, index: number) => (
<Text key={`context-metadata-${index}`}>
{typeof metadata === 'string' ? `${metadata}` : `${metadata.label}: ${metadata.value}`}
</Text>
))
) : (
<>
{typeof button.metadata === 'object' &&
Object.entries(button.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,122 @@
import { Button, Box, Group, Stack, Text, Progress, HoverCard, Image, createStyles } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ReactMarkdown from 'react-markdown';
import { Option, ContextMenuProps } from '../../../../typings';
import { fetchNui } from '../../../../utils/fetchNui';
const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>('openContext', { id: id, back: false });
};
const clickContext = (id: string) => {
fetchNui('clickContext', id);
};
const useStyles = createStyles((theme, params: { disabled?: boolean }) => ({
inner: {
justifyContent: 'flex-start',
},
label: {
width: '100%',
color: params.disabled ? theme.colors.dark[3] : theme.colors.dark[0],
whiteSpace: 'pre-wrap',
},
description: {
color: params.disabled ? theme.colors.dark[3] : theme.colors.dark[2],
},
dropdown: {
padding: 10,
color: theme.colors.dark[0],
fontSize: 14,
maxWidth: 256,
width: 'fit-content',
border: 'none',
},
}));
const ContextButton: React.FC<{
option: [string, Option];
}> = ({ option }) => {
const button = option[1];
const buttonKey = option[0];
const { classes } = useStyles({ disabled: button.disabled });
return (
<>
<HoverCard
position="right-start"
disabled={button.disabled || !(button.metadata || button.image)}
openDelay={200}
>
<HoverCard.Target>
<Button
classNames={{ inner: classes.inner, label: classes.label }}
onClick={() => (!button.disabled ? (button.menu ? openMenu(button.menu) : clickContext(buttonKey)) : null)}
variant="default"
h="fit-content"
p={10}
fullWidth
disabled={button.disabled}
>
<Group position="apart" w="100%" noWrap>
<Stack spacing={4} style={{ flex: '1' }}>
<Group spacing={8} noWrap>
{button?.icon && (
<Stack w={25} h={25} justify="center" align="center">
<FontAwesomeIcon icon={button.icon} fixedWidth size="lg" style={{ color: button.iconColor }} />
</Stack>
)}
<Text sx={{ overflowWrap: 'break-word' }}>
<ReactMarkdown>{button.title || buttonKey}</ReactMarkdown>
</Text>
</Group>
{button.description && (
<Text size={12} className={classes.description}>
<ReactMarkdown>{button.description}</ReactMarkdown>
</Text>
)}
{button.progress !== undefined && (
<Progress value={button.progress} size="sm" color={button.colorScheme || 'dark.3'} />
)}
</Stack>
{(button.menu || button.arrow) && button.arrow !== false && (
<Stack justify="center" w={25} h={25} align="center">
<FontAwesomeIcon icon="chevron-right" fixedWidth />
</Stack>
)}
</Group>
</Button>
</HoverCard.Target>
<HoverCard.Dropdown className={classes.dropdown}>
{button.image && <Image src={button.image} />}
{Array.isArray(button.metadata) ? (
button.metadata.map(
(metadata: string | { label: string; value?: any; progress?: number }, index: number) => (
<>
<Text key={`context-metadata-${index}`}>
{typeof metadata === 'string' ? `${metadata}` : `${metadata.label}: ${metadata?.value ?? ''}`}
</Text>
{typeof metadata === 'object' && metadata.progress !== undefined && (
<Progress value={metadata.progress} size="sm" color={button.colorScheme || 'dark.3'} />
)}
</>
)
)
) : (
<>
{typeof button.metadata === 'object' &&
Object.entries(button.metadata).map((metadata: { [key: string]: any }, index) => (
<Text key={`context-metadata-${index}`}>
{metadata[0]}: {metadata[1]}
</Text>
))}
</>
)}
</HoverCard.Dropdown>
</HoverCard>
</>
);
};
export default ContextButton;

View File

@@ -0,0 +1,46 @@
import { Button, createStyles } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
interface Props {
icon: IconProp;
canClose?: boolean;
iconSize: number;
handleClick: () => void;
}
const useStyles = createStyles((theme, params: { canClose?: boolean }) => ({
button: {
borderRadius: 4,
flex: '1 15%',
alignSelf: 'stretch',
height: 'auto',
textAlign: 'center',
justifyContent: 'center',
padding: 2,
},
root: {
border: 'none',
},
label: {
color: params.canClose === false ? theme.colors.dark[2] : theme.colors.dark[0],
},
}));
const HeaderButton: React.FC<Props> = ({ icon, canClose, iconSize, handleClick }) => {
const { classes } = useStyles({ canClose });
return (
<Button
variant="default"
className={classes.button}
classNames={{ label: classes.label, root: classes.root }}
disabled={canClose === false}
onClick={handleClick}
>
<FontAwesomeIcon icon={icon} fontSize={iconSize} fixedWidth />
</Button>
);
};
export default HeaderButton;

View File

@@ -1,35 +1,29 @@
import { Box, Flex, useCheckbox, chakra, CheckboxIcon } from '@chakra-ui/react';
import { Checkbox, createStyles, Stack } from '@mantine/core';
const useStyles = createStyles((theme) => ({
root: {
display: 'flex',
alignItems: 'center',
},
input: {
backgroundColor: theme.colors.dark[7],
'&:checked': { backgroundColor: theme.colors.dark[2], borderColor: theme.colors.dark[2] },
},
inner: {
'> svg > path': {
fill: theme.colors.dark[6],
},
},
}));
const CustomCheckbox: React.FC<{ checked: boolean }> = ({ checked }) => {
const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox();
const { classes } = useStyles();
return (
<chakra.label
display="flex"
flexDirection="row"
alignItems="center"
gridColumnGap={2}
pr={3}
cursor="pointer"
{...htmlProps}
>
<input {...getInputProps()} hidden />
<Flex
alignItems="center"
justifyContent="center"
border="2px solid"
borderColor="#909296"
rounded={'sm'}
w={5}
h={5}
{...getCheckboxProps()}
>
{checked && (
<Box w={4} h={4} bg="#909296">
<CheckboxIcon isChecked color="#25262B" />
</Box>
)}
</Flex>
</chakra.label>
<Checkbox
checked={checked}
size="md"
classNames={{ root: classes.root, input: classes.input, inner: classes.inner }}
/>
);
};

View File

@@ -1,20 +1,31 @@
import { Box, Text } from '@chakra-ui/react';
import { Box, createStyles, Text } from '@mantine/core';
import React from 'react';
const useStyles = createStyles((theme) => ({
container: {
textAlign: 'center',
borderTopLeftRadius: theme.radius.md,
borderTopRightRadius: theme.radius.md,
backgroundColor: theme.colors.dark[6],
height: 60,
width: 384,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
heading: {
fontSize: 24,
textTransform: 'uppercase',
fontWeight: 500,
},
}));
const Header: React.FC<{ title: string }> = ({ title }) => {
const { classes } = useStyles();
return (
<Box
p={3}
textAlign="center"
borderTopLeftRadius="md"
borderTopRightRadius="md"
bg="#25262B"
height="60px"
width="sm"
>
<Text fontSize={24} textTransform="uppercase" fontWeight={600} fontFamily="Nunito">
{title}
</Text>
<Box className={classes.container}>
<Text className={classes.heading}>{title}</Text>
</Box>
);
};

View File

@@ -1,8 +1,9 @@
import { Box, Flex, Stack, Text, Progress } from '@chakra-ui/react';
import { Box, Group, Stack, Text, Progress } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { forwardRef } from 'react';
import CustomCheckbox from './CustomCheckbox';
import type { MenuItem } from './index';
import type { MenuItem } from '../../../typings';
import { createStyles } from '@mantine/core';
interface Props {
item: MenuItem;
@@ -11,35 +12,80 @@ interface Props {
checked: boolean;
}
const useStyles = createStyles((theme, params: { iconColor?: string }) => ({
buttonContainer: {
backgroundColor: theme.colors.dark[6],
borderRadius: theme.radius.md,
padding: 2,
height: 60,
scrollMargin: 8,
'&:focus': {
backgroundColor: theme.colors.dark[4],
outline: 'none',
},
},
buttonWrapper: {
paddingLeft: 5,
paddingRight: 12,
height: '100%',
},
iconContainer: {
display: 'flex',
alignItems: 'center',
},
icon: {
fontSize: 24,
color: params.iconColor || theme.colors.dark[2],
},
label: {
color: theme.colors.dark[2],
textTransform: 'uppercase',
fontSize: 12,
verticalAlign: 'middle',
},
chevronIcon: {
fontSize: 14,
color: theme.colors.dark[2],
},
scrollIndexValue: {
color: theme.colors.dark[2],
textTransform: 'uppercase',
fontSize: 14,
},
progressStack: {
width: '100%',
marginRight: 5,
},
progressLabel: {
verticalAlign: 'middle',
marginBottom: 3,
},
}));
const ListItem = forwardRef<Array<HTMLDivElement | null>, Props>(({ item, index, scrollIndex, checked }, ref) => {
const { classes } = useStyles({ iconColor: item.iconColor });
return (
<Box
bg="#25262B"
borderRadius="md"
tabIndex={index}
scrollMargin={2}
p={2}
height="60px"
className={classes.buttonContainer}
key={`item-${index}`}
_focus={{ bg: '#373A40', outline: 'none' }}
ref={(element) => {
ref={(element: HTMLDivElement) => {
if (ref)
// @ts-ignore i cba
return (ref.current = [...ref.current, element]);
}}
>
<Flex alignItems="center" height="100%" gap="15px">
<Group spacing={15} noWrap className={classes.buttonWrapper}>
{item.icon && (
<Box display="flex" alignItems="center">
<FontAwesomeIcon icon={item.icon} fontSize={24} color={item.iconColor || '#909296'} fixedWidth />
<Box className={classes.iconContainer}>
<FontAwesomeIcon icon={item.icon} className={classes.icon} fixedWidth />
</Box>
)}
{Array.isArray(item.values) ? (
<Flex alignItems="center" justifyContent="space-between" w="100%">
<Stack spacing={1} justifyContent="space-between">
<Text color="#909296" textTransform="uppercase" fontSize={12} verticalAlign="middle">
{item.label}
</Text>
<Group position="apart" w="100%">
<Stack spacing={0} justify="space-between">
<Text className={classes.label}>{item.label}</Text>
<Text>
{typeof item.values[scrollIndex] === 'object'
? // @ts-ignore for some reason even checking the type TS still thinks it's a string
@@ -47,30 +93,32 @@ const ListItem = forwardRef<Array<HTMLDivElement | null>, Props>(({ item, index,
: item.values[scrollIndex]}
</Text>
</Stack>
<Stack direction="row" spacing="sm" pr={3} justifyContent="center" alignItems="center">
<FontAwesomeIcon icon="chevron-left" fontSize={16} color="#909296" />
<Text color="#909296" textTransform="uppercase" fontSize={14}>
<Group spacing={1} position="center">
<FontAwesomeIcon icon="chevron-left" className={classes.chevronIcon} />
<Text className={classes.scrollIndexValue}>
{scrollIndex + 1}/{item.values.length}
</Text>
<FontAwesomeIcon icon="chevron-right" fontSize={16} color="#909296" />
</Stack>
</Flex>
<FontAwesomeIcon icon="chevron-right" className={classes.chevronIcon} />
</Group>
</Group>
) : item.checked !== undefined ? (
<Flex alignItems="center" justifyContent="space-between" w="100%">
<Group position="apart" w="100%">
<Text>{item.label}</Text>
<CustomCheckbox checked={checked}></CustomCheckbox>
</Flex>
</Group>
) : item.progress !== undefined ? (
<Flex flexDirection="column" w="100%" marginRight="5px">
<Text verticalAlign="middle" marginBottom="3px">
{item.label}
</Text>
<Progress value={item.progress} size="sm" colorScheme={item.colorScheme || 'gray'} borderRadius="md" />
</Flex>
<Stack className={classes.progressStack} spacing={0}>
<Text className={classes.progressLabel}>{item.label}</Text>
<Progress
value={item.progress}
color={item.colorScheme || 'dark.0'}
styles={(theme) => ({ root: { backgroundColor: theme.colors.dark[3] } })}
/>
</Stack>
) : (
<Text>{item.label}</Text>
)}
</Flex>
</Group>
</Box>
);
});

View File

@@ -1,34 +1,55 @@
import { Box, Stack, Tooltip } from '@chakra-ui/react';
import { Box, createStyles, Stack, Tooltip } from '@mantine/core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNuiEvent } from '../../../hooks/useNuiEvent';
import ListItem from './ListItem';
import Header from './Header';
import FocusTrap from 'focus-trap-react';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { fetchNui } from '../../../utils/fetchNui';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import type { MenuPosition, MenuSettings } from '../../../typings';
export interface MenuItem {
label: string;
progress?: number;
colorScheme?: string;
checked?: boolean;
values?: Array<string | { label: string; description: string }>;
description?: string;
icon?: IconProp;
iconColor?: string;
defaultIndex?: number;
close?: boolean;
}
export interface MenuSettings {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
title: string;
canClose?: boolean;
items: Array<MenuItem>;
startItemIndex?: number;
}
const useStyles = createStyles((theme, params: { position?: MenuPosition; itemCount: number; selected: number }) => ({
tooltip: {
backgroundColor: theme.colors.dark[6],
color: theme.colors.dark[2],
borderRadius: theme.radius.sm,
maxWidth: 350,
whiteSpace: 'normal',
},
container: {
position: 'absolute',
pointerEvents: 'none',
marginTop: params.position === 'top-left' || params.position === 'top-right' ? 5 : 0,
marginLeft: params.position === 'top-left' || params.position === 'bottom-left' ? 5 : 0,
marginRight: params.position === 'top-right' || params.position === 'bottom-right' ? 5 : 0,
marginBottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 5 : 0,
right: params.position === 'top-right' || params.position === 'bottom-right' ? 1 : undefined,
left: params.position === 'bottom-left' ? 1 : undefined,
bottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 1 : undefined,
fontFamily: 'Roboto',
},
buttonsWrapper: {
height: 'fit-content',
maxHeight: 415,
overflow: 'hidden',
borderRadius: params.itemCount <= 6 || params.selected === params.itemCount - 1 ? theme.radius.md : undefined,
backgroundColor: theme.colors.dark[8],
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
scrollArrow: {
backgroundColor: theme.colors.dark[8],
textAlign: 'center',
borderBottomLeftRadius: theme.radius.md,
borderBottomRightRadius: theme.radius.md,
height: 25,
},
scrollArrowIcon: {
color: theme.colors.dark[2],
fontSize: 20,
},
}));
const ListMenu: React.FC = () => {
const [menu, setMenu] = useState<MenuSettings>({
@@ -42,6 +63,7 @@ const ListMenu: React.FC = () => {
const [checkedStates, setCheckedStates] = useState<Record<number, boolean>>({});
const listRefs = useRef<Array<HTMLDivElement | null>>([]);
const firstRenderRef = useRef(false);
const { classes } = useStyles({ position: menu.position, itemCount: menu.items.length, selected });
const closeMenu = (ignoreFetch?: boolean, keyPressed?: string, forceClose?: boolean) => {
if (menu.canClose === false && !forceClose) return;
@@ -184,44 +206,20 @@ const ListMenu: React.FC = () => {
menu.items[selected].values[indexStates[selected]].description
: menu.items[selected].description
}
isOpen={
opened={
isValuesObject(menu.items[selected].values)
? // @ts-ignore
!!menu.items[selected].values[indexStates[selected]].description
: !!menu.items[selected].description
}
bg="#25262B"
color="#909296"
placement="bottom"
borderRadius="md"
fontFamily="Nunito"
transitionDuration={0}
classNames={{ tooltip: classes.tooltip }}
>
<Box
position="absolute"
pointerEvents="none"
mt={menu.position === 'top-left' || menu.position === 'top-right' ? 5 : 0}
ml={menu.position === 'top-left' || menu.position === 'bottom-left' ? 5 : 0}
mr={menu.position === 'top-right' || menu.position === 'bottom-right' ? 5 : 0}
mb={menu.position === 'bottom-left' || menu.position === 'bottom-right' ? 5 : 0}
right={menu.position === 'top-right' || menu.position === 'bottom-right' ? 1 : undefined}
left={menu.position === 'bottom-left' ? 1 : undefined}
bottom={menu.position === 'bottom-left' || menu.position === 'bottom-right' ? 1 : undefined}
>
<Box className={classes.container}>
<Header title={menu.title} />
<Box
width="sm"
height="fit-content"
maxHeight={415}
overflow="hidden"
borderRadius={menu.items.length <= 6 || selected === menu.items.length - 1 ? 'md' : undefined}
bg="#141517"
fontFamily="Nunito"
borderTopLeftRadius="none"
borderTopRightRadius="none"
onKeyDown={(e) => moveMenu(e)}
>
<Box className={classes.buttonsWrapper} onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => moveMenu(e)}>
<FocusTrap active={visible}>
<Stack direction="column" p={2} overflowY="scroll">
<Stack spacing={8} p={8} sx={{ overflowY: 'scroll' }}>
{menu.items.map((item, index) => (
<React.Fragment key={`menu-item-${index}`}>
{item.label && (
@@ -239,8 +237,8 @@ const ListMenu: React.FC = () => {
</FocusTrap>
</Box>
{menu.items.length > 6 && selected !== menu.items.length - 1 && (
<Box bg="#141517" textAlign="center" borderBottomLeftRadius="md" borderBottomRightRadius="md" height={25}>
<FontAwesomeIcon icon="chevron-down" color="#909296" fontSize={20} />
<Box className={classes.scrollArrow}>
<FontAwesomeIcon icon="chevron-down" className={classes.scrollArrowIcon} />
</Box>
)}
</Box>

View File

@@ -0,0 +1,152 @@
import { Box, createStyles } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState } from 'react';
import { useNuiEvent } from '../../../hooks/useNuiEvent';
import { fetchNui } from '../../../utils/fetchNui';
import ScaleFade from '../../../transitions/ScaleFade';
import type { RadialMenuItem } from '../../../typings';
const useStyles = createStyles((theme) => ({
wrapper: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
sector: {
fill: theme.colors.dark[6],
color: theme.colors.dark[0],
'&:hover': {
fill: theme.fn.primaryColor(),
cursor: 'pointer',
'> g > text, > g > svg > path': {
fill: '#fff',
},
},
'> g > text': {
fill: theme.colors.dark[0],
},
},
backgroundCircle: {
fill: theme.colors.dark[6],
},
centerCircle: {
fill: theme.fn.primaryColor(),
color: '#fff',
'&:hover': {
fill: theme.colors[theme.primaryColor][theme.fn.primaryShade() - 1],
cursor: 'pointer',
},
},
centerIconContainer: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
},
centerIcon: {
color: '#fff',
},
}));
const degToRad = (deg: number) => deg * (Math.PI / 180);
const RadialMenu: React.FC = () => {
const { classes } = useStyles();
const [visible, setVisible] = useState(false);
const [menu, setMenu] = useState<{ items: RadialMenuItem[]; sub?: boolean }>({
items: [],
sub: false,
});
useNuiEvent('openRadialMenu', async (data: { items: RadialMenuItem[]; sub?: boolean } | false) => {
if (!data) return setVisible(false);
if (visible) {
setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 100));
}
setMenu(data);
setVisible(true);
});
useNuiEvent('refreshItems', (data: RadialMenuItem[]) => {
setMenu({ ...menu, items: data });
});
return (
<>
<Box className={classes.wrapper} onContextMenu={() => menu.sub && fetchNui('radialBack')}>
<ScaleFade visible={visible}>
<svg width="350px" height="350px" transform="rotate(90)">
{/*Fixed issues with background circle extending the circle when there's less than 3 items*/}
<g transform="translate(175, 175)">
<circle r={175} className={classes.backgroundCircle} />
</g>
{menu.items.map((item, index) => {
// Always draw full circle to avoid elipse circles with 2 or less items
const pieAngle = 360 / (menu.items.length < 3 ? 3 : menu.items.length);
const angle = degToRad(pieAngle / 2 + 90);
const radius = 175 * 0.65;
const iconX = 175 + Math.sin(angle) * radius;
const iconY = 175 + Math.cos(angle) * radius;
return (
<>
<g
transform={`rotate(-${index * pieAngle} 175 175)`}
className={classes.sector}
onClick={() => fetchNui('radialClick', index)}
>
<path
d={`M175.01,175.01 l175,0 A175.01,175.01 0 0,0 ${175 + 175 * Math.cos(-degToRad(pieAngle))}, ${
175 + 175 * Math.sin(-degToRad(pieAngle))
} z`}
/>
<g transform={`rotate(${index * pieAngle - 90} ${iconX} ${iconY})`} pointerEvents="none">
<FontAwesomeIcon
x={iconX - 12.5}
y={iconY - 17.5}
icon={item.icon}
width={25}
height={25}
fixedWidth
/>
<text x={iconX} y={iconY + 25} fill="#fff" textAnchor="middle" pointerEvents="none">
{item.label}
</text>
</g>
</g>
</>
);
})}
<g
transform={`translate(175, 175)`}
onClick={() => {
if (menu.sub) fetchNui('radialBack');
else {
setVisible(false);
fetchNui('radialClose');
}
}}
>
<circle r={30} className={classes.centerCircle} />
</g>
</svg>
<div className={classes.centerIconContainer}>
<FontAwesomeIcon
icon={!menu.sub ? 'xmark' : 'arrow-rotate-left'}
fixedWidth
className={classes.centerIcon}
color="#fff"
size="2x"
/>
</div>
</ScaleFade>
</Box>
</>
);
};
export default RadialMenu;

View File

@@ -1,84 +1,183 @@
import { useToast, type ToastPosition, Box, HStack, Text } from '@chakra-ui/react';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { toast, Toaster } from 'react-hot-toast';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ReactMarkdown from 'react-markdown';
import { Avatar, createStyles, Group, Stack, Box, Text, keyframes } from '@mantine/core';
import React from 'react';
import type { NotificationProps } from '../../typings';
export interface NotificationProps {
title?: string;
description?: string;
duration?: number;
position?: ToastPosition;
variant?: string;
status?: 'info' | 'warning' | 'success' | 'error';
id?: number;
}
const useStyles = createStyles((theme) => ({
container: {
width: 300,
height: 'fit-content',
backgroundColor: theme.colors.dark[6],
color: theme.colors.dark[0],
padding: 12,
borderRadius: theme.radius.sm,
fontFamily: 'Roboto',
boxShadow: theme.shadows.sm,
},
title: {
fontWeight: 500,
lineHeight: 'normal',
},
description: {
fontSize: 12,
color: theme.colors.dark[2],
fontFamily: 'Roboto',
lineHeight: 'normal',
},
descriptionOnly: {
fontSize: 14,
color: theme.colors.dark[2],
fontFamily: 'Roboto',
lineHeight: 'normal',
},
}));
export interface CustomNotificationProps {
style?: React.CSSProperties;
description?: string;
title?: string;
duration?: number;
icon?: IconProp;
iconColor?: string;
position?: ToastPosition;
id?: number;
type?: string;
}
// I hate this
const enterAnimationTop = keyframes({
from: {
opacity: 0,
transform: 'translateY(-30px)',
},
to: {
opacity: 1,
transform: 'translateY(0px)',
},
});
const enterAnimationBottom = keyframes({
from: {
opacity: 0,
transform: 'translateY(30px)',
},
to: {
opacity: 1,
transform: 'translateY(0px)',
},
});
const exitAnimationTop = keyframes({
from: {
opacity: 1,
transform: 'translateY(0px)',
},
to: {
opacity: 0,
transform: 'translateY(-100%)',
},
});
const exitAnimationRight = keyframes({
from: {
opacity: 1,
transform: 'translateX(0px)',
},
to: {
opacity: 0,
transform: 'translateX(100%)',
},
});
const exitAnimationLeft = keyframes({
from: {
opacity: 1,
transform: 'translateX(0px)',
},
to: {
opacity: 0,
transform: 'translateX(-100%)',
},
});
const exitAnimationBottom = keyframes({
from: {
opacity: 1,
transform: 'translateY(0px)',
},
to: {
opacity: 0,
transform: 'translateY(100%)',
},
});
const Notifications: React.FC = () => {
const toast = useToast();
useNuiEvent<CustomNotificationProps>('customNotify', (data) => {
if (!data.title && !data.description) return;
if (data.id && toast.isActive(data.id)) return;
if (!data.icon) {
data.icon = data.type === 'error' ? 'circle-xmark' : data.type === 'success' ? 'circle-check' : 'circle-info';
}
const id = data.id;
toast({
id,
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}>
{data.icon && (
<FontAwesomeIcon
fixedWidth
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><ReactMarkdown>{data.description}</ReactMarkdown></Text>}
</Box>
</HStack>
</Box>
),
});
});
const { classes } = useStyles();
useNuiEvent<NotificationProps>('notify', (data) => {
if (!data.title && !data.description) return;
if (data.id && toast.isActive(data.id)) return;
const id = data.id;
toast({
id,
title: data.title,
description: data.description,
duration: data.duration || 4000,
position: data.position || 'top-right',
variant: data.variant,
status: data.status,
});
// Backwards compat with old notifications
let position = data.position;
switch (position) {
case 'top':
position = 'top-center';
break;
case 'bottom':
position = 'bottom-center';
break;
}
if (!data.icon) {
data.icon = data.type === 'error' ? 'xmark' : data.type === 'success' ? 'check' : 'info';
}
toast.custom(
(t) => (
<Box
sx={{
animation: t.visible
? `${position?.includes('bottom') ? enterAnimationBottom : enterAnimationTop} 0.2s ease-out forwards`
: `${
position?.includes('right')
? exitAnimationRight
: position?.includes('left')
? exitAnimationLeft
: position === 'top-center'
? exitAnimationTop
: position
? exitAnimationBottom
: exitAnimationRight
} 0.4s ease-in forwards`,
}}
style={data.style}
className={`${classes.container}`}
>
<Group noWrap spacing={12}>
{data.icon && (
<>
{!data.iconColor ? (
<Avatar
color={data.type === 'error' ? 'red' : data.type === 'success' ? 'teal' : 'blue'}
radius="xl"
size={32}
>
<FontAwesomeIcon icon={data.icon} fixedWidth size="lg" />
</Avatar>
) : (
<FontAwesomeIcon icon={data.icon} style={{ color: data.iconColor }} fixedWidth size="lg" />
)}
</>
)}
<Stack spacing={0}>
{data.title && <Text className={classes.title}>{data.title}</Text>}
{data.description && (
<ReactMarkdown className={!data.title ? classes.descriptionOnly : classes.description}>
{data.description}
</ReactMarkdown>
)}
</Stack>
</Group>
</Box>
),
{
id: data.id?.toString(),
duration: data.duration || 3000,
position: position || 'top-right',
}
);
});
return <></>;
return <Toaster />;
};
export default Notifications;

View File

@@ -1,14 +1,53 @@
import React from 'react';
import { CircularProgress, CircularProgressLabel, Flex, Text } from '@chakra-ui/react';
import { RingProgress, Text, useMantineTheme, keyframes, Stack, createStyles } from '@mantine/core';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui';
import ScaleFade from '../../transitions/ScaleFade';
import type { CircleProgressbarProps } from '../../typings';
export interface CircleProgressbarProps {
label?: string;
duration: number;
position?: 'middle' | 'bottom';
percent?: boolean;
}
// 33.5 is the r of the circle
const progressCircle = keyframes({
'0%': { strokeDasharray: `0, ${33.5 * 2 * Math.PI}` },
'100%': { strokeDasharray: `${33.5 * 2 * Math.PI}, 0` },
});
const useStyles = createStyles((theme, params: { position: 'middle' | 'bottom'; duration: number }) => ({
container: {
width: '100%',
height: params.position === 'middle' ? '100%' : '20%',
bottom: 0,
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
progress: {
'> svg > circle:nth-child(1)': {
stroke: theme.colors.dark[5],
},
// Scuffed way of grabbing the first section and animating it
'> svg > circle:nth-child(2)': {
transition: 'none',
animation: `${progressCircle} linear forwards`,
animationDuration: `${params.duration}ms`,
},
},
value: {
textAlign: 'center',
fontFamily: 'roboto-mono',
textShadow: theme.shadows.sm,
color: theme.colors.gray[3],
},
label: {
textAlign: 'center',
textShadow: theme.shadows.sm,
color: theme.colors.gray[3],
height: 25,
},
wrapper: {
marginTop: params.position === 'middle' ? 25 : undefined,
},
}));
const CircleProgressbar: React.FC = () => {
const [visible, setVisible] = React.useState(false);
@@ -16,7 +55,8 @@ const CircleProgressbar: React.FC = () => {
const [position, setPosition] = React.useState<'middle' | 'bottom'>('middle');
const [value, setValue] = React.useState(0);
const [label, setLabel] = React.useState('');
const [cancelled, setCancelled] = React.useState(false);
const theme = useMantineTheme();
const { classes } = useStyles({ position, duration: progressDuration });
const progressComplete = () => {
setVisible(false);
@@ -24,7 +64,6 @@ const CircleProgressbar: React.FC = () => {
};
const progressCancel = () => {
setCancelled(true);
setValue(99); // Sets the final value to 100% kek
setVisible(false);
};
@@ -33,7 +72,6 @@ const CircleProgressbar: React.FC = () => {
useNuiEvent<CircleProgressbarProps>('circleProgress', (data) => {
if (visible) return;
setCancelled(false);
setVisible(true);
setValue(0);
setLabel(data.label || '');
@@ -50,50 +88,23 @@ const CircleProgressbar: React.FC = () => {
});
return (
<Flex
h={position === 'middle' ? '100%' : '20%'}
w="100%"
position="absolute"
bottom="0"
justifyContent="center"
alignItems="center"
>
{visible && (
<Flex alignItems="center" flexDirection="column">
<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',
},
}
: {
// Currently unused
'.chakra-progress__indicator': {
transition: 'none !important',
strokeDasharray: '264, 0 !important', // sets circle to full
},
}
}
>
<CircularProgressLabel fontFamily="Fira Mono">{value}%</CircularProgressLabel>
</CircularProgress>
<Text fontFamily="Inter" fontSize={18} fontWeight="light">
{label}
</Text>
</Flex>
)}
</Flex>
<>
<Stack spacing={0} className={classes.container}>
<ScaleFade visible={visible}>
<Stack spacing={0} align="center" className={classes.wrapper}>
<RingProgress
size={90}
thickness={7}
sections={[{ value: 0, color: theme.primaryColor }]}
onAnimationEnd={progressComplete}
className={classes.progress}
label={<Text className={classes.value}>{value}%</Text>}
/>
{label && <Text className={classes.label}>{label}</Text>}
</Stack>
</ScaleFade>
</Stack>
</>
);
};

View File

@@ -1,18 +1,56 @@
import React from 'react';
import { Text, Flex, Box } from '@chakra-ui/react';
import { Box, Text, createStyles } from '@mantine/core';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui';
import ScaleFade from '../../transitions/ScaleFade';
import type { ProgressbarProps } from '../../typings';
export interface ProgressbarProps {
label: string;
duration: number;
}
const useStyles = createStyles((theme) => ({
container: {
width: 350,
height: 45,
borderRadius: theme.radius.sm,
backgroundColor: theme.colors.dark[5],
overflow: 'hidden',
},
wrapper: {
width: '100%',
height: '20%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bottom: 0,
position: 'absolute',
},
bar: {
height: '100%',
backgroundColor: theme.colors[theme.primaryColor][theme.fn.primaryShade()],
},
labelWrapper: {
position: 'absolute',
display: 'flex',
width: 350,
height: 45,
alignItems: 'center',
justifyContent: 'center',
},
label: {
maxWidth: 350,
padding: 8,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
fontSize: 20,
color: theme.colors.gray[3],
textShadow: theme.shadows.sm,
},
}));
const Progressbar: React.FC = () => {
const { classes } = useStyles();
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);
@@ -20,69 +58,38 @@ const Progressbar: React.FC = () => {
};
const progressCancel = () => {
setCancelled(true);
setVisible(false);
};
useNuiEvent('progressCancel', progressCancel);
useNuiEvent<ProgressbarProps>('progress', (data) => {
setCancelled(false);
setVisible(true);
setLabel(data.label);
setDuration(data.duration);
});
return (
<Flex h="30%" w="100%" position="absolute" bottom="0" justifyContent="center" alignItems="center">
<Box width={350}>
{visible && (
<Box
height={45}
bg="rgba(0, 0, 0, 0.6)"
textAlign="center"
borderRadius="sm"
boxShadow="lg"
overflow="hidden"
>
<>
<Box className={classes.wrapper}>
<ScaleFade visible={visible}>
<Box className={classes.container}>
<Box
height={45}
className={classes.bar}
onAnimationEnd={progressComplete}
sx={
!cancelled
? {
width: '0%',
backgroundColor: 'green.400',
animation: 'progress-bar linear',
animationDuration: `${duration}ms`,
}
: {
// Currently unused
width: '100%',
animationPlayState: 'paused',
backgroundColor: 'rgb(198, 40, 40)',
}
}
/>
<Text
maxWidth={350}
fontFamily="Inter"
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="nowrap"
fontSize={22}
fontWeight="light"
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
sx={{
animation: 'progress-bar linear',
animationDuration: `${duration}ms`,
}}
>
{label}
</Text>
<Box className={classes.labelWrapper}>
<Text className={classes.label}>{label}</Text>
</Box>
</Box>
</Box>
)}
</ScaleFade>
</Box>
</Flex>
</>
);
};

View File

@@ -1,22 +1,9 @@
import { Box, Center } from '@chakra-ui/react';
import { useRef, useState } from 'react';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { debugData } from '../../utils/debugData';
import Indicator from './indicator';
import { fetchNui } from '../../utils/fetchNui';
interface CustomGameDifficulty {
areaSize: number;
speedMultiplier: number;
}
export type GameDifficulty = 'easy' | 'medium' | 'hard' | CustomGameDifficulty;
export interface SkillCheckProps {
angle: number;
difficultyOffset: number;
difficulty: GameDifficulty;
}
import { Box, createStyles } from '@mantine/core';
import type { SkillCheckProps, GameDifficulty } from '../../typings';
export const circleCircumference = 2 * 50 * Math.PI;
@@ -28,77 +15,113 @@ const difficultyOffsets = {
hard: 25,
};
const useStyles = createStyles((theme) => ({
svg: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
track: {
fill: 'transparent',
stroke: theme.colors.dark[5],
strokeWidth: 8,
},
skillArea: {
fill: 'transparent',
stroke: theme.fn.primaryColor(),
strokeWidth: 8,
},
indicator: {
stroke: 'red',
strokeWidth: 16,
fill: 'transparent',
},
button: {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: theme.colors.dark[5],
width: 25,
height: 25,
textAlign: 'center',
borderRadius: 5,
fontSize: 16,
fontWeight: 500,
},
}));
const SkillCheck: React.FC = () => {
const { classes } = useStyles();
const [visible, setVisible] = useState(false);
const dataRef = useRef<GameDifficulty | GameDifficulty[] | null>(null);
const dataRef = useRef<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] } | null>(null);
const dataIndexRef = useRef<number>(0);
const [skillCheck, setSkillCheck] = useState<SkillCheckProps>({
angle: 0,
difficultyOffset: 50,
difficulty: 'easy',
key: 'e',
});
useNuiEvent('startSkillCheck', (data: GameDifficulty | GameDifficulty[]) => {
useNuiEvent('startSkillCheck', (data: { difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }) => {
dataRef.current = data;
dataIndexRef.current = 0;
const gameData = Array.isArray(data) ? data[0] : data;
const gameData = Array.isArray(data.difficulty) ? data.difficulty[0] : data.difficulty;
const offset = typeof gameData === 'object' ? gameData.areaSize : difficultyOffsets[gameData];
const randomKey = data.inputs ? data.inputs[Math.floor(Math.random() * data.inputs.length)] : 'e';
setSkillCheck({
angle: -90 + getRandomAngle(120, 360 - offset),
difficultyOffset: offset,
difficulty: gameData,
key: randomKey,
});
setVisible(true);
});
const handleComplete = (success: boolean) => {
if (!success || !Array.isArray(dataRef.current)) {
if (!dataRef.current) return;
if (!success || !Array.isArray(dataRef.current.difficulty)) {
setVisible(false);
return fetchNui('skillCheckOver', success);
}
if (dataIndexRef.current >= dataRef.current.length - 1) {
if (dataIndexRef.current >= dataRef.current.difficulty.length - 1) {
setVisible(false);
return fetchNui('skillCheckOver', success);
}
dataIndexRef.current++;
const data = dataRef.current[dataIndexRef.current];
const data = dataRef.current.difficulty[dataIndexRef.current];
const key = dataRef.current.inputs
? dataRef.current.inputs[Math.floor(Math.random() * dataRef.current.inputs.length)]
: 'e';
const offset = typeof data === 'object' ? data.areaSize : difficultyOffsets[data];
setSkillCheck({
angle: -90 + getRandomAngle(120, 360 - offset),
difficultyOffset: offset,
difficulty: data,
key,
});
};
return (
<Center height="100%" width="100%">
<>
{visible && (
<>
<svg width={500} height={500}>
<svg r={50} width={500} height={500} className={classes.svg}>
{/*Circle track*/}
<circle
r={50}
cx={250}
cy={250}
fill="transparent"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth={5}
strokeDasharray={circleCircumference}
/>
<circle r={50} cx={250} cy={250} className={classes.track} strokeDasharray={circleCircumference} />
{/*SkillCheck area*/}
<circle
r={50}
cx={250}
cy={250}
fill="transparent"
stroke="white"
strokeDasharray={circleCircumference}
strokeDashoffset={circleCircumference - (Math.PI * 50 * skillCheck.difficultyOffset) / 180}
strokeWidth={5}
transform={`rotate(${skillCheck.angle}, 250, 250)`}
className={classes.skillArea}
/>
<Indicator
angle={skillCheck.angle}
@@ -113,27 +136,14 @@ const SkillCheck: React.FC = () => {
: skillCheck.difficulty.speedMultiplier
}
handleComplete={handleComplete}
className={classes.indicator}
skillCheck={skillCheck}
/>
</svg>
<Box
position="absolute"
left="50%"
top="50%"
transform="translate(-50%, -50%)"
backgroundColor="rgba(0, 0, 0, 0.4)"
w={25}
h={25}
textAlign="center"
borderRadius={5}
fontFamily="Inter"
fontSize={16}
>
E
</Box>
<Box className={classes.button}>{skillCheck.key.toUpperCase()}</Box>
</>
)}
</Center>
</>
);
};

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useKeyPress } from '../../hooks/useKeyPress';
import { SkillCheckProps } from './index';
import { useInterval } from '@chakra-ui/react';
import { useCallback, useEffect, useState } from 'react';
import type { SkillCheckProps } from '../../typings';
import { useInterval } from '@mantine/hooks';
import { circleCircumference } from './index';
interface Props {
@@ -9,55 +8,63 @@ interface Props {
offset: number;
multiplier: number;
skillCheck: SkillCheckProps;
className: string;
handleComplete: (success: boolean) => void;
}
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck }) => {
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck, className }) => {
const [indicatorAngle, setIndicatorAngle] = useState(-90);
const [gameState, setGameState] = useState(false);
const isKeyPressed = useKeyPress('e');
useInterval(
() => {
const [keyPressed, setKeyPressed] = useState(false);
const interval = useInterval(
() =>
setIndicatorAngle((prevState) => {
return (prevState += multiplier);
});
}),
1
);
const keyHandler = useCallback(
(e: KeyboardEvent) => {
if (e.key.toLowerCase() !== skillCheck.key.toLowerCase()) return;
setKeyPressed(true);
},
gameState ? 1 : null
[skillCheck]
);
useEffect(() => {
setIndicatorAngle(-90);
setGameState(true);
window.addEventListener('keydown', keyHandler);
interval.start();
}, [skillCheck]);
useEffect(() => {
if (indicatorAngle + 90 >= 360) {
setGameState(false);
interval.stop();
handleComplete(false);
}
}, [indicatorAngle]);
useEffect(() => {
if (!isKeyPressed) return;
if (!keyPressed) return;
setGameState(false);
interval.stop();
setKeyPressed(false);
window.removeEventListener('keydown', keyHandler);
if (indicatorAngle < angle || indicatorAngle > angle + offset) handleComplete(false);
else handleComplete(true);
}, [isKeyPressed]);
}, [keyPressed]);
return (
<circle
r={50}
cx={250}
cy={250}
fill="transparent"
stroke="red"
strokeWidth={15}
strokeDasharray={circleCircumference}
strokeDashoffset={circleCircumference - 3}
transform={`rotate(${indicatorAngle}, 250, 250)`}
className={className}
/>
);
};

View File

@@ -1,17 +1,33 @@
import React from 'react';
import { useNuiEvent } from '../../hooks/useNuiEvent';
import { Box, Flex, ScaleFade } from '@chakra-ui/react';
import { Box, createStyles, Group } from '@mantine/core';
import ReactMarkdown from 'react-markdown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import ScaleFade from '../../transitions/ScaleFade';
import remarkGfm from 'remark-gfm';
import type { TextUiProps, TextUiPosition } from '../../typings';
export interface TextUiProps {
text: string;
position?: 'right-center' | 'left-center' | 'top-center';
icon?: IconProp;
iconColor?: string;
style?: React.CSSProperties;
}
const useStyles = createStyles((theme, params: { position?: TextUiPosition }) => ({
wrapper: {
height: '100%',
width: '100%',
position: 'absolute',
display: 'flex',
alignItems: params.position === 'top-center' ? 'baseline' : 'center',
justifyContent:
params.position === 'right-center' ? 'flex-end' : params.position === 'left-center' ? 'flex-start' : 'center',
},
container: {
fontSize: 16,
padding: 12,
margin: 8,
backgroundColor: theme.colors.dark[6],
color: theme.colors.dark[0],
fontFamily: 'Roboto',
borderRadius: theme.radius.sm,
boxShadow: theme.shadows.sm,
},
}));
const TextUI: React.FC = () => {
const [data, setData] = React.useState<TextUiProps>({
@@ -19,6 +35,7 @@ const TextUI: React.FC = () => {
position: 'right-center',
});
const [visible, setVisible] = React.useState(false);
const { classes } = useStyles({ position: data.position });
useNuiEvent<TextUiProps>('textUi', (data) => {
if (!data.position) data.position = 'right-center'; // Default right position
@@ -29,42 +46,18 @@ const TextUI: React.FC = () => {
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="md"
p={3}
fontFamily="Poppins"
fontSize="0.95em"
style={data.style}
borderRadius="sm"
maxW="xs"
>
<Flex justifyContent="center" alignItems="center">
{data.icon && (
<FontAwesomeIcon
fixedWidth
icon={data.icon}
color={data.iconColor}
fontSize="1.3em"
style={{ paddingRight: 8 }}
/>
)}
<ReactMarkdown>{data.text}</ReactMarkdown>
</Flex>
</Box>
</ScaleFade>
</Flex>
<>
<Box className={classes.wrapper}>
<ScaleFade visible={visible}>
<Box style={data.style} className={classes.container}>
<Group spacing={12}>
{data.icon && <FontAwesomeIcon icon={data.icon} fixedWidth size="lg" style={{ color: data.iconColor }} />}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.text}</ReactMarkdown>
</Group>
</Box>
</ScaleFade>
</Box>
</>
);
};

View File

@@ -3,6 +3,8 @@
@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');
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap');
@import url("https://use.typekit.net/wxh5ury.css");
@import url("https://use.typekit.net/qgr5ebd.css");
html {
color-scheme: normal !important;
@@ -20,6 +22,10 @@ body {
overflow: hidden !important;
}
p {
margin: 0;
}
#root {
height: 100%;
}
@@ -28,15 +34,6 @@ 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%;

View File

@@ -1,52 +0,0 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
export interface IInput {
type: 'input';
label: string;
placeholder?: string;
default?: string;
password?: boolean;
icon?: IconProp;
disabled?: boolean;
description?: string;
}
export interface ICheckbox {
type: 'checkbox';
label: string;
checked?: boolean;
disabled?: boolean;
description?: string;
}
export interface ISelect {
type: 'select';
label: string;
default?: string;
options?: { value: string; label?: string }[];
disabled?: boolean;
description?: string;
}
export interface INumber {
type: 'number';
label: string;
placeholder?: string;
default?: number;
icon?: IconProp;
min?: number;
max?: number;
disabled?: boolean;
description?: string;
}
export interface ISlider {
type: 'slider';
label: string;
default?: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
description?: string;
}

View File

@@ -2,16 +2,13 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
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';
import LocaleProvider from './providers/LocaleProvider';
import ConfigProvider from './providers/ConfigProvider';
library.add(fas, far, fab);
@@ -25,22 +22,13 @@ if (isEnvBrowser()) {
root!.style.backgroundPosition = 'center';
}
debugData([
{
action: 'setVisible',
data: true,
},
]);
const root = document.getElementById('root');
ReactDOM.createRoot(root!).render(
<React.StrictMode>
<LocaleProvider>
<VisibilityProvider>
<ChakraProvider theme={theme}>
<App />
</ChakraProvider>
</VisibilityProvider>
<ConfigProvider>
<App />
</ConfigProvider>
</LocaleProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,32 @@
import { Context, createContext, useContext, useEffect, useState } from 'react';
import { MantineColor } from '@mantine/core';
import { fetchNui } from '../utils/fetchNui';
interface Config {
primaryColor: MantineColor;
primaryShade: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
}
interface ConfigCtxValue {
config: Config;
setConfig: (config: Config) => void;
}
const ConfigCtx = createContext<{ config: Config; setConfig: (config: Config) => void } | null>(null);
const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<Config>({
primaryColor: 'blue',
primaryShade: 6,
});
useEffect(() => {
fetchNui<Config>('getConfig').then((data) => setConfig(data));
}, []);
return <ConfigCtx.Provider value={{ config, setConfig }}>{children}</ConfigCtx.Provider>;
};
export default ConfigProvider;
export const useConfig = () => useContext<ConfigCtxValue>(ConfigCtx as Context<ConfigCtxValue>);

View File

@@ -15,6 +15,7 @@ debugData([
data: {
language: 'English',
ui: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
},

View File

@@ -1,31 +0,0 @@
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: React.ReactNode }> = ({ 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>);

View File

@@ -1,9 +1,16 @@
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
import { MantineThemeOverride } from '@mantine/core';
const config: ThemeConfig = {
initialColorMode: 'dark',
export const theme: MantineThemeOverride = {
colorScheme: 'dark',
fontFamily: 'Roboto',
shadows: { sm: '1px 1px 3px rgba(0, 0, 0, 0.5)' },
components: {
Button: {
styles: {
root: {
border: 'none',
},
},
},
},
};
export const theme = extendTheme({
config,
});

View File

@@ -0,0 +1,21 @@
import { AnimatePresence, motion } from 'framer-motion';
const ScaleFade: React.FC<{ visible: boolean; children: React.ReactNode }> = ({ visible, children }) => {
return (
<>
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1, transition: { duration: 0.2, ease: [0, 0, 0.2, 1] } }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.1, ease: [0.4, 0, 1, 1] } }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default ScaleFade;

12
web/src/typings/alert.ts Normal file
View File

@@ -0,0 +1,12 @@
export interface AlertProps {
header: string;
content: string;
centered?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
overflow?: boolean;
cancel?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
}

View File

@@ -10,7 +10,7 @@ export interface Option {
iconColor?: string;
progress?: number;
colorScheme?: string;
metadata?: string[] | { [key: string]: any } | { label: string; value: any }[];
metadata?: string[] | { [key: string]: any } | { label: string; value: any; progress?: number }[];
disabled?: boolean;
event?: string;
serverEvent?: string;

72
web/src/typings/dialog.ts Normal file
View File

@@ -0,0 +1,72 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
export interface InputProps {
heading: string;
rows: Array<IInput | ICheckbox | ISelect | INumber | ISlider | IColorInput | IDateInput | ITextarea | ITimeInput>;
options?: {
allowCancel?: boolean;
};
}
type BaseField<T, U> = {
type: T;
label: string;
description?: string;
placeholder?: string;
default?: U;
icon?: IconProp;
disabled?: boolean;
required?: boolean;
};
export interface IInput extends BaseField<'input', string> {
password?: boolean;
}
export interface ICheckbox {
type: 'checkbox';
label: string;
checked?: boolean;
disabled?: boolean;
required?: boolean;
}
export type OptionValue = { value: string; label?: string };
export interface ISelect extends BaseField<'select' | 'multi-select', string | string[]> {
options: Array<OptionValue>;
clearable?: boolean;
}
export interface INumber extends BaseField<'number', number> {
min?: number;
max?: number;
}
export interface ISlider extends Omit<BaseField<'slider', number>, 'description' | 'placeholder'> {
min?: number;
max?: number;
step?: number;
}
export interface IColorInput extends BaseField<'color', string> {
format?: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla';
}
export interface IDateInput
extends Omit<BaseField<'date' | 'date-range', string | [string, string] | true>, 'placeholder'> {
format?: string;
clearable?: boolean;
min?: string;
max?: string;
}
export interface ITimeInput extends Omit<BaseField<'time', string>, 'placeholder'> {
format?: '12' | '24';
clearable?: boolean;
}
export interface ITextarea extends BaseField<'textarea', string> {
autosize?: boolean;
min?: number;
max?: number;
}

9
web/src/typings/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './alert';
export * from './context';
export * from './dialog';
export * from './menu';
export * from './notifications';
export * from './progress';
export * from './radial';
export * from './skillcheck';
export * from './textui';

24
web/src/typings/menu.ts Normal file
View File

@@ -0,0 +1,24 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
export type MenuPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export interface MenuItem {
label: string;
progress?: number;
colorScheme?: string;
checked?: boolean;
values?: Array<string | { label: string; description: string }>;
description?: string;
icon?: IconProp;
iconColor?: string;
defaultIndex?: number;
close?: boolean;
}
export interface MenuSettings {
position?: MenuPosition;
title: string;
canClose?: boolean;
items: Array<MenuItem>;
startItemIndex?: number;
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ToastPosition } from 'react-hot-toast';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
export interface NotificationProps {
style?: React.CSSProperties;
description?: string;
title?: string;
duration?: number;
icon?: IconProp;
iconColor?: string;
position?: ToastPosition | 'top' | 'bottom';
id?: number | string;
type?: string;
}

View File

@@ -0,0 +1,11 @@
export interface CircleProgressbarProps {
label?: string;
duration: number;
position?: 'middle' | 'bottom';
percent?: boolean;
}
export interface ProgressbarProps {
label: string;
duration: number;
}

View File

@@ -0,0 +1,6 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
export interface RadialMenuItem {
icon: IconProp;
label: string;
}

View File

@@ -0,0 +1,13 @@
interface CustomGameDifficulty {
areaSize: number;
speedMultiplier: number;
}
export type GameDifficulty = 'easy' | 'medium' | 'hard' | CustomGameDifficulty;
export interface SkillCheckProps {
angle: number;
difficultyOffset: number;
difficulty: GameDifficulty;
key: string;
}

12
web/src/typings/textui.ts Normal file
View File

@@ -0,0 +1,12 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import React from 'react';
export type TextUiPosition = 'right-center' | 'left-center' | 'top-center';
export interface TextUiProps {
text: string;
position?: TextUiPosition;
icon?: IconProp;
iconColor?: string;
style?: React.CSSProperties;
}

View File

@@ -1,10 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const path = require("path");
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: "./",
server: {
port: 3000,
},
build: {
outDir: "build",
target: "esnext",