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 ]]-- --[[ Resource Information ]]--
name 'ox_lib' name 'ox_lib'
author 'Linden' author 'Overextended'
version '2.21.0' version '2.21.0'
license 'LGPL-3.0-or-later' license 'LGPL-3.0-or-later'
repository 'https://github.com/overextended/ox_lib' repository 'https://github.com/overextended/ox_lib'
@@ -41,6 +41,7 @@ client_scripts {
'imports/callback/client.lua', 'imports/callback/client.lua',
'imports/requestModel/client.lua', 'imports/requestModel/client.lua',
'imports/requestAnimDict/client.lua', 'imports/requestAnimDict/client.lua',
'imports/addKeybind/client.lua',
'resource/**/client.lua', 'resource/**/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({ lib = setmetatable({
name = ox_lib, name = ox_lib,
---@deprecated
service = context,
context = context, context = context,
exports = {}, exports = {},
onCache = function(key, cb) onCache = function(key, cb)

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,19 @@
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types'; 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 { 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; label: string;
options?: { value: string; label: string; default?: string }[]; options?: { value: string; label: string; default?: string }[];
password?: boolean; password?: boolean;
@@ -13,13 +25,19 @@ interface InputDialogRowProps {
checked?: boolean; checked?: boolean;
min?: number; min?: number;
max?: number; max?: number;
autosize?: boolean;
step?: number; step?: number;
required?: boolean;
format?: string;
description?: string; description?: string;
} }
type inputDialog = ( type inputDialog = (
heading: string, heading: string,
rows: string[] | InputDialogRowProps[] rows: string[] | InputDialogRowProps[],
options: {
allowCancel?: boolean;
}
) => Promise<Array<string | number | boolean> | undefined>; ) => Promise<Array<string | number | boolean> | undefined>;
export const inputDialog: inputDialog = async (heading, rows) => await exports.ox_lib.inputDialog(heading, rows); 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 { CSSProperties } from 'react';
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types'; 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'; type NotificationType = 'inform' | 'error' | 'success';
interface NotifyProps { interface NotifyProps {
id?: string; id?: string | number;
title?: string; title?: string;
description?: string; description?: string;
duration?: number; duration?: number;
@@ -18,6 +26,7 @@ interface NotifyProps {
export const notify = (data: NotifyProps): void => exports.ox_lib.notify(data); export const notify = (data: NotifyProps): void => exports.ox_lib.notify(data);
// Keep for backwards compat with v2
interface DefaultNotifyProps { interface DefaultNotifyProps {
title?: string; title?: string;
description?: 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 }; 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); exports.ox_lib.skillCheck(difficulty);

View File

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

View File

@@ -10,3 +10,10 @@ function RegisterCommand(commandName, callback, restricted)
end end
end) 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 header string;
---@field content string; ---@field content string;
---@field centered? boolean?; ---@field centered? boolean?;
---@field size? 'xs' | 'sm' | 'md' | 'lg' | 'xl';
---@field overflow? boolean?;
---@field cancel? boolean?; ---@field cancel? boolean?;
---@field labels? {cancel?: string, confirm?: string} ---@field labels? {cancel?: string, confirm?: string}

View File

@@ -1,7 +1,7 @@
local input local input
---@class InputDialogRowProps ---@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 label string
---@field options? { value: string, label: string, default?: string }[] ---@field options? { value: string, label: string, default?: string }[]
---@field password? boolean ---@field password? boolean
@@ -14,12 +14,20 @@ local input
---@field min? number ---@field min? number
---@field max? number ---@field max? number
---@field step? number ---@field step? number
---@field autosize? boolean
---@field required? boolean
---@field format? string
---@field clearable? string
---@field description? string ---@field description? string
---@class InputDialogOptionsProps
---@field allowCancel? boolean
---@param heading string ---@param heading string
---@param rows string[] | InputDialogRowProps[] ---@param rows string[] | InputDialogRowProps[]
---@param options InputDialogOptionsProps[]
---@return string[] | number[] | boolean[] | nil ---@return string[] | number[] | boolean[] | nil
function lib.inputDialog(heading, rows) function lib.inputDialog(heading, rows, options)
if input then return end if input then return end
input = promise.new() input = promise.new()
@@ -35,7 +43,8 @@ function lib.inputDialog(heading, rows)
action = 'openDialog', action = 'openDialog',
data = { data = {
heading = heading, 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' ---@alias NotificationType 'inform' | 'error' | 'success'
---@class NotifyProps ---@class NotifyProps
@@ -15,7 +15,7 @@
---@param data NotifyProps ---@param data NotifyProps
function lib.notify(data) function lib.notify(data)
SendNUIMessage({ SendNUIMessage({
action = 'customNotify', action = 'notify',
data = data data = data
}) })
end end
@@ -30,10 +30,10 @@ end
---@param data DefaultNotifyProps ---@param data DefaultNotifyProps
function lib.defaultNotify(data) function lib.defaultNotify(data)
SendNUIMessage({ -- Backwards compat for v3
action = 'notify', data.type = data.status
data = data if data.type == 'info' or data.type == 'warning' then data.type = 'inform' end
}) return lib.notify(data)
end end
RegisterNetEvent('ox_lib:notify', lib.notify) RegisterNetEvent('ox_lib:notify', lib.notify)

View File

@@ -198,8 +198,6 @@ end
function lib.cancelProgress() function lib.cancelProgress()
if not progress then if not progress then
error('No progress bar is active') error('No progress bar is active')
elseif not progress.canCancel then
error('Progress bar cannot be cancelled')
end end
progress = false 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 } ---@alias SkillCheckDifficulity 'easy' | 'medium' | 'hard' | { areaSize: number, speedMultiplier: number }
---@param difficulty SkillCheckDifficulity | SkillCheckDifficulity[] ---@param difficulty SkillCheckDifficulity | SkillCheckDifficulity[]
---@param inputs string[]
---@return boolean? ---@return boolean?
function lib.skillCheck(difficulty) function lib.skillCheck(difficulty, inputs)
if skillcheck then return end if skillcheck then return end
skillcheck = promise:new() skillcheck = promise:new()
SetNuiFocus(true, false) SetNuiFocus(true, false)
SendNUIMessage({ SendNUIMessage({
action = 'startSkillCheck', action = 'startSkillCheck',
data = difficulty data = {
difficulty = difficulty,
inputs = inputs
}
}) })
return Citizen.Await(skillcheck) return Citizen.Await(skillcheck)

View File

@@ -1,33 +1,31 @@
{ {
"name": "web", "name": "ox_lib",
"version": "0.1.0", "version": "0.1.0",
"homepage": "web/build", "homepage": "web/build",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@chakra-ui/react": "2.3.6", "@emotion/react": "^11.10.5",
"@emotion/react": "^11.8.2", "@fortawesome/fontawesome-svg-core": "^6.3.0",
"@emotion/styled": "^11.8.1", "@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.19",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@mantine/core": "^5.10.0",
"@fortawesome/react-fontawesome": "^0.1.18", "@mantine/dates": "^5.10.0",
"@testing-library/jest-dom": "^5.16.3", "@mantine/hooks": "^5.10.0",
"@testing-library/react": "^12.1.4", "@vitejs/plugin-react": "^3.0.1",
"@testing-library/user-event": "^13.5.0", "dayjs": "^1.11.7",
"@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",
"focus-trap-react": "^9.0.2", "focus-trap-react": "^9.0.2",
"framer-motion": "^6.2.8", "framer-motion": "^8.0.2",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "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", "react-markdown": "^8.0.1",
"remark-gfm": "^3.0.1",
"typescript": "^4.6.3", "typescript": "^4.6.3",
"vite": "^2.9.13", "vite": "^4.0.4",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
@@ -36,33 +34,23 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "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": { "devDependencies": {
"@babel/core": ">=7.0.0 <8.0.0", "@babel/core": ">=7.0.0 <8.0.0",
"@babel/plugin-syntax-flow": "^7.14.5", "@babel/plugin-syntax-flow": "^7.14.5",
"@babel/plugin-transform-react-jsx": "^7.14.9", "@babel/plugin-transform-react-jsx": "^7.14.9",
"@testing-library/dom": "^8.11.4",
"autoprefixer": "^10.0.2", "autoprefixer": "^10.0.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"csstype": "^3.0.10",
"postcss": "^8.1.0", "postcss": "^8.1.0",
"prop-types": "^15.8.1", "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 Dev from './features/dev';
import { isEnvBrowser } from './utils/misc'; import { isEnvBrowser } from './utils/misc';
import SkillCheck from './features/skillcheck'; 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 App: React.FC = () => {
const { config } = useConfig();
useNuiEvent('setClipboard', (data: string) => { useNuiEvent('setClipboard', (data: string) => {
setClipboard(data); setClipboard(data);
}); });
@@ -21,7 +27,7 @@ const App: React.FC = () => {
fetchNui('init'); fetchNui('init');
return ( return (
<> <MantineProvider withNormalizeCSS withGlobalStyles theme={{ ...theme, ...config }}>
<Progressbar /> <Progressbar />
<CircleProgressbar /> <CircleProgressbar />
<Notifications /> <Notifications />
@@ -30,9 +36,10 @@ const App: React.FC = () => {
<AlertDialog /> <AlertDialog />
<ContextMenu /> <ContextMenu />
<ListMenu /> <ListMenu />
<RadialMenu />
<SkillCheck /> <SkillCheck />
{isEnvBrowser() && <Dev />} {isEnvBrowser() && <Dev />}
</> </MantineProvider>
); );
}; };

View File

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

View File

@@ -1,4 +1,4 @@
import { ContextMenuProps } from '../../../interfaces/context'; import { ContextMenuProps } from '../../../typings';
import { debugData } from '../../../utils/debugData'; import { debugData } from '../../../utils/debugData';
export const debugContext = () => { export const debugContext = () => {
@@ -9,6 +9,32 @@ export const debugContext = () => {
title: 'Vehicle garage', title: 'Vehicle garage',
options: [ options: [
{ title: 'Empty button' }, { 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', title: 'Example button',
description: 'Example button description', description: 'Example button description',
@@ -30,7 +56,7 @@ export const debugContext = () => {
progress: 80, progress: 80,
icon: 'car-side', icon: 'car-side',
metadata: [{ label: 'Durability', value: '80%' }], metadata: [{ label: 'Durability', value: '80%' }],
colorScheme: 'blue' colorScheme: 'blue',
}, },
{ {
title: 'Menu button', title: 'Menu button',

View File

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

View File

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

View File

@@ -1,30 +1,47 @@
import { CustomNotificationProps, NotificationProps } from '../../notifications/NotificationWrapper'; import { NotificationProps } from '../../../typings';
import { debugData } from '../../../utils/debugData'; import { debugData } from '../../../utils/debugData';
export const debugNotification = () => { export const debugCustomNotification = () => {
debugData<NotificationProps>([ debugData<NotificationProps>([
{ {
action: 'notify', action: 'notify',
data: { data: {
description: 'Dunak is nerd', title: 'Success',
title: 'Dunak', description: 'Notification description',
id: 1, type: 'success',
id: 'pogchamp',
}, },
}, },
]); ]);
}; debugData<NotificationProps>([
export const debugCustomNotification = () => {
debugData<CustomNotificationProps>([
{ {
action: 'customNotify', action: 'notify',
data: { data: {
description: 'Dunak is nerd', title: 'Success',
icon: 'basket-shopping', description: 'Notification description',
style: { type: 'success',
backgroundColor: '#2D3748', id: 'pogchamp',
color: 'white', },
}, },
]);
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 { debugData } from '../../../utils/debugData';
import { ProgressbarProps } from '../../progress/Progressbar'; import { ProgressbarProps } from '../../../typings';
export const debugProgressbar = () => { export const debugProgressbar = () => {
debugData<ProgressbarProps>([ 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 { debugData } from '../../../utils/debugData';
import { GameDifficulty } from '../../skillcheck'; import { GameDifficulty } from '../../../typings';
export const debugSkillCheck = () => { export const debugSkillCheck = () => {
debugData<GameDifficulty | GameDifficulty[]>([ debugData<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }>([
{ {
action: 'startSkillCheck', 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'; import { debugData } from '../../../utils/debugData';
export const debugTextUI = () => { export const debugTextUI = () => {

View File

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

View File

@@ -1,83 +1,72 @@
import { import { Modal, Button, Stack, Group, useMantineTheme } from '@mantine/core';
AlertDialog as Dialog, import { useState } from 'react';
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
useDisclosure,
Button,
} from '@chakra-ui/react';
import { useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { useNuiEvent } from '../../hooks/useNuiEvent'; import { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui'; import { fetchNui } from '../../utils/fetchNui';
import { useLocales } from '../../providers/LocaleProvider'; import { useLocales } from '../../providers/LocaleProvider';
import remarkGfm from 'remark-gfm';
export interface AlertProps { import type { AlertProps } from '../../typings';
header: string;
content: string;
centered?: boolean;
cancel?: boolean;
labels?: {
cancel?: string;
confirm?: string;
};
}
const AlertDialog: React.FC = () => { const AlertDialog: React.FC = () => {
const { locale } = useLocales(); const { locale } = useLocales();
const { isOpen, onOpen, onClose } = useDisclosure(); const theme = useMantineTheme();
const cancelRef = useRef(null); const [opened, setOpened] = useState(false);
const [dialogData, setDialogData] = useState<AlertProps>({ const [dialogData, setDialogData] = useState<AlertProps>({
header: '', header: '',
content: '', content: '',
}); });
const closeAlert = (button: string) => { const closeAlert = (button: string) => {
onClose(); setOpened(false);
fetchNui('closeAlert', button); fetchNui('closeAlert', button);
}; };
useNuiEvent('sendAlert', (data: AlertProps) => { useNuiEvent('sendAlert', (data: AlertProps) => {
setDialogData(data); setDialogData(data);
onOpen(); setOpened(true);
}); });
useNuiEvent('closeAlertDialog', () => { useNuiEvent('closeAlertDialog', () => {
onClose(); setOpened(false);
}); });
return ( return (
<> <>
<Dialog <Modal
leastDestructiveRef={cancelRef} opened={opened}
onClose={onClose} centered={dialogData.centered}
isOpen={isOpen} size={dialogData.size || 'md'}
isCentered={dialogData.centered} overflow={dialogData.overflow ? 'inside' : 'outside'}
closeOnOverlayClick={false} closeOnClickOutside={false}
onEsc={() => closeAlert('cancel')} onClose={() => {
setOpened(false);
closeAlert('cancel');
}}
withCloseButton={false}
overlayOpacity={0.5}
exitTransitionDuration={150}
transition="fade"
title={<ReactMarkdown>{dialogData.header}</ReactMarkdown>}
> >
<AlertDialogOverlay /> <Stack>
<AlertDialogContent fontFamily="Inter"> <ReactMarkdown remarkPlugins={[remarkGfm]}>{dialogData.content}</ReactMarkdown>
<AlertDialogHeader fontSize="lg" fontWeight="bold"> <Group position="right" spacing={10}>
{dialogData.header}
</AlertDialogHeader>
<AlertDialogBody>
<ReactMarkdown>{dialogData.content}</ReactMarkdown>
</AlertDialogBody>
<AlertDialogFooter>
{dialogData.cancel && ( {dialogData.cancel && (
<Button onClick={() => closeAlert('cancel')} mr={3}> <Button uppercase variant="default" onClick={() => closeAlert('cancel')} mr={3}>
{dialogData.labels?.cancel || locale.ui.cancel} {dialogData.labels?.cancel || locale.ui.cancel}
</Button> </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} {dialogData.labels?.confirm || locale.ui.confirm}
</Button> </Button>
</AlertDialogFooter> </Group>
</AlertDialogContent> </Stack>
</Dialog> </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 React from 'react';
import { useNuiEvent } from '../../hooks/useNuiEvent'; import { useNuiEvent } from '../../hooks/useNuiEvent';
import { useLocales } from '../../providers/LocaleProvider'; import { useLocales } from '../../providers/LocaleProvider';
import { fetchNui } from '../../utils/fetchNui'; 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 InputField from './components/fields/input';
import CheckboxField from './components/fields/checkbox'; import CheckboxField from './components/fields/checkbox';
import SelectField from './components/fields/select'; import SelectField from './components/fields/select';
import NumberField from './components/fields/number'; import NumberField from './components/fields/number';
import SliderField from './components/fields/slider'; 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 { export type FormValues = {
heading: string; test: {
rows: Array<IInput | ICheckbox | ISelect | INumber | ISlider>; value: any;
} }[];
};
const InputDialog: React.FC = () => { const InputDialog: React.FC = () => {
const [fields, setFields] = React.useState<InputProps>({ const [fields, setFields] = React.useState<InputProps>({
heading: '', heading: '',
rows: [{ type: 'input', label: '' }], 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 [visible, setVisible] = React.useState(false);
const { locale } = useLocales(); const { locale } = useLocales();
const handlePasswordStates = (index: number) => { const form = useForm<{ test: { value: any }[] }>({});
setPasswordStates({ const fieldForm = useFieldArray({
...passwordStates, control: form.control,
[index]: !passwordStates[index], name: 'test',
}); });
};
useNuiEvent<InputProps>('openDialog', (data) => { useNuiEvent<InputProps>('openDialog', (data) => {
setPasswordStates([]);
setFields(data); setFields(data);
setInputData([]);
setVisible(true); 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', () => { useNuiEvent('closeInputDialog', () => {
setVisible(false); setVisible(false);
}); });
const handleClose = () => { const handleClose = async () => {
setVisible(false); setVisible(false);
fetchNui('inputData'); fetchNui('inputData');
await new Promise((resolve) => setTimeout(resolve, 200));
form.reset();
fieldForm.remove();
}; };
const handleChange = (value: string | number | boolean, index: number) => { const onSubmit = form.handleSubmit(async (data) => {
setInputData((previousData) => {
previousData[index] = value;
return previousData;
});
};
const handleConfirm = () => {
setVisible(false); 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 ( return (
<> <>
<Modal isOpen={visible} onClose={handleClose} isCentered closeOnEsc closeOnOverlayClick={false} size="xs"> <Modal
<ModalOverlay /> opened={visible}
<ModalContent onClose={handleClose}
onKeyDown={(e) => { centered
if (e.key === 'Enter' && visible) return handleConfirm(); closeOnEscape={fields.options?.allowCancel !== false}
}} closeOnClickOutside={false}
> size="xs"
<ModalHeader textAlign="center">{fields.heading}</ModalHeader> styles={{ title: { textAlign: 'center', width: '100%', fontSize: 18 } }}
<ModalBody fontFamily="Poppins" textAlign="left"> title={fields.heading}
{fields.rows.map((row: IInput | ICheckbox | ISelect | INumber | ISlider, index) => ( withCloseButton={false}
<React.Fragment key={`row-${index}-${row.type}-${row.label}`}> overlayOpacity={0.5}
{row.type === 'input' && ( transition="fade"
<InputField exitTransitionDuration={150}
row={row} >
index={index} <form onSubmit={onSubmit}>
handleChange={handleChange} <Stack>
passwordStates={passwordStates} {fieldForm.fields.map((item, index) => {
handlePasswordStates={handlePasswordStates} const row = fields.rows[index];
/> return (
)} <React.Fragment key={item.id}>
{row.type === 'checkbox' && <CheckboxField row={row} index={index} handleChange={handleChange} />} {row.type === 'input' && (
{row.type === 'select' && <SelectField row={row} index={index} handleChange={handleChange} />} <InputField
{row.type === 'number' && <NumberField row={row} index={index} handleChange={handleChange} />} register={form.register(`test.${index}.value`, { required: row.required })}
{row.type === 'slider' && <SliderField row={row} index={index} handleChange={handleChange} />} row={row}
</React.Fragment> index={index}
))} />
</ModalBody> )}
<ModalFooter> {row.type === 'checkbox' && (
<Button mr={3} onClick={handleClose}> <CheckboxField
{locale.ui.close} register={form.register(`test.${index}.value`, { required: row.required })}
</Button> row={row}
<Button colorScheme="blue" onClick={handleConfirm}> index={index}
{locale.ui.confirm} />
</Button> )}
</ModalFooter> {(row.type === 'select' || row.type === 'multi-select') && (
</ModalContent> <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> </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 { Checkbox } from '@mantine/core';
import { useEffect } from 'react'; import { ICheckbox } from '../../../../typings/dialog';
import { ICheckbox } from '../../../../interfaces/dialog'; import { UseFormRegisterReturn } from 'react-hook-form';
import Label from '../Label';
interface Props { interface Props {
row: ICheckbox; row: ICheckbox;
index: number; index: number;
handleChange: (value: boolean, index: number) => void; register: UseFormRegisterReturn;
} }
const CheckboxField: React.FC<Props> = (props) => { const CheckboxField: React.FC<Props> = (props) => {
useEffect(() => {
if (props.row.checked) props.handleChange(props.row.checked, props.index);
}, []);
return ( return (
<> <Checkbox
<Box mb={3}> {...props.register}
<Checkbox sx={{ display: 'flex' }}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.handleChange(e.target.checked, props.index)} required={props.row.required}
defaultChecked={props.row.checked} label={props.row.label}
> defaultChecked={props.row.checked}
<Label label={props.row.label} description={props.row.description} /> />
</Checkbox>
</Box>
</>
); );
}; };

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

View File

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

View File

@@ -1,45 +1,60 @@
import { Box, Select } from '@chakra-ui/react'; import { MultiSelect, Select } from '@mantine/core';
import { useEffect } from 'react'; import { ISelect } from '../../../../typings/dialog';
import { ISelect } from '../../../../interfaces/dialog'; import { Control, useController } from 'react-hook-form';
import { FormValues } from '../../InputDialog';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
interface Props { interface Props {
row: ISelect; row: ISelect;
index: number; index: number;
handleChange: (value: string, index: number) => void; control: Control<FormValues>;
} }
const SelectField: React.FC<Props> = (props) => { const SelectField: React.FC<Props> = (props) => {
useEffect(() => { const controller = useController({
if (props.row.default) { name: `test.${props.index}.value`,
props.row.options?.map((option) => { control: props.control,
if (props.row.default === option.value) { defaultValue: props.row.default || props.row.type !== 'multi-select' ? props.row.options[0].value : undefined,
props.handleChange(option.value, props.index); rules: { required: props.row.required },
} });
});
}
}, []);
return ( return (
<> <>
<Box mb={3}> {props.row.type === 'select' ? (
<Select <Select
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => props.handleChange(e.target.value, props.index)} data={props.row.options}
defaultValue={props.row.default || ''} value={controller.field.value}
isDisabled={props.row.disabled} name={controller.field.name}
> ref={controller.field.ref}
{/* Hacky workaround for selectable placeholder issue */} onBlur={controller.field.onBlur}
{!props.row.default && ( onChange={controller.field.onChange}
<option value="" hidden disabled> disabled={props.row.disabled}
{props.row.label} label={props.row.label}
</option> 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 { Box, Slider, Text } from '@mantine/core';
import { useEffect, useState } from 'react'; import { ISlider } from '../../../../typings/dialog';
import { ISlider } from '../../../../interfaces/dialog'; import { Control, useController } from 'react-hook-form';
import Label from '../Label'; import { FormValues } from '../../InputDialog';
interface Props { interface Props {
row: ISlider; row: ISlider;
index: number; index: number;
handleChange: (value: number, index: number) => void; control: Control<FormValues>;
} }
const SliderField: React.FC<Props> = (props) => { const SliderField: React.FC<Props> = (props) => {
useEffect(() => { const controller = useController({
if (props.row.default || props.row.min) props.handleChange(props.row.default || props.row.min!, props.index); name: `test.${props.index}.value`,
}, []); control: props.control,
defaultValue: props.row.default || props.row.min || 0,
const [sliderValue, setSliderValue] = useState(props.row.default || props.row.min || 0); });
return ( return (
<> <Box>
<Box mb={3}> <Text sx={{ fontSize: 14, fontWeight: 500 }}>{props.row.label}</Text>
<Label label={props.row.label} description={props.row.description} /> <Slider
<Slider mb={10}
onChangeEnd={(val: number) => props.handleChange(val, props.index)} value={controller.field.value}
onChange={(val: number) => setSliderValue(val)} name={controller.field.name}
defaultValue={props.row.default || props.row.min || 0} ref={controller.field.ref}
min={props.row.min} onBlur={controller.field.onBlur}
max={props.row.max} onChange={controller.field.onChange}
step={props.row.step} defaultValue={props.row.default || props.row.min || 0}
isDisabled={props.row.disabled} min={props.row.min}
> max={props.row.max}
<SliderTrack> step={props.row.step}
<SliderFilledTrack /> disabled={props.row.disabled}
</SliderTrack> marks={[
<Tooltip hasArrow label={sliderValue} placement="bottom" gutter={10}> { value: props.row.min || 0, label: props.row.min || 0 },
<SliderThumb /> { value: props.row.max || 100, label: props.row.max || 100 },
</Tooltip> ]}
</Slider> />
<HStack justifyContent="space-between"> </Box>
<Text fontSize="sm">{props.row.min || 0}</Text>
<Text fontSize="sm">{props.row.max || 100}</Text>
</HStack>
</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 { 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 { useEffect, useState } from 'react';
import { ContextMenuProps } from '../../../interfaces/context'; import { ContextMenuProps } from '../../../typings';
import Item from './Item'; import ContextButton from './components/ContextButton';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { fetchNui } from '../../../utils/fetchNui'; import { fetchNui } from '../../../utils/fetchNui';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import HeaderButton from './components/HeaderButton';
import ScaleFade from '../../../transitions/ScaleFade';
const openMenu = (id: string | undefined) => { const openMenu = (id: string | undefined) => {
fetchNui<ContextMenuProps>('openContext', { id: id, back: true }); fetchNui<ContextMenuProps>('openContext', { id: id, back: true });
@@ -49,64 +50,28 @@ const ContextMenu: React.FC = () => {
}); });
return ( return (
<Flex position="absolute" w="75%" h="80%" justifyContent="flex-end" alignItems="center"> <Box sx={{ position: 'absolute', top: '15%', right: '25%' }} w={320} h={580}>
<ScaleFade in={visible} unmountOnExit> <ScaleFade visible={visible}>
<Box w="xs" h={580}> <Flex justify="center" align="center" mb={10} gap={6}>
<Flex justifyContent="center" alignItems="center" mb={3}> {contextMenu.menu && (
{contextMenu.menu && ( <HeaderButton icon="chevron-left" iconSize={16} handleClick={() => openMenu(contextMenu.menu)} />
<Flex )}
borderRadius="md" <Box sx={{ borderRadius: 4, flex: '1 85%' }} bg="dark.6">
bg="gray.800" <Text color="dark.0" p={6} align="center">
flex="1 15%" <ReactMarkdown>{contextMenu.title}</ReactMarkdown>
alignSelf="stretch" </Text>
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> </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> </Box>
</ScaleFade> </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 CustomCheckbox: React.FC<{ checked: boolean }> = ({ checked }) => {
const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox(); const { classes } = useStyles();
return ( return (
<chakra.label <Checkbox
display="flex" checked={checked}
flexDirection="row" size="md"
alignItems="center" classNames={{ root: classes.root, input: classes.input, inner: classes.inner }}
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>
); );
}; };

View File

@@ -1,20 +1,31 @@
import { Box, Text } from '@chakra-ui/react'; import { Box, createStyles, Text } from '@mantine/core';
import React from 'react'; 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 Header: React.FC<{ title: string }> = ({ title }) => {
const { classes } = useStyles();
return ( return (
<Box <Box className={classes.container}>
p={3} <Text className={classes.heading}>{title}</Text>
textAlign="center"
borderTopLeftRadius="md"
borderTopRightRadius="md"
bg="#25262B"
height="60px"
width="sm"
>
<Text fontSize={24} textTransform="uppercase" fontWeight={600} fontFamily="Nunito">
{title}
</Text>
</Box> </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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import CustomCheckbox from './CustomCheckbox'; import CustomCheckbox from './CustomCheckbox';
import type { MenuItem } from './index'; import type { MenuItem } from '../../../typings';
import { createStyles } from '@mantine/core';
interface Props { interface Props {
item: MenuItem; item: MenuItem;
@@ -11,35 +12,80 @@ interface Props {
checked: boolean; 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 ListItem = forwardRef<Array<HTMLDivElement | null>, Props>(({ item, index, scrollIndex, checked }, ref) => {
const { classes } = useStyles({ iconColor: item.iconColor });
return ( return (
<Box <Box
bg="#25262B"
borderRadius="md"
tabIndex={index} tabIndex={index}
scrollMargin={2} className={classes.buttonContainer}
p={2}
height="60px"
key={`item-${index}`} key={`item-${index}`}
_focus={{ bg: '#373A40', outline: 'none' }} ref={(element: HTMLDivElement) => {
ref={(element) => {
if (ref) if (ref)
// @ts-ignore i cba // @ts-ignore i cba
return (ref.current = [...ref.current, element]); return (ref.current = [...ref.current, element]);
}} }}
> >
<Flex alignItems="center" height="100%" gap="15px"> <Group spacing={15} noWrap className={classes.buttonWrapper}>
{item.icon && ( {item.icon && (
<Box display="flex" alignItems="center"> <Box className={classes.iconContainer}>
<FontAwesomeIcon icon={item.icon} fontSize={24} color={item.iconColor || '#909296'} fixedWidth /> <FontAwesomeIcon icon={item.icon} className={classes.icon} fixedWidth />
</Box> </Box>
)} )}
{Array.isArray(item.values) ? ( {Array.isArray(item.values) ? (
<Flex alignItems="center" justifyContent="space-between" w="100%"> <Group position="apart" w="100%">
<Stack spacing={1} justifyContent="space-between"> <Stack spacing={0} justify="space-between">
<Text color="#909296" textTransform="uppercase" fontSize={12} verticalAlign="middle"> <Text className={classes.label}>{item.label}</Text>
{item.label}
</Text>
<Text> <Text>
{typeof item.values[scrollIndex] === 'object' {typeof item.values[scrollIndex] === 'object'
? // @ts-ignore for some reason even checking the type TS still thinks it's a string ? // @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]} : item.values[scrollIndex]}
</Text> </Text>
</Stack> </Stack>
<Stack direction="row" spacing="sm" pr={3} justifyContent="center" alignItems="center"> <Group spacing={1} position="center">
<FontAwesomeIcon icon="chevron-left" fontSize={16} color="#909296" /> <FontAwesomeIcon icon="chevron-left" className={classes.chevronIcon} />
<Text color="#909296" textTransform="uppercase" fontSize={14}> <Text className={classes.scrollIndexValue}>
{scrollIndex + 1}/{item.values.length} {scrollIndex + 1}/{item.values.length}
</Text> </Text>
<FontAwesomeIcon icon="chevron-right" fontSize={16} color="#909296" /> <FontAwesomeIcon icon="chevron-right" className={classes.chevronIcon} />
</Stack> </Group>
</Flex> </Group>
) : item.checked !== undefined ? ( ) : item.checked !== undefined ? (
<Flex alignItems="center" justifyContent="space-between" w="100%"> <Group position="apart" w="100%">
<Text>{item.label}</Text> <Text>{item.label}</Text>
<CustomCheckbox checked={checked}></CustomCheckbox> <CustomCheckbox checked={checked}></CustomCheckbox>
</Flex> </Group>
) : item.progress !== undefined ? ( ) : item.progress !== undefined ? (
<Flex flexDirection="column" w="100%" marginRight="5px"> <Stack className={classes.progressStack} spacing={0}>
<Text verticalAlign="middle" marginBottom="3px"> <Text className={classes.progressLabel}>{item.label}</Text>
{item.label} <Progress
</Text> value={item.progress}
<Progress value={item.progress} size="sm" colorScheme={item.colorScheme || 'gray'} borderRadius="md" /> color={item.colorScheme || 'dark.0'}
</Flex> styles={(theme) => ({ root: { backgroundColor: theme.colors.dark[3] } })}
/>
</Stack>
) : ( ) : (
<Text>{item.label}</Text> <Text>{item.label}</Text>
)} )}
</Flex> </Group>
</Box> </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 { useCallback, useEffect, useRef, useState } from 'react';
import { useNuiEvent } from '../../../hooks/useNuiEvent'; import { useNuiEvent } from '../../../hooks/useNuiEvent';
import ListItem from './ListItem'; import ListItem from './ListItem';
import Header from './Header'; import Header from './Header';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { fetchNui } from '../../../utils/fetchNui'; import { fetchNui } from '../../../utils/fetchNui';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import type { MenuPosition, MenuSettings } from '../../../typings';
export interface MenuItem { const useStyles = createStyles((theme, params: { position?: MenuPosition; itemCount: number; selected: number }) => ({
label: string; tooltip: {
progress?: number; backgroundColor: theme.colors.dark[6],
colorScheme?: string; color: theme.colors.dark[2],
checked?: boolean; borderRadius: theme.radius.sm,
values?: Array<string | { label: string; description: string }>; maxWidth: 350,
description?: string; whiteSpace: 'normal',
icon?: IconProp; },
iconColor?: string; container: {
defaultIndex?: number; position: 'absolute',
close?: boolean; pointerEvents: 'none',
} marginTop: params.position === 'top-left' || params.position === 'top-right' ? 5 : 0,
marginLeft: params.position === 'top-left' || params.position === 'bottom-left' ? 5 : 0,
export interface MenuSettings { marginRight: params.position === 'top-right' || params.position === 'bottom-right' ? 5 : 0,
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; marginBottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 5 : 0,
title: string; right: params.position === 'top-right' || params.position === 'bottom-right' ? 1 : undefined,
canClose?: boolean; left: params.position === 'bottom-left' ? 1 : undefined,
items: Array<MenuItem>; bottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 1 : undefined,
startItemIndex?: number; 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 ListMenu: React.FC = () => {
const [menu, setMenu] = useState<MenuSettings>({ const [menu, setMenu] = useState<MenuSettings>({
@@ -42,6 +63,7 @@ const ListMenu: React.FC = () => {
const [checkedStates, setCheckedStates] = useState<Record<number, boolean>>({}); const [checkedStates, setCheckedStates] = useState<Record<number, boolean>>({});
const listRefs = useRef<Array<HTMLDivElement | null>>([]); const listRefs = useRef<Array<HTMLDivElement | null>>([]);
const firstRenderRef = useRef(false); const firstRenderRef = useRef(false);
const { classes } = useStyles({ position: menu.position, itemCount: menu.items.length, selected });
const closeMenu = (ignoreFetch?: boolean, keyPressed?: string, forceClose?: boolean) => { const closeMenu = (ignoreFetch?: boolean, keyPressed?: string, forceClose?: boolean) => {
if (menu.canClose === false && !forceClose) return; if (menu.canClose === false && !forceClose) return;
@@ -184,44 +206,20 @@ const ListMenu: React.FC = () => {
menu.items[selected].values[indexStates[selected]].description menu.items[selected].values[indexStates[selected]].description
: menu.items[selected].description : menu.items[selected].description
} }
isOpen={ opened={
isValuesObject(menu.items[selected].values) isValuesObject(menu.items[selected].values)
? // @ts-ignore ? // @ts-ignore
!!menu.items[selected].values[indexStates[selected]].description !!menu.items[selected].values[indexStates[selected]].description
: !!menu.items[selected].description : !!menu.items[selected].description
} }
bg="#25262B" transitionDuration={0}
color="#909296" classNames={{ tooltip: classes.tooltip }}
placement="bottom"
borderRadius="md"
fontFamily="Nunito"
> >
<Box <Box className={classes.container}>
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}
>
<Header title={menu.title} /> <Header title={menu.title} />
<Box <Box className={classes.buttonsWrapper} onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => moveMenu(e)}>
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)}
>
<FocusTrap active={visible}> <FocusTrap active={visible}>
<Stack direction="column" p={2} overflowY="scroll"> <Stack spacing={8} p={8} sx={{ overflowY: 'scroll' }}>
{menu.items.map((item, index) => ( {menu.items.map((item, index) => (
<React.Fragment key={`menu-item-${index}`}> <React.Fragment key={`menu-item-${index}`}>
{item.label && ( {item.label && (
@@ -239,8 +237,8 @@ const ListMenu: React.FC = () => {
</FocusTrap> </FocusTrap>
</Box> </Box>
{menu.items.length > 6 && selected !== menu.items.length - 1 && ( {menu.items.length > 6 && selected !== menu.items.length - 1 && (
<Box bg="#141517" textAlign="center" borderBottomLeftRadius="md" borderBottomRightRadius="md" height={25}> <Box className={classes.scrollArrow}>
<FontAwesomeIcon icon="chevron-down" color="#909296" fontSize={20} /> <FontAwesomeIcon icon="chevron-down" className={classes.scrollArrowIcon} />
</Box> </Box>
)} )}
</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 { 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import ReactMarkdown from 'react-markdown'; 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 { const useStyles = createStyles((theme) => ({
title?: string; container: {
description?: string; width: 300,
duration?: number; height: 'fit-content',
position?: ToastPosition; backgroundColor: theme.colors.dark[6],
variant?: string; color: theme.colors.dark[0],
status?: 'info' | 'warning' | 'success' | 'error'; padding: 12,
id?: number; 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 { // I hate this
style?: React.CSSProperties; const enterAnimationTop = keyframes({
description?: string; from: {
title?: string; opacity: 0,
duration?: number; transform: 'translateY(-30px)',
icon?: IconProp; },
iconColor?: string; to: {
position?: ToastPosition; opacity: 1,
id?: number; transform: 'translateY(0px)',
type?: string; },
} });
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 Notifications: React.FC = () => {
const toast = useToast(); const { classes } = useStyles();
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>
),
});
});
useNuiEvent<NotificationProps>('notify', (data) => { useNuiEvent<NotificationProps>('notify', (data) => {
if (!data.title && !data.description) return; if (!data.title && !data.description) return;
if (data.id && toast.isActive(data.id)) return; // Backwards compat with old notifications
const id = data.id; let position = data.position;
toast({ switch (position) {
id, case 'top':
title: data.title, position = 'top-center';
description: data.description, break;
duration: data.duration || 4000, case 'bottom':
position: data.position || 'top-right', position = 'bottom-center';
variant: data.variant, break;
status: data.status, }
}); 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; export default Notifications;

View File

@@ -1,14 +1,53 @@
import React from 'react'; 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 { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui'; import { fetchNui } from '../../utils/fetchNui';
import ScaleFade from '../../transitions/ScaleFade';
import type { CircleProgressbarProps } from '../../typings';
export interface CircleProgressbarProps { // 33.5 is the r of the circle
label?: string; const progressCircle = keyframes({
duration: number; '0%': { strokeDasharray: `0, ${33.5 * 2 * Math.PI}` },
position?: 'middle' | 'bottom'; '100%': { strokeDasharray: `${33.5 * 2 * Math.PI}, 0` },
percent?: boolean; });
}
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 CircleProgressbar: React.FC = () => {
const [visible, setVisible] = React.useState(false); const [visible, setVisible] = React.useState(false);
@@ -16,7 +55,8 @@ const CircleProgressbar: React.FC = () => {
const [position, setPosition] = React.useState<'middle' | 'bottom'>('middle'); const [position, setPosition] = React.useState<'middle' | 'bottom'>('middle');
const [value, setValue] = React.useState(0); const [value, setValue] = React.useState(0);
const [label, setLabel] = React.useState(''); const [label, setLabel] = React.useState('');
const [cancelled, setCancelled] = React.useState(false); const theme = useMantineTheme();
const { classes } = useStyles({ position, duration: progressDuration });
const progressComplete = () => { const progressComplete = () => {
setVisible(false); setVisible(false);
@@ -24,7 +64,6 @@ const CircleProgressbar: React.FC = () => {
}; };
const progressCancel = () => { const progressCancel = () => {
setCancelled(true);
setValue(99); // Sets the final value to 100% kek setValue(99); // Sets the final value to 100% kek
setVisible(false); setVisible(false);
}; };
@@ -33,7 +72,6 @@ const CircleProgressbar: React.FC = () => {
useNuiEvent<CircleProgressbarProps>('circleProgress', (data) => { useNuiEvent<CircleProgressbarProps>('circleProgress', (data) => {
if (visible) return; if (visible) return;
setCancelled(false);
setVisible(true); setVisible(true);
setValue(0); setValue(0);
setLabel(data.label || ''); setLabel(data.label || '');
@@ -50,50 +88,23 @@ const CircleProgressbar: React.FC = () => {
}); });
return ( return (
<Flex <>
h={position === 'middle' ? '100%' : '20%'} <Stack spacing={0} className={classes.container}>
w="100%" <ScaleFade visible={visible}>
position="absolute" <Stack spacing={0} align="center" className={classes.wrapper}>
bottom="0" <RingProgress
justifyContent="center" size={90}
alignItems="center" thickness={7}
> sections={[{ value: 0, color: theme.primaryColor }]}
{visible && ( onAnimationEnd={progressComplete}
<Flex alignItems="center" flexDirection="column"> className={classes.progress}
<CircularProgress label={<Text className={classes.value}>{value}%</Text>}
value={value} />
size="5rem" {label && <Text className={classes.label}>{label}</Text>}
trackColor="rgba(0, 0, 0, 0.6)" </Stack>
onAnimationEnd={progressComplete} </ScaleFade>
thickness={6} </Stack>
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>
); );
}; };

View File

@@ -1,18 +1,56 @@
import React from 'react'; 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 { useNuiEvent } from '../../hooks/useNuiEvent';
import { fetchNui } from '../../utils/fetchNui'; import { fetchNui } from '../../utils/fetchNui';
import ScaleFade from '../../transitions/ScaleFade';
import type { ProgressbarProps } from '../../typings';
export interface ProgressbarProps { const useStyles = createStyles((theme) => ({
label: string; container: {
duration: number; 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 Progressbar: React.FC = () => {
const { classes } = useStyles();
const [visible, setVisible] = React.useState(false); const [visible, setVisible] = React.useState(false);
const [label, setLabel] = React.useState(''); const [label, setLabel] = React.useState('');
const [duration, setDuration] = React.useState(0); const [duration, setDuration] = React.useState(0);
const [cancelled, setCancelled] = React.useState(false);
const progressComplete = () => { const progressComplete = () => {
setVisible(false); setVisible(false);
@@ -20,69 +58,38 @@ const Progressbar: React.FC = () => {
}; };
const progressCancel = () => { const progressCancel = () => {
setCancelled(true);
setVisible(false); setVisible(false);
}; };
useNuiEvent('progressCancel', progressCancel); useNuiEvent('progressCancel', progressCancel);
useNuiEvent<ProgressbarProps>('progress', (data) => { useNuiEvent<ProgressbarProps>('progress', (data) => {
setCancelled(false);
setVisible(true); setVisible(true);
setLabel(data.label); setLabel(data.label);
setDuration(data.duration); setDuration(data.duration);
}); });
return ( return (
<Flex h="30%" w="100%" position="absolute" bottom="0" justifyContent="center" alignItems="center"> <>
<Box width={350}> <Box className={classes.wrapper}>
{visible && ( <ScaleFade visible={visible}>
<Box <Box className={classes.container}>
height={45}
bg="rgba(0, 0, 0, 0.6)"
textAlign="center"
borderRadius="sm"
boxShadow="lg"
overflow="hidden"
>
<Box <Box
height={45} className={classes.bar}
onAnimationEnd={progressComplete} onAnimationEnd={progressComplete}
sx={ sx={{
!cancelled animation: 'progress-bar linear',
? { animationDuration: `${duration}ms`,
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%)"
> >
{label} <Box className={classes.labelWrapper}>
</Text> <Text className={classes.label}>{label}</Text>
</Box>
</Box>
</Box> </Box>
)} </ScaleFade>
</Box> </Box>
</Flex> </>
); );
}; };

View File

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

View File

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

View File

@@ -1,17 +1,33 @@
import React from 'react'; import React from 'react';
import { useNuiEvent } from '../../hooks/useNuiEvent'; 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 ReactMarkdown from 'react-markdown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { const useStyles = createStyles((theme, params: { position?: TextUiPosition }) => ({
text: string; wrapper: {
position?: 'right-center' | 'left-center' | 'top-center'; height: '100%',
icon?: IconProp; width: '100%',
iconColor?: string; position: 'absolute',
style?: React.CSSProperties; 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 TextUI: React.FC = () => {
const [data, setData] = React.useState<TextUiProps>({ const [data, setData] = React.useState<TextUiProps>({
@@ -19,6 +35,7 @@ const TextUI: React.FC = () => {
position: 'right-center', position: 'right-center',
}); });
const [visible, setVisible] = React.useState(false); const [visible, setVisible] = React.useState(false);
const { classes } = useStyles({ position: data.position });
useNuiEvent<TextUiProps>('textUi', (data) => { useNuiEvent<TextUiProps>('textUi', (data) => {
if (!data.position) data.position = 'right-center'; // Default right position if (!data.position) data.position = 'right-center'; // Default right position
@@ -29,42 +46,18 @@ const TextUI: React.FC = () => {
useNuiEvent('textUiHide', () => setVisible(false)); useNuiEvent('textUiHide', () => setVisible(false));
return ( return (
<Flex <>
w="100%" <Box className={classes.wrapper}>
h="100%" <ScaleFade visible={visible}>
p={3} <Box style={data.style} className={classes.container}>
position="absolute" <Group spacing={12}>
alignItems={data.position === 'top-center' ? 'baseline' : 'center'} {data.icon && <FontAwesomeIcon icon={data.icon} fixedWidth size="lg" style={{ color: data.iconColor }} />}
justifyContent={ <ReactMarkdown remarkPlugins={[remarkGfm]}>{data.text}</ReactMarkdown>
data.position === 'right-center' ? 'flex-end' : data.position === 'left-center' ? 'flex-start' : 'center' </Group>
} </Box>
> </ScaleFade>
<ScaleFade in={visible} unmountOnExit> </Box>
<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>
); );
}; };

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=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=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://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 { html {
color-scheme: normal !important; color-scheme: normal !important;
@@ -20,6 +22,10 @@ body {
overflow: hidden !important; overflow: hidden !important;
} }
p {
margin: 0;
}
#root { #root {
height: 100%; height: 100%;
} }
@@ -28,15 +34,6 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 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 { @keyframes progress-bar {
from { from {
width: 0%; 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 ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; 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 { fas } from '@fortawesome/free-solid-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons'; import { far } from '@fortawesome/free-regular-svg-icons';
import { fab } from '@fortawesome/free-brands-svg-icons'; import { fab } from '@fortawesome/free-brands-svg-icons';
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { isEnvBrowser } from './utils/misc'; import { isEnvBrowser } from './utils/misc';
import LocaleProvider from './providers/LocaleProvider'; import LocaleProvider from './providers/LocaleProvider';
import ConfigProvider from './providers/ConfigProvider';
library.add(fas, far, fab); library.add(fas, far, fab);
@@ -25,22 +22,13 @@ if (isEnvBrowser()) {
root!.style.backgroundPosition = 'center'; root!.style.backgroundPosition = 'center';
} }
debugData([
{
action: 'setVisible',
data: true,
},
]);
const root = document.getElementById('root'); const root = document.getElementById('root');
ReactDOM.createRoot(root!).render( ReactDOM.createRoot(root!).render(
<React.StrictMode> <React.StrictMode>
<LocaleProvider> <LocaleProvider>
<VisibilityProvider> <ConfigProvider>
<ChakraProvider theme={theme}> <App />
<App /> </ConfigProvider>
</ChakraProvider>
</VisibilityProvider>
</LocaleProvider> </LocaleProvider>
</React.StrictMode> </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: { data: {
language: 'English', language: 'English',
ui: { ui: {
cancel: 'Cancel',
close: 'Close', close: 'Close',
confirm: 'Confirm', 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 = { export const theme: MantineThemeOverride = {
initialColorMode: 'dark', 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; iconColor?: string;
progress?: number; progress?: number;
colorScheme?: string; colorScheme?: string;
metadata?: string[] | { [key: string]: any } | { label: string; value: any }[]; metadata?: string[] | { [key: string]: any } | { label: string; value: any; progress?: number }[];
disabled?: boolean; disabled?: boolean;
event?: string; event?: string;
serverEvent?: 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 { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
const path = require("path");
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
base: "./", base: "./",
server: {
port: 3000,
},
build: { build: {
outDir: "build", outDir: "build",
target: "esnext", target: "esnext",