diff --git a/locales/en.json b/locales/en.json index 35f52734f..4c5ee2aba 100644 --- a/locales/en.json +++ b/locales/en.json @@ -9,6 +9,26 @@ "settings": { "locale": "Change locale", "locale_description": "Current language: ${language} (%s)", + "colorblind": "Colorblind mode", + "colorblind_current": "Current selection", + "colorblind_saved": "Saved colorblind preset: %s", + "ui_test_action": "UI test action", + "ui_test_action_none": "Do not run a test", + "ui_test_action_circle": "Run circle progress test", + "ui_tests_open": "Open UI tests after saving", + "ui_tests": "UI Tests", + "ui_tests_progressbar": "Test progress bar", + "ui_tests_circleprogress": "Test circle progress", + "ui_tests_skillcheck": "Test skill check", + "ui_tests_notification": "Test notification", + "ui_tests_notification_description": "If you can read this clearly, the UI test menu worked.", + "colorblind_modes": { + "off": "Off", + "protanopia": "Protanopia", + "deuteranopia": "Deuteranopia", + "tritanopia": "Tritanopia", + "achromatopsia": "Achromatopsia" + }, "notification_audio": "Notification audio", "notification_position": "Notification position" }, diff --git a/resource/client.lua b/resource/client.lua index 727c9c88f..f88885d83 100644 --- a/resource/client.lua +++ b/resource/client.lua @@ -6,6 +6,8 @@ Copyright © 2025 Linden ]] +local settings = require 'resource.settings' + local _registerCommand = RegisterCommand ---@param commandName string @@ -22,7 +24,8 @@ end RegisterNUICallback('getConfig', function(_, cb) cb({ primaryColor = GetConvar('ox:primaryColor', 'blue'), - primaryShade = GetConvarInt('ox:primaryShade', 8) + primaryShade = GetConvarInt('ox:primaryShade', 8), + colorblindMode = settings.colorblind_mode or 'off' }) end) diff --git a/resource/init.lua b/resource/init.lua index 0e21a1be3..a9d90b1f2 100644 --- a/resource/init.lua +++ b/resource/init.lua @@ -42,6 +42,14 @@ cache = { game = GetGameName(), } +if not GetCurrentResourceName() == "ox_lib" then + local err = + '^1Resource name mismatch. Please ensure the resource is named "ox_lib".\n ^3https://github.com/overextended/ox_lib/releases/latest/download/ox_lib.zip^0' + function lib.hasLoaded() return err end + + error(err) +end + if not LoadResourceFile(lib.name, 'web/build/index.html') then local err = '^1Unable to load UI. Build ox_lib or download the latest release.\n ^3https://github.com/overextended/ox_lib/releases/latest/download/ox_lib.zip^0' diff --git a/resource/settings.lua b/resource/settings.lua index 7ea7026d0..67e01bb22 100644 --- a/resource/settings.lua +++ b/resource/settings.lua @@ -30,6 +30,65 @@ local userLocales = GetConvarInt('ox:userLocales', 1) == 1 settings.locale = userLocales and safeGetKvp(GetResourceKvpString, 'locale') or settings.default_locale +local colorblindModes = { + off = { + label = 'ui.settings.colorblind_modes.off', + primaryColor = GetConvar('ox:primaryColor', 'blue'), + primaryShade = GetConvarInt('ox:primaryShade', 8), + indicatorColor = 'red', + indicatorShade = 6, + }, + protanopia = { + label = 'ui.settings.colorblind_modes.protanopia', + primaryColor = 'cyan', + primaryShade = 6, + indicatorColor = 'orange', + indicatorShade = 6, + }, + deuteranopia = { + label = 'ui.settings.colorblind_modes.deuteranopia', + primaryColor = 'blue', + primaryShade = 6, + indicatorColor = 'orange', + indicatorShade = 6, + }, + tritanopia = { + label = 'ui.settings.colorblind_modes.tritanopia', + primaryColor = 'pink', + primaryShade = 6, + indicatorColor = 'green', + indicatorShade = 6, + }, + achromatopsia = { + label = 'ui.settings.colorblind_modes.achromatopsia', + primaryColor = 'gray', + primaryShade = 7, + indicatorColor = 'yellow', + indicatorShade = 4, + }, +} + +local function getColorblindData(mode) + return colorblindModes[mode] or colorblindModes.off +end + +settings.colorblind_mode = safeGetKvp(GetResourceKvpString, 'colorblind_mode', 'off') + +if not colorblindModes[settings.colorblind_mode] then + settings.colorblind_mode = 'off' + DeleteResourceKvp('colorblind_mode') +end + +function settings.getColorblindConfig() + local colorblindData = getColorblindData(settings.colorblind_mode) + + return { + colorblindMode = settings.colorblind_mode, + primaryColor = colorblindData.primaryColor, + primaryShade = colorblindData.primaryShade, + } +end + local function set(key, value) if settings[key] == value then return false end @@ -53,7 +112,172 @@ local function set(key, value) return true end +local function syncColorblindMode(mode) + if not colorblindModes[mode] then return false end + + if not set('colorblind_mode', mode) then return false end + + SendNUIMessage({ + action = 'setColorblindMode', + data = mode, + }) + + return true +end + +local function openColorblindMenu() + if not lib.registerContext or not lib.showContext then + local input = lib.inputDialog(locale('ui.settings.colorblind'), { + { + type = 'select', + label = locale('ui.settings.colorblind'), + default = settings.colorblind_mode, + required = true, + options = { + { label = locale('ui.settings.colorblind_modes.off'), value = 'off' }, + { label = locale('ui.settings.colorblind_modes.protanopia'), value = 'protanopia' }, + { label = locale('ui.settings.colorblind_modes.deuteranopia'), value = 'deuteranopia' }, + { label = locale('ui.settings.colorblind_modes.tritanopia'), value = 'tritanopia' }, + { label = locale('ui.settings.colorblind_modes.achromatopsia'), value = 'achromatopsia' }, + } + } + }) --[[@as table?]] + + if not input then return end + + local mode = input[1] + + if mode and syncColorblindMode(mode) then + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale(getColorblindData(mode).label)), + type = 'success' + }) + end + + return + end + + local options = { + { + title = locale('ui.settings.colorblind_modes.off'), + description = settings.colorblind_mode == 'off' and locale('ui.settings.colorblind_current') or nil, + icon = settings.colorblind_mode == 'off' and 'check' or 'circle', + onSelect = function() + syncColorblindMode('off') + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale('ui.settings.colorblind_modes.off')), + type = 'success' + }) + end, + }, + { + title = locale('ui.settings.colorblind_modes.protanopia'), + description = settings.colorblind_mode == 'protanopia' and locale('ui.settings.colorblind_current') or nil, + icon = settings.colorblind_mode == 'protanopia' and 'check' or 'circle', + onSelect = function() + syncColorblindMode('protanopia') + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale('ui.settings.colorblind_modes.protanopia')), + type = 'success' + }) + end, + }, + { + title = locale('ui.settings.colorblind_modes.deuteranopia'), + description = settings.colorblind_mode == 'deuteranopia' and locale('ui.settings.colorblind_current') or nil, + icon = settings.colorblind_mode == 'deuteranopia' and 'check' or 'circle', + onSelect = function() + syncColorblindMode('deuteranopia') + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale('ui.settings.colorblind_modes.deuteranopia')), + type = 'success' + }) + end, + }, + { + title = locale('ui.settings.colorblind_modes.tritanopia'), + description = settings.colorblind_mode == 'tritanopia' and locale('ui.settings.colorblind_current') or nil, + icon = settings.colorblind_mode == 'tritanopia' and 'check' or 'circle', + onSelect = function() + syncColorblindMode('tritanopia') + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale('ui.settings.colorblind_modes.tritanopia')), + type = 'success' + }) + end, + }, + { + title = locale('ui.settings.colorblind_modes.achromatopsia'), + description = settings.colorblind_mode == 'achromatopsia' and locale('ui.settings.colorblind_current') or nil, + icon = settings.colorblind_mode == 'achromatopsia' and 'check' or 'circle', + onSelect = function() + syncColorblindMode('achromatopsia') + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale('ui.settings.colorblind_modes.achromatopsia')), + type = 'success' + }) + end, + }, + } + + lib.registerContext({ + id = 'ox_lib_colorblind', + title = locale('ui.settings.colorblind'), + options = options, + }) + + local ok = pcall(lib.showContext, 'ox_lib_colorblind') + + if ok then return end + + local input = lib.inputDialog(locale('ui.settings.colorblind'), { + { + type = 'select', + label = locale('ui.settings.colorblind'), + default = settings.colorblind_mode, + required = true, + options = { + { label = locale('ui.settings.colorblind_modes.off'), value = 'off' }, + { label = locale('ui.settings.colorblind_modes.protanopia'), value = 'protanopia' }, + { label = locale('ui.settings.colorblind_modes.deuteranopia'), value = 'deuteranopia' }, + { label = locale('ui.settings.colorblind_modes.tritanopia'), value = 'tritanopia' }, + { label = locale('ui.settings.colorblind_modes.achromatopsia'), value = 'achromatopsia' }, + } + } + }) --[[@as table?]] + + if not input then return end + + local mode = input[1] + + if mode and syncColorblindMode(mode) then + lib.notify({ + title = locale('settings'), + description = locale('ui.settings.colorblind_saved', locale(getColorblindData(mode).label)), + type = 'success' + }) + end +end + +RegisterNUICallback('syncColorblindMode', function(data, cb) + local mode = type(data) == 'table' and data.mode or nil + + if mode and colorblindModes[mode] then + syncColorblindMode(mode) + end + + cb(1) +end) + RegisterCommand('ox_lib', function() + + local inputSettings = { { type = 'checkbox', @@ -77,6 +301,31 @@ RegisterCommand('ox_lib', function() required = true, icon = 'message', }, + { + type = 'select', + label = locale('ui.settings.colorblind'), + options = { + { label = locale('ui.settings.colorblind_modes.off'), value = 'off' }, + { label = locale('ui.settings.colorblind_modes.protanopia'), value = 'protanopia' }, + { label = locale('ui.settings.colorblind_modes.deuteranopia'), value = 'deuteranopia' }, + { label = locale('ui.settings.colorblind_modes.tritanopia'), value = 'tritanopia' }, + { label = locale('ui.settings.colorblind_modes.achromatopsia'), value = 'achromatopsia' }, + }, + default = settings.colorblind_mode, + required = true, + icon = 'eye', + }, + { + type = 'select', + label = locale('ui.settings.ui_test_action'), + options = { + { label = locale('ui.settings.ui_test_action_none'), value = 'none' }, + { label = locale('ui.settings.ui_test_action_circle'), value = 'circle' }, + }, + default = 'none', + required = true, + icon = 'flask', + }, } if userLocales then @@ -97,13 +346,28 @@ RegisterCommand('ox_lib', function() if not input then return end - ---@type boolean, string, string - local notification_audio, notification_position, locale = table.unpack(input) + local notification_audio = input[1] + local notification_position = input[2] + local colorblind_mode = input[3] + local ui_test_action = input[4] + local localeInput = userLocales and input[5] or nil - if userLocales and set('locale', locale) then lib.setLocale(locale) end + if userLocales and set('locale', localeInput) then lib.setLocale(localeInput) end set('notification_position', notification_position) set('notification_audio', notification_audio) -end) + syncColorblindMode(colorblind_mode) + + if ui_test_action == 'circle' then + CreateThread(function() + lib.progressCircle({ + duration = 5000, + label = locale('ui.settings.ui_test_action_circle'), + position = 'middle', + canCancel = true, + }) + end) + end +end, false) return settings diff --git a/web/src/App.tsx b/web/src/App.tsx index bc6e9f491..643c1016d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import SkillCheck from './features/skillcheck'; import RadialMenu from './features/menu/radial'; import { theme } from './theme'; import { MantineProvider } from '@mantine/core'; +import { useEffect } from 'react'; import { useConfig } from './providers/ConfigProvider'; const App: React.FC = () => { @@ -24,7 +25,9 @@ const App: React.FC = () => { setClipboard(data); }); - fetchNui('init'); + useEffect(() => { + fetchNui('init').catch(() => undefined); + }, []); return ( diff --git a/web/src/config/colorblind.ts b/web/src/config/colorblind.ts new file mode 100644 index 000000000..8ba465650 --- /dev/null +++ b/web/src/config/colorblind.ts @@ -0,0 +1,48 @@ +import type { MantineColor } from '@mantine/core'; + +export type ColorblindMode = 'off' | 'protanopia' | 'deuteranopia' | 'tritanopia' | 'achromatopsia'; + +const colorblindModes: Record = { + off: { + primaryColor: 'blue', + primaryShade: 6, + skillAreaColor: 'blue', + skillAreaShade: 6, + indicatorColor: 'red', + indicatorShade: 6, + }, + protanopia: { + primaryColor: 'cyan', + primaryShade: 6, + skillAreaColor: 'cyan', + skillAreaShade: 6, + indicatorColor: 'orange', + indicatorShade: 6, + }, + deuteranopia: { + primaryColor: 'blue', + primaryShade: 6, + skillAreaColor: 'blue', + skillAreaShade: 6, + indicatorColor: 'orange', + indicatorShade: 6, + }, + tritanopia: { + primaryColor: 'pink', + primaryShade: 6, + skillAreaColor: 'pink', + skillAreaShade: 6, + indicatorColor: 'green', + indicatorShade: 6, + }, + achromatopsia: { + primaryColor: 'gray', + primaryShade: 7, + skillAreaColor: 'gray', + skillAreaShade: 7, + indicatorColor: 'yellow', + indicatorShade: 4, + }, +}; + +export const getColorblindTheme = (mode: ColorblindMode) => colorblindModes[mode]; diff --git a/web/src/features/skillcheck/index.tsx b/web/src/features/skillcheck/index.tsx index cd3cb669d..43a013766 100644 --- a/web/src/features/skillcheck/index.tsx +++ b/web/src/features/skillcheck/index.tsx @@ -4,6 +4,8 @@ import Indicator from './indicator'; import { fetchNui } from '../../utils/fetchNui'; import { Box, createStyles } from '@mantine/core'; import type { GameDifficulty, SkillCheckProps } from '../../typings'; +import { getColorblindTheme } from '../../config/colorblind'; +import { useConfig } from '../../providers/ConfigProvider'; export const circleCircumference = 2 * 50 * Math.PI; @@ -15,7 +17,8 @@ const difficultyOffsets = { hard: 25, }; -const useStyles = createStyles((theme, params: { difficultyOffset: number }) => ({ +const useStyles = createStyles( + (theme, params: { difficultyOffset: number; skillAreaColor: string; indicatorColor: string }) => ({ svg: { position: 'absolute', top: '50%', @@ -41,7 +44,7 @@ const useStyles = createStyles((theme, params: { difficultyOffset: number }) => }, skillArea: { fill: 'transparent', - stroke: theme.fn.primaryColor(), + stroke: params.skillAreaColor, strokeWidth: 8, r: 50, cx: 250, @@ -56,7 +59,7 @@ const useStyles = createStyles((theme, params: { difficultyOffset: number }) => }, }, indicator: { - stroke: 'red', + stroke: params.indicatorColor, strokeWidth: 16, fill: 'transparent', r: 50, @@ -92,9 +95,11 @@ const useStyles = createStyles((theme, params: { difficultyOffset: number }) => fontSize: 22, }, }, -})); + }) +); const SkillCheck: React.FC = () => { + const { config } = useConfig(); const [visible, setVisible] = useState(false); const dataRef = useRef<{ difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] } | null>(null); const dataIndexRef = useRef(0); @@ -104,7 +109,12 @@ const SkillCheck: React.FC = () => { difficulty: 'easy', key: 'e', }); - const { classes } = useStyles({ difficultyOffset: skillCheck.difficultyOffset }); + const colorblindTheme = getColorblindTheme(config.colorblindMode); + const { classes } = useStyles({ + difficultyOffset: skillCheck.difficultyOffset, + skillAreaColor: colorblindTheme.skillAreaColor, + indicatorColor: colorblindTheme.indicatorColor, + }); useNuiEvent('startSkillCheck', (data: { difficulty: GameDifficulty | GameDifficulty[]; inputs?: string[] }) => { dataRef.current = data; diff --git a/web/src/providers/ConfigProvider.tsx b/web/src/providers/ConfigProvider.tsx index c1219c879..2a35d53d0 100644 --- a/web/src/providers/ConfigProvider.tsx +++ b/web/src/providers/ConfigProvider.tsx @@ -1,10 +1,13 @@ import { Context, createContext, useContext, useEffect, useState } from 'react'; import { MantineColor } from '@mantine/core'; import { fetchNui } from '../utils/fetchNui'; +import { useNuiEvent } from '../hooks/useNuiEvent'; +import { ColorblindMode, getColorblindTheme } from '../config/colorblind'; interface Config { primaryColor: MantineColor; primaryShade: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + colorblindMode: ColorblindMode; } interface ConfigCtxValue { @@ -18,12 +21,30 @@ const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const [config, setConfig] = useState({ primaryColor: 'blue', primaryShade: 6, + colorblindMode: 'off', }); useEffect(() => { - fetchNui('getConfig').then((data) => setConfig(data)); + fetchNui('getConfig').then((data) => { + setConfig({ + ...data, + ...getColorblindTheme(data.colorblindMode), + }); + }); }, []); + useEffect(() => { + fetchNui('syncColorblindMode', { mode: config.colorblindMode }).catch(() => undefined); + }, [config.colorblindMode]); + + useNuiEvent('setColorblindMode', (mode) => { + setConfig((previous) => ({ + ...previous, + ...getColorblindTheme(mode), + colorblindMode: mode, + })); + }); + return {children}; }; diff --git a/web/src/utils/fetchNui.ts b/web/src/utils/fetchNui.ts index f45f19aef..954374f21 100644 --- a/web/src/utils/fetchNui.ts +++ b/web/src/utils/fetchNui.ts @@ -29,7 +29,15 @@ export async function fetchNui(eventName: string, data?: any): Promise< : 'nui-frame-app'; const resp = await fetch(`https://${resourceName}/${eventName}`, options); - const respFormatted = await resp.json(); + const rawResponse = await resp.text(); - return respFormatted; + if (!rawResponse) { + return null as T; + } + + try { + return JSON.parse(rawResponse) as T; + } catch { + return null as T; + } }