mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-24 05:30:05 +01:00
Rewrites chat settings internals (#94436)
## About The Pull Request
Atomized from a larger PR. There should be no UI behavior changes with
this. It rewrites how settings are handled in tgui-panel internals.
Previously, all UI actions go through TGUI's bespoke redux
implementation. This is all part of the broader system we used to give
us reactive state in inferno. We have the real tools now - we shouldn't
be using this to get by. For the most part, I find
middleware/reducers/actions much more confusing.
Previously:
- user clicks open settings
- this fires a message ('settings/toggle')
- this message is intercepted by the store
- the message is filtered through a sieve of middleware and reducers
- the appropriate action is taken
- rerender
It's a miracle all of this works after having so many cooks in the
kitchen.
Now:
- user clicks open
- it directly flips the settingsVisible state
- rerender
BONUS: This now does runtime type checking, which should fix broken
settings and reduce the need to clear cache
## Why It's Good For The Game
It's done in a React-y way and should be easier to work on.
Eventually, I want to remove the redux implementation to just work with
the real state tools. This is a huge chunk.
Hopefully more reliable settings panel?
## Changelog
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
+15
-10
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ZodObject } from 'zod';
|
||||
|
||||
type MergeInput<TObj> = {
|
||||
/**
|
||||
* A zod object.
|
||||
* @see Writing a Zod Schema: https://zod.dev/basics
|
||||
*/
|
||||
schema: ZodObject<any>;
|
||||
/** The input getting merged */
|
||||
source: Record<string, unknown>;
|
||||
/** 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<TObj extends Record<string, unknown>>(
|
||||
input: MergeInput<TObj>,
|
||||
): 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 };
|
||||
}
|
||||
@@ -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 <KitchenSink panel />;
|
||||
}
|
||||
}
|
||||
const [settingsVisible, setSettingsVisible] = useAtom(settingsVisibleAtom);
|
||||
|
||||
return (
|
||||
<Pane theme={settings.theme}>
|
||||
@@ -51,13 +47,11 @@ export const Panel = (props) => {
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon={settings.visible ? 'times' : 'cog'}
|
||||
selected={settings.visible}
|
||||
tooltip={
|
||||
settings.visible ? 'Close settings' : 'Open settings'
|
||||
}
|
||||
icon={settingsVisible ? 'times' : 'cog'}
|
||||
selected={settingsVisible}
|
||||
tooltip={settingsVisible ? 'Close settings' : 'Open settings'}
|
||||
tooltipPosition="bottom-start"
|
||||
onClick={() => settings.toggle()}
|
||||
onClick={() => setSettingsVisible(!settingsVisible)}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
@@ -70,7 +64,7 @@ export const Panel = (props) => {
|
||||
</Section>
|
||||
</Stack.Item>
|
||||
)}
|
||||
{settings.visible && (
|
||||
{settingsVisible && (
|
||||
<Stack.Item>
|
||||
<SettingsPanel />
|
||||
</Stack.Item>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Provider } from 'jotai';
|
||||
import { store } from './events/store';
|
||||
import { Panel } from './Panel';
|
||||
|
||||
/** Just an expandable wrapper for setup shenanigans and providers */
|
||||
export function App() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const { useDebug, KitchenSink } = require('tgui/debug');
|
||||
const debug = useDebug();
|
||||
if (debug.kitchenSink) {
|
||||
return <KitchenSink panel />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Panel />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
+3
-4
@@ -7,14 +7,13 @@
|
||||
import { useDispatch, useSelector } from 'tgui/backend';
|
||||
import { Button, Collapsible, Flex, Knob, Section } from 'tgui-core/components';
|
||||
import { toFixed } from 'tgui-core/math';
|
||||
|
||||
import { useSettings } from '../settings';
|
||||
import { useSettings } from '../settings/use-settings';
|
||||
import { selectAudio } from './selectors';
|
||||
|
||||
export const NowPlayingWidget = (props) => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const audio = useSelector(selectAudio),
|
||||
dispatch = useDispatch(),
|
||||
settings = useSettings(),
|
||||
title = audio.meta?.title,
|
||||
URL = audio.meta?.link,
|
||||
Artist = audio.meta?.artist || 'Unknown Artist',
|
||||
@@ -100,7 +99,7 @@ export const NowPlayingWidget = (props) => {
|
||||
stepPixelSize={1}
|
||||
format={(value) => `${toFixed(value * 100)}%`}
|
||||
onChange={(e, value) =>
|
||||
settings.update({
|
||||
updateSettings({
|
||||
adminMusicVolume: value,
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { chatRenderer } from './renderer';
|
||||
|
||||
type Props = {
|
||||
fontSize?: string;
|
||||
lineHeight: string;
|
||||
lineHeight: string | number;
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useDispatch, useSelector } from 'tgui/backend';
|
||||
import { Box, Button, Stack, Tabs } from 'tgui-core/components';
|
||||
|
||||
import { openChatSettings } from '../settings/actions';
|
||||
import { settingsVisibleAtom } from '../settings/atoms';
|
||||
import { addChatPage, changeChatPage } from './actions';
|
||||
import { selectChatPages, selectCurrentChatPage } from './selectors';
|
||||
|
||||
@@ -20,6 +20,8 @@ export function ChatTabs(props) {
|
||||
const currentPage = useSelector(selectCurrentChatPage);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [, setSettingsVisible] = useAtom(settingsVisibleAtom);
|
||||
|
||||
return (
|
||||
<Stack align="center">
|
||||
<Stack.Item>
|
||||
@@ -50,7 +52,7 @@ export function ChatTabs(props) {
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dispatch(addChatPage());
|
||||
dispatch(openChatSettings());
|
||||
setSettingsVisible(true);
|
||||
}}
|
||||
/>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -7,16 +7,8 @@
|
||||
import type { Store } from 'common/redux';
|
||||
import { storage } from 'common/storage';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import {
|
||||
addHighlightSetting,
|
||||
importSettings,
|
||||
loadSettings,
|
||||
removeHighlightSetting,
|
||||
updateHighlightSetting,
|
||||
updateSettings,
|
||||
} from '../settings/actions';
|
||||
import { selectSettings } from '../settings/selectors';
|
||||
import { store as jotaiStore } from '../events/store';
|
||||
import { settingsAtom } from '../settings/atoms';
|
||||
import {
|
||||
addChatPage,
|
||||
changeChatPage,
|
||||
@@ -101,7 +93,7 @@ export const chatMiddleware = (store: Store) => {
|
||||
});
|
||||
return (next) => (action) => {
|
||||
const { type, payload } = action;
|
||||
const settings = selectSettings(store.getState());
|
||||
const settings = jotaiStore.get(settingsAtom);
|
||||
// Load the chat once settings are loaded
|
||||
if (!initialized && settings.initialized) {
|
||||
setInterval(() => {
|
||||
@@ -174,23 +166,6 @@ export const chatMiddleware = (store: Store) => {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
if (
|
||||
type === updateSettings.type ||
|
||||
type === loadSettings.type ||
|
||||
type === addHighlightSetting.type ||
|
||||
type === removeHighlightSetting.type ||
|
||||
type === updateHighlightSetting.type ||
|
||||
type === importSettings.type
|
||||
) {
|
||||
next(action);
|
||||
const nextSettings = selectSettings(store.getState());
|
||||
chatRenderer.setHighlight(
|
||||
nextSettings.highlightSettings,
|
||||
nextSettings.highlightSettingById,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if (type === 'roundrestart') {
|
||||
// Save chat as soon as possible
|
||||
saveChatToStorage(store);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export type ChatPages = {
|
||||
chatPages: Record<string, Page>;
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
isMain: boolean;
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createStore } from 'jotai';
|
||||
|
||||
export const store = createStore();
|
||||
@@ -16,14 +16,12 @@ import { render } from 'tgui/renderer';
|
||||
import { configureStore } from 'tgui/store';
|
||||
import { setupGlobalEvents } from 'tgui-core/events';
|
||||
import { setupHotReloading } from 'tgui-dev-server/link/client';
|
||||
|
||||
import { App } from './app';
|
||||
import { audioMiddleware, audioReducer } from './audio';
|
||||
import { chatMiddleware, chatReducer } from './chat';
|
||||
import { gameMiddleware, gameReducer } from './game';
|
||||
import { Panel } from './Panel';
|
||||
import { setupPanelFocusHacks } from './panelFocus';
|
||||
import { pingMiddleware, pingReducer } from './ping';
|
||||
import { settingsMiddleware, settingsReducer } from './settings';
|
||||
import { telemetryMiddleware } from './telemetry';
|
||||
|
||||
perf.mark('inception', window.performance?.timeOrigin);
|
||||
@@ -35,14 +33,12 @@ const store = configureStore({
|
||||
chat: chatReducer,
|
||||
game: gameReducer,
|
||||
ping: pingReducer,
|
||||
settings: settingsReducer,
|
||||
}),
|
||||
middleware: {
|
||||
pre: [
|
||||
chatMiddleware,
|
||||
pingMiddleware,
|
||||
telemetryMiddleware,
|
||||
settingsMiddleware,
|
||||
audioMiddleware,
|
||||
gameMiddleware,
|
||||
],
|
||||
@@ -66,7 +62,7 @@ function setupApp() {
|
||||
captureExternalLinks();
|
||||
|
||||
// Re-render UI on store updates
|
||||
store.subscribe(() => render(<Panel />));
|
||||
store.subscribe(() => render(<App />));
|
||||
|
||||
// Dispatch incoming messages as store actions
|
||||
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
|
||||
@@ -95,11 +91,10 @@ function setupApp() {
|
||||
'./Notifications',
|
||||
'./Panel',
|
||||
'./ping',
|
||||
'./settings',
|
||||
'./telemetry',
|
||||
],
|
||||
() => {
|
||||
render(<Panel />);
|
||||
render(<App />);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,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-dev-server": "workspace:*"
|
||||
"tgui-core": "^5.5.10",
|
||||
"tgui-dev-server": "workspace:*",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -12,20 +12,18 @@ import {
|
||||
} from 'tgui-core/components';
|
||||
import { toFixed } from 'tgui-core/math';
|
||||
import { capitalize } from 'tgui-core/string';
|
||||
|
||||
import { clearChat, saveChatToDisk } from '../chat/actions';
|
||||
import { THEMES } from '../themes';
|
||||
import { exportSettings, updateSettings } from './actions';
|
||||
import { FONTS } from './constants';
|
||||
import { selectChatPages } from '../chat/selectors';
|
||||
import { FONTS, THEMES } from './constants';
|
||||
import { resetPaneSplitters, setEditPaneSplitters } from './scaling';
|
||||
import { selectSettings } from './selectors';
|
||||
import { importChatSettings } from './settingsImExport';
|
||||
import { exportChatSettings, importChatSettings } from './settingsImExport';
|
||||
import { useSettings } from './use-settings';
|
||||
|
||||
export function SettingsGeneral(props) {
|
||||
const { theme, fontFamily, fontSize, lineHeight } =
|
||||
useSelector(selectSettings);
|
||||
const dispatch = useDispatch();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [freeFont, setFreeFont] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const chat = useSelector(selectChatPages);
|
||||
|
||||
const [editingPanes, setEditingPanes] = useState(false);
|
||||
|
||||
@@ -36,14 +34,12 @@ export function SettingsGeneral(props) {
|
||||
{THEMES.map((THEME) => (
|
||||
<Button
|
||||
key={THEME}
|
||||
selected={theme === THEME}
|
||||
selected={settings.theme === THEME}
|
||||
color="transparent"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
theme: THEME,
|
||||
}),
|
||||
)
|
||||
updateSettings({
|
||||
theme: THEME,
|
||||
})
|
||||
}
|
||||
>
|
||||
{capitalize(THEME)}
|
||||
@@ -77,8 +73,8 @@ export function SettingsGeneral(props) {
|
||||
<Stack.Item>
|
||||
{!freeFont ? (
|
||||
<Collapsible
|
||||
title={fontFamily}
|
||||
width={'100%'}
|
||||
title={settings.fontFamily}
|
||||
width="100%"
|
||||
buttons={
|
||||
<Button
|
||||
icon={freeFont ? 'lock-open' : 'lock'}
|
||||
@@ -95,14 +91,12 @@ export function SettingsGeneral(props) {
|
||||
<Button
|
||||
key={FONT}
|
||||
fontFamily={FONT}
|
||||
selected={fontFamily === FONT}
|
||||
selected={settings.fontFamily === FONT}
|
||||
color="transparent"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
fontFamily: FONT,
|
||||
}),
|
||||
)
|
||||
updateSettings({
|
||||
fontFamily: FONT,
|
||||
})
|
||||
}
|
||||
>
|
||||
{FONT}
|
||||
@@ -113,13 +107,11 @@ export function SettingsGeneral(props) {
|
||||
<Stack>
|
||||
<Input
|
||||
fluid
|
||||
value={fontFamily}
|
||||
value={settings.fontFamily}
|
||||
onBlur={(value) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
fontFamily: value,
|
||||
}),
|
||||
)
|
||||
updateSettings({
|
||||
fontFamily: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
@@ -145,12 +137,10 @@ export function SettingsGeneral(props) {
|
||||
stepPixelSize={20}
|
||||
minValue={8}
|
||||
maxValue={32}
|
||||
value={fontSize}
|
||||
value={settings.fontSize}
|
||||
unit="px"
|
||||
format={(value) => toFixed(value)}
|
||||
onChange={(e, value) =>
|
||||
dispatch(updateSettings({ fontSize: value }))
|
||||
}
|
||||
onChange={(e, value) => updateSettings({ fontSize: value })}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
@@ -161,14 +151,12 @@ export function SettingsGeneral(props) {
|
||||
step={0.01}
|
||||
minValue={0.8}
|
||||
maxValue={5}
|
||||
value={lineHeight}
|
||||
value={settings.lineHeight}
|
||||
format={(value) => toFixed(value, 2)}
|
||||
onChange={(e, value) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
lineHeight: value,
|
||||
}),
|
||||
)
|
||||
updateSettings({
|
||||
lineHeight: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
@@ -179,7 +167,7 @@ export function SettingsGeneral(props) {
|
||||
<Button
|
||||
icon="compact-disc"
|
||||
tooltip="Export chat settings"
|
||||
onClick={() => dispatch(exportSettings())}
|
||||
onClick={() => exportChatSettings(chat)}
|
||||
>
|
||||
Export settings
|
||||
</Button>
|
||||
@@ -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
|
||||
</Button.File>
|
||||
|
||||
@@ -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 (
|
||||
<Stack fill>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = () => (
|
||||
<NoticeBox color="red">Unlink Stat Panel from chat!</NoticeBox>
|
||||
);
|
||||
function LinkedToChat() {
|
||||
return <NoticeBox color="red">Unlink Stat Panel from chat!</NoticeBox>;
|
||||
}
|
||||
|
||||
export function SettingsStatPanel(props) {
|
||||
const { statLinked, statFontSize, statTabsStyle } =
|
||||
useSelector(selectSettings);
|
||||
const dispatch = useDispatch();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { statLinked, statFontSize, statTabsStyle } = settings;
|
||||
|
||||
return (
|
||||
<Section fill>
|
||||
@@ -29,14 +26,12 @@ export function SettingsStatPanel(props) {
|
||||
<Stack.Item>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Tabs" verticalAlign="middle">
|
||||
{TabsViews.map((view) => (
|
||||
{tabViews.map((view) => (
|
||||
<Button
|
||||
key={view}
|
||||
color="transparent"
|
||||
selected={statTabsStyle === view}
|
||||
onClick={() =>
|
||||
dispatch(updateSettings({ statTabsStyle: view }))
|
||||
}
|
||||
onClick={() => updateSettings({ statTabsStyle: view })}
|
||||
>
|
||||
{capitalize(view)}
|
||||
</Button>
|
||||
@@ -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'}
|
||||
</Button>
|
||||
|
||||
@@ -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) {
|
||||
<Button
|
||||
color="transparent"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dispatch(addHighlightSetting());
|
||||
}}
|
||||
onClick={() => addHighlight()}
|
||||
>
|
||||
Add Highlight Setting
|
||||
</Button>
|
||||
@@ -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) {
|
||||
<Button
|
||||
color="transparent"
|
||||
icon="times"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
removeHighlightSetting({
|
||||
id: id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClick={() => removeHighlight(id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack.Item>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* @file
|
||||
*/
|
||||
import { createUuid } from 'tgui-core/uuid';
|
||||
|
||||
export const createHighlightSetting = (obj?: Record<string, any>) => ({
|
||||
id: createUuid(),
|
||||
highlightText: '',
|
||||
highlightColor: '#ffdd44',
|
||||
highlightWholeMessage: true,
|
||||
matchWord: false,
|
||||
matchCase: false,
|
||||
...obj,
|
||||
});
|
||||
|
||||
export const createDefaultHighlightSetting = (obj?: Record<string, any>) =>
|
||||
createHighlightSetting({
|
||||
id: 'default',
|
||||
...obj,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<string, Page>): void {
|
||||
const settings = store.get(storedSettingsAtom);
|
||||
|
||||
export function exportChatSettings(
|
||||
settings: Record<string, any>,
|
||||
pages: Record<string, Page>[],
|
||||
) {
|
||||
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<string, Page>[] = [];
|
||||
if ('chatPages' in ourImport) {
|
||||
pageRecord = ourImport.chatPages;
|
||||
delete ourImport.chatPages;
|
||||
}
|
||||
const pageRecord = ourImport.chatPages;
|
||||
delete ourImport.chatPages;
|
||||
|
||||
dispatch(importSettings(ourImport, pageRecord));
|
||||
startSettingsMigration(ourImport);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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<string, HighlightSetting>;
|
||||
/** Keep this for compatibility with other servers */
|
||||
highlightText: string;
|
||||
};
|
||||
|
||||
export type SettingsState = z.infer<typeof settingsSchema>;
|
||||
|
||||
// Imported and loaded settings without chatpages
|
||||
export interface MergedSettings extends SettingsState, HighlightState {}
|
||||
|
||||
// Full exported settings with chatpages
|
||||
export interface ExportedSettings extends MergedSettings, ChatPages {}
|
||||
@@ -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<HighlightSetting> & { 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<string, HighlightSetting> = {};
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<TKey extends keyof SettingsState>(
|
||||
update: Record<TKey, SettingsState[TKey]>,
|
||||
): void {
|
||||
const newSettings: SettingsState = {
|
||||
...settings,
|
||||
...update,
|
||||
};
|
||||
|
||||
generalSettingsHandler(newSettings);
|
||||
storeSettings(newSettings);
|
||||
}
|
||||
|
||||
return { settings, updateSettings };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user