mirror of
https://github.com/CommunityOx/ox_lib.git
synced 2026-08-17 06:56:03 +01:00
Merge branch 'v3'
This commit is contained in:
@@ -7,7 +7,7 @@ rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aw
|
||||
|
||||
--[[ Resource Information ]]--
|
||||
name 'ox_lib'
|
||||
author 'Linden'
|
||||
author 'Overextended'
|
||||
version '2.21.0'
|
||||
license 'LGPL-3.0-or-later'
|
||||
repository 'https://github.com/overextended/ox_lib'
|
||||
@@ -41,6 +41,7 @@ client_scripts {
|
||||
'imports/callback/client.lua',
|
||||
'imports/requestModel/client.lua',
|
||||
'imports/requestAnimDict/client.lua',
|
||||
'imports/addKeybind/client.lua',
|
||||
'resource/**/client.lua',
|
||||
'resource/**/client/*.lua'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
2
init.lua
2
init.lua
@@ -76,8 +76,6 @@ end
|
||||
|
||||
lib = setmetatable({
|
||||
name = ox_lib,
|
||||
---@deprecated
|
||||
service = context,
|
||||
context = context,
|
||||
exports = {},
|
||||
onCache = function(key, cb)
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './interface/notify';
|
||||
export * from './interface/progress';
|
||||
export * from './interface/textui';
|
||||
export * from './interface/skillcheck';
|
||||
export * from './interface/radial';
|
||||
|
||||
export * from './streaming';
|
||||
export * from './vehicleProperties';
|
||||
|
||||
@@ -2,6 +2,8 @@ interface AlertDialogProps {
|
||||
header: string;
|
||||
content: string;
|
||||
centered?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
overflow?: boolean;
|
||||
cancel?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
|
||||
|
||||
interface ContextMenuItem {
|
||||
title?: string;
|
||||
menu?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
arrow?: boolean;
|
||||
image?: string;
|
||||
icon?: IconName | [IconPrefix, IconName];
|
||||
iconColor?: string;
|
||||
progress?: number;
|
||||
colorScheme?: string;
|
||||
onSelect?: (args: any) => void;
|
||||
arrow?: boolean;
|
||||
description?: string;
|
||||
metadata?: string | { [key: string]: any } | string[];
|
||||
metadata?: string[] | { [key: string]: any } | { label: string; value: any; progress?: number }[];
|
||||
disabled?: boolean;
|
||||
event?: string;
|
||||
serverEvent?: string;
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
|
||||
|
||||
// Should really be improved at some point to only display properties depending on the input type
|
||||
interface InputDialogRowProps {
|
||||
type: 'input' | 'number' | 'checkbox' | 'select' | 'slider';
|
||||
type:
|
||||
| 'input'
|
||||
| 'number'
|
||||
| 'checkbox'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'slider'
|
||||
| 'color'
|
||||
| 'date'
|
||||
| 'date-range'
|
||||
| 'time'
|
||||
| 'text-area';
|
||||
label: string;
|
||||
options?: { value: string; label: string; default?: string }[];
|
||||
password?: boolean;
|
||||
@@ -13,13 +25,19 @@ interface InputDialogRowProps {
|
||||
checked?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
autosize?: boolean;
|
||||
step?: number;
|
||||
required?: boolean;
|
||||
format?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type inputDialog = (
|
||||
heading: string,
|
||||
rows: string[] | InputDialogRowProps[]
|
||||
rows: string[] | InputDialogRowProps[],
|
||||
options: {
|
||||
allowCancel?: boolean;
|
||||
}
|
||||
) => Promise<Array<string | number | boolean> | undefined>;
|
||||
export const inputDialog: inputDialog = async (heading, rows) => await exports.ox_lib.inputDialog(heading, rows);
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types';
|
||||
|
||||
type NotificationPosition = 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left';
|
||||
type NotificationPosition =
|
||||
| 'top'
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'bottom'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left'
|
||||
| 'center-right'
|
||||
| 'center-left';
|
||||
type NotificationType = 'inform' | 'error' | 'success';
|
||||
|
||||
interface NotifyProps {
|
||||
id?: string;
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
@@ -18,6 +26,7 @@ interface NotifyProps {
|
||||
|
||||
export const notify = (data: NotifyProps): void => exports.ox_lib.notify(data);
|
||||
|
||||
// Keep for backwards compat with v2
|
||||
interface DefaultNotifyProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
|
||||
18
package/client/resource/interface/radial.ts
Normal file
18
package/client/resource/interface/radial.ts
Normal 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();
|
||||
@@ -1,4 +1,4 @@
|
||||
type SkillCheckDifficulty = 'easy' | 'medium' | 'hard' | { areaSize: number; speedMultiplier: number };
|
||||
|
||||
export const skillCheck = (difficulty: SkillCheckDifficulty | SkillCheckDifficulty[]) =>
|
||||
export const skillCheck = (difficulty: SkillCheckDifficulty | SkillCheckDifficulty[], inputs?: string[]) =>
|
||||
exports.ox_lib.skillCheck(difficulty);
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
"@citizenfx/server": "2.0.5885-1",
|
||||
"@fortawesome/fontawesome-common-types": "6.1.1",
|
||||
"@types/node": "16.9.1",
|
||||
"@types/react": "^18.0.20",
|
||||
"typescript": "^4.8.3"
|
||||
"@types/react": "^18.0.26",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.7.1"
|
||||
"prettier": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
24
package/pnpm-lock.yaml
generated
24
package/pnpm-lock.yaml
generated
@@ -5,20 +5,20 @@ specifiers:
|
||||
'@citizenfx/server': 2.0.5885-1
|
||||
'@fortawesome/fontawesome-common-types': 6.1.1
|
||||
'@types/node': 16.9.1
|
||||
'@types/react': ^18.0.20
|
||||
prettier: ^2.7.1
|
||||
typescript: ^4.8.3
|
||||
'@types/react': ^18.0.26
|
||||
prettier: ^2.8.1
|
||||
typescript: ^4.9.4
|
||||
|
||||
dependencies:
|
||||
'@citizenfx/client': 2.0.5885-1
|
||||
'@citizenfx/server': 2.0.5885-1
|
||||
'@fortawesome/fontawesome-common-types': 6.1.1
|
||||
'@types/node': 16.9.1
|
||||
'@types/react': 18.0.20
|
||||
typescript: 4.8.3
|
||||
'@types/react': 18.0.26
|
||||
typescript: 4.9.4
|
||||
|
||||
devDependencies:
|
||||
prettier: 2.7.1
|
||||
prettier: 2.8.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -44,8 +44,8 @@ packages:
|
||||
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
||||
dev: false
|
||||
|
||||
/@types/react/18.0.20:
|
||||
resolution: {integrity: sha512-MWul1teSPxujEHVwZl4a5HxQ9vVNsjTchVA+xRqv/VYGCuKGAU6UhfrTdF5aBefwD1BHUD8i/zq+O/vyCm/FrA==}
|
||||
/@types/react/18.0.26:
|
||||
resolution: {integrity: sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==}
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.5
|
||||
'@types/scheduler': 0.16.2
|
||||
@@ -60,14 +60,14 @@ packages:
|
||||
resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==}
|
||||
dev: false
|
||||
|
||||
/prettier/2.7.1:
|
||||
resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==}
|
||||
/prettier/2.8.1:
|
||||
resolution: {integrity: sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/typescript/4.8.3:
|
||||
resolution: {integrity: sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==}
|
||||
/typescript/4.9.4:
|
||||
resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==}
|
||||
engines: {node: '>=4.2.0'}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
@@ -10,3 +10,10 @@ function RegisterCommand(commandName, callback, restricted)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
RegisterNUICallback('getConfig', function(_, cb)
|
||||
cb({
|
||||
primaryColor = GetConvar('ox:primaryColor', 'blue'),
|
||||
primaryShade = GetConvarInt('ox:primaryShade', 8)
|
||||
})
|
||||
end)
|
||||
@@ -10,6 +10,8 @@ end
|
||||
---@field header string;
|
||||
---@field content string;
|
||||
---@field centered? boolean?;
|
||||
---@field size? 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
---@field overflow? boolean?;
|
||||
---@field cancel? boolean?;
|
||||
---@field labels? {cancel?: string, confirm?: string}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
local input
|
||||
|
||||
---@class InputDialogRowProps
|
||||
---@field type 'input' | 'number' | 'checkbox' | 'select' | 'slider'
|
||||
---@field type 'input' | 'number' | 'checkbox' | 'select' | 'slider' | 'multi-select' | 'date' | 'date-range' | 'time' | 'textarea'
|
||||
---@field label string
|
||||
---@field options? { value: string, label: string, default?: string }[]
|
||||
---@field password? boolean
|
||||
@@ -14,12 +14,20 @@ local input
|
||||
---@field min? number
|
||||
---@field max? number
|
||||
---@field step? number
|
||||
---@field autosize? boolean
|
||||
---@field required? boolean
|
||||
---@field format? string
|
||||
---@field clearable? string
|
||||
---@field description? string
|
||||
|
||||
---@class InputDialogOptionsProps
|
||||
---@field allowCancel? boolean
|
||||
|
||||
---@param heading string
|
||||
---@param rows string[] | InputDialogRowProps[]
|
||||
---@param options InputDialogOptionsProps[]
|
||||
---@return string[] | number[] | boolean[] | nil
|
||||
function lib.inputDialog(heading, rows)
|
||||
function lib.inputDialog(heading, rows, options)
|
||||
if input then return end
|
||||
input = promise.new()
|
||||
|
||||
@@ -35,7 +43,8 @@ function lib.inputDialog(heading, rows)
|
||||
action = 'openDialog',
|
||||
data = {
|
||||
heading = heading,
|
||||
rows = rows
|
||||
rows = rows,
|
||||
options = options
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---@alias NotificationPosition 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'
|
||||
---@alias NotificationPosition 'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left' | 'center-right' | 'center-left'
|
||||
---@alias NotificationType 'inform' | 'error' | 'success'
|
||||
|
||||
---@class NotifyProps
|
||||
@@ -15,7 +15,7 @@
|
||||
---@param data NotifyProps
|
||||
function lib.notify(data)
|
||||
SendNUIMessage({
|
||||
action = 'customNotify',
|
||||
action = 'notify',
|
||||
data = data
|
||||
})
|
||||
end
|
||||
@@ -30,10 +30,10 @@ end
|
||||
|
||||
---@param data DefaultNotifyProps
|
||||
function lib.defaultNotify(data)
|
||||
SendNUIMessage({
|
||||
action = 'notify',
|
||||
data = data
|
||||
})
|
||||
-- Backwards compat for v3
|
||||
data.type = data.status
|
||||
if data.type == 'info' or data.type == 'warning' then data.type = 'inform' end
|
||||
return lib.notify(data)
|
||||
end
|
||||
|
||||
RegisterNetEvent('ox_lib:notify', lib.notify)
|
||||
|
||||
@@ -198,8 +198,6 @@ end
|
||||
function lib.cancelProgress()
|
||||
if not progress then
|
||||
error('No progress bar is active')
|
||||
elseif not progress.canCancel then
|
||||
error('Progress bar cannot be cancelled')
|
||||
end
|
||||
|
||||
progress = false
|
||||
|
||||
173
resource/interface/client/radial.lua
Normal file
173
resource/interface/client/radial.lua
Normal 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)
|
||||
@@ -4,15 +4,19 @@ local skillcheck
|
||||
---@alias SkillCheckDifficulity 'easy' | 'medium' | 'hard' | { areaSize: number, speedMultiplier: number }
|
||||
|
||||
---@param difficulty SkillCheckDifficulity | SkillCheckDifficulity[]
|
||||
---@param inputs string[]
|
||||
---@return boolean?
|
||||
function lib.skillCheck(difficulty)
|
||||
function lib.skillCheck(difficulty, inputs)
|
||||
if skillcheck then return end
|
||||
skillcheck = promise:new()
|
||||
|
||||
SetNuiFocus(true, false)
|
||||
SendNUIMessage({
|
||||
action = 'startSkillCheck',
|
||||
data = difficulty
|
||||
data = {
|
||||
difficulty = difficulty,
|
||||
inputs = inputs
|
||||
}
|
||||
})
|
||||
|
||||
return Citizen.Await(skillcheck)
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "ox_lib",
|
||||
"version": "0.1.0",
|
||||
"homepage": "web/build",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "2.3.6",
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||
"@testing-library/jest-dom": "^5.16.3",
|
||||
"@testing-library/react": "^12.1.4",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"@vitejs/plugin-react": "^1.3.2",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.3.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.19",
|
||||
"@mantine/core": "^5.10.0",
|
||||
"@mantine/dates": "^5.10.0",
|
||||
"@mantine/hooks": "^5.10.0",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"dayjs": "^1.11.7",
|
||||
"focus-trap-react": "^9.0.2",
|
||||
"framer-motion": "^6.2.8",
|
||||
"framer-motion": "^8.0.2",
|
||||
"prettier": "^2.7.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.41.3",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-markdown": "^8.0.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"typescript": "^4.6.3",
|
||||
"vite": "^2.9.13",
|
||||
"vite": "^4.0.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -36,33 +34,23 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": ">=7.0.0 <8.0.0",
|
||||
"@babel/plugin-syntax-flow": "^7.14.5",
|
||||
"@babel/plugin-transform-react-jsx": "^7.14.9",
|
||||
"@testing-library/dom": "^8.11.4",
|
||||
"autoprefixer": "^10.0.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.0.10",
|
||||
"postcss": "^8.1.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"rimraf": "^3.0.2"
|
||||
"rimraf": "^3.0.2",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/react-dom": "^18.0.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"react-hot-toast@2.4.0": "patches/react-hot-toast@2.4.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
228
web/patches/react-hot-toast@2.4.0.patch
Normal file
228
web/patches/react-hot-toast@2.4.0.patch
Normal file
File diff suppressed because one or more lines are too long
3682
web/pnpm-lock.yaml
generated
3682
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,14 @@ import ListMenu from './features/menu/list';
|
||||
import Dev from './features/dev';
|
||||
import { isEnvBrowser } from './utils/misc';
|
||||
import SkillCheck from './features/skillcheck';
|
||||
import RadialMenu from './features/menu/radial';
|
||||
import { theme } from './theme';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useConfig } from './providers/ConfigProvider';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
useNuiEvent('setClipboard', (data: string) => {
|
||||
setClipboard(data);
|
||||
});
|
||||
@@ -21,7 +27,7 @@ const App: React.FC = () => {
|
||||
fetchNui('init');
|
||||
|
||||
return (
|
||||
<>
|
||||
<MantineProvider withNormalizeCSS withGlobalStyles theme={{ ...theme, ...config }}>
|
||||
<Progressbar />
|
||||
<CircleProgressbar />
|
||||
<Notifications />
|
||||
@@ -30,9 +36,10 @@ const App: React.FC = () => {
|
||||
<AlertDialog />
|
||||
<ContextMenu />
|
||||
<ListMenu />
|
||||
<RadialMenu />
|
||||
<SkillCheck />
|
||||
{isEnvBrowser() && <Dev />}
|
||||
</>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
import { AlertProps } from '../../dialog/AlertDialog';
|
||||
import { AlertProps } from '../../../typings';
|
||||
|
||||
export const debugAlert = () => {
|
||||
debugData<AlertProps>([
|
||||
@@ -9,11 +9,13 @@ export const debugAlert = () => {
|
||||
header: 'Hello there',
|
||||
content: 'General kenobi \n Markdown works',
|
||||
centered: true,
|
||||
size: 'lg',
|
||||
overflow: true,
|
||||
cancel: true,
|
||||
labels: {
|
||||
confirm: 'Ok',
|
||||
cancel: 'Not ok',
|
||||
},
|
||||
// labels: {
|
||||
// confirm: 'Ok',
|
||||
// cancel: 'Not ok',
|
||||
// },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ContextMenuProps } from '../../../interfaces/context';
|
||||
import { ContextMenuProps } from '../../../typings';
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
|
||||
export const debugContext = () => {
|
||||
@@ -9,6 +9,32 @@ export const debugContext = () => {
|
||||
title: 'Vehicle garage',
|
||||
options: [
|
||||
{ title: 'Empty button' },
|
||||
{
|
||||
title: 'Karin Kuruma',
|
||||
image: 'https://cdn.discordapp.com/attachments/1063098499027173461/1064276343585505330/screenshot.jpg',
|
||||
arrow: true,
|
||||
colorScheme: 'blue',
|
||||
metadata: [
|
||||
{
|
||||
['label']: 'Body',
|
||||
['value']: '55%',
|
||||
['progress']: 55,
|
||||
},
|
||||
{
|
||||
['label']: 'Engine',
|
||||
['value']: '100%',
|
||||
['progress']: 100,
|
||||
},
|
||||
{
|
||||
['label']: 'Oil',
|
||||
['progress']: 11,
|
||||
},
|
||||
{
|
||||
['label']: 'Fuel',
|
||||
['progress']: 87,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Example button',
|
||||
description: 'Example button description',
|
||||
@@ -30,7 +56,7 @@ export const debugContext = () => {
|
||||
progress: 80,
|
||||
icon: 'car-side',
|
||||
metadata: [{ label: 'Durability', value: '80%' }],
|
||||
colorScheme: 'blue'
|
||||
colorScheme: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'Menu button',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
import { InputProps } from '../../dialog/InputDialog';
|
||||
import type { InputProps } from '../../../typings';
|
||||
|
||||
export const debugInput = () => {
|
||||
debugData<InputProps>([
|
||||
@@ -14,6 +14,12 @@ export const debugInput = () => {
|
||||
placeholder: '420',
|
||||
description: 'Description that tells you what this input field does',
|
||||
},
|
||||
{
|
||||
type: 'time',
|
||||
format: '12',
|
||||
label: 'Locker Time',
|
||||
description: 'Description that tells you what this input field does',
|
||||
},
|
||||
{ type: 'checkbox', label: 'Some checkbox' },
|
||||
{ type: 'input', label: 'Locker PIN', password: true, icon: 'lock' },
|
||||
{ type: 'checkbox', label: 'Some other checkbox', checked: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
import { MenuSettings } from '../../menu/list';
|
||||
import { MenuSettings } from '../../../typings';
|
||||
|
||||
export const debugMenu = () => {
|
||||
debugData<MenuSettings>([
|
||||
@@ -34,7 +34,7 @@ export const debugMenu = () => {
|
||||
icon: 'car-side',
|
||||
description: 'Durability: 80%',
|
||||
colorScheme: 'blue',
|
||||
iconColor: '#55778d'
|
||||
iconColor: '#55778d',
|
||||
},
|
||||
{ label: 'Option 1' },
|
||||
{ label: 'Option 2' },
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import { CustomNotificationProps, NotificationProps } from '../../notifications/NotificationWrapper';
|
||||
import { NotificationProps } from '../../../typings';
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
|
||||
export const debugNotification = () => {
|
||||
export const debugCustomNotification = () => {
|
||||
debugData<NotificationProps>([
|
||||
{
|
||||
action: 'notify',
|
||||
data: {
|
||||
description: 'Dunak is nerd',
|
||||
title: 'Dunak',
|
||||
id: 1,
|
||||
title: 'Success',
|
||||
description: 'Notification description',
|
||||
type: 'success',
|
||||
id: 'pogchamp',
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
export const debugCustomNotification = () => {
|
||||
debugData<CustomNotificationProps>([
|
||||
debugData<NotificationProps>([
|
||||
{
|
||||
action: 'customNotify',
|
||||
action: 'notify',
|
||||
data: {
|
||||
description: 'Dunak is nerd',
|
||||
icon: 'basket-shopping',
|
||||
style: {
|
||||
backgroundColor: '#2D3748',
|
||||
color: 'white',
|
||||
},
|
||||
title: 'Success',
|
||||
description: 'Notification description',
|
||||
type: 'success',
|
||||
id: 'pogchamp',
|
||||
},
|
||||
},
|
||||
]);
|
||||
debugData<NotificationProps>([
|
||||
{
|
||||
action: 'notify',
|
||||
data: {
|
||||
title: 'Error',
|
||||
description: 'Notification description',
|
||||
type: 'error',
|
||||
},
|
||||
},
|
||||
]);
|
||||
debugData<NotificationProps>([
|
||||
{
|
||||
action: 'notify',
|
||||
data: {
|
||||
title: 'Custom icon success',
|
||||
description: 'Notification description',
|
||||
type: 'success',
|
||||
icon: 'microchip',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
import { ProgressbarProps } from '../../progress/Progressbar';
|
||||
import { ProgressbarProps } from '../../../typings';
|
||||
|
||||
export const debugProgressbar = () => {
|
||||
debugData<ProgressbarProps>([
|
||||
|
||||
19
web/src/features/dev/debug/radial.ts
Normal file
19
web/src/features/dev/debug/radial.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
import { GameDifficulty } from '../../skillcheck';
|
||||
import { GameDifficulty } from '../../../typings';
|
||||
|
||||
export const debugSkillCheck = () => {
|
||||
debugData<GameDifficulty | GameDifficulty[]>([
|
||||
debugData<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }>([
|
||||
{
|
||||
action: 'startSkillCheck',
|
||||
data: ['easy', 'easy', 'hard'],
|
||||
data: {
|
||||
difficulty: ['easy', 'easy', 'hard'],
|
||||
inputs: ['W', 'A', 'S', 'D'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TextUiProps } from '../../textui/TextUI';
|
||||
import { TextUiProps } from '../../../typings';
|
||||
import { debugData } from '../../../utils/debugData';
|
||||
|
||||
export const debugTextUI = () => {
|
||||
|
||||
@@ -1,91 +1,75 @@
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
VStack,
|
||||
Divider,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { ActionIcon, Tooltip, Drawer, Stack, Divider, Button } from '@mantine/core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { debugAlert } from './debug/alert';
|
||||
import { debugContext } from './debug/context';
|
||||
import { debugInput } from './debug/input';
|
||||
import { debugMenu } from './debug/menu';
|
||||
import { debugCustomNotification, debugNotification } from './debug/notification';
|
||||
import { debugCustomNotification } from './debug/notification';
|
||||
import { debugCircleProgressbar, debugProgressbar } from './debug/progress';
|
||||
import { debugTextUI } from './debug/textui';
|
||||
import { debugSkillCheck } from './debug/skillcheck';
|
||||
import { useState } from 'react';
|
||||
import { debugRadial } from './debug/radial';
|
||||
|
||||
const Dev: React.FC = () => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Developer drawer">
|
||||
<IconButton
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
mr={20}
|
||||
mb={20}
|
||||
borderRadius="50%"
|
||||
icon={<FontAwesomeIcon icon="wrench" fixedWidth size="lg" />}
|
||||
colorScheme="orange"
|
||||
size="lg"
|
||||
aria-label="Dev tools"
|
||||
onClick={() => onOpen()}
|
||||
/>
|
||||
<Tooltip label="Developer drawer" position="bottom">
|
||||
<ActionIcon
|
||||
onClick={() => setOpened(true)}
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="orange"
|
||||
sx={{ position: 'absolute', bottom: 0, right: 0, width: 50, height: 50 }}
|
||||
size="xl"
|
||||
mr={50}
|
||||
mb={50}
|
||||
>
|
||||
<FontAwesomeIcon icon="wrench" fontSize={24} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Drawer placement="left" onClose={onClose} isOpen={isOpen}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerHeader>Developer drawer</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugInput()}>
|
||||
Open input dialog
|
||||
</Button>
|
||||
<Button width="full" onClick={() => debugAlert()}>
|
||||
Open alert dialog
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugContext()}>
|
||||
Open context menu
|
||||
</Button>
|
||||
<Button width="full" onClick={() => debugMenu()}>
|
||||
Open list menu
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugCustomNotification()}>
|
||||
Send custom notification
|
||||
</Button>
|
||||
<Button width="full" onClick={() => debugNotification()}>
|
||||
Send default notification
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugProgressbar()}>
|
||||
Activate progress bar
|
||||
</Button>
|
||||
<Button width="full" onClick={() => debugCircleProgressbar()}>
|
||||
Activate progress circle
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugTextUI()}>
|
||||
Show TextUI
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button width="full" onClick={() => debugSkillCheck()}>
|
||||
Run skill check
|
||||
</Button>
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
|
||||
<Drawer position="left" onClose={() => setOpened(false)} opened={opened} title="Developer drawer" padding="xl">
|
||||
<Stack>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugInput()}>
|
||||
Open input dialog
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => debugAlert()}>
|
||||
Open alert dialog
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugContext()}>
|
||||
Open context menu
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => debugMenu()}>
|
||||
Open list menu
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => debugRadial()}>
|
||||
Open radial menu
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugCustomNotification()}>
|
||||
Send notification
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugProgressbar()}>
|
||||
Activate progress bar
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => debugCircleProgressbar()}>
|
||||
Activate progress circle
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugTextUI()}>
|
||||
Show TextUI
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button fullWidth onClick={() => debugSkillCheck()}>
|
||||
Run skill check
|
||||
</Button>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,83 +1,72 @@
|
||||
import {
|
||||
AlertDialog as Dialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
useDisclosure,
|
||||
Button,
|
||||
} from '@chakra-ui/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Button, Stack, Group, useMantineTheme } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { fetchNui } from '../../utils/fetchNui';
|
||||
import { useLocales } from '../../providers/LocaleProvider';
|
||||
|
||||
export interface AlertProps {
|
||||
header: string;
|
||||
content: string;
|
||||
centered?: boolean;
|
||||
cancel?: boolean;
|
||||
labels?: {
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
};
|
||||
}
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { AlertProps } from '../../typings';
|
||||
|
||||
const AlertDialog: React.FC = () => {
|
||||
const { locale } = useLocales();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const cancelRef = useRef(null);
|
||||
const theme = useMantineTheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [dialogData, setDialogData] = useState<AlertProps>({
|
||||
header: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const closeAlert = (button: string) => {
|
||||
onClose();
|
||||
setOpened(false);
|
||||
fetchNui('closeAlert', button);
|
||||
};
|
||||
|
||||
useNuiEvent('sendAlert', (data: AlertProps) => {
|
||||
setDialogData(data);
|
||||
onOpen();
|
||||
setOpened(true);
|
||||
});
|
||||
|
||||
useNuiEvent('closeAlertDialog', () => {
|
||||
onClose();
|
||||
setOpened(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
isCentered={dialogData.centered}
|
||||
closeOnOverlayClick={false}
|
||||
onEsc={() => closeAlert('cancel')}
|
||||
<Modal
|
||||
opened={opened}
|
||||
centered={dialogData.centered}
|
||||
size={dialogData.size || 'md'}
|
||||
overflow={dialogData.overflow ? 'inside' : 'outside'}
|
||||
closeOnClickOutside={false}
|
||||
onClose={() => {
|
||||
setOpened(false);
|
||||
closeAlert('cancel');
|
||||
}}
|
||||
withCloseButton={false}
|
||||
overlayOpacity={0.5}
|
||||
exitTransitionDuration={150}
|
||||
transition="fade"
|
||||
title={<ReactMarkdown>{dialogData.header}</ReactMarkdown>}
|
||||
>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContent fontFamily="Inter">
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{dialogData.header}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogBody>
|
||||
<ReactMarkdown>{dialogData.content}</ReactMarkdown>
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Stack>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{dialogData.content}</ReactMarkdown>
|
||||
<Group position="right" spacing={10}>
|
||||
{dialogData.cancel && (
|
||||
<Button onClick={() => closeAlert('cancel')} mr={3}>
|
||||
<Button uppercase variant="default" onClick={() => closeAlert('cancel')} mr={3}>
|
||||
{dialogData.labels?.cancel || locale.ui.cancel}
|
||||
</Button>
|
||||
)}
|
||||
<Button colorScheme={dialogData.cancel ? 'blue' : undefined} onClick={() => closeAlert('confirm')}>
|
||||
<Button
|
||||
uppercase
|
||||
variant={dialogData.cancel ? 'light' : 'default'}
|
||||
color={dialogData.cancel ? theme.primaryColor : undefined}
|
||||
onClick={() => closeAlert('confirm')}
|
||||
>
|
||||
{dialogData.labels?.confirm || locale.ui.confirm}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</Dialog>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +1,165 @@
|
||||
import { Modal, ModalOverlay, ModalContent, ModalFooter, ModalHeader, ModalBody, Button } from '@chakra-ui/react';
|
||||
import { Group, Modal, Button, Stack } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { useLocales } from '../../providers/LocaleProvider';
|
||||
import { fetchNui } from '../../utils/fetchNui';
|
||||
import { IInput, ICheckbox, ISelect, INumber, ISlider } from '../../interfaces/dialog';
|
||||
import { OptionValue } from '../../typings';
|
||||
import InputField from './components/fields/input';
|
||||
import CheckboxField from './components/fields/checkbox';
|
||||
import SelectField from './components/fields/select';
|
||||
import NumberField from './components/fields/number';
|
||||
import SliderField from './components/fields/slider';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import ColorField from './components/fields/color';
|
||||
import DateField from './components/fields/date';
|
||||
import TextareaField from './components/fields/textarea';
|
||||
import TimeField from './components/fields/time';
|
||||
import type { InputProps } from '../../typings';
|
||||
|
||||
export interface InputProps {
|
||||
heading: string;
|
||||
rows: Array<IInput | ICheckbox | ISelect | INumber | ISlider>;
|
||||
}
|
||||
export type FormValues = {
|
||||
test: {
|
||||
value: any;
|
||||
}[];
|
||||
};
|
||||
|
||||
const InputDialog: React.FC = () => {
|
||||
const [fields, setFields] = React.useState<InputProps>({
|
||||
heading: '',
|
||||
rows: [{ type: 'input', label: '' }],
|
||||
});
|
||||
const [inputData, setInputData] = React.useState<Array<string | number | boolean>>([]);
|
||||
const [passwordStates, setPasswordStates] = React.useState<boolean[]>([]);
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const { locale } = useLocales();
|
||||
|
||||
const handlePasswordStates = (index: number) => {
|
||||
setPasswordStates({
|
||||
...passwordStates,
|
||||
[index]: !passwordStates[index],
|
||||
});
|
||||
};
|
||||
const form = useForm<{ test: { value: any }[] }>({});
|
||||
const fieldForm = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
useNuiEvent<InputProps>('openDialog', (data) => {
|
||||
setPasswordStates([]);
|
||||
setFields(data);
|
||||
setInputData([]);
|
||||
setVisible(true);
|
||||
data.rows.forEach((row, index) => {
|
||||
fieldForm.insert(
|
||||
index,
|
||||
{
|
||||
value:
|
||||
row.type !== 'checkbox'
|
||||
? row.type === 'date' || row.type === 'date-range' || row.type === 'time'
|
||||
? // Set date to current one if default is set to true
|
||||
row.default === true
|
||||
? new Date().getTime()
|
||||
: Array.isArray(row.default)
|
||||
? row.default.map((date) => new Date(date).getTime())
|
||||
: row.default && new Date(row.default).getTime()
|
||||
: row.default
|
||||
: row.checked,
|
||||
} || { value: null }
|
||||
);
|
||||
// Backwards compat with new Select data type
|
||||
if (row.type === 'select' || row.type === 'multi-select') {
|
||||
row.options = row.options.map((option) =>
|
||||
!option.label ? { ...option, label: option.value } : option
|
||||
) as Array<OptionValue>;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
useNuiEvent('closeInputDialog', () => {
|
||||
setVisible(false);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = async () => {
|
||||
setVisible(false);
|
||||
fetchNui('inputData');
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
form.reset();
|
||||
fieldForm.remove();
|
||||
};
|
||||
|
||||
const handleChange = (value: string | number | boolean, index: number) => {
|
||||
setInputData((previousData) => {
|
||||
previousData[index] = value;
|
||||
return previousData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
setVisible(false);
|
||||
fetchNui('inputData', inputData);
|
||||
};
|
||||
const values: any[] = [];
|
||||
Object.values(data.test).forEach((obj: { value: any }) => values.push(obj.value));
|
||||
console.log(values);
|
||||
fetchNui('inputData', values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
form.reset();
|
||||
fieldForm.remove();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={visible} onClose={handleClose} isCentered closeOnEsc closeOnOverlayClick={false} size="xs">
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && visible) return handleConfirm();
|
||||
}}
|
||||
>
|
||||
<ModalHeader textAlign="center">{fields.heading}</ModalHeader>
|
||||
<ModalBody fontFamily="Poppins" textAlign="left">
|
||||
{fields.rows.map((row: IInput | ICheckbox | ISelect | INumber | ISlider, index) => (
|
||||
<React.Fragment key={`row-${index}-${row.type}-${row.label}`}>
|
||||
{row.type === 'input' && (
|
||||
<InputField
|
||||
row={row}
|
||||
index={index}
|
||||
handleChange={handleChange}
|
||||
passwordStates={passwordStates}
|
||||
handlePasswordStates={handlePasswordStates}
|
||||
/>
|
||||
)}
|
||||
{row.type === 'checkbox' && <CheckboxField row={row} index={index} handleChange={handleChange} />}
|
||||
{row.type === 'select' && <SelectField row={row} index={index} handleChange={handleChange} />}
|
||||
{row.type === 'number' && <NumberField row={row} index={index} handleChange={handleChange} />}
|
||||
{row.type === 'slider' && <SliderField row={row} index={index} handleChange={handleChange} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button mr={3} onClick={handleClose}>
|
||||
{locale.ui.close}
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleConfirm}>
|
||||
{locale.ui.confirm}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
<Modal
|
||||
opened={visible}
|
||||
onClose={handleClose}
|
||||
centered
|
||||
closeOnEscape={fields.options?.allowCancel !== false}
|
||||
closeOnClickOutside={false}
|
||||
size="xs"
|
||||
styles={{ title: { textAlign: 'center', width: '100%', fontSize: 18 } }}
|
||||
title={fields.heading}
|
||||
withCloseButton={false}
|
||||
overlayOpacity={0.5}
|
||||
transition="fade"
|
||||
exitTransitionDuration={150}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Stack>
|
||||
{fieldForm.fields.map((item, index) => {
|
||||
const row = fields.rows[index];
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{row.type === 'input' && (
|
||||
<InputField
|
||||
register={form.register(`test.${index}.value`, { required: row.required })}
|
||||
row={row}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
{row.type === 'checkbox' && (
|
||||
<CheckboxField
|
||||
register={form.register(`test.${index}.value`, { required: row.required })}
|
||||
row={row}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
{(row.type === 'select' || row.type === 'multi-select') && (
|
||||
<SelectField row={row} index={index} control={form.control} />
|
||||
)}
|
||||
{row.type === 'number' && <NumberField control={form.control} row={row} index={index} />}
|
||||
{row.type === 'slider' && <SliderField control={form.control} row={row} index={index} />}
|
||||
{row.type === 'color' && <ColorField control={form.control} row={row} index={index} />}
|
||||
{row.type === 'time' && <TimeField control={form.control} row={row} index={index} />}
|
||||
{row.type === 'date' || row.type === 'date-range' ? (
|
||||
<DateField control={form.control} row={row} index={index} />
|
||||
) : null}
|
||||
{row.type === 'textarea' && (
|
||||
<TextareaField
|
||||
register={form.register(`test.${index}.value`, { required: row.required })}
|
||||
row={row}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<Group position="right" spacing={10}>
|
||||
<Button
|
||||
uppercase
|
||||
variant="default"
|
||||
onClick={handleClose}
|
||||
mr={3}
|
||||
disabled={fields.options?.allowCancel === false}
|
||||
>
|
||||
{locale.ui.cancel}
|
||||
</Button>
|
||||
<Button uppercase variant="light" type="submit">
|
||||
{locale.ui.confirm}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,30 +1,22 @@
|
||||
import { Box, Checkbox, HStack, Text } from '@chakra-ui/react';
|
||||
import { useEffect } from 'react';
|
||||
import { ICheckbox } from '../../../../interfaces/dialog';
|
||||
import Label from '../Label';
|
||||
import { Checkbox } from '@mantine/core';
|
||||
import { ICheckbox } from '../../../../typings/dialog';
|
||||
import { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
interface Props {
|
||||
row: ICheckbox;
|
||||
index: number;
|
||||
handleChange: (value: boolean, index: number) => void;
|
||||
register: UseFormRegisterReturn;
|
||||
}
|
||||
|
||||
const CheckboxField: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (props.row.checked) props.handleChange(props.row.checked, props.index);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={3}>
|
||||
<Checkbox
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.handleChange(e.target.checked, props.index)}
|
||||
defaultChecked={props.row.checked}
|
||||
>
|
||||
<Label label={props.row.label} description={props.row.description} />
|
||||
</Checkbox>
|
||||
</Box>
|
||||
</>
|
||||
<Checkbox
|
||||
{...props.register}
|
||||
sx={{ display: 'flex' }}
|
||||
required={props.row.required}
|
||||
label={props.row.label}
|
||||
defaultChecked={props.row.checked}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
40
web/src/features/dialog/components/fields/color.tsx
Normal file
40
web/src/features/dialog/components/fields/color.tsx
Normal 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;
|
||||
73
web/src/features/dialog/components/fields/date.tsx
Normal file
73
web/src/features/dialog/components/fields/date.tsx
Normal 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;
|
||||
@@ -1,53 +1,58 @@
|
||||
import { Box, InputGroup, InputLeftElement, InputRightElement, Input } from '@chakra-ui/react';
|
||||
import { createStyles, PasswordInput, TextInput } from '@mantine/core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useEffect } from 'react';
|
||||
import { IInput } from '../../../../interfaces/dialog';
|
||||
import Label from '../Label';
|
||||
import React from 'react';
|
||||
import { IInput } from '../../../../typings/dialog';
|
||||
import { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
interface Props {
|
||||
register: UseFormRegisterReturn;
|
||||
row: IInput;
|
||||
index: number;
|
||||
handleChange: (value: string, index: number) => void;
|
||||
passwordStates: boolean[];
|
||||
handlePasswordStates: (index: number) => void;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
eyeIcon: {
|
||||
color: theme.colors.dark[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const InputField: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (props.row.default) props.handleChange(props.row.default, props.index);
|
||||
}, []);
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={3} textAlign="left">
|
||||
<Label label={props.row.label} description={props.row.description} />
|
||||
<InputGroup>
|
||||
{props.row.icon && (
|
||||
<InputLeftElement pointerEvents="none" children={<FontAwesomeIcon icon={props.row.icon} fixedWidth />} />
|
||||
)}
|
||||
<Input
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.handleChange(e.target.value, props.index)}
|
||||
placeholder={props.row.placeholder}
|
||||
defaultValue={props.row.default}
|
||||
type={!props.row.password || props.passwordStates[props.index] ? 'text' : 'password'}
|
||||
isDisabled={props.row.disabled}
|
||||
/>
|
||||
{props.row.password && (
|
||||
<InputRightElement
|
||||
{!props.row.password ? (
|
||||
<TextInput
|
||||
{...props.register}
|
||||
defaultValue={props.row.default}
|
||||
label={props.row.label}
|
||||
description={props.row.description}
|
||||
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
|
||||
placeholder={props.row.placeholder}
|
||||
disabled={props.row.disabled}
|
||||
withAsterisk={props.row.required}
|
||||
/>
|
||||
) : (
|
||||
<PasswordInput
|
||||
{...props.register}
|
||||
defaultValue={props.row.default}
|
||||
label={props.row.label}
|
||||
description={props.row.description}
|
||||
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
|
||||
placeholder={props.row.placeholder}
|
||||
disabled={props.row.disabled}
|
||||
withAsterisk={props.row.required}
|
||||
visibilityToggleIcon={({ reveal, size }) => (
|
||||
<FontAwesomeIcon
|
||||
icon={reveal ? 'eye-slash' : 'eye'}
|
||||
fontSize={size}
|
||||
cursor="pointer"
|
||||
onClick={() => props.handlePasswordStates(props.index)}
|
||||
children={
|
||||
<FontAwesomeIcon
|
||||
fixedWidth
|
||||
icon={props.passwordStates[props.index] ? 'eye' : 'eye-slash'}
|
||||
fontSize="1em"
|
||||
style={{ paddingRight: 8 }}
|
||||
/>
|
||||
}
|
||||
className={classes.eyeIcon}
|
||||
fixedWidth
|
||||
/>
|
||||
)}
|
||||
</InputGroup>
|
||||
</Box>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,52 +1,39 @@
|
||||
import {
|
||||
Box,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
InputLeftElement,
|
||||
InputGroup,
|
||||
} from '@chakra-ui/react';
|
||||
import { useEffect } from 'react';
|
||||
import { INumber } from '../../../../interfaces/dialog';
|
||||
import { NumberInput } from '@mantine/core';
|
||||
import { INumber } from '../../../../typings/dialog';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import Label from '../Label';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { FormValues } from '../../InputDialog';
|
||||
|
||||
interface Props {
|
||||
row: INumber;
|
||||
index: number;
|
||||
handleChange: (value: number, index: number) => void;
|
||||
control: Control<FormValues>;
|
||||
}
|
||||
|
||||
const NumberField: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (props.row.default) props.handleChange(props.row.default, props.index);
|
||||
}, []);
|
||||
const controller = useController({
|
||||
name: `test.${props.index}.value`,
|
||||
control: props.control,
|
||||
defaultValue: props.row.default,
|
||||
rules: { required: props.row.required },
|
||||
});
|
||||
|
||||
return (
|
||||
<Box mb={3}>
|
||||
<Label label={props.row.label} description={props.row.description} />
|
||||
<InputGroup>
|
||||
<NumberInput
|
||||
onChange={(val: string) => props.handleChange(+val, props.index)}
|
||||
defaultValue={props.row.default}
|
||||
min={props.row.min}
|
||||
max={props.row.max}
|
||||
isDisabled={props.row.disabled}
|
||||
w="100%"
|
||||
>
|
||||
{props.row.icon && (
|
||||
<InputLeftElement pointerEvents="none" children={<FontAwesomeIcon icon={props.row.icon} fixedWidth />} />
|
||||
)}
|
||||
<NumberInputField placeholder={props.row.placeholder} pl={props.row.icon ? '40px' : undefined} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</InputGroup>
|
||||
</Box>
|
||||
<NumberInput
|
||||
value={controller.field.value}
|
||||
name={controller.field.name}
|
||||
ref={controller.field.ref}
|
||||
onBlur={controller.field.onBlur}
|
||||
onChange={controller.field.onChange}
|
||||
label={props.row.label}
|
||||
description={props.row.description}
|
||||
defaultValue={props.row.default}
|
||||
min={props.row.min}
|
||||
max={props.row.max}
|
||||
disabled={props.row.disabled}
|
||||
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
|
||||
withAsterisk={props.row.required}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,45 +1,60 @@
|
||||
import { Box, Select } from '@chakra-ui/react';
|
||||
import { useEffect } from 'react';
|
||||
import { ISelect } from '../../../../interfaces/dialog';
|
||||
import { MultiSelect, Select } from '@mantine/core';
|
||||
import { ISelect } from '../../../../typings/dialog';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { FormValues } from '../../InputDialog';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
interface Props {
|
||||
row: ISelect;
|
||||
index: number;
|
||||
handleChange: (value: string, index: number) => void;
|
||||
control: Control<FormValues>;
|
||||
}
|
||||
|
||||
const SelectField: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (props.row.default) {
|
||||
props.row.options?.map((option) => {
|
||||
if (props.row.default === option.value) {
|
||||
props.handleChange(option.value, props.index);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const controller = useController({
|
||||
name: `test.${props.index}.value`,
|
||||
control: props.control,
|
||||
defaultValue: props.row.default || props.row.type !== 'multi-select' ? props.row.options[0].value : undefined,
|
||||
rules: { required: props.row.required },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={3}>
|
||||
{props.row.type === 'select' ? (
|
||||
<Select
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => props.handleChange(e.target.value, props.index)}
|
||||
defaultValue={props.row.default || ''}
|
||||
isDisabled={props.row.disabled}
|
||||
>
|
||||
{/* Hacky workaround for selectable placeholder issue */}
|
||||
{!props.row.default && (
|
||||
<option value="" hidden disabled>
|
||||
{props.row.label}
|
||||
</option>
|
||||
data={props.row.options}
|
||||
value={controller.field.value}
|
||||
name={controller.field.name}
|
||||
ref={controller.field.ref}
|
||||
onBlur={controller.field.onBlur}
|
||||
onChange={controller.field.onChange}
|
||||
disabled={props.row.disabled}
|
||||
label={props.row.label}
|
||||
description={props.row.description}
|
||||
withAsterisk={props.row.required}
|
||||
clearable={props.row.clearable}
|
||||
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{props.row.type === 'multi-select' && (
|
||||
<MultiSelect
|
||||
data={props.row.options}
|
||||
value={controller.field.value}
|
||||
name={controller.field.name}
|
||||
ref={controller.field.ref}
|
||||
onBlur={controller.field.onBlur}
|
||||
onChange={controller.field.onChange}
|
||||
disabled={props.row.disabled}
|
||||
label={props.row.label}
|
||||
description={props.row.description}
|
||||
withAsterisk={props.row.required}
|
||||
clearable={props.row.clearable}
|
||||
icon={props.row.icon && <FontAwesomeIcon icon={props.row.icon} fixedWidth />}
|
||||
/>
|
||||
)}
|
||||
{props.row.options?.map((option, index) => (
|
||||
<option key={`option-${index}`} value={option.value}>
|
||||
{option.label || option.value}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,42 @@
|
||||
import { Box, Text, Slider, SliderTrack, SliderFilledTrack, SliderThumb, HStack, Tooltip } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ISlider } from '../../../../interfaces/dialog';
|
||||
import Label from '../Label';
|
||||
import { Box, Slider, Text } from '@mantine/core';
|
||||
import { ISlider } from '../../../../typings/dialog';
|
||||
import { Control, useController } from 'react-hook-form';
|
||||
import { FormValues } from '../../InputDialog';
|
||||
|
||||
interface Props {
|
||||
row: ISlider;
|
||||
index: number;
|
||||
handleChange: (value: number, index: number) => void;
|
||||
control: Control<FormValues>;
|
||||
}
|
||||
|
||||
const SliderField: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (props.row.default || props.row.min) props.handleChange(props.row.default || props.row.min!, props.index);
|
||||
}, []);
|
||||
|
||||
const [sliderValue, setSliderValue] = useState(props.row.default || props.row.min || 0);
|
||||
const controller = useController({
|
||||
name: `test.${props.index}.value`,
|
||||
control: props.control,
|
||||
defaultValue: props.row.default || props.row.min || 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box mb={3}>
|
||||
<Label label={props.row.label} description={props.row.description} />
|
||||
<Slider
|
||||
onChangeEnd={(val: number) => props.handleChange(val, props.index)}
|
||||
onChange={(val: number) => setSliderValue(val)}
|
||||
defaultValue={props.row.default || props.row.min || 0}
|
||||
min={props.row.min}
|
||||
max={props.row.max}
|
||||
step={props.row.step}
|
||||
isDisabled={props.row.disabled}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<Tooltip hasArrow label={sliderValue} placement="bottom" gutter={10}>
|
||||
<SliderThumb />
|
||||
</Tooltip>
|
||||
</Slider>
|
||||
<HStack justifyContent="space-between">
|
||||
<Text fontSize="sm">{props.row.min || 0}</Text>
|
||||
<Text fontSize="sm">{props.row.max || 100}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</>
|
||||
<Box>
|
||||
<Text sx={{ fontSize: 14, fontWeight: 500 }}>{props.row.label}</Text>
|
||||
<Slider
|
||||
mb={10}
|
||||
value={controller.field.value}
|
||||
name={controller.field.name}
|
||||
ref={controller.field.ref}
|
||||
onBlur={controller.field.onBlur}
|
||||
onChange={controller.field.onChange}
|
||||
defaultValue={props.row.default || props.row.min || 0}
|
||||
min={props.row.min}
|
||||
max={props.row.max}
|
||||
step={props.row.step}
|
||||
disabled={props.row.disabled}
|
||||
marks={[
|
||||
{ value: props.row.min || 0, label: props.row.min || 0 },
|
||||
{ value: props.row.max || 100, label: props.row.max || 100 },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
31
web/src/features/dialog/components/fields/textarea.tsx
Normal file
31
web/src/features/dialog/components/fields/textarea.tsx
Normal 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;
|
||||
38
web/src/features/dialog/components/fields/time.tsx
Normal file
38
web/src/features/dialog/components/fields/time.tsx
Normal 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;
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useNuiEvent } from '../../../hooks/useNuiEvent';
|
||||
import { Box, Text, Flex, ScaleFade } from '@chakra-ui/react';
|
||||
import { Box, Stack, Text, Flex } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ContextMenuProps } from '../../../interfaces/context';
|
||||
import Item from './Item';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { ContextMenuProps } from '../../../typings';
|
||||
import ContextButton from './components/ContextButton';
|
||||
import { fetchNui } from '../../../utils/fetchNui';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import HeaderButton from './components/HeaderButton';
|
||||
import ScaleFade from '../../../transitions/ScaleFade';
|
||||
|
||||
const openMenu = (id: string | undefined) => {
|
||||
fetchNui<ContextMenuProps>('openContext', { id: id, back: true });
|
||||
@@ -49,64 +50,28 @@ const ContextMenu: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex position="absolute" w="75%" h="80%" justifyContent="flex-end" alignItems="center">
|
||||
<ScaleFade in={visible} unmountOnExit>
|
||||
<Box w="xs" h={580}>
|
||||
<Flex justifyContent="center" alignItems="center" mb={3}>
|
||||
{contextMenu.menu && (
|
||||
<Flex
|
||||
borderRadius="md"
|
||||
bg="gray.800"
|
||||
flex="1 15%"
|
||||
alignSelf="stretch"
|
||||
textAlign="center"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
marginRight={2}
|
||||
p={2}
|
||||
_hover={{ bg: 'gray.700' }}
|
||||
transition="300ms"
|
||||
onClick={() => openMenu(contextMenu.menu)}
|
||||
>
|
||||
<FontAwesomeIcon icon="chevron-left" />
|
||||
</Flex>
|
||||
)}
|
||||
<Box borderRadius="md" bg="gray.800" flex="1 85%">
|
||||
<Text fontFamily="Poppins" fontSize="md" p={2} textAlign="center" fontWeight="light">
|
||||
<ReactMarkdown>{contextMenu.title}</ReactMarkdown>
|
||||
</Text>
|
||||
</Box>
|
||||
<Flex
|
||||
borderRadius="md"
|
||||
as="button"
|
||||
bg={contextMenu.canClose === false ? 'gray.600' : 'gray.800'}
|
||||
flex="1 15%"
|
||||
alignSelf="stretch"
|
||||
textAlign="center"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
marginLeft={2}
|
||||
p={2}
|
||||
cursor={contextMenu.canClose === false ? 'not-allowed' : undefined}
|
||||
_hover={{ bg: contextMenu.canClose === false ? undefined : 'gray.700' }}
|
||||
transition="300ms"
|
||||
onClick={() => closeContext()}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
fontSize={20}
|
||||
color={contextMenu.canClose === false ? '#718096' : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box maxH={560} overflowY="scroll">
|
||||
{Object.entries(contextMenu.options).map((option, index) => (
|
||||
<Item option={option} key={`context-item-${index}`} />
|
||||
))}
|
||||
<Box sx={{ position: 'absolute', top: '15%', right: '25%' }} w={320} h={580}>
|
||||
<ScaleFade visible={visible}>
|
||||
<Flex justify="center" align="center" mb={10} gap={6}>
|
||||
{contextMenu.menu && (
|
||||
<HeaderButton icon="chevron-left" iconSize={16} handleClick={() => openMenu(contextMenu.menu)} />
|
||||
)}
|
||||
<Box sx={{ borderRadius: 4, flex: '1 85%' }} bg="dark.6">
|
||||
<Text color="dark.0" p={6} align="center">
|
||||
<ReactMarkdown>{contextMenu.title}</ReactMarkdown>
|
||||
</Text>
|
||||
</Box>
|
||||
<HeaderButton icon="xmark" canClose={contextMenu.canClose} iconSize={18} handleClick={closeContext} />
|
||||
</Flex>
|
||||
<Box sx={{ height: 560, overflowY: 'scroll' }}>
|
||||
<Stack spacing={3}>
|
||||
{Object.entries(contextMenu.options).map((option, index) => (
|
||||
<ContextButton option={option} key={`context-item-${index}`} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
122
web/src/features/menu/context/components/ContextButton.tsx
Normal file
122
web/src/features/menu/context/components/ContextButton.tsx
Normal 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;
|
||||
46
web/src/features/menu/context/components/HeaderButton.tsx
Normal file
46
web/src/features/menu/context/components/HeaderButton.tsx
Normal 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;
|
||||
@@ -1,35 +1,29 @@
|
||||
import { Box, Flex, useCheckbox, chakra, CheckboxIcon } from '@chakra-ui/react';
|
||||
import { Checkbox, createStyles, Stack } from '@mantine/core';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.dark[7],
|
||||
'&:checked': { backgroundColor: theme.colors.dark[2], borderColor: theme.colors.dark[2] },
|
||||
},
|
||||
inner: {
|
||||
'> svg > path': {
|
||||
fill: theme.colors.dark[6],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const CustomCheckbox: React.FC<{ checked: boolean }> = ({ checked }) => {
|
||||
const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox();
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<chakra.label
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
gridColumnGap={2}
|
||||
pr={3}
|
||||
cursor="pointer"
|
||||
{...htmlProps}
|
||||
>
|
||||
<input {...getInputProps()} hidden />
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border="2px solid"
|
||||
borderColor="#909296"
|
||||
rounded={'sm'}
|
||||
w={5}
|
||||
h={5}
|
||||
{...getCheckboxProps()}
|
||||
>
|
||||
{checked && (
|
||||
<Box w={4} h={4} bg="#909296">
|
||||
<CheckboxIcon isChecked color="#25262B" />
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</chakra.label>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
size="md"
|
||||
classNames={{ root: classes.root, input: classes.input, inner: classes.inner }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { Box, createStyles, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
container: {
|
||||
textAlign: 'center',
|
||||
borderTopLeftRadius: theme.radius.md,
|
||||
borderTopRightRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
height: 60,
|
||||
width: 384,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
heading: {
|
||||
fontSize: 24,
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
const Header: React.FC<{ title: string }> = ({ title }) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
textAlign="center"
|
||||
borderTopLeftRadius="md"
|
||||
borderTopRightRadius="md"
|
||||
bg="#25262B"
|
||||
height="60px"
|
||||
width="sm"
|
||||
>
|
||||
<Text fontSize={24} textTransform="uppercase" fontWeight={600} fontFamily="Nunito">
|
||||
{title}
|
||||
</Text>
|
||||
<Box className={classes.container}>
|
||||
<Text className={classes.heading}>{title}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Box, Flex, Stack, Text, Progress } from '@chakra-ui/react';
|
||||
import { Box, Group, Stack, Text, Progress } from '@mantine/core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { forwardRef } from 'react';
|
||||
import CustomCheckbox from './CustomCheckbox';
|
||||
import type { MenuItem } from './index';
|
||||
import type { MenuItem } from '../../../typings';
|
||||
import { createStyles } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
item: MenuItem;
|
||||
@@ -11,35 +12,80 @@ interface Props {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
const useStyles = createStyles((theme, params: { iconColor?: string }) => ({
|
||||
buttonContainer: {
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
borderRadius: theme.radius.md,
|
||||
padding: 2,
|
||||
height: 60,
|
||||
scrollMargin: 8,
|
||||
'&:focus': {
|
||||
backgroundColor: theme.colors.dark[4],
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
buttonWrapper: {
|
||||
paddingLeft: 5,
|
||||
paddingRight: 12,
|
||||
height: '100%',
|
||||
},
|
||||
iconContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
color: params.iconColor || theme.colors.dark[2],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.dark[2],
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
chevronIcon: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.dark[2],
|
||||
},
|
||||
scrollIndexValue: {
|
||||
color: theme.colors.dark[2],
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 14,
|
||||
},
|
||||
progressStack: {
|
||||
width: '100%',
|
||||
marginRight: 5,
|
||||
},
|
||||
progressLabel: {
|
||||
verticalAlign: 'middle',
|
||||
marginBottom: 3,
|
||||
},
|
||||
}));
|
||||
|
||||
const ListItem = forwardRef<Array<HTMLDivElement | null>, Props>(({ item, index, scrollIndex, checked }, ref) => {
|
||||
const { classes } = useStyles({ iconColor: item.iconColor });
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="#25262B"
|
||||
borderRadius="md"
|
||||
tabIndex={index}
|
||||
scrollMargin={2}
|
||||
p={2}
|
||||
height="60px"
|
||||
className={classes.buttonContainer}
|
||||
key={`item-${index}`}
|
||||
_focus={{ bg: '#373A40', outline: 'none' }}
|
||||
ref={(element) => {
|
||||
ref={(element: HTMLDivElement) => {
|
||||
if (ref)
|
||||
// @ts-ignore i cba
|
||||
return (ref.current = [...ref.current, element]);
|
||||
}}
|
||||
>
|
||||
<Flex alignItems="center" height="100%" gap="15px">
|
||||
<Group spacing={15} noWrap className={classes.buttonWrapper}>
|
||||
{item.icon && (
|
||||
<Box display="flex" alignItems="center">
|
||||
<FontAwesomeIcon icon={item.icon} fontSize={24} color={item.iconColor || '#909296'} fixedWidth />
|
||||
<Box className={classes.iconContainer}>
|
||||
<FontAwesomeIcon icon={item.icon} className={classes.icon} fixedWidth />
|
||||
</Box>
|
||||
)}
|
||||
{Array.isArray(item.values) ? (
|
||||
<Flex alignItems="center" justifyContent="space-between" w="100%">
|
||||
<Stack spacing={1} justifyContent="space-between">
|
||||
<Text color="#909296" textTransform="uppercase" fontSize={12} verticalAlign="middle">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group position="apart" w="100%">
|
||||
<Stack spacing={0} justify="space-between">
|
||||
<Text className={classes.label}>{item.label}</Text>
|
||||
<Text>
|
||||
{typeof item.values[scrollIndex] === 'object'
|
||||
? // @ts-ignore for some reason even checking the type TS still thinks it's a string
|
||||
@@ -47,30 +93,32 @@ const ListItem = forwardRef<Array<HTMLDivElement | null>, Props>(({ item, index,
|
||||
: item.values[scrollIndex]}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing="sm" pr={3} justifyContent="center" alignItems="center">
|
||||
<FontAwesomeIcon icon="chevron-left" fontSize={16} color="#909296" />
|
||||
<Text color="#909296" textTransform="uppercase" fontSize={14}>
|
||||
<Group spacing={1} position="center">
|
||||
<FontAwesomeIcon icon="chevron-left" className={classes.chevronIcon} />
|
||||
<Text className={classes.scrollIndexValue}>
|
||||
{scrollIndex + 1}/{item.values.length}
|
||||
</Text>
|
||||
<FontAwesomeIcon icon="chevron-right" fontSize={16} color="#909296" />
|
||||
</Stack>
|
||||
</Flex>
|
||||
<FontAwesomeIcon icon="chevron-right" className={classes.chevronIcon} />
|
||||
</Group>
|
||||
</Group>
|
||||
) : item.checked !== undefined ? (
|
||||
<Flex alignItems="center" justifyContent="space-between" w="100%">
|
||||
<Group position="apart" w="100%">
|
||||
<Text>{item.label}</Text>
|
||||
<CustomCheckbox checked={checked}></CustomCheckbox>
|
||||
</Flex>
|
||||
</Group>
|
||||
) : item.progress !== undefined ? (
|
||||
<Flex flexDirection="column" w="100%" marginRight="5px">
|
||||
<Text verticalAlign="middle" marginBottom="3px">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Progress value={item.progress} size="sm" colorScheme={item.colorScheme || 'gray'} borderRadius="md" />
|
||||
</Flex>
|
||||
<Stack className={classes.progressStack} spacing={0}>
|
||||
<Text className={classes.progressLabel}>{item.label}</Text>
|
||||
<Progress
|
||||
value={item.progress}
|
||||
color={item.colorScheme || 'dark.0'}
|
||||
styles={(theme) => ({ root: { backgroundColor: theme.colors.dark[3] } })}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{item.label}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,34 +1,55 @@
|
||||
import { Box, Stack, Tooltip } from '@chakra-ui/react';
|
||||
import { Box, createStyles, Stack, Tooltip } from '@mantine/core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNuiEvent } from '../../../hooks/useNuiEvent';
|
||||
import ListItem from './ListItem';
|
||||
import Header from './Header';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { fetchNui } from '../../../utils/fetchNui';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
import type { MenuPosition, MenuSettings } from '../../../typings';
|
||||
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
progress?: number;
|
||||
colorScheme?: string;
|
||||
checked?: boolean;
|
||||
values?: Array<string | { label: string; description: string }>;
|
||||
description?: string;
|
||||
icon?: IconProp;
|
||||
iconColor?: string;
|
||||
defaultIndex?: number;
|
||||
close?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuSettings {
|
||||
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
title: string;
|
||||
canClose?: boolean;
|
||||
items: Array<MenuItem>;
|
||||
startItemIndex?: number;
|
||||
}
|
||||
const useStyles = createStyles((theme, params: { position?: MenuPosition; itemCount: number; selected: number }) => ({
|
||||
tooltip: {
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
color: theme.colors.dark[2],
|
||||
borderRadius: theme.radius.sm,
|
||||
maxWidth: 350,
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
container: {
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
marginTop: params.position === 'top-left' || params.position === 'top-right' ? 5 : 0,
|
||||
marginLeft: params.position === 'top-left' || params.position === 'bottom-left' ? 5 : 0,
|
||||
marginRight: params.position === 'top-right' || params.position === 'bottom-right' ? 5 : 0,
|
||||
marginBottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 5 : 0,
|
||||
right: params.position === 'top-right' || params.position === 'bottom-right' ? 1 : undefined,
|
||||
left: params.position === 'bottom-left' ? 1 : undefined,
|
||||
bottom: params.position === 'bottom-left' || params.position === 'bottom-right' ? 1 : undefined,
|
||||
fontFamily: 'Roboto',
|
||||
},
|
||||
buttonsWrapper: {
|
||||
height: 'fit-content',
|
||||
maxHeight: 415,
|
||||
overflow: 'hidden',
|
||||
borderRadius: params.itemCount <= 6 || params.selected === params.itemCount - 1 ? theme.radius.md : undefined,
|
||||
backgroundColor: theme.colors.dark[8],
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
},
|
||||
scrollArrow: {
|
||||
backgroundColor: theme.colors.dark[8],
|
||||
textAlign: 'center',
|
||||
borderBottomLeftRadius: theme.radius.md,
|
||||
borderBottomRightRadius: theme.radius.md,
|
||||
height: 25,
|
||||
},
|
||||
scrollArrowIcon: {
|
||||
color: theme.colors.dark[2],
|
||||
fontSize: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const ListMenu: React.FC = () => {
|
||||
const [menu, setMenu] = useState<MenuSettings>({
|
||||
@@ -42,6 +63,7 @@ const ListMenu: React.FC = () => {
|
||||
const [checkedStates, setCheckedStates] = useState<Record<number, boolean>>({});
|
||||
const listRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const firstRenderRef = useRef(false);
|
||||
const { classes } = useStyles({ position: menu.position, itemCount: menu.items.length, selected });
|
||||
|
||||
const closeMenu = (ignoreFetch?: boolean, keyPressed?: string, forceClose?: boolean) => {
|
||||
if (menu.canClose === false && !forceClose) return;
|
||||
@@ -184,44 +206,20 @@ const ListMenu: React.FC = () => {
|
||||
menu.items[selected].values[indexStates[selected]].description
|
||||
: menu.items[selected].description
|
||||
}
|
||||
isOpen={
|
||||
opened={
|
||||
isValuesObject(menu.items[selected].values)
|
||||
? // @ts-ignore
|
||||
!!menu.items[selected].values[indexStates[selected]].description
|
||||
: !!menu.items[selected].description
|
||||
}
|
||||
bg="#25262B"
|
||||
color="#909296"
|
||||
placement="bottom"
|
||||
borderRadius="md"
|
||||
fontFamily="Nunito"
|
||||
transitionDuration={0}
|
||||
classNames={{ tooltip: classes.tooltip }}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
pointerEvents="none"
|
||||
mt={menu.position === 'top-left' || menu.position === 'top-right' ? 5 : 0}
|
||||
ml={menu.position === 'top-left' || menu.position === 'bottom-left' ? 5 : 0}
|
||||
mr={menu.position === 'top-right' || menu.position === 'bottom-right' ? 5 : 0}
|
||||
mb={menu.position === 'bottom-left' || menu.position === 'bottom-right' ? 5 : 0}
|
||||
right={menu.position === 'top-right' || menu.position === 'bottom-right' ? 1 : undefined}
|
||||
left={menu.position === 'bottom-left' ? 1 : undefined}
|
||||
bottom={menu.position === 'bottom-left' || menu.position === 'bottom-right' ? 1 : undefined}
|
||||
>
|
||||
<Box className={classes.container}>
|
||||
<Header title={menu.title} />
|
||||
<Box
|
||||
width="sm"
|
||||
height="fit-content"
|
||||
maxHeight={415}
|
||||
overflow="hidden"
|
||||
borderRadius={menu.items.length <= 6 || selected === menu.items.length - 1 ? 'md' : undefined}
|
||||
bg="#141517"
|
||||
fontFamily="Nunito"
|
||||
borderTopLeftRadius="none"
|
||||
borderTopRightRadius="none"
|
||||
onKeyDown={(e) => moveMenu(e)}
|
||||
>
|
||||
<Box className={classes.buttonsWrapper} onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => moveMenu(e)}>
|
||||
<FocusTrap active={visible}>
|
||||
<Stack direction="column" p={2} overflowY="scroll">
|
||||
<Stack spacing={8} p={8} sx={{ overflowY: 'scroll' }}>
|
||||
{menu.items.map((item, index) => (
|
||||
<React.Fragment key={`menu-item-${index}`}>
|
||||
{item.label && (
|
||||
@@ -239,8 +237,8 @@ const ListMenu: React.FC = () => {
|
||||
</FocusTrap>
|
||||
</Box>
|
||||
{menu.items.length > 6 && selected !== menu.items.length - 1 && (
|
||||
<Box bg="#141517" textAlign="center" borderBottomLeftRadius="md" borderBottomRightRadius="md" height={25}>
|
||||
<FontAwesomeIcon icon="chevron-down" color="#909296" fontSize={20} />
|
||||
<Box className={classes.scrollArrow}>
|
||||
<FontAwesomeIcon icon="chevron-down" className={classes.scrollArrowIcon} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
152
web/src/features/menu/radial/index.tsx
Normal file
152
web/src/features/menu/radial/index.tsx
Normal 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;
|
||||
@@ -1,84 +1,183 @@
|
||||
import { useToast, type ToastPosition, Box, HStack, Text } from '@chakra-ui/react';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { toast, Toaster } from 'react-hot-toast';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Avatar, createStyles, Group, Stack, Box, Text, keyframes } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import type { NotificationProps } from '../../typings';
|
||||
|
||||
export interface NotificationProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
position?: ToastPosition;
|
||||
variant?: string;
|
||||
status?: 'info' | 'warning' | 'success' | 'error';
|
||||
id?: number;
|
||||
}
|
||||
const useStyles = createStyles((theme) => ({
|
||||
container: {
|
||||
width: 300,
|
||||
height: 'fit-content',
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
color: theme.colors.dark[0],
|
||||
padding: 12,
|
||||
borderRadius: theme.radius.sm,
|
||||
fontFamily: 'Roboto',
|
||||
boxShadow: theme.shadows.sm,
|
||||
},
|
||||
title: {
|
||||
fontWeight: 500,
|
||||
lineHeight: 'normal',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.dark[2],
|
||||
fontFamily: 'Roboto',
|
||||
lineHeight: 'normal',
|
||||
},
|
||||
descriptionOnly: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.dark[2],
|
||||
fontFamily: 'Roboto',
|
||||
lineHeight: 'normal',
|
||||
},
|
||||
}));
|
||||
|
||||
export interface CustomNotificationProps {
|
||||
style?: React.CSSProperties;
|
||||
description?: string;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
icon?: IconProp;
|
||||
iconColor?: string;
|
||||
position?: ToastPosition;
|
||||
id?: number;
|
||||
type?: string;
|
||||
}
|
||||
// I hate this
|
||||
const enterAnimationTop = keyframes({
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-30px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
});
|
||||
|
||||
const enterAnimationBottom = keyframes({
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(30px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
});
|
||||
|
||||
const exitAnimationTop = keyframes({
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-100%)',
|
||||
},
|
||||
});
|
||||
|
||||
const exitAnimationRight = keyframes({
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
});
|
||||
|
||||
const exitAnimationLeft = keyframes({
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateX(-100%)',
|
||||
},
|
||||
});
|
||||
|
||||
const exitAnimationBottom = keyframes({
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(100%)',
|
||||
},
|
||||
});
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
useNuiEvent<CustomNotificationProps>('customNotify', (data) => {
|
||||
if (!data.title && !data.description) return;
|
||||
if (data.id && toast.isActive(data.id)) return;
|
||||
if (!data.icon) {
|
||||
data.icon = data.type === 'error' ? 'circle-xmark' : data.type === 'success' ? 'circle-check' : 'circle-info';
|
||||
}
|
||||
|
||||
const id = data.id;
|
||||
toast({
|
||||
id,
|
||||
duration: data.duration || 3000,
|
||||
position: data.position || 'top-right',
|
||||
render: () => (
|
||||
<Box className={`toast-${data.type || 'inform'}`} style={data.style} p={2} borderRadius="sm" boxShadow="md">
|
||||
<HStack spacing={0}>
|
||||
{data.icon && (
|
||||
<FontAwesomeIcon
|
||||
fixedWidth
|
||||
icon={data.icon}
|
||||
fontSize="1.3em"
|
||||
style={{ paddingRight: 8 }}
|
||||
color={data.iconColor}
|
||||
/>
|
||||
)}
|
||||
<Box w="100%">
|
||||
{data.title && <Text as="b">{data.title}</Text>}
|
||||
{data.description && <Text><ReactMarkdown>{data.description}</ReactMarkdown></Text>}
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
});
|
||||
const { classes } = useStyles();
|
||||
|
||||
useNuiEvent<NotificationProps>('notify', (data) => {
|
||||
if (!data.title && !data.description) return;
|
||||
if (data.id && toast.isActive(data.id)) return;
|
||||
const id = data.id;
|
||||
toast({
|
||||
id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
duration: data.duration || 4000,
|
||||
position: data.position || 'top-right',
|
||||
variant: data.variant,
|
||||
status: data.status,
|
||||
});
|
||||
// Backwards compat with old notifications
|
||||
let position = data.position;
|
||||
switch (position) {
|
||||
case 'top':
|
||||
position = 'top-center';
|
||||
break;
|
||||
case 'bottom':
|
||||
position = 'bottom-center';
|
||||
break;
|
||||
}
|
||||
if (!data.icon) {
|
||||
data.icon = data.type === 'error' ? 'xmark' : data.type === 'success' ? 'check' : 'info';
|
||||
}
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<Box
|
||||
sx={{
|
||||
animation: t.visible
|
||||
? `${position?.includes('bottom') ? enterAnimationBottom : enterAnimationTop} 0.2s ease-out forwards`
|
||||
: `${
|
||||
position?.includes('right')
|
||||
? exitAnimationRight
|
||||
: position?.includes('left')
|
||||
? exitAnimationLeft
|
||||
: position === 'top-center'
|
||||
? exitAnimationTop
|
||||
: position
|
||||
? exitAnimationBottom
|
||||
: exitAnimationRight
|
||||
} 0.4s ease-in forwards`,
|
||||
}}
|
||||
style={data.style}
|
||||
className={`${classes.container}`}
|
||||
>
|
||||
<Group noWrap spacing={12}>
|
||||
{data.icon && (
|
||||
<>
|
||||
{!data.iconColor ? (
|
||||
<Avatar
|
||||
color={data.type === 'error' ? 'red' : data.type === 'success' ? 'teal' : 'blue'}
|
||||
radius="xl"
|
||||
size={32}
|
||||
>
|
||||
<FontAwesomeIcon icon={data.icon} fixedWidth size="lg" />
|
||||
</Avatar>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={data.icon} style={{ color: data.iconColor }} fixedWidth size="lg" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Stack spacing={0}>
|
||||
{data.title && <Text className={classes.title}>{data.title}</Text>}
|
||||
{data.description && (
|
||||
<ReactMarkdown className={!data.title ? classes.descriptionOnly : classes.description}>
|
||||
{data.description}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
),
|
||||
{
|
||||
id: data.id?.toString(),
|
||||
duration: data.duration || 3000,
|
||||
position: position || 'top-right',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return <></>;
|
||||
return <Toaster />;
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import React from 'react';
|
||||
import { CircularProgress, CircularProgressLabel, Flex, Text } from '@chakra-ui/react';
|
||||
import { RingProgress, Text, useMantineTheme, keyframes, Stack, createStyles } from '@mantine/core';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { fetchNui } from '../../utils/fetchNui';
|
||||
import ScaleFade from '../../transitions/ScaleFade';
|
||||
import type { CircleProgressbarProps } from '../../typings';
|
||||
|
||||
export interface CircleProgressbarProps {
|
||||
label?: string;
|
||||
duration: number;
|
||||
position?: 'middle' | 'bottom';
|
||||
percent?: boolean;
|
||||
}
|
||||
// 33.5 is the r of the circle
|
||||
const progressCircle = keyframes({
|
||||
'0%': { strokeDasharray: `0, ${33.5 * 2 * Math.PI}` },
|
||||
'100%': { strokeDasharray: `${33.5 * 2 * Math.PI}, 0` },
|
||||
});
|
||||
|
||||
const useStyles = createStyles((theme, params: { position: 'middle' | 'bottom'; duration: number }) => ({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: params.position === 'middle' ? '100%' : '20%',
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
progress: {
|
||||
'> svg > circle:nth-child(1)': {
|
||||
stroke: theme.colors.dark[5],
|
||||
},
|
||||
// Scuffed way of grabbing the first section and animating it
|
||||
'> svg > circle:nth-child(2)': {
|
||||
transition: 'none',
|
||||
animation: `${progressCircle} linear forwards`,
|
||||
animationDuration: `${params.duration}ms`,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
textAlign: 'center',
|
||||
fontFamily: 'roboto-mono',
|
||||
textShadow: theme.shadows.sm,
|
||||
color: theme.colors.gray[3],
|
||||
},
|
||||
label: {
|
||||
textAlign: 'center',
|
||||
textShadow: theme.shadows.sm,
|
||||
color: theme.colors.gray[3],
|
||||
height: 25,
|
||||
},
|
||||
wrapper: {
|
||||
marginTop: params.position === 'middle' ? 25 : undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const CircleProgressbar: React.FC = () => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
@@ -16,7 +55,8 @@ const CircleProgressbar: React.FC = () => {
|
||||
const [position, setPosition] = React.useState<'middle' | 'bottom'>('middle');
|
||||
const [value, setValue] = React.useState(0);
|
||||
const [label, setLabel] = React.useState('');
|
||||
const [cancelled, setCancelled] = React.useState(false);
|
||||
const theme = useMantineTheme();
|
||||
const { classes } = useStyles({ position, duration: progressDuration });
|
||||
|
||||
const progressComplete = () => {
|
||||
setVisible(false);
|
||||
@@ -24,7 +64,6 @@ const CircleProgressbar: React.FC = () => {
|
||||
};
|
||||
|
||||
const progressCancel = () => {
|
||||
setCancelled(true);
|
||||
setValue(99); // Sets the final value to 100% kek
|
||||
setVisible(false);
|
||||
};
|
||||
@@ -33,7 +72,6 @@ const CircleProgressbar: React.FC = () => {
|
||||
|
||||
useNuiEvent<CircleProgressbarProps>('circleProgress', (data) => {
|
||||
if (visible) return;
|
||||
setCancelled(false);
|
||||
setVisible(true);
|
||||
setValue(0);
|
||||
setLabel(data.label || '');
|
||||
@@ -50,50 +88,23 @@ const CircleProgressbar: React.FC = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex
|
||||
h={position === 'middle' ? '100%' : '20%'}
|
||||
w="100%"
|
||||
position="absolute"
|
||||
bottom="0"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{visible && (
|
||||
<Flex alignItems="center" flexDirection="column">
|
||||
<CircularProgress
|
||||
value={value}
|
||||
size="5rem"
|
||||
trackColor="rgba(0, 0, 0, 0.6)"
|
||||
onAnimationEnd={progressComplete}
|
||||
thickness={6}
|
||||
color={cancelled ? 'rgb(198, 40, 40)' : 'white'}
|
||||
sx={
|
||||
!cancelled
|
||||
? {
|
||||
'.chakra-progress__indicator': {
|
||||
transition: 'none !important',
|
||||
animation: 'progress linear forwards !important',
|
||||
animationDuration: `${progressDuration}ms !important`,
|
||||
opacity: '1 !important',
|
||||
},
|
||||
}
|
||||
: {
|
||||
// Currently unused
|
||||
'.chakra-progress__indicator': {
|
||||
transition: 'none !important',
|
||||
strokeDasharray: '264, 0 !important', // sets circle to full
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<CircularProgressLabel fontFamily="Fira Mono">{value}%</CircularProgressLabel>
|
||||
</CircularProgress>
|
||||
<Text fontFamily="Inter" fontSize={18} fontWeight="light">
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<>
|
||||
<Stack spacing={0} className={classes.container}>
|
||||
<ScaleFade visible={visible}>
|
||||
<Stack spacing={0} align="center" className={classes.wrapper}>
|
||||
<RingProgress
|
||||
size={90}
|
||||
thickness={7}
|
||||
sections={[{ value: 0, color: theme.primaryColor }]}
|
||||
onAnimationEnd={progressComplete}
|
||||
className={classes.progress}
|
||||
label={<Text className={classes.value}>{value}%</Text>}
|
||||
/>
|
||||
{label && <Text className={classes.label}>{label}</Text>}
|
||||
</Stack>
|
||||
</ScaleFade>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,56 @@
|
||||
import React from 'react';
|
||||
import { Text, Flex, Box } from '@chakra-ui/react';
|
||||
import { Box, Text, createStyles } from '@mantine/core';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { fetchNui } from '../../utils/fetchNui';
|
||||
import ScaleFade from '../../transitions/ScaleFade';
|
||||
import type { ProgressbarProps } from '../../typings';
|
||||
|
||||
export interface ProgressbarProps {
|
||||
label: string;
|
||||
duration: number;
|
||||
}
|
||||
const useStyles = createStyles((theme) => ({
|
||||
container: {
|
||||
width: 350,
|
||||
height: 45,
|
||||
borderRadius: theme.radius.sm,
|
||||
backgroundColor: theme.colors.dark[5],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
height: '20%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
bar: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors[theme.primaryColor][theme.fn.primaryShade()],
|
||||
},
|
||||
labelWrapper: {
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
width: 350,
|
||||
height: 45,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
label: {
|
||||
maxWidth: 350,
|
||||
padding: 8,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 20,
|
||||
color: theme.colors.gray[3],
|
||||
textShadow: theme.shadows.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const Progressbar: React.FC = () => {
|
||||
const { classes } = useStyles();
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [label, setLabel] = React.useState('');
|
||||
const [duration, setDuration] = React.useState(0);
|
||||
const [cancelled, setCancelled] = React.useState(false);
|
||||
|
||||
const progressComplete = () => {
|
||||
setVisible(false);
|
||||
@@ -20,69 +58,38 @@ const Progressbar: React.FC = () => {
|
||||
};
|
||||
|
||||
const progressCancel = () => {
|
||||
setCancelled(true);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
useNuiEvent('progressCancel', progressCancel);
|
||||
|
||||
useNuiEvent<ProgressbarProps>('progress', (data) => {
|
||||
setCancelled(false);
|
||||
setVisible(true);
|
||||
setLabel(data.label);
|
||||
setDuration(data.duration);
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex h="30%" w="100%" position="absolute" bottom="0" justifyContent="center" alignItems="center">
|
||||
<Box width={350}>
|
||||
{visible && (
|
||||
<Box
|
||||
height={45}
|
||||
bg="rgba(0, 0, 0, 0.6)"
|
||||
textAlign="center"
|
||||
borderRadius="sm"
|
||||
boxShadow="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<>
|
||||
<Box className={classes.wrapper}>
|
||||
<ScaleFade visible={visible}>
|
||||
<Box className={classes.container}>
|
||||
<Box
|
||||
height={45}
|
||||
className={classes.bar}
|
||||
onAnimationEnd={progressComplete}
|
||||
sx={
|
||||
!cancelled
|
||||
? {
|
||||
width: '0%',
|
||||
backgroundColor: 'green.400',
|
||||
animation: 'progress-bar linear',
|
||||
animationDuration: `${duration}ms`,
|
||||
}
|
||||
: {
|
||||
// Currently unused
|
||||
width: '100%',
|
||||
animationPlayState: 'paused',
|
||||
backgroundColor: 'rgb(198, 40, 40)',
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
maxWidth={350}
|
||||
fontFamily="Inter"
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
fontSize={22}
|
||||
fontWeight="light"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
sx={{
|
||||
animation: 'progress-bar linear',
|
||||
animationDuration: `${duration}ms`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Box className={classes.labelWrapper}>
|
||||
<Text className={classes.label}>{label}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScaleFade>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { Box, Center } from '@chakra-ui/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { debugData } from '../../utils/debugData';
|
||||
import Indicator from './indicator';
|
||||
import { fetchNui } from '../../utils/fetchNui';
|
||||
|
||||
interface CustomGameDifficulty {
|
||||
areaSize: number;
|
||||
speedMultiplier: number;
|
||||
}
|
||||
|
||||
export type GameDifficulty = 'easy' | 'medium' | 'hard' | CustomGameDifficulty;
|
||||
|
||||
export interface SkillCheckProps {
|
||||
angle: number;
|
||||
difficultyOffset: number;
|
||||
difficulty: GameDifficulty;
|
||||
}
|
||||
import { Box, createStyles } from '@mantine/core';
|
||||
import type { SkillCheckProps, GameDifficulty } from '../../typings';
|
||||
|
||||
export const circleCircumference = 2 * 50 * Math.PI;
|
||||
|
||||
@@ -28,77 +15,113 @@ const difficultyOffsets = {
|
||||
hard: 25,
|
||||
};
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
svg: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
track: {
|
||||
fill: 'transparent',
|
||||
stroke: theme.colors.dark[5],
|
||||
strokeWidth: 8,
|
||||
},
|
||||
skillArea: {
|
||||
fill: 'transparent',
|
||||
stroke: theme.fn.primaryColor(),
|
||||
strokeWidth: 8,
|
||||
},
|
||||
indicator: {
|
||||
stroke: 'red',
|
||||
strokeWidth: 16,
|
||||
fill: 'transparent',
|
||||
},
|
||||
button: {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: theme.colors.dark[5],
|
||||
width: 25,
|
||||
height: 25,
|
||||
textAlign: 'center',
|
||||
borderRadius: 5,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
const SkillCheck: React.FC = () => {
|
||||
const { classes } = useStyles();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const dataRef = useRef<GameDifficulty | GameDifficulty[] | null>(null);
|
||||
const dataRef = useRef<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] } | null>(null);
|
||||
const dataIndexRef = useRef<number>(0);
|
||||
const [skillCheck, setSkillCheck] = useState<SkillCheckProps>({
|
||||
angle: 0,
|
||||
difficultyOffset: 50,
|
||||
difficulty: 'easy',
|
||||
key: 'e',
|
||||
});
|
||||
|
||||
useNuiEvent('startSkillCheck', (data: GameDifficulty | GameDifficulty[]) => {
|
||||
useNuiEvent('startSkillCheck', (data: { difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }) => {
|
||||
dataRef.current = data;
|
||||
dataIndexRef.current = 0;
|
||||
const gameData = Array.isArray(data) ? data[0] : data;
|
||||
const gameData = Array.isArray(data.difficulty) ? data.difficulty[0] : data.difficulty;
|
||||
const offset = typeof gameData === 'object' ? gameData.areaSize : difficultyOffsets[gameData];
|
||||
const randomKey = data.inputs ? data.inputs[Math.floor(Math.random() * data.inputs.length)] : 'e';
|
||||
setSkillCheck({
|
||||
angle: -90 + getRandomAngle(120, 360 - offset),
|
||||
difficultyOffset: offset,
|
||||
difficulty: gameData,
|
||||
key: randomKey,
|
||||
});
|
||||
|
||||
setVisible(true);
|
||||
});
|
||||
|
||||
const handleComplete = (success: boolean) => {
|
||||
if (!success || !Array.isArray(dataRef.current)) {
|
||||
if (!dataRef.current) return;
|
||||
if (!success || !Array.isArray(dataRef.current.difficulty)) {
|
||||
setVisible(false);
|
||||
return fetchNui('skillCheckOver', success);
|
||||
}
|
||||
|
||||
if (dataIndexRef.current >= dataRef.current.length - 1) {
|
||||
if (dataIndexRef.current >= dataRef.current.difficulty.length - 1) {
|
||||
setVisible(false);
|
||||
return fetchNui('skillCheckOver', success);
|
||||
}
|
||||
|
||||
dataIndexRef.current++;
|
||||
const data = dataRef.current[dataIndexRef.current];
|
||||
const data = dataRef.current.difficulty[dataIndexRef.current];
|
||||
const key = dataRef.current.inputs
|
||||
? dataRef.current.inputs[Math.floor(Math.random() * dataRef.current.inputs.length)]
|
||||
: 'e';
|
||||
const offset = typeof data === 'object' ? data.areaSize : difficultyOffsets[data];
|
||||
setSkillCheck({
|
||||
angle: -90 + getRandomAngle(120, 360 - offset),
|
||||
difficultyOffset: offset,
|
||||
difficulty: data,
|
||||
key,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Center height="100%" width="100%">
|
||||
<>
|
||||
{visible && (
|
||||
<>
|
||||
<svg width={500} height={500}>
|
||||
<svg r={50} width={500} height={500} className={classes.svg}>
|
||||
{/*Circle track*/}
|
||||
<circle
|
||||
r={50}
|
||||
cx={250}
|
||||
cy={250}
|
||||
fill="transparent"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth={5}
|
||||
strokeDasharray={circleCircumference}
|
||||
/>
|
||||
<circle r={50} cx={250} cy={250} className={classes.track} strokeDasharray={circleCircumference} />
|
||||
{/*SkillCheck area*/}
|
||||
<circle
|
||||
r={50}
|
||||
cx={250}
|
||||
cy={250}
|
||||
fill="transparent"
|
||||
stroke="white"
|
||||
strokeDasharray={circleCircumference}
|
||||
strokeDashoffset={circleCircumference - (Math.PI * 50 * skillCheck.difficultyOffset) / 180}
|
||||
strokeWidth={5}
|
||||
transform={`rotate(${skillCheck.angle}, 250, 250)`}
|
||||
className={classes.skillArea}
|
||||
/>
|
||||
<Indicator
|
||||
angle={skillCheck.angle}
|
||||
@@ -113,27 +136,14 @@ const SkillCheck: React.FC = () => {
|
||||
: skillCheck.difficulty.speedMultiplier
|
||||
}
|
||||
handleComplete={handleComplete}
|
||||
className={classes.indicator}
|
||||
skillCheck={skillCheck}
|
||||
/>
|
||||
</svg>
|
||||
<Box
|
||||
position="absolute"
|
||||
left="50%"
|
||||
top="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
backgroundColor="rgba(0, 0, 0, 0.4)"
|
||||
w={25}
|
||||
h={25}
|
||||
textAlign="center"
|
||||
borderRadius={5}
|
||||
fontFamily="Inter"
|
||||
fontSize={16}
|
||||
>
|
||||
E
|
||||
</Box>
|
||||
<Box className={classes.button}>{skillCheck.key.toUpperCase()}</Box>
|
||||
</>
|
||||
)}
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useKeyPress } from '../../hooks/useKeyPress';
|
||||
import { SkillCheckProps } from './index';
|
||||
import { useInterval } from '@chakra-ui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { SkillCheckProps } from '../../typings';
|
||||
import { useInterval } from '@mantine/hooks';
|
||||
import { circleCircumference } from './index';
|
||||
|
||||
interface Props {
|
||||
@@ -9,55 +8,63 @@ interface Props {
|
||||
offset: number;
|
||||
multiplier: number;
|
||||
skillCheck: SkillCheckProps;
|
||||
className: string;
|
||||
handleComplete: (success: boolean) => void;
|
||||
}
|
||||
|
||||
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck }) => {
|
||||
const Indicator: React.FC<Props> = ({ angle, offset, multiplier, handleComplete, skillCheck, className }) => {
|
||||
const [indicatorAngle, setIndicatorAngle] = useState(-90);
|
||||
const [gameState, setGameState] = useState(false);
|
||||
const isKeyPressed = useKeyPress('e');
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
const [keyPressed, setKeyPressed] = useState(false);
|
||||
const interval = useInterval(
|
||||
() =>
|
||||
setIndicatorAngle((prevState) => {
|
||||
return (prevState += multiplier);
|
||||
});
|
||||
}),
|
||||
1
|
||||
);
|
||||
|
||||
const keyHandler = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() !== skillCheck.key.toLowerCase()) return;
|
||||
setKeyPressed(true);
|
||||
},
|
||||
gameState ? 1 : null
|
||||
[skillCheck]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIndicatorAngle(-90);
|
||||
setGameState(true);
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
interval.start();
|
||||
}, [skillCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
if (indicatorAngle + 90 >= 360) {
|
||||
setGameState(false);
|
||||
interval.stop();
|
||||
handleComplete(false);
|
||||
}
|
||||
}, [indicatorAngle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isKeyPressed) return;
|
||||
if (!keyPressed) return;
|
||||
|
||||
setGameState(false);
|
||||
interval.stop();
|
||||
|
||||
setKeyPressed(false);
|
||||
window.removeEventListener('keydown', keyHandler);
|
||||
|
||||
if (indicatorAngle < angle || indicatorAngle > angle + offset) handleComplete(false);
|
||||
else handleComplete(true);
|
||||
}, [isKeyPressed]);
|
||||
}, [keyPressed]);
|
||||
|
||||
return (
|
||||
<circle
|
||||
r={50}
|
||||
cx={250}
|
||||
cy={250}
|
||||
fill="transparent"
|
||||
stroke="red"
|
||||
strokeWidth={15}
|
||||
strokeDasharray={circleCircumference}
|
||||
strokeDashoffset={circleCircumference - 3}
|
||||
transform={`rotate(${indicatorAngle}, 250, 250)`}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useNuiEvent } from '../../hooks/useNuiEvent';
|
||||
import { Box, Flex, ScaleFade } from '@chakra-ui/react';
|
||||
import { Box, createStyles, Group } from '@mantine/core';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import ScaleFade from '../../transitions/ScaleFade';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { TextUiProps, TextUiPosition } from '../../typings';
|
||||
|
||||
export interface TextUiProps {
|
||||
text: string;
|
||||
position?: 'right-center' | 'left-center' | 'top-center';
|
||||
icon?: IconProp;
|
||||
iconColor?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
const useStyles = createStyles((theme, params: { position?: TextUiPosition }) => ({
|
||||
wrapper: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: params.position === 'top-center' ? 'baseline' : 'center',
|
||||
justifyContent:
|
||||
params.position === 'right-center' ? 'flex-end' : params.position === 'left-center' ? 'flex-start' : 'center',
|
||||
},
|
||||
container: {
|
||||
fontSize: 16,
|
||||
padding: 12,
|
||||
margin: 8,
|
||||
backgroundColor: theme.colors.dark[6],
|
||||
color: theme.colors.dark[0],
|
||||
fontFamily: 'Roboto',
|
||||
borderRadius: theme.radius.sm,
|
||||
boxShadow: theme.shadows.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const TextUI: React.FC = () => {
|
||||
const [data, setData] = React.useState<TextUiProps>({
|
||||
@@ -19,6 +35,7 @@ const TextUI: React.FC = () => {
|
||||
position: 'right-center',
|
||||
});
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const { classes } = useStyles({ position: data.position });
|
||||
|
||||
useNuiEvent<TextUiProps>('textUi', (data) => {
|
||||
if (!data.position) data.position = 'right-center'; // Default right position
|
||||
@@ -29,42 +46,18 @@ const TextUI: React.FC = () => {
|
||||
useNuiEvent('textUiHide', () => setVisible(false));
|
||||
|
||||
return (
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
p={3}
|
||||
position="absolute"
|
||||
alignItems={data.position === 'top-center' ? 'baseline' : 'center'}
|
||||
justifyContent={
|
||||
data.position === 'right-center' ? 'flex-end' : data.position === 'left-center' ? 'flex-start' : 'center'
|
||||
}
|
||||
>
|
||||
<ScaleFade in={visible} unmountOnExit>
|
||||
<Box
|
||||
bg="gray.700"
|
||||
boxShadow="md"
|
||||
p={3}
|
||||
fontFamily="Poppins"
|
||||
fontSize="0.95em"
|
||||
style={data.style}
|
||||
borderRadius="sm"
|
||||
maxW="xs"
|
||||
>
|
||||
<Flex justifyContent="center" alignItems="center">
|
||||
{data.icon && (
|
||||
<FontAwesomeIcon
|
||||
fixedWidth
|
||||
icon={data.icon}
|
||||
color={data.iconColor}
|
||||
fontSize="1.3em"
|
||||
style={{ paddingRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<ReactMarkdown>{data.text}</ReactMarkdown>
|
||||
</Flex>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
</Flex>
|
||||
<>
|
||||
<Box className={classes.wrapper}>
|
||||
<ScaleFade visible={visible}>
|
||||
<Box style={data.style} className={classes.container}>
|
||||
<Group spacing={12}>
|
||||
{data.icon && <FontAwesomeIcon icon={data.icon} fixedWidth size="lg" style={{ color: data.iconColor }} />}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.text}</ReactMarkdown>
|
||||
</Group>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap');
|
||||
@import url("https://use.typekit.net/wxh5ury.css");
|
||||
@import url("https://use.typekit.net/qgr5ebd.css");
|
||||
|
||||
html {
|
||||
color-scheme: normal !important;
|
||||
@@ -20,6 +22,10 @@ body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -28,15 +34,6 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
stroke-dasharray: 0, 264;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 264, 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-bar {
|
||||
from {
|
||||
width: 0%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,16 +2,13 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { VisibilityProvider } from './providers/VisibilityProvider';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { theme } from './theme';
|
||||
import { debugData } from './utils/debugData';
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons';
|
||||
import { far } from '@fortawesome/free-regular-svg-icons';
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons';
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { isEnvBrowser } from './utils/misc';
|
||||
import LocaleProvider from './providers/LocaleProvider';
|
||||
import ConfigProvider from './providers/ConfigProvider';
|
||||
|
||||
library.add(fas, far, fab);
|
||||
|
||||
@@ -25,22 +22,13 @@ if (isEnvBrowser()) {
|
||||
root!.style.backgroundPosition = 'center';
|
||||
}
|
||||
|
||||
debugData([
|
||||
{
|
||||
action: 'setVisible',
|
||||
data: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const root = document.getElementById('root');
|
||||
ReactDOM.createRoot(root!).render(
|
||||
<React.StrictMode>
|
||||
<LocaleProvider>
|
||||
<VisibilityProvider>
|
||||
<ChakraProvider theme={theme}>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
</VisibilityProvider>
|
||||
<ConfigProvider>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</LocaleProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
32
web/src/providers/ConfigProvider.tsx
Normal file
32
web/src/providers/ConfigProvider.tsx
Normal 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>);
|
||||
@@ -15,6 +15,7 @@ debugData([
|
||||
data: {
|
||||
language: 'English',
|
||||
ui: {
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
},
|
||||
|
||||
@@ -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>);
|
||||
@@ -1,9 +1,16 @@
|
||||
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
|
||||
import { MantineThemeOverride } from '@mantine/core';
|
||||
|
||||
const config: ThemeConfig = {
|
||||
initialColorMode: 'dark',
|
||||
export const theme: MantineThemeOverride = {
|
||||
colorScheme: 'dark',
|
||||
fontFamily: 'Roboto',
|
||||
shadows: { sm: '1px 1px 3px rgba(0, 0, 0, 0.5)' },
|
||||
components: {
|
||||
Button: {
|
||||
styles: {
|
||||
root: {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const theme = extendTheme({
|
||||
config,
|
||||
});
|
||||
|
||||
21
web/src/transitions/ScaleFade.tsx
Normal file
21
web/src/transitions/ScaleFade.tsx
Normal 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
12
web/src/typings/alert.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export interface Option {
|
||||
iconColor?: string;
|
||||
progress?: number;
|
||||
colorScheme?: string;
|
||||
metadata?: string[] | { [key: string]: any } | { label: string; value: any }[];
|
||||
metadata?: string[] | { [key: string]: any } | { label: string; value: any; progress?: number }[];
|
||||
disabled?: boolean;
|
||||
event?: string;
|
||||
serverEvent?: string;
|
||||
72
web/src/typings/dialog.ts
Normal file
72
web/src/typings/dialog.ts
Normal 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
9
web/src/typings/index.ts
Normal 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
24
web/src/typings/menu.ts
Normal 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;
|
||||
}
|
||||
15
web/src/typings/notifications.ts
Normal file
15
web/src/typings/notifications.ts
Normal 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;
|
||||
}
|
||||
11
web/src/typings/progress.ts
Normal file
11
web/src/typings/progress.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface CircleProgressbarProps {
|
||||
label?: string;
|
||||
duration: number;
|
||||
position?: 'middle' | 'bottom';
|
||||
percent?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressbarProps {
|
||||
label: string;
|
||||
duration: number;
|
||||
}
|
||||
6
web/src/typings/radial.ts
Normal file
6
web/src/typings/radial.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
export interface RadialMenuItem {
|
||||
icon: IconProp;
|
||||
label: string;
|
||||
}
|
||||
13
web/src/typings/skillcheck.ts
Normal file
13
web/src/typings/skillcheck.ts
Normal 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
12
web/src/typings/textui.ts
Normal 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;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
const path = require("path");
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: "build",
|
||||
target: "esnext",
|
||||
|
||||
Reference in New Issue
Block a user