diff --git a/tgui/bun.lock b/tgui/bun.lock index d5ca5c7549e..10639816fa5 100644 --- a/tgui/bun.lock +++ b/tgui/bun.lock @@ -23,7 +23,8 @@ "name": "common", "version": "4.3.1", "dependencies": { - "es-toolkit": "^1.39.3", + "es-toolkit": "^1.43.0", + "zod": "^4.1.13", }, }, "packages/tgfont": { @@ -41,9 +42,9 @@ "common": "workspace:*", "dateformat": "^5.0.3", "dompurify": "^3.2.5", - "es-toolkit": "^1.39.3", + "es-toolkit": "^1.43.0", "highlight.js": "^11.11.1", - "jotai": "^2.12.4", + "jotai": "^2.16.0", "js-yaml": "^4.1.0", "marked": "^15.0.11", "marked-base-url": "^1.1.6", @@ -51,7 +52,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-json-tree": "^0.20.0", - "tgui-core": "^5.3.1", + "tgui-core": "^5.5.10", "tgui-dev-server": "workspace:*", }, }, @@ -70,12 +71,14 @@ "dependencies": { "common": "workspace:*", "dompurify": "^3.2.5", - "es-toolkit": "^1.39.3", + "es-toolkit": "^1.43.0", + "jotai": "^2.16.0", "react": "^19.1.0", "react-dom": "^19.1.0", "tgui": "workspace:*", - "tgui-core": "^5.3.1", + "tgui-core": "^5.5.10", "tgui-dev-server": "workspace:*", + "zod": "^4.1.13", }, }, "packages/tgui-say": { @@ -86,7 +89,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "tgui": "workspace:*", - "tgui-core": "^5.3.1", + "tgui-core": "^5.5.10", }, }, "packages/tgui-setup": { @@ -477,7 +480,7 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-toolkit": ["es-toolkit@1.39.7", "", {}, "sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw=="], + "es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -647,7 +650,7 @@ "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], - "jotai": ["jotai@2.12.5", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw=="], + "jotai": ["jotai@2.16.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-NmkwPBet0SHQ28GBfEb10sqnbVOYyn6DL4iazZgGRDUKxSWL0iqcm+IK4TqTSFC2ixGk+XX2e46Wbv364a3cKg=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -1009,7 +1012,7 @@ "tgui": ["tgui@workspace:packages/tgui"], - "tgui-core": ["tgui-core@5.3.1", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@nozbe/microfuzz": "^1.0.0" }, "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-+8dDM4R2G6T1SpzpkRXGYc7YD+9ohqYjTV/4Zgcqc1I4Ld8bJH9vKHCQVbEAsHTCIjHsTBW4xnOpKyAx1gRB5w=="], + "tgui-core": ["tgui-core@5.5.10", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@nozbe/microfuzz": "^1.0.0" }, "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-/LrZOS9BXSGItzNWzqGpQSo+5BmXQiydbc95+vJGpO/44bW9PqZ2/AnyvP6JCSbBhHT/PQSGVJscJcBnqB+eiA=="], "tgui-dev-server": ["tgui-dev-server@workspace:packages/tgui-dev-server"], @@ -1121,6 +1124,8 @@ "yerror": ["yerror@8.0.0", "", {}, "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g=="], + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], diff --git a/tgui/packages/common/package.json b/tgui/packages/common/package.json index f40c3b79237..c9e6b725e22 100644 --- a/tgui/packages/common/package.json +++ b/tgui/packages/common/package.json @@ -2,7 +2,8 @@ "name": "common", "version": "4.3.1", "dependencies": { - "es-toolkit": "^1.39.3" + "es-toolkit": "^1.43.0", + "zod": "^4.1.13" }, "private": true } diff --git a/tgui/packages/common/type-safety.test.ts b/tgui/packages/common/type-safety.test.ts new file mode 100644 index 00000000000..f572a19781d --- /dev/null +++ b/tgui/packages/common/type-safety.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from 'bun:test'; +import assert from 'node:assert/strict'; +import * as z from 'zod'; +import { smoothMerge } from './type-safety'; + +describe('smoothMerge', () => { + it('merges valid fields from source into target', () => { + const schema = z.object({ + a: z.string(), + b: z.number(), + }); + + const source = { a: 'hello', b: 'not a number', c: true }; + const target = { a: 'default', b: 42 }; + + const result = smoothMerge({ schema, source, target }); + assert.deepEqual(result, { a: 'hello', b: 42 }); + }); + + it('returns target if source is empty', () => { + const schema = z.object({ + a: z.string(), + }); + + const source = {}; + const target = { a: 'default' }; + const result = smoothMerge({ schema, source, target }); + assert.deepEqual(result, target); + }); + + it('completely ignores an object if its not in the schema', () => { + const schema = z.object({ + a: z.string(), + b: z.number(), + }); + + const source = { + c: 1, + d: [1, 2, 3], + }; + + const target = { + a: 'default', + b: 42, + }; + + const result = smoothMerge({ schema, source, target }); + assert.deepEqual(result, { + a: 'default', + b: 42, + }); + }); +}); diff --git a/tgui/packages/common/type-safety.ts b/tgui/packages/common/type-safety.ts new file mode 100644 index 00000000000..71e1c6aa568 --- /dev/null +++ b/tgui/packages/common/type-safety.ts @@ -0,0 +1,56 @@ +import type { ZodObject } from 'zod'; + +type MergeInput = { + /** + * A zod object. + * @see Writing a Zod Schema: https://zod.dev/basics + */ + schema: ZodObject; + /** The input getting merged */ + source: Record; + /** The defaults, which is the shape of the output */ + target: TObj; +}; + +/** + * Merges two objects together while validating the output against a zod schema. + * Different than just parsing - it does not throw errors, it simply discards + * invalid fields and invalid value types. + * + * @example + * + * ```ts + * const schema = z.object({ + * a: z.string(), + * b: z.number(), + * }); + * + * const source = { a: 'hello', b: 'not a number', c: true }; + * const target = { a: 'default', b: 42 }; + * + * const result = smoothMerge({ schema, source, target }); + * // result is { a: 'hello', b: 42 } + * ``` + */ +export function smoothMerge>( + input: MergeInput, +): TObj { + if (Object.keys(input.source).length === 0) return input.target; + + const validated = {}; + + for (const [key, value] of Object.entries(input.source)) { + // Skip keys that are not in the schema + if (!(key in input.schema.shape)) continue; + + const fieldSchema = input.schema.shape[key]; + const result = fieldSchema.safeParse(value); + + // Only assign fields which pass validation + if (result.success) { + validated[key] = result.data; + } + } + + return { ...input.target, ...validated }; +} diff --git a/tgui/packages/tgui-panel/Panel.tsx b/tgui/packages/tgui-panel/Panel.tsx index d79fd985fdb..319f774a4ba 100644 --- a/tgui/packages/tgui-panel/Panel.tsx +++ b/tgui/packages/tgui-panel/Panel.tsx @@ -4,28 +4,24 @@ * @license MIT */ +import { useAtom } from 'jotai'; import { Pane } from 'tgui/layouts'; import { Button, Section, Stack } from 'tgui-core/components'; - import { NowPlayingWidget, useAudio } from './audio'; import { ChatPanel, ChatTabs } from './chat'; import { useGame } from './game'; import { Notifications } from './Notifications'; import { PingIndicator } from './ping'; import { ReconnectButton } from './reconnect'; -import { SettingsPanel, useSettings } from './settings'; +import { settingsVisibleAtom } from './settings/atoms'; +import { SettingsPanel } from './settings/SettingsPanel'; +import { useSettings } from './settings/use-settings'; export const Panel = (props) => { const audio = useAudio(); - const settings = useSettings(); + const { settings } = useSettings(); const game = useGame(); - if (process.env.NODE_ENV !== 'production') { - const { useDebug, KitchenSink } = require('tgui/debug'); - const debug = useDebug(); - if (debug.kitchenSink) { - return ; - } - } + const [settingsVisible, setSettingsVisible] = useAtom(settingsVisibleAtom); return ( @@ -51,13 +47,11 @@ export const Panel = (props) => { @@ -189,7 +177,7 @@ export function SettingsGeneral(props) { accept=".json" tooltip="Import chat settings" icon="arrow-up-from-bracket" - onSelectFiles={(files) => importChatSettings(files)} + onSelectFiles={importChatSettings} > Import settings diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx index 68f69833bc5..9f936aa5a45 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx @@ -4,20 +4,20 @@ * @license MIT */ -import { useDispatch, useSelector } from 'tgui/backend'; import { Section, Stack, Tabs } from 'tgui-core/components'; - import { ChatPageSettings } from '../chat'; -import { changeSettingsTab } from './actions'; import { SETTINGS_TABS } from './constants'; -import { selectActiveTab } from './selectors'; import { SettingsGeneral } from './SettingsGeneral'; import { SettingsStatPanel } from './SettingsStatPanel'; import { TextHighlightSettings } from './TextHighlight'; +import { useSettings } from './use-settings'; export function SettingsPanel(props) { - const activeTab = useSelector(selectActiveTab); - const dispatch = useDispatch(); + const { + settings: { view }, + updateSettings, + } = useSettings(); + const { activeTab } = view; return ( @@ -29,11 +29,12 @@ export function SettingsPanel(props) { key={tab.id} selected={tab.id === activeTab} onClick={() => - dispatch( - changeSettingsTab({ - tabId: tab.id, - }), - ) + updateSettings({ + view: { + ...view, + activeTab: tab.id, + }, + }) } > {tab.name} diff --git a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx index 4502c52ae90..d4a28c1ab33 100644 --- a/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsStatPanel.tsx @@ -1,4 +1,3 @@ -import { useDispatch, useSelector } from 'tgui/backend'; import { Button, LabeledList, @@ -9,19 +8,17 @@ import { } from 'tgui-core/components'; import { toFixed } from 'tgui-core/math'; import { capitalize } from 'tgui-core/string'; +import { useSettings } from './use-settings'; -import { updateSettings } from './actions'; -import { selectSettings } from './selectors'; +const tabViews = ['default', 'classic', 'scrollable']; -const TabsViews = ['default', 'classic', 'scrollable']; -const LinkedToChat = () => ( - Unlink Stat Panel from chat! -); +function LinkedToChat() { + return Unlink Stat Panel from chat!; +} export function SettingsStatPanel(props) { - const { statLinked, statFontSize, statTabsStyle } = - useSelector(selectSettings); - const dispatch = useDispatch(); + const { settings, updateSettings } = useSettings(); + const { statLinked, statFontSize, statTabsStyle } = settings; return (
@@ -29,14 +26,12 @@ export function SettingsStatPanel(props) { - {TabsViews.map((view) => ( + {tabViews.map((view) => ( @@ -57,7 +52,7 @@ export function SettingsStatPanel(props) { unit="px" format={(value) => toFixed(value)} onChange={(e, value) => - dispatch(updateSettings({ statFontSize: value })) + updateSettings({ statFontSize: value }) } /> )} @@ -71,9 +66,7 @@ export function SettingsStatPanel(props) { fluid icon={statLinked ? 'unlink' : 'link'} color={statLinked ? 'bad' : 'good'} - onClick={() => - dispatch(updateSettings({ statLinked: !statLinked })) - } + onClick={() => updateSettings({ statLinked: !statLinked })} > {statLinked ? 'Unlink from chat' : 'Link to chat'} diff --git a/tgui/packages/tgui-panel/settings/TextHighlight.tsx b/tgui/packages/tgui-panel/settings/TextHighlight.tsx index 29d28b3647b..6bb53fac771 100644 --- a/tgui/packages/tgui-panel/settings/TextHighlight.tsx +++ b/tgui/packages/tgui-panel/settings/TextHighlight.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'tgui/backend'; +import { useDispatch } from 'tgui/backend'; import { Box, Button, @@ -10,21 +10,15 @@ import { Stack, TextArea, } from 'tgui-core/components'; - import { rebuildChat } from '../chat/actions'; -import { - addHighlightSetting, - removeHighlightSetting, - updateHighlightSetting, -} from './actions'; import { WARN_AFTER_HIGHLIGHT_AMT } from './constants'; -import { - selectHighlightSettingById, - selectHighlightSettings, -} from './selectors'; +import { useHighlights } from './use-highlights'; export function TextHighlightSettings(props) { - const highlightSettings = useSelector(selectHighlightSettings); + const { + highlights: { highlightSettings }, + addHighlight, + } = useHighlights(); const dispatch = useDispatch(); return ( @@ -42,9 +36,7 @@ export function TextHighlightSettings(props) { @@ -73,8 +65,11 @@ export function TextHighlightSettings(props) { function TextHighlightSetting(props) { const { id, ...rest } = props; - const highlightSettingById = useSelector(selectHighlightSettingById); - const dispatch = useDispatch(); + const { + highlights: { highlightSettingById }, + updateHighlight, + removeHighlight, + } = useHighlights(); const { highlightColor, highlightText, @@ -90,13 +85,7 @@ function TextHighlightSetting(props) { @@ -106,12 +95,10 @@ function TextHighlightSetting(props) { checked={highlightWholeMessage} tooltip="If this option is selected, the entire message will be highlighted in yellow." onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - highlightWholeMessage: !highlightWholeMessage, - }), - ) + updateHighlight({ + id, + highlightWholeMessage: !highlightWholeMessage, + }) } > Whole Message @@ -123,12 +110,10 @@ function TextHighlightSetting(props) { tooltipPosition="bottom-start" tooltip="If this option is selected, only exact matches (no extra letters before or after) will trigger. Not compatible with punctuation. Overriden if regex is used." onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - matchWord: !matchWord, - }), - ) + updateHighlight({ + id, + matchWord: !matchWord, + }) } > Exact @@ -139,12 +124,10 @@ function TextHighlightSetting(props) { tooltip="If this option is selected, the highlight will be case-sensitive." checked={matchCase} onClick={() => - dispatch( - updateHighlightSetting({ - id: id, - matchCase: !matchCase, - }), - ) + updateHighlight({ + id, + matchCase: !matchCase, + }) } > Case @@ -158,12 +141,10 @@ function TextHighlightSetting(props) { placeholder="#ffffff" value={highlightColor} onBlur={(value) => - dispatch( - updateHighlightSetting({ - id: id, - highlightColor: value, - }), - ) + updateHighlight({ + id, + highlightColor: value, + }) } /> @@ -174,12 +155,10 @@ function TextHighlightSetting(props) { value={highlightText} placeholder="Put words to highlight here. Separate terms with commas, i.e. (term1, term2, term3)" onBlur={(value) => - dispatch( - updateHighlightSetting({ - id: id, - highlightText: value, - }), - ) + updateHighlight({ + id: id, + highlightText: value, + }) } /> diff --git a/tgui/packages/tgui-panel/settings/actions.ts b/tgui/packages/tgui-panel/settings/actions.ts index 3c4a13b45b0..4190a25234f 100644 --- a/tgui/packages/tgui-panel/settings/actions.ts +++ b/tgui/packages/tgui-panel/settings/actions.ts @@ -6,26 +6,6 @@ import { createAction } from 'common/redux'; -import { createHighlightSetting } from './model'; - -export const updateSettings = createAction('settings/update'); -export const loadSettings = createAction('settings/load'); -export const changeSettingsTab = createAction('settings/changeTab'); -export const toggleSettings = createAction('settings/toggle'); -export const openChatSettings = createAction('settings/openChatTab'); -export const addHighlightSetting = createAction( - 'settings/addHighlightSetting', - () => ({ - payload: createHighlightSetting(), - }), -); -export const removeHighlightSetting = createAction( - 'settings/removeHighlightSetting', -); -export const updateHighlightSetting = createAction( - 'settings/updateHighlightSetting', -); -export const exportSettings = createAction('settings/export'); export const importSettings = createAction( 'settings/import', (settings, pages) => ({ diff --git a/tgui/packages/tgui-panel/settings/atoms.ts b/tgui/packages/tgui-panel/settings/atoms.ts new file mode 100644 index 00000000000..d232ac8fad9 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/atoms.ts @@ -0,0 +1,55 @@ +import { atom } from 'jotai'; +import { FONTS, SETTINGS_TABS } from './constants'; +import type { HighlightSetting, HighlightState, SettingsState } from './types'; + +export const defaultSettings: SettingsState = { + adminMusicVolume: 0.5, + fontFamily: FONTS[0], + fontSize: 13, + initialized: false, + lineHeight: 1.2, + statFontSize: 12, + statLinked: true, + statTabsStyle: 'default', + theme: 'light', + version: 1, + view: { + visible: false, + activeTab: SETTINGS_TABS[0].id, + }, +}; + +export const defaultHighlightSetting: HighlightSetting = { + id: 'default', + highlightText: '', + highlightColor: '#ffdd44', + highlightWholeMessage: true, + matchWord: false, + matchCase: false, +}; + +export const defaultHighlights: HighlightState = { + highlightSettings: ['default'], + highlightSettingById: { + default: defaultHighlightSetting, + }, + // Keep these two state vars for compatibility with other servers + highlightText: '', + highlightColor: '#ffdd44', + // END compatibility state vars +}; + +/** + * Separate from 'initialized' in settings. This is to keep chat from loading + * settings prior to settings being ready + */ +export const settingsLoadedAtom = atom(false); +export const settingsAtom = atom(defaultSettings); +export const settingsVisibleAtom = atom(false); + +export const highlightsAtom = atom(defaultHighlights); + +export const storedSettingsAtom = atom((get) => ({ + ...get(settingsAtom), + ...get(highlightsAtom), +})); diff --git a/tgui/packages/tgui-panel/settings/constants.ts b/tgui/packages/tgui-panel/settings/constants.ts index 3fd4e5c2e4a..0e076fc07f6 100644 --- a/tgui/packages/tgui-panel/settings/constants.ts +++ b/tgui/packages/tgui-panel/settings/constants.ts @@ -4,6 +4,23 @@ * @license MIT */ +export const THEMES = ['light', 'dark'] as const; + +export const COLORS = { + DARK: { + BG_BASE: '#202020', + BG_SECOND: '#151515', + BUTTON: '#404040', + TEXT: '#A6A6A6', + }, + LIGHT: { + BG_BASE: '#EEEEEE', + BG_SECOND: '#FFFFFF', + BUTTON: '#FFFFFF', + TEXT: '#000000', + }, +} as const; + export const SETTINGS_TABS = [ { id: 'general', @@ -22,7 +39,7 @@ export const SETTINGS_TABS = [ id: 'statPanel', name: 'Stat Panel', }, -]; +] as const; export const FONTS_DISABLED = 'Default'; @@ -38,6 +55,6 @@ export const FONTS = [ 'Trebuchet MS', 'Courier New', 'Lucida Console', -]; +] as const; export const WARN_AFTER_HIGHLIGHT_AMT = 10; diff --git a/tgui/packages/tgui-panel/settings/helpers.ts b/tgui/packages/tgui-panel/settings/helpers.ts new file mode 100644 index 00000000000..5f02f47d32a --- /dev/null +++ b/tgui/packages/tgui-panel/settings/helpers.ts @@ -0,0 +1,85 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { FONTS_DISABLED } from './constants'; +import { setClientTheme } from './themes'; +import type { SettingsState } from './types'; + +let statFontTimer: NodeJS.Timeout; +let statTabsTimer: NodeJS.Timeout; +let overrideRule: HTMLStyleElement; +let overrideFontFamily: string | undefined; +let overrideFontSize: string; + +/** Updates the global CSS rule to override the font family and size. */ +function updateGlobalOverrideRule(): void { + let fontFamily = ''; + + if (overrideFontFamily !== undefined) { + fontFamily = `font-family: ${overrideFontFamily} !important;`; + } + + const constructedRule = `body * :not(.Icon) { + ${fontFamily} + }`; + + if (overrideRule === undefined) { + overrideRule = document.createElement('style'); + document.querySelector('head')!.append(overrideRule); + } + + // no other way to force a CSS refresh other than to update its innerText + overrideRule.innerText = constructedRule; + + document.body.style.setProperty('font-size', overrideFontSize); +} + +function setGlobalFontSize( + fontSize: string | number, + statFontSize: string | number, + statLinked: boolean, +): void { + overrideFontSize = `${fontSize}px`; + + // Used solution from theme.ts + clearInterval(statFontTimer); + Byond.command( + `.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`, + ); + statFontTimer = setTimeout(() => { + Byond.command( + `.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`, + ); + }, 1500); +} + +function setGlobalFontFamily(fontFamily: string): void { + overrideFontFamily = fontFamily === FONTS_DISABLED ? undefined : fontFamily; +} + +function setStatTabsStyle(style: string): void { + clearInterval(statTabsTimer); + Byond.command(`.output statbrowser:set_tabs_style ${style}`); + statTabsTimer = setTimeout(() => { + Byond.command(`.output statbrowser:set_tabs_style ${style}`); + }, 1500); +} + +export function generalSettingsHandler(update: SettingsState): void { + // Set client theme + const theme = update?.theme; + if (theme) { + setClientTheme(theme); + } + + // Update stat panel settings + setStatTabsStyle(update.statTabsStyle); + + // Update global UI font size + setGlobalFontSize(update.fontSize, update.statFontSize, update.statLinked); + setGlobalFontFamily(update.fontFamily); + updateGlobalOverrideRule(); +} diff --git a/tgui/packages/tgui-panel/settings/hooks.ts b/tgui/packages/tgui-panel/settings/hooks.ts deleted file mode 100644 index b37b49a22c8..00000000000 --- a/tgui/packages/tgui-panel/settings/hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { useDispatch, useSelector } from 'tgui/backend'; - -import { toggleSettings, updateSettings } from './actions'; -import { selectSettings } from './selectors'; - -export const useSettings = () => { - const settings = useSelector(selectSettings); - const dispatch = useDispatch(); - return { - ...settings, - visible: settings.view.visible, - toggle: () => dispatch(toggleSettings()), - update: (obj) => dispatch(updateSettings(obj)), - }; -}; diff --git a/tgui/packages/tgui-panel/settings/index.ts b/tgui/packages/tgui-panel/settings/index.ts deleted file mode 100644 index 7d5dcda427a..00000000000 --- a/tgui/packages/tgui-panel/settings/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export { useSettings } from './hooks'; -export { settingsMiddleware } from './middleware'; -export { settingsReducer } from './reducer'; -export { SettingsPanel } from './SettingsPanel'; diff --git a/tgui/packages/tgui-panel/settings/middleware.ts b/tgui/packages/tgui-panel/settings/middleware.ts deleted file mode 100644 index ca1df55a69b..00000000000 --- a/tgui/packages/tgui-panel/settings/middleware.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { storage } from 'common/storage'; - -import { setClientTheme } from '../themes'; -import { - addHighlightSetting, - exportSettings, - importSettings, - loadSettings, - removeHighlightSetting, - updateHighlightSetting, - updateSettings, -} from './actions'; -import { FONTS_DISABLED } from './constants'; -import { setDisplayScaling } from './scaling'; -import { selectSettings } from './selectors'; -import { exportChatSettings } from './settingsImExport'; - -let statFontTimer: NodeJS.Timeout; -let statTabsTimer: NodeJS.Timeout; -let overrideRule: HTMLStyleElement; -let overrideFontFamily: string | undefined; -let overrideFontSize: string; - -/** Updates the global CSS rule to override the font family and size. */ -function updateGlobalOverrideRule() { - let fontFamily = ''; - - if (overrideFontFamily !== undefined) { - fontFamily = `font-family: ${overrideFontFamily} !important;`; - } - - const constructedRule = `body * :not(.Icon) { - ${fontFamily} - }`; - - if (overrideRule === undefined) { - overrideRule = document.createElement('style'); - document.querySelector('head')!.append(overrideRule); - } - - // no other way to force a CSS refresh other than to update its innerText - overrideRule.innerText = constructedRule; - - document.body.style.setProperty('font-size', overrideFontSize); -} - -function setGlobalFontSize( - fontSize: string, - statFontSize: string, - statLinked: boolean, -) { - overrideFontSize = `${fontSize}px`; - - // Used solution from theme.ts - clearInterval(statFontTimer); - Byond.command( - `.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`, - ); - statFontTimer = setTimeout(() => { - Byond.command( - `.output statbrowser:set_font_size ${statLinked ? fontSize : statFontSize}px`, - ); - }, 1500); -} - -function setGlobalFontFamily(fontFamily: string) { - overrideFontFamily = fontFamily === FONTS_DISABLED ? undefined : fontFamily; -} - -function setStatTabsStyle(style: string) { - clearInterval(statTabsTimer); - Byond.command(`.output statbrowser:set_tabs_style ${style}`); - statTabsTimer = setTimeout(() => { - Byond.command(`.output statbrowser:set_tabs_style ${style}`); - }, 1500); -} - -export function settingsMiddleware(store) { - let initialized = false; - - return (next) => (action) => { - const { type, payload } = action; - - if (!initialized) { - initialized = true; - - setDisplayScaling(); - - storage.get('panel-settings').then((settings) => { - store.dispatch(loadSettings(settings)); - }); - } - if (type === exportSettings.type) { - const state = store.getState(); - const settings = selectSettings(state); - exportChatSettings(settings, state.chat.pageById); - return; - } - if ( - type !== updateSettings.type && - type !== loadSettings.type && - type !== addHighlightSetting.type && - type !== removeHighlightSetting.type && - type !== updateHighlightSetting.type && - type !== importSettings.type - ) { - return next(action); - } - - // Set client theme - const theme = payload?.theme; - if (theme) { - setClientTheme(theme); - } - - // Pass action to get an updated state - next(action); - - const settings = selectSettings(store.getState()); - - if (importSettings.type) { - setClientTheme(settings.theme); - } - - // Update stat panel settings - setStatTabsStyle(settings.statTabsStyle); - - // Update global UI font size - setGlobalFontSize( - settings.fontSize, - settings.statFontSize, - settings.statLinked, - ); - setGlobalFontFamily(settings.fontFamily); - updateGlobalOverrideRule(); - - // Save settings to the web storage - storage.set('panel-settings', settings); - - return; - }; -} diff --git a/tgui/packages/tgui-panel/settings/migration.ts b/tgui/packages/tgui-panel/settings/migration.ts new file mode 100644 index 00000000000..77f1096bf37 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/migration.ts @@ -0,0 +1,115 @@ +import { storage } from 'common/storage'; +import { smoothMerge } from 'common/type-safety'; +import { omit, pick } from 'es-toolkit'; +import { chatRenderer } from '../chat/renderer'; +import { store } from '../events/store'; +import { + defaultHighlightSetting, + type defaultHighlights, + defaultSettings, + highlightsAtom, + settingsAtom, +} from './atoms'; +import { generalSettingsHandler } from './helpers'; +import { + type HighlightState, + type MergedSettings, + type SettingsState, + settingsSchema, +} from './types'; + +/** Fixes issues with stored highlight settings */ +function migrateHighlights(next: HighlightState): HighlightState { + const draft: HighlightState = { ...next }; + + // Lazy init the list for compatibility reasons + if (!draft.highlightSettings) { + draft.highlightSettings = [defaultHighlightSetting.id]; + } + + if (!draft.highlightSettingById) { + draft.highlightSettingById = { + [defaultHighlightSetting.id]: defaultHighlightSetting, + }; + } + + // Compensating for mishandling of default highlight settings + if (!draft.highlightSettingById[defaultHighlightSetting.id]) { + draft.highlightSettings = [ + defaultHighlightSetting.id, + ...draft.highlightSettings, + ]; + draft.highlightSettingById[defaultHighlightSetting.id] = + defaultHighlightSetting; + } + + // Update the highlight settings for default highlight + // settings compatibility — don't overwrite existing values + const defaultHighlight = + draft.highlightSettingById[defaultHighlightSetting.id]; + + if (!defaultHighlight.highlightColor) { + defaultHighlight.highlightColor = + draft.highlightColor ?? defaultHighlightSetting.highlightColor; + } + + if (!defaultHighlight.highlightText) { + defaultHighlight.highlightText = + draft.highlightText ?? defaultHighlightSetting.highlightText; + } + + return draft; +} + +const highlightKeys: (keyof typeof defaultHighlights)[] = [ + 'highlightSettings', + 'highlightSettingById', + 'highlightText', + 'highlightColor', +] as const; + +/** A bit of a chunky procedural function. Handles imported and loaded settings */ +export function startSettingsMigration(next: MergedSettings): void { + // No stored settings found, initialize with defaults + if (!next) { + console.log('Initializing panel settings with defaults.'); + const initialized: SettingsState = { + ...defaultSettings, + initialized: true, + }; + storage.set('panel-settings', initialized); + store.set(settingsAtom, initialized); + return; + } + + // Split the merged object as we save in two different atoms + const settingsPart = omit(next, highlightKeys); + const highlightPart = pick(next, highlightKeys); + + const draftSettings = smoothMerge({ + source: settingsPart, + target: defaultSettings, + schema: settingsSchema, + }); + draftSettings.initialized = true; + draftSettings.view = defaultSettings.view; // Preserve view state + + generalSettingsHandler(draftSettings); + store.set(settingsAtom, draftSettings); + console.log('Migrated panel settings:', draftSettings); + + const migratedHighlights = migrateHighlights(highlightPart); + + // Just exit if no valid version was found + if (!next.version) { + storage.set('panel-settings', { ...draftSettings, ...migratedHighlights }); + return; + } + + chatRenderer.setHighlight( + migratedHighlights.highlightSettings, + migratedHighlights.highlightSettingById, + ); + store.set(highlightsAtom, migratedHighlights); + console.log('Migrated panel highlight settings:', migratedHighlights); +} diff --git a/tgui/packages/tgui-panel/settings/model.ts b/tgui/packages/tgui-panel/settings/model.ts deleted file mode 100644 index 9c86be37628..00000000000 --- a/tgui/packages/tgui-panel/settings/model.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @file - */ -import { createUuid } from 'tgui-core/uuid'; - -export const createHighlightSetting = (obj?: Record) => ({ - id: createUuid(), - highlightText: '', - highlightColor: '#ffdd44', - highlightWholeMessage: true, - matchWord: false, - matchCase: false, - ...obj, -}); - -export const createDefaultHighlightSetting = (obj?: Record) => - createHighlightSetting({ - id: 'default', - ...obj, - }); diff --git a/tgui/packages/tgui-panel/settings/reducer.ts b/tgui/packages/tgui-panel/settings/reducer.ts deleted file mode 100644 index 4328f6530a4..00000000000 --- a/tgui/packages/tgui-panel/settings/reducer.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { - addHighlightSetting, - changeSettingsTab, - importSettings, - loadSettings, - openChatSettings, - removeHighlightSetting, - toggleSettings, - updateHighlightSetting, - updateSettings, -} from './actions'; -import { FONTS, SETTINGS_TABS } from './constants'; -import { createDefaultHighlightSetting } from './model'; - -const defaultHighlightSetting = createDefaultHighlightSetting(); - -const initialState = { - version: 1, - fontSize: 13, - fontFamily: FONTS[0], - lineHeight: 1.2, - theme: 'light', - adminMusicVolume: 0.5, - // Keep these two state vars for compatibility with other servers - highlightText: '', - highlightColor: '#ffdd44', - // END compatibility state vars - highlightSettings: [defaultHighlightSetting.id], - highlightSettingById: { - [defaultHighlightSetting.id]: defaultHighlightSetting, - }, - view: { - visible: false, - activeTab: SETTINGS_TABS[0].id, - }, - initialized: false, - statLinked: true, - statFontSize: 12, - statTabsStyle: 'default', -} as const; - -export function settingsReducer( - state = initialState, - action: { type: string; payload: any }, -) { - const { type, payload } = action; - - switch (type) { - case updateSettings.type: - return { - ...state, - ...payload, - }; - - case loadSettings.type: { - { - // Validate version and/or migrate state - if (!payload?.version) { - const nextState = { - ...state, - ...payload, - }; - nextState.initialized = true; - return nextState; - } - - delete payload.view; - const nextState = { - ...state, - ...payload, - }; - nextState.initialized = true; - // Lazy init the list for compatibility reasons - if (!nextState.highlightSettings) { - nextState.highlightSettings = [defaultHighlightSetting.id]; - nextState.highlightSettingById[defaultHighlightSetting.id] = - defaultHighlightSetting; - } - // Compensating for mishandling of default highlight settings - else if (!nextState.highlightSettingById[defaultHighlightSetting.id]) { - nextState.highlightSettings = [ - defaultHighlightSetting.id, - ...nextState.highlightSettings, - ]; - nextState.highlightSettingById[defaultHighlightSetting.id] = - defaultHighlightSetting; - } - - // Update the highlight settings for default highlight - // settings compatibility - const highlightSetting = - nextState.highlightSettingById[defaultHighlightSetting.id]; - highlightSetting.highlightColor = nextState.highlightColor; - highlightSetting.highlightText = nextState.highlightText; - - return nextState; - } - } - - case importSettings.type: { - const newSettings = payload.newSettings; - if (!newSettings) { - return state; - } - const nextState = { - ...state, - ...newSettings, - }; - return nextState; - } - - case toggleSettings.type: { - return { - ...state, - view: { - ...state.view, - visible: !state.view.visible, - }, - }; - } - - case openChatSettings.type: { - return { - ...state, - view: { - ...state.view, - visible: true, - activeTab: 'chatPage', - }, - }; - } - - case changeSettingsTab.type: { - const { tabId } = payload; - - return { - ...state, - view: { - ...state.view, - activeTab: tabId, - }, - }; - } - - case addHighlightSetting.type: { - const highlightSetting = payload; - - return { - ...state, - highlightSettings: [...state.highlightSettings, highlightSetting.id], - highlightSettingById: { - ...state.highlightSettingById, - [highlightSetting.id]: highlightSetting, - }, - }; - } - - case removeHighlightSetting.type: { - const { id } = payload; - - const nextState = { - ...state, - highlightSettings: [...state.highlightSettings], - highlightSettingById: { - ...state.highlightSettingById, - }, - }; - - if (id === defaultHighlightSetting.id) { - nextState.highlightSettings[defaultHighlightSetting.id] = - defaultHighlightSetting; - } else { - delete nextState.highlightSettingById[id]; - nextState.highlightSettings = nextState.highlightSettings.filter( - (sid) => sid !== id, - ); - if (!nextState.highlightSettings.length) { - nextState.highlightSettings.push(defaultHighlightSetting.id); - nextState.highlightSettingById[defaultHighlightSetting.id] = - defaultHighlightSetting; - } - } - - return nextState; - } - - case updateHighlightSetting.type: { - const { id, ...settings } = payload; - - const nextState = { - ...state, - highlightSettings: [...state.highlightSettings], - highlightSettingById: { - ...state.highlightSettingById, - }, - }; - - // Transfer this data from the default highlight setting - // so they carry over to other servers - if (id === defaultHighlightSetting.id) { - if (settings.highlightText) { - nextState.highlightText = settings.highlightText; - } - if (settings.highlightColor) { - nextState.highlightColor = settings.highlightColor; - } - } - - if (nextState.highlightSettingById[id]) { - nextState.highlightSettingById[id] = { - ...nextState.highlightSettingById[id], - ...settings, - }; - } - - return nextState; - } - } - - return state; -} diff --git a/tgui/packages/tgui-panel/settings/selectors.ts b/tgui/packages/tgui-panel/settings/selectors.ts deleted file mode 100644 index 75f9ecb4988..00000000000 --- a/tgui/packages/tgui-panel/settings/selectors.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -export const selectSettings = (state) => state.settings; -export const selectActiveTab = (state) => state.settings.view.activeTab; -export const selectHighlightSettings = (state) => - state.settings.highlightSettings; -export const selectHighlightSettingById = (state) => - state.settings.highlightSettingById; diff --git a/tgui/packages/tgui-panel/settings/settingsImExport.ts b/tgui/packages/tgui-panel/settings/settingsImExport.ts index 1cc7ba9c6fa..305fa394775 100644 --- a/tgui/packages/tgui-panel/settings/settingsImExport.ts +++ b/tgui/packages/tgui-panel/settings/settingsImExport.ts @@ -1,12 +1,13 @@ import { useDispatch } from 'tgui/backend'; - import type { Page } from '../chat/types'; +import { store } from '../events/store'; import { importSettings } from './actions'; +import { storedSettingsAtom } from './atoms'; +import { startSettingsMigration } from './migration'; + +export function exportChatSettings(pages: Record): void { + const settings = store.get(storedSettingsAtom); -export function exportChatSettings( - settings: Record, - pages: Record[], -) { const opts: SaveFilePickerOptions = { id: `ss13-chatprefs-${Date.now()}`, suggestedName: `ss13-chatsettings-${new Date().toJSON().slice(0, 10)}.json`, @@ -38,17 +39,20 @@ export function exportChatSettings( }); } -export function importChatSettings(settings: string | string[]) { +export function importChatSettings(settings: string | string[]): void { const dispatch = useDispatch(); if (Array.isArray(settings)) { return; } const ourImport = JSON.parse(settings); - if (!ourImport?.version) { - return; + if (!ourImport?.version) return; + + let pageRecord: Record[] = []; + if ('chatPages' in ourImport) { + pageRecord = ourImport.chatPages; + delete ourImport.chatPages; } - const pageRecord = ourImport.chatPages; - delete ourImport.chatPages; dispatch(importSettings(ourImport, pageRecord)); + startSettingsMigration(ourImport); } diff --git a/tgui/packages/tgui-panel/themes.ts b/tgui/packages/tgui-panel/settings/themes.ts similarity index 89% rename from tgui/packages/tgui-panel/themes.ts rename to tgui/packages/tgui-panel/settings/themes.ts index 5679c62d955..e94aaf7be3a 100644 --- a/tgui/packages/tgui-panel/themes.ts +++ b/tgui/packages/tgui-panel/settings/themes.ts @@ -4,22 +4,7 @@ * @license MIT */ -export const THEMES = ['light', 'dark']; - -const COLORS = { - DARK: { - BG_BASE: '#202020', - BG_SECOND: '#151515', - BUTTON: '#404040', - TEXT: '#A6A6A6', - }, - LIGHT: { - BG_BASE: '#EEEEEE', - BG_SECOND: '#FFFFFF', - BUTTON: '#FFFFFF', - TEXT: '#000000', - }, -}; +import { COLORS } from './constants'; let setClientThemeTimer: NodeJS.Timeout; @@ -33,7 +18,7 @@ let setClientThemeTimer: NodeJS.Timeout; * There's no way round it. We're essentially changing the skin by hand. * It's painful but it works, and is the way Lummox suggested. */ -export const setClientTheme = (name) => { +export function setClientTheme(name): void | Promise { // Transmit once for fast updates and again in a little while in case we won // the race against statbrowser init. clearInterval(setClientThemeTimer); @@ -43,9 +28,7 @@ export const setClientTheme = (name) => { }, 1500); const themeColor = COLORS[name.toUpperCase()]; - if (!themeColor) { - return; - } + if (!themeColor) return; return Byond.winset({ // Main windows @@ -94,4 +77,4 @@ export const setClientTheme = (name) => { 'input.background-color': themeColor.BG_SECOND, 'input.text-color': themeColor.TEXT, }); -}; +} diff --git a/tgui/packages/tgui-panel/settings/types.ts b/tgui/packages/tgui-panel/settings/types.ts new file mode 100644 index 00000000000..480735ad332 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/types.ts @@ -0,0 +1,47 @@ +import * as z from 'zod'; +import type { ChatPages } from '../chat/types'; + +const viewSchema = z.object({ + activeTab: z.string(), + visible: z.boolean(), +}); + +export const settingsSchema = z.object({ + adminMusicVolume: z.number(), + fontFamily: z.string(), + fontSize: z.number(), + initialized: z.boolean(), + lineHeight: z.number(), + statFontSize: z.number(), + statLinked: z.boolean(), + statTabsStyle: z.string(), + theme: z.string(), + version: z.number(), + view: viewSchema, +}); + +export type HighlightSetting = { + highlightColor: string; + highlightText: string; + highlightWholeMessage: boolean; + id: string; + matchCase: boolean; + matchWord: boolean; +}; + +export type HighlightState = { + /** Keep this for compatibility with other servers */ + highlightColor: string; + highlightSettings: string[]; + highlightSettingById: Record; + /** Keep this for compatibility with other servers */ + highlightText: string; +}; + +export type SettingsState = z.infer; + +// Imported and loaded settings without chatpages +export interface MergedSettings extends SettingsState, HighlightState {} + +// Full exported settings with chatpages +export interface ExportedSettings extends MergedSettings, ChatPages {} diff --git a/tgui/packages/tgui-panel/settings/use-highlights.ts b/tgui/packages/tgui-panel/settings/use-highlights.ts new file mode 100644 index 00000000000..b96cf67b8e7 --- /dev/null +++ b/tgui/packages/tgui-panel/settings/use-highlights.ts @@ -0,0 +1,109 @@ +import { storage } from 'common/storage'; +import { useAtom, useAtomValue } from 'jotai'; +import { createUuid } from 'tgui-core/uuid'; +import { chatRenderer } from '../chat/renderer'; +import { defaultHighlightSetting, highlightsAtom, settingsAtom } from './atoms'; +import type { HighlightSetting, HighlightState } from './types'; + +/** Custom hook with utility functions for updating highlight settings */ +export function useHighlights() { + const [highlights, setHighlights] = useAtom(highlightsAtom); + const settings = useAtomValue(settingsAtom); + + function storeHighlights(update: HighlightState): void { + setHighlights(update); + storage.set('panel-settings', { + ...settings, + ...update, + }); + chatRenderer.setHighlight( + update.highlightSettings, + update.highlightSettingById, + ); + } + + function updateHighlight( + update: Partial & { id: string }, + ): void { + const { id } = update; + const current = highlights.highlightSettingById[id]; + if (!current) return; + + // Copies highlights and updates the specified setting + const draft: HighlightState['highlightSettingById'] = { + ...highlights.highlightSettingById, + [id]: { + ...current, + ...update, + }, + }; + + // Reconstruct the overall highlight structure + const newState: HighlightState = { + ...highlights, + highlightSettings: Object.keys(draft), + highlightSettingById: draft, + }; + + // Update state and persist to storage + storeHighlights(newState); + } + + function removeHighlight(id: string): void { + const draft: Record = {}; + // Rebuild the highlight settings without the specified id + for (const key in highlights.highlightSettingById) { + if (key !== id) { + draft[key] = highlights.highlightSettingById[key]; + } + } + + const draftKeys = highlights.highlightSettings.filter((key) => key !== id); + + // Ensure the default highlight setting always exists + if (id === defaultHighlightSetting.id) { + draft[defaultHighlightSetting.id] = defaultHighlightSetting; + draftKeys.unshift(defaultHighlightSetting.id); + } + + // Construct the updated highlight settings structure + const newState: HighlightState = { + ...highlights, + highlightSettingById: draft, + highlightSettings: draftKeys, + }; + + // Update state and persist to storage + storeHighlights(newState); + } + + function addHighlight(): void { + const draft: HighlightSetting = { + ...defaultHighlightSetting, + id: createUuid(), + }; + + // Append to the existing highlight settings + const updatedIds: HighlightState['highlightSettingById'] = { + ...highlights.highlightSettingById, + [draft.id]: draft, + }; + + // Reconstruct the overall highlight settings structure + const newState: HighlightState = { + ...highlights, + highlightSettings: [...highlights.highlightSettings, draft.id], + highlightSettingById: updatedIds, + }; + + // Update state and persist to storage + storeHighlights(newState); + } + + return { + highlights, + updateHighlight, + removeHighlight, + addHighlight, + }; +} diff --git a/tgui/packages/tgui-panel/settings/use-settings.ts b/tgui/packages/tgui-panel/settings/use-settings.ts new file mode 100644 index 00000000000..6422e735b2d --- /dev/null +++ b/tgui/packages/tgui-panel/settings/use-settings.ts @@ -0,0 +1,56 @@ +import { storage } from 'common/storage'; +import { useAtom, useAtomValue } from 'jotai'; +import { useEffect } from 'react'; +import { highlightsAtom, settingsAtom, settingsLoadedAtom } from './atoms'; +import { generalSettingsHandler } from './helpers'; +import { startSettingsMigration } from './migration'; +import { setDisplayScaling } from './scaling'; +import type { SettingsState } from './types'; + +/** Custom hook that handles loading and updating settings from storage. */ +export function useSettings() { + const [settings, setSettings] = useAtom(settingsAtom); + const highlights = useAtomValue(highlightsAtom); + + const [loaded, setLoaded] = useAtom(settingsLoadedAtom); + + /** Load and migrate settings */ + useEffect(() => { + if (loaded) return; + + async function fetchSettings(): Promise { + try { + const storedSettings = await storage.get('panel-settings'); + console.log('Loaded panel settings from storage:', storedSettings); + startSettingsMigration(storedSettings); + } catch (error) { + console.error('Failed to load panel settings:', error); + } + } + + fetchSettings(); + setDisplayScaling(); + setLoaded(true); + }, []); + + function storeSettings(update: SettingsState): void { + setSettings(update); + console.log('Updated panel settings:', update); + storage.set('panel-settings', { ...update, ...highlights }); + } + + /** Updates any set of keys. Offers type safety based on the selection */ + function updateSettings( + update: Record, + ): void { + const newSettings: SettingsState = { + ...settings, + ...update, + }; + + generalSettingsHandler(newSettings); + storeSettings(newSettings); + } + + return { settings, updateSettings }; +} diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json index 47d64bc70e9..4ea8abe8411 100644 --- a/tgui/packages/tgui-say/package.json +++ b/tgui/packages/tgui-say/package.json @@ -6,7 +6,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "tgui": "workspace:*", - "tgui-core": "^5.3.1" + "tgui-core": "^5.5.10" }, "private": true } diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json index c33da97bede..13157f150e7 100644 --- a/tgui/packages/tgui/package.json +++ b/tgui/packages/tgui/package.json @@ -5,9 +5,9 @@ "common": "workspace:*", "dateformat": "^5.0.3", "dompurify": "^3.2.5", - "es-toolkit": "^1.39.3", + "es-toolkit": "^1.43.0", "highlight.js": "^11.11.1", - "jotai": "^2.12.4", + "jotai": "^2.16.0", "js-yaml": "^4.1.0", "marked": "^15.0.11", "marked-base-url": "^1.1.6", @@ -15,7 +15,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-json-tree": "^0.20.0", - "tgui-core": "^5.3.1", + "tgui-core": "^5.5.10", "tgui-dev-server": "workspace:*" }, "private": true