mirror of
https://github.com/CHOMPStation2/CHOMPStation2.git
synced 2025-12-15 04:32:42 +00:00
Bundle Copy
This commit is contained in:
37
tgui/packages/tgui_ch/assets.js
Normal file
37
tgui/packages/tgui_ch/assets.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const EXCLUDED_PATTERNS = [/v4shim/i];
|
||||
const loadedMappings = {};
|
||||
|
||||
export const resolveAsset = (name) => loadedMappings[name] || name;
|
||||
|
||||
export const assetMiddleware = (store) => (next) => (action) => {
|
||||
const { type, payload } = action;
|
||||
if (type === 'asset/stylesheet') {
|
||||
Byond.loadCss(payload);
|
||||
return;
|
||||
}
|
||||
if (type === 'asset/mappings') {
|
||||
for (let name of Object.keys(payload)) {
|
||||
// Skip anything that matches excluded patterns
|
||||
if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) {
|
||||
continue;
|
||||
}
|
||||
const url = payload[name];
|
||||
const ext = name.split('.').pop();
|
||||
loadedMappings[name] = url;
|
||||
if (ext === 'css') {
|
||||
Byond.loadCss(url);
|
||||
}
|
||||
if (ext === 'js') {
|
||||
Byond.loadJs(url);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
next(action);
|
||||
};
|
||||
8
tgui/packages/tgui_ch/assets/bg-nanotrasen.svg
Normal file
8
tgui/packages/tgui_ch/assets/bg-nanotrasen.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 425 200" opacity=".33">
|
||||
<path d="m 178.00399,0.03869 -71.20393,0 a 6.7613422,6.0255495 0 0 0 -6.76134,6.02555 l 0,187.87147 a 6.7613422,6.0255495 0 0 0 6.76134,6.02554 l 53.1072,0 a 6.7613422,6.0255495 0 0 0 6.76135,-6.02554 l 0,-101.544018 72.21628,104.699398 a 6.7613422,6.0255495 0 0 0 5.76015,2.87016 l 73.55487,0 a 6.7613422,6.0255495 0 0 0 6.76135,-6.02554 l 0,-187.87147 a 6.7613422,6.0255495 0 0 0 -6.76135,-6.02555 l -54.71644,0 a 6.7613422,6.0255495 0 0 0 -6.76133,6.02555 l 0,102.61935 L 183.76413,2.90886 a 6.7613422,6.0255495 0 0 0 -5.76014,-2.87017 z" />
|
||||
<path d="M 4.8446333,22.10875 A 13.412039,12.501842 0 0 1 13.477588,0.03924 l 66.118315,0 a 5.3648158,5.000737 0 0 1 5.364823,5.00073 l 0,79.87931 z" />
|
||||
<path d="m 420.15535,177.89119 a 13.412038,12.501842 0 0 1 -8.63295,22.06951 l -66.11832,0 a 5.3648152,5.000737 0 0 1 -5.36482,-5.00074 l 0,-79.87931 z" />
|
||||
</svg>
|
||||
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
|
||||
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
5
tgui/packages/tgui_ch/assets/bg-neutral.svg
Normal file
5
tgui/packages/tgui_ch/assets/bg-neutral.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="user-secret" class="svg-inline--fa fa-user-secret fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" opacity=".33">
|
||||
<path fill="currentColor" d="M383.9 308.3l23.9-62.6c4-10.5-3.7-21.7-15-21.7h-58.5c11-18.9 17.8-40.6 17.8-64v-.3c39.2-7.8 64-19.1 64-31.7 0-13.3-27.3-25.1-70.1-33-9.2-32.8-27-65.8-40.6-82.8-9.5-11.9-25.9-15.6-39.5-8.8l-27.6 13.8c-9 4.5-19.6 4.5-28.6 0L182.1 3.4c-13.6-6.8-30-3.1-39.5 8.8-13.5 17-31.4 50-40.6 82.8-42.7 7.9-70 19.7-70 33 0 12.6 24.8 23.9 64 31.7v.3c0 23.4 6.8 45.1 17.8 64H56.3c-11.5 0-19.2 11.7-14.7 22.3l25.8 60.2C27.3 329.8 0 372.7 0 422.4v44.8C0 491.9 20.1 512 44.8 512h358.4c24.7 0 44.8-20.1 44.8-44.8v-44.8c0-48.4-25.8-90.4-64.1-114.1zM176 480l-41.6-192 49.6 32 24 40-32 120zm96 0l-32-120 24-40 49.6-32L272 480zm41.7-298.5c-3.9 11.9-7 24.6-16.5 33.4-10.1 9.3-48 22.4-64-25-2.8-8.4-15.4-8.4-18.3 0-17 50.2-56 32.4-64 25-9.5-8.8-12.7-21.5-16.5-33.4-.8-2.5-6.3-5.7-6.3-5.8v-10.8c28.3 3.6 61 5.8 96 5.8s67.7-2.1 96-5.8v10.8c-.1.1-5.6 3.2-6.4 5.8z"></path>
|
||||
</svg>
|
||||
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
|
||||
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
tgui/packages/tgui_ch/assets/bg-syndicate.svg
Normal file
6
tgui/packages/tgui_ch/assets/bg-syndicate.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 200 289.742" opacity=".33">
|
||||
<path d="m 93.537677,0 c -18.113125,0 -34.220133,3.11164 -48.323484,9.33437 -13.965092,6.22167 -24.612442,15.07114 -31.940651,26.5471 -7.1899398,11.33789 -10.3012266,24.74911 -10.3012266,40.23478 0,10.64662 2.7250026,20.46465 8.1751116,29.45258 5.615277,8.98686 14.038277,17.35204 25.268821,25.09436 11.230544,7.60531 26.507421,15.41835 45.830514,23.43782 19.983748,8.29557 34.848848,15.55471 44.592998,21.77638 9.74414,6.22273 16.7617,12.8585 21.05572,19.90951 4.29404,7.05208 6.44193,15.76408 6.44193,26.13459 0,16.17702 -5.20196,28.48222 -15.60673,36.91682 -10.2396,8.4347 -25.02203,12.6523 -44.345169,12.6523 -14.038171,0 -25.515247,-1.6594 -34.433618,-4.9777 -8.91837,-3.4566 -16.185572,-8.7113 -21.800839,-15.7633 -5.615277,-7.0521 -10.074795,-16.66088 -13.377899,-28.82812 l -24.7731626293945,0 0,56.82632 C 33.856769,286.07601 63.74904,289.74201 89.678383,289.74201 c 16.020027,0 30.719787,-1.3827 44.097337,-4.1479 13.54272,-2.9043 25.1041,-7.4676 34.68309,-13.6893 9.74413,-6.3597 17.34042,-14.5195 22.79052,-24.4748 5.4501,-10.09332 8.17511,-22.39959 8.17511,-36.91682 0,-12.99764 -3.3021,-24.33539 -9.90829,-34.0146 -6.44105,-9.81725 -15.52545,-18.52707 -27.25146,-26.13133 -11.56085,-7.60427 -27.91083,-15.83142 -49.05066,-24.68022 -17.50644,-7.19012 -30.719668,-13.68948 -39.638038,-19.49701 -8.918371,-5.80752 -18.607474,-12.43409 -24.096524,-18.87417 -5.426043,-6.36616 -9.658826,-15.07003 -9.658826,-24.88729 0,-9.26401 2.075414,-17.21345 6.223454,-23.85033 11.098298,-14.39748 41.286638,-1.79507 45.075609,24.34762 4.839392,6.77491 8.84935,16.24729 12.029515,28.4156 l 20.53234,0 0,-55.99967 c -4.47825,-5.92448 -9.95488,-10.63222 -15.90837,-14.37411 1.64055,0.47905 3.19039,1.02376 4.63865,1.64024 6.49861,2.62607 12.16793,7.32747 17.0073,14.10345 4.83939,6.77491 8.84935,16.24567 12.02952,28.41397 0,0 8.48128,-0.12894 8.48978,-0.002 0.41776,6.41494 -1.75339,9.45286 -4.12342,12.56104 -2.4174,3.16978 -5.14486,6.78973 -4.00278,13.0029 1.50786,8.20318 10.18354,10.59642 14.62194,9.31154 -3.31842,-0.49911 -5.31855,-1.74948 -5.31855,-1.74948 0,0 1.87646,0.99868 5.65117,-1.35981 -3.27695,0.95571 -10.70529,-0.79738 -11.80125,-6.76313 -0.95752,-5.20861 0.94654,-7.29514 3.40113,-10.51482 2.45462,-3.21968 5.28426,-6.95831 4.6843,-14.48824 l 0.003,0.002 8.92676,0 0,-55.99967 c -15.07125,-3.87168 -27.65314,-6.36042 -37.74671,-7.46586 -9.95531,-1.10755 -20.18823,-1.65981 -30.696613,-1.65981 z m 70.321603,17.30893 0.23805,40.3049 c 1.31808,1.22666 2.43965,2.27815 3.34081,3.10602 4.83939,6.77491 8.84934,16.24566 12.02951,28.41397 l 20.53234,0 0,-55.99967 c -6.67731,-4.59381 -19.83643,-10.47309 -36.14071,-15.82522 z m -28.12049,5.60551 8.56479,17.71655 c -11.97037,-6.46697 -13.84678,-9.71726 -8.56479,-17.71655 z m 22.79705,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z m 15.22195,24.00848 8.56479,17.71655 c -11.97038,-6.46697 -13.84679,-9.71726 -8.56479,-17.71655 z m 22.79704,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z m -99.11384,2.20764 8.56479,17.71655 c -11.970382,-6.46697 -13.846782,-9.71726 -8.56479,-17.71655 z m 22.79542,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z" />
|
||||
</svg>
|
||||
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
|
||||
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
3
tgui/packages/tgui_ch/assets/bg-wizard.svg
Normal file
3
tgui/packages/tgui_ch/assets/bg-wizard.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="meteor" class="svg-inline--fa fa-meteor fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" opacity=".33"><path fill="currentColor" d="M511.328,20.8027c-11.60759,38.70264-34.30724,111.70173-61.30311,187.70077,6.99893,2.09372,13.4042,4,18.60653,5.59368a16.06158,16.06158,0,0,1,9.49854,22.906c-22.106,42.29635-82.69047,152.795-142.47819,214.40356-.99984,1.09373-1.99969,2.5-2.99954,3.49995A194.83046,194.83046,0,1,1,57.085,179.41009c.99985-1,2.40588-2,3.49947-3,61.59994-59.90549,171.97367-120.40473,214.37343-142.4982a16.058,16.058,0,0,1,22.90274,9.49988c1.59351,5.09368,3.49947,11.5936,5.5929,18.59351C379.34818,35.00565,452.43074,12.30281,491.12794.70921A16.18325,16.18325,0,0,1,511.328,20.8027ZM319.951,320.00207A127.98041,127.98041,0,1,0,191.97061,448.00046,127.97573,127.97573,0,0,0,319.951,320.00207Zm-127.98041-31.9996a31.9951,31.9951,0,1,1-31.9951-31.9996A31.959,31.959,0,0,1,191.97061,288.00247Zm31.9951,79.999a15.99755,15.99755,0,1,1-15.99755-15.9998A16.04975,16.04975,0,0,1,223.96571,368.00147Z"></path></svg>
|
||||
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
|
||||
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
333
tgui/packages/tgui_ch/backend.ts
Normal file
333
tgui/packages/tgui_ch/backend.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* This file provides a clear separation layer between backend updates
|
||||
* and what state our React app sees.
|
||||
*
|
||||
* Sometimes backend can response without a "data" field, but our final
|
||||
* state will still contain previous "data" because we are merging
|
||||
* the response with already existing state.
|
||||
*
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { perf } from 'common/perf';
|
||||
import { createAction } from 'common/redux';
|
||||
import { setupDrag } from './drag';
|
||||
import { focusMap } from './focus';
|
||||
import { createLogger } from './logging';
|
||||
import { resumeRenderer, suspendRenderer } from './renderer';
|
||||
|
||||
const logger = createLogger('backend');
|
||||
|
||||
export const backendUpdate = createAction('backend/update');
|
||||
export const backendSetSharedState = createAction('backend/setSharedState');
|
||||
export const backendSuspendStart = createAction('backend/suspendStart');
|
||||
|
||||
export const backendSuspendSuccess = () => ({
|
||||
type: 'backend/suspendSuccess',
|
||||
payload: {
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
config: {},
|
||||
data: {},
|
||||
shared: {},
|
||||
// Start as suspended
|
||||
suspended: Date.now(),
|
||||
suspending: false,
|
||||
};
|
||||
|
||||
export const backendReducer = (state = initialState, action) => {
|
||||
const { type, payload } = action;
|
||||
|
||||
if (type === 'backend/update') {
|
||||
// Merge config
|
||||
const config = {
|
||||
...state.config,
|
||||
...payload.config,
|
||||
};
|
||||
// Merge data
|
||||
const data = {
|
||||
...state.data,
|
||||
...payload.static_data,
|
||||
...payload.data,
|
||||
};
|
||||
// Merge shared states
|
||||
const shared = { ...state.shared };
|
||||
if (payload.shared) {
|
||||
for (let key of Object.keys(payload.shared)) {
|
||||
const value = payload.shared[key];
|
||||
if (value === '') {
|
||||
shared[key] = undefined;
|
||||
} else {
|
||||
shared[key] = JSON.parse(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return new state
|
||||
return {
|
||||
...state,
|
||||
config,
|
||||
data,
|
||||
shared,
|
||||
suspended: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'backend/setSharedState') {
|
||||
const { key, nextState } = payload;
|
||||
return {
|
||||
...state,
|
||||
shared: {
|
||||
...state.shared,
|
||||
[key]: nextState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'backend/suspendStart') {
|
||||
return {
|
||||
...state,
|
||||
suspending: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'backend/suspendSuccess') {
|
||||
const { timestamp } = payload;
|
||||
return {
|
||||
...state,
|
||||
data: {},
|
||||
shared: {},
|
||||
config: {
|
||||
...state.config,
|
||||
title: '',
|
||||
status: 1,
|
||||
},
|
||||
suspending: false,
|
||||
suspended: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const backendMiddleware = (store) => {
|
||||
let fancyState;
|
||||
let suspendInterval;
|
||||
|
||||
return (next) => (action) => {
|
||||
const { suspended } = selectBackend(store.getState());
|
||||
const { type, payload } = action;
|
||||
|
||||
if (type === 'update') {
|
||||
store.dispatch(backendUpdate(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'suspend') {
|
||||
store.dispatch(backendSuspendSuccess());
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ping') {
|
||||
Byond.sendMessage('pingReply');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'backend/suspendStart' && !suspendInterval) {
|
||||
logger.log(`suspending (${Byond.windowId})`);
|
||||
// Keep sending suspend messages until it succeeds.
|
||||
// It may fail multiple times due to topic rate limiting.
|
||||
const suspendFn = () => Byond.sendMessage('suspend');
|
||||
suspendFn();
|
||||
suspendInterval = setInterval(suspendFn, 2000);
|
||||
}
|
||||
|
||||
if (type === 'backend/suspendSuccess') {
|
||||
suspendRenderer();
|
||||
clearInterval(suspendInterval);
|
||||
suspendInterval = undefined;
|
||||
Byond.winset(Byond.windowId, {
|
||||
'is-visible': false,
|
||||
});
|
||||
setImmediate(() => focusMap());
|
||||
}
|
||||
|
||||
if (type === 'backend/update') {
|
||||
const fancy = payload.config?.window?.fancy;
|
||||
// Initialize fancy state
|
||||
if (fancyState === undefined) {
|
||||
fancyState = fancy;
|
||||
}
|
||||
// React to changes in fancy
|
||||
else if (fancyState !== fancy) {
|
||||
logger.log('changing fancy mode to', fancy);
|
||||
fancyState = fancy;
|
||||
Byond.winset(Byond.windowId, {
|
||||
titlebar: !fancy,
|
||||
'can-resize': !fancy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resume on incoming update
|
||||
if (type === 'backend/update' && suspended) {
|
||||
// Show the payload
|
||||
logger.log('backend/update', payload);
|
||||
// Signal renderer that we have resumed
|
||||
resumeRenderer();
|
||||
// Setup drag
|
||||
setupDrag();
|
||||
// We schedule this for the next tick here because resizing and unhiding
|
||||
// during the same tick will flash with a white background.
|
||||
setImmediate(() => {
|
||||
perf.mark('resume/start');
|
||||
// Doublecheck if we are not re-suspended.
|
||||
const { suspended } = selectBackend(store.getState());
|
||||
if (suspended) {
|
||||
return;
|
||||
}
|
||||
Byond.winset(Byond.windowId, {
|
||||
'is-visible': true,
|
||||
});
|
||||
perf.mark('resume/finish');
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.log('visible in', perf.measure('render/finish', 'resume/finish'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends an action to `ui_act` on `src_object` that this tgui window
|
||||
* is associated with.
|
||||
*/
|
||||
export const sendAct = (action: string, payload: object = {}) => {
|
||||
// Validate that payload is an object
|
||||
const isObject = typeof payload === 'object' && payload !== null && !Array.isArray(payload);
|
||||
if (!isObject) {
|
||||
logger.error(`Payload for act() must be an object, got this:`, payload);
|
||||
return;
|
||||
}
|
||||
Byond.sendMessage('act/' + action, payload);
|
||||
};
|
||||
|
||||
type BackendState<TData> = {
|
||||
config: {
|
||||
title: string;
|
||||
status: number;
|
||||
interface: string;
|
||||
refreshing: boolean;
|
||||
window: {
|
||||
key: string;
|
||||
size: [number, number];
|
||||
fancy: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
client: {
|
||||
ckey: string;
|
||||
address: string;
|
||||
computer_id: string;
|
||||
};
|
||||
user: {
|
||||
name: string;
|
||||
observer: number;
|
||||
};
|
||||
};
|
||||
data: TData;
|
||||
shared: Record<string, any>;
|
||||
suspending: boolean;
|
||||
suspended: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects a backend-related slice of Redux state
|
||||
*/
|
||||
export const selectBackend = <TData>(state: any): BackendState<TData> => state.backend || {};
|
||||
|
||||
/**
|
||||
* Get data from tgui backend.
|
||||
*
|
||||
* Includes the `act` function for performing DM actions.
|
||||
*/
|
||||
export const useBackend = <TData>(context: any) => {
|
||||
const { store } = context;
|
||||
const state = selectBackend<TData>(store.getState());
|
||||
return {
|
||||
...state,
|
||||
act: sendAct,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A tuple that contains the state and a setter function for it.
|
||||
*/
|
||||
type StateWithSetter<T> = [T, (nextState: T) => void];
|
||||
|
||||
/**
|
||||
* Allocates state on Redux store without sharing it with other clients.
|
||||
*
|
||||
* Use it when you want to have a stateful variable in your component
|
||||
* that persists between renders, but will be forgotten after you close
|
||||
* the UI.
|
||||
*
|
||||
* It is a lot more performant than `setSharedState`.
|
||||
*
|
||||
* @param context React context.
|
||||
* @param key Key which uniquely identifies this state in Redux store.
|
||||
* @param initialState Initializes your global variable with this value.
|
||||
*/
|
||||
export const useLocalState = <T>(context: any, key: string, initialState: T): StateWithSetter<T> => {
|
||||
const { store } = context;
|
||||
const state = selectBackend(store.getState());
|
||||
const sharedStates = state.shared ?? {};
|
||||
const sharedState = key in sharedStates ? sharedStates[key] : initialState;
|
||||
return [
|
||||
sharedState,
|
||||
(nextState) => {
|
||||
store.dispatch(
|
||||
backendSetSharedState({
|
||||
key,
|
||||
nextState: typeof nextState === 'function' ? nextState(sharedState) : nextState,
|
||||
})
|
||||
);
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Allocates state on Redux store, and **shares** it with other clients
|
||||
* in the game.
|
||||
*
|
||||
* Use it when you want to have a stateful variable in your component
|
||||
* that persists not only between renders, but also gets pushed to other
|
||||
* clients that observe this UI.
|
||||
*
|
||||
* This makes creation of observable s
|
||||
*
|
||||
* @param context React context.
|
||||
* @param key Key which uniquely identifies this state in Redux store.
|
||||
* @param initialState Initializes your global variable with this value.
|
||||
*/
|
||||
export const useSharedState = <T>(context: any, key: string, initialState: T): StateWithSetter<T> => {
|
||||
const { store } = context;
|
||||
const state = selectBackend(store.getState());
|
||||
const sharedStates = state.shared ?? {};
|
||||
const sharedState = key in sharedStates ? sharedStates[key] : initialState;
|
||||
return [
|
||||
sharedState,
|
||||
(nextState) => {
|
||||
Byond.sendMessage({
|
||||
type: 'setSharedState',
|
||||
key,
|
||||
value: JSON.stringify(typeof nextState === 'function' ? nextState(sharedState) : nextState) || '',
|
||||
});
|
||||
},
|
||||
];
|
||||
};
|
||||
81
tgui/packages/tgui_ch/components/AnimatedNumber.js
Normal file
81
tgui/packages/tgui_ch/components/AnimatedNumber.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp, toFixed } from 'common/math';
|
||||
import { Component } from 'inferno';
|
||||
|
||||
const FPS = 20;
|
||||
const Q = 0.5;
|
||||
|
||||
const isSafeNumber = (value) => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value);
|
||||
};
|
||||
|
||||
export class AnimatedNumber extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.timer = null;
|
||||
this.state = {
|
||||
value: 0,
|
||||
};
|
||||
// Use provided initial state
|
||||
if (isSafeNumber(props.initial)) {
|
||||
this.state.value = props.initial;
|
||||
}
|
||||
// Set initial state with value provided in props
|
||||
else if (isSafeNumber(props.value)) {
|
||||
this.state.value = Number(props.value);
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
const { props, state } = this;
|
||||
const currentValue = Number(state.value);
|
||||
const targetValue = Number(props.value);
|
||||
// Avoid poisoning our state with infinities and NaN
|
||||
if (!isSafeNumber(targetValue)) {
|
||||
return;
|
||||
}
|
||||
// Smooth the value using an exponential moving average
|
||||
const value = currentValue * Q + targetValue * (1 - Q);
|
||||
this.setState({ value });
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.timer = setInterval(() => this.tick(), 1000 / FPS);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const { format, children } = props;
|
||||
const currentValue = state.value;
|
||||
const targetValue = props.value;
|
||||
// Directly display values which can't be animated
|
||||
if (!isSafeNumber(targetValue)) {
|
||||
return targetValue || null;
|
||||
}
|
||||
let formattedValue;
|
||||
// Use custom formatter
|
||||
if (format) {
|
||||
formattedValue = format(currentValue);
|
||||
}
|
||||
// Fix our animated precision at target value's precision.
|
||||
else {
|
||||
const fraction = String(targetValue).split('.')[1];
|
||||
const precision = fraction ? fraction.length : 0;
|
||||
formattedValue = toFixed(currentValue, clamp(precision, 0, 8));
|
||||
}
|
||||
// Use a custom render function
|
||||
if (typeof children === 'function') {
|
||||
return children(formattedValue, currentValue);
|
||||
}
|
||||
return formattedValue;
|
||||
}
|
||||
}
|
||||
19
tgui/packages/tgui_ch/components/Autofocus.tsx
Normal file
19
tgui/packages/tgui_ch/components/Autofocus.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component, createRef } from 'inferno';
|
||||
|
||||
export class Autofocus extends Component {
|
||||
ref = createRef<HTMLDivElement>();
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
this.ref.current?.focus();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div ref={this.ref} tabIndex={-1}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
62
tgui/packages/tgui_ch/components/Blink.js
Normal file
62
tgui/packages/tgui_ch/components/Blink.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Component } from 'inferno';
|
||||
|
||||
const DEFAULT_BLINKING_INTERVAL = 1000;
|
||||
const DEFAULT_BLINKING_TIME = 1000;
|
||||
|
||||
export class Blink extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
createTimer() {
|
||||
const { interval = DEFAULT_BLINKING_INTERVAL, time = DEFAULT_BLINKING_TIME } = this.props;
|
||||
|
||||
clearInterval(this.interval);
|
||||
clearTimeout(this.timer);
|
||||
|
||||
this.setState({
|
||||
hidden: false,
|
||||
});
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
this.setState({
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.setState({
|
||||
hidden: false,
|
||||
});
|
||||
}, time);
|
||||
}, interval + time);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.createTimer();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.interval !== this.props.interval || prevProps.time !== this.props.time) {
|
||||
this.createTimer();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.interval);
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
render(props) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
visibility: this.state.hidden ? 'hidden' : 'visible',
|
||||
}}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
tgui/packages/tgui_ch/components/BlockQuote.js
Normal file
13
tgui/packages/tgui_ch/components/BlockQuote.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { Box } from './Box';
|
||||
|
||||
export const BlockQuote = (props) => {
|
||||
const { className, ...rest } = props;
|
||||
return <Box className={classes(['BlockQuote', className])} {...rest} />;
|
||||
};
|
||||
298
tgui/packages/tgui_ch/components/Box.tsx
Normal file
298
tgui/packages/tgui_ch/components/Box.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
|
||||
import { createVNode, InfernoNode, SFC } from 'inferno';
|
||||
import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags';
|
||||
import { CSS_COLORS } from '../constants';
|
||||
|
||||
export interface BoxProps {
|
||||
[key: string]: any;
|
||||
as?: string;
|
||||
className?: string | BooleanLike;
|
||||
children?: InfernoNode;
|
||||
position?: string | BooleanLike;
|
||||
overflow?: string | BooleanLike;
|
||||
overflowX?: string | BooleanLike;
|
||||
overflowY?: string | BooleanLike;
|
||||
top?: string | BooleanLike;
|
||||
bottom?: string | BooleanLike;
|
||||
left?: string | BooleanLike;
|
||||
right?: string | BooleanLike;
|
||||
width?: string | BooleanLike;
|
||||
minWidth?: string | BooleanLike;
|
||||
maxWidth?: string | BooleanLike;
|
||||
height?: string | BooleanLike;
|
||||
minHeight?: string | BooleanLike;
|
||||
maxHeight?: string | BooleanLike;
|
||||
fontSize?: string | BooleanLike;
|
||||
fontFamily?: string;
|
||||
lineHeight?: string | BooleanLike;
|
||||
opacity?: number;
|
||||
textAlign?: string | BooleanLike;
|
||||
verticalAlign?: string | BooleanLike;
|
||||
textTransform?: string | BooleanLike; // VOREStation Addition
|
||||
unselectable?: string | BooleanLike; // VOREStation Addition
|
||||
inline?: BooleanLike;
|
||||
bold?: BooleanLike;
|
||||
italic?: BooleanLike;
|
||||
nowrap?: BooleanLike;
|
||||
preserveWhitespace?: BooleanLike;
|
||||
m?: string | BooleanLike;
|
||||
mx?: string | BooleanLike;
|
||||
my?: string | BooleanLike;
|
||||
mt?: string | BooleanLike;
|
||||
mb?: string | BooleanLike;
|
||||
ml?: string | BooleanLike;
|
||||
mr?: string | BooleanLike;
|
||||
p?: string | BooleanLike;
|
||||
px?: string | BooleanLike;
|
||||
py?: string | BooleanLike;
|
||||
pt?: string | BooleanLike;
|
||||
pb?: string | BooleanLike;
|
||||
pl?: string | BooleanLike;
|
||||
pr?: string | BooleanLike;
|
||||
color?: string | BooleanLike;
|
||||
textColor?: string | BooleanLike;
|
||||
backgroundColor?: string | BooleanLike;
|
||||
// VOREStation Addition Start
|
||||
// Flex props
|
||||
order?: string | BooleanLike;
|
||||
flexDirection?: string | BooleanLike;
|
||||
flexGrow?: string | BooleanLike;
|
||||
flexShrink?: string | BooleanLike;
|
||||
flexWrap?: string | BooleanLike;
|
||||
flexFlow?: string | BooleanLike;
|
||||
flexBasis?: string | BooleanLike;
|
||||
flex?: string | BooleanLike;
|
||||
alignItems?: string | BooleanLike;
|
||||
justifyContent?: string | BooleanLike;
|
||||
alignSelf?: string | BooleanLike;
|
||||
// VOREStation Addition End
|
||||
fillPositionedParent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coverts our rem-like spacing unit into a CSS unit.
|
||||
*/
|
||||
export const unit = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
// Transparently convert pixels into rem units
|
||||
if (value.endsWith('px') && !Byond.IS_LTE_IE8) {
|
||||
return parseFloat(value) / 12 + 'rem';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (Byond.IS_LTE_IE8) {
|
||||
return value * 12 + 'px';
|
||||
}
|
||||
return value + 'rem';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as `unit`, but half the size for integers numbers.
|
||||
*/
|
||||
export const halfUnit = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return unit(value);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return unit(value * 0.5);
|
||||
}
|
||||
};
|
||||
|
||||
const isColorCode = (str: unknown) => !isColorClass(str);
|
||||
|
||||
const isColorClass = (str: unknown): boolean => {
|
||||
return typeof str === 'string' && CSS_COLORS.includes(str);
|
||||
};
|
||||
|
||||
const mapRawPropTo = (attrName) => (style, value) => {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
style[attrName] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const mapUnitPropTo = (attrName, unit) => (style, value) => {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
style[attrName] = unit(value);
|
||||
}
|
||||
};
|
||||
|
||||
const mapBooleanPropTo = (attrName, attrValue) => (style, value) => {
|
||||
if (value) {
|
||||
style[attrName] = attrValue;
|
||||
}
|
||||
};
|
||||
|
||||
const mapDirectionalUnitPropTo = (attrName, unit, dirs) => (style, value) => {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
for (let i = 0; i < dirs.length; i++) {
|
||||
style[attrName + '-' + dirs[i]] = unit(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mapColorPropTo = (attrName) => (style, value) => {
|
||||
if (isColorCode(value)) {
|
||||
style[attrName] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const styleMapperByPropName = {
|
||||
// Direct mapping
|
||||
position: mapRawPropTo('position'),
|
||||
overflow: mapRawPropTo('overflow'),
|
||||
overflowX: mapRawPropTo('overflow-x'),
|
||||
overflowY: mapRawPropTo('overflow-y'),
|
||||
top: mapUnitPropTo('top', unit),
|
||||
bottom: mapUnitPropTo('bottom', unit),
|
||||
left: mapUnitPropTo('left', unit),
|
||||
right: mapUnitPropTo('right', unit),
|
||||
width: mapUnitPropTo('width', unit),
|
||||
minWidth: mapUnitPropTo('min-width', unit),
|
||||
maxWidth: mapUnitPropTo('max-width', unit),
|
||||
height: mapUnitPropTo('height', unit),
|
||||
minHeight: mapUnitPropTo('min-height', unit),
|
||||
maxHeight: mapUnitPropTo('max-height', unit),
|
||||
fontSize: mapUnitPropTo('font-size', unit),
|
||||
fontFamily: mapRawPropTo('font-family'),
|
||||
lineHeight: (style, value) => {
|
||||
if (typeof value === 'number') {
|
||||
style['line-height'] = value;
|
||||
} else if (typeof value === 'string') {
|
||||
style['line-height'] = unit(value);
|
||||
}
|
||||
},
|
||||
opacity: mapRawPropTo('opacity'),
|
||||
textAlign: mapRawPropTo('text-align'),
|
||||
verticalAlign: mapRawPropTo('vertical-align'),
|
||||
textTransform: mapRawPropTo('text-transform'), // VOREStation Addition
|
||||
unselectable: mapRawPropTo('unselectable'), // VOREStation Addition
|
||||
// Boolean props
|
||||
inline: mapBooleanPropTo('display', 'inline-block'),
|
||||
bold: mapBooleanPropTo('font-weight', 'bold'),
|
||||
italic: mapBooleanPropTo('font-style', 'italic'),
|
||||
nowrap: mapBooleanPropTo('white-space', 'nowrap'),
|
||||
preserveWhitespace: mapBooleanPropTo('white-space', 'pre-wrap'),
|
||||
// Margins
|
||||
m: mapDirectionalUnitPropTo('margin', halfUnit, ['top', 'bottom', 'left', 'right']),
|
||||
mx: mapDirectionalUnitPropTo('margin', halfUnit, ['left', 'right']),
|
||||
my: mapDirectionalUnitPropTo('margin', halfUnit, ['top', 'bottom']),
|
||||
mt: mapUnitPropTo('margin-top', halfUnit),
|
||||
mb: mapUnitPropTo('margin-bottom', halfUnit),
|
||||
ml: mapUnitPropTo('margin-left', halfUnit),
|
||||
mr: mapUnitPropTo('margin-right', halfUnit),
|
||||
// Margins
|
||||
p: mapDirectionalUnitPropTo('padding', halfUnit, ['top', 'bottom', 'left', 'right']),
|
||||
px: mapDirectionalUnitPropTo('padding', halfUnit, ['left', 'right']),
|
||||
py: mapDirectionalUnitPropTo('padding', halfUnit, ['top', 'bottom']),
|
||||
pt: mapUnitPropTo('padding-top', halfUnit),
|
||||
pb: mapUnitPropTo('padding-bottom', halfUnit),
|
||||
pl: mapUnitPropTo('padding-left', halfUnit),
|
||||
pr: mapUnitPropTo('padding-right', halfUnit),
|
||||
// Color props
|
||||
color: mapColorPropTo('color'),
|
||||
textColor: mapColorPropTo('color'),
|
||||
backgroundColor: mapColorPropTo('background-color'),
|
||||
// VOREStation Addition Start
|
||||
// Flex props
|
||||
order: mapRawPropTo('order'),
|
||||
flexDirection: mapRawPropTo('flex-direction'),
|
||||
flexGrow: mapRawPropTo('flex-grow'),
|
||||
flexShrink: mapRawPropTo('flex-shrink'),
|
||||
flexWrap: mapRawPropTo('flex-wrap'),
|
||||
flexFlow: mapRawPropTo('flex-flow'),
|
||||
flexBasis: mapRawPropTo('flex-basis'),
|
||||
flex: mapRawPropTo('flex'),
|
||||
alignItems: mapRawPropTo('align-items'),
|
||||
justifyContent: mapRawPropTo('justify-content'),
|
||||
alignSelf: mapRawPropTo('align-self'),
|
||||
// VOREStation Addition End
|
||||
// Utility props
|
||||
fillPositionedParent: (style, value) => {
|
||||
if (value) {
|
||||
style['position'] = 'absolute';
|
||||
style['top'] = 0;
|
||||
style['bottom'] = 0;
|
||||
style['left'] = 0;
|
||||
style['right'] = 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const computeBoxProps = (props: BoxProps) => {
|
||||
const computedProps: HTMLAttributes<any> = {};
|
||||
const computedStyles = {};
|
||||
// Compute props
|
||||
for (let propName of Object.keys(props)) {
|
||||
if (propName === 'style') {
|
||||
continue;
|
||||
}
|
||||
// IE8: onclick workaround
|
||||
if (Byond.IS_LTE_IE8 && propName === 'onClick') {
|
||||
computedProps.onclick = props[propName];
|
||||
continue;
|
||||
}
|
||||
const propValue = props[propName];
|
||||
const mapPropToStyle = styleMapperByPropName[propName];
|
||||
if (mapPropToStyle) {
|
||||
mapPropToStyle(computedStyles, propValue);
|
||||
} else {
|
||||
computedProps[propName] = propValue;
|
||||
}
|
||||
}
|
||||
// Concatenate styles
|
||||
let style = '';
|
||||
for (let attrName of Object.keys(computedStyles)) {
|
||||
const attrValue = computedStyles[attrName];
|
||||
style += attrName + ':' + attrValue + ';';
|
||||
}
|
||||
if (props.style) {
|
||||
for (let attrName of Object.keys(props.style)) {
|
||||
const attrValue = props.style[attrName];
|
||||
style += attrName + ':' + attrValue + ';';
|
||||
}
|
||||
}
|
||||
if (style.length > 0) {
|
||||
computedProps.style = style;
|
||||
}
|
||||
return computedProps;
|
||||
};
|
||||
|
||||
export const computeBoxClassName = (props: BoxProps) => {
|
||||
const color = props.textColor || props.color;
|
||||
const backgroundColor = props.backgroundColor;
|
||||
return classes([
|
||||
isColorClass(color) && 'color-' + color,
|
||||
isColorClass(backgroundColor) && 'color-bg-' + backgroundColor,
|
||||
]);
|
||||
};
|
||||
|
||||
export const Box: SFC<BoxProps> = (props: BoxProps) => {
|
||||
const { as = 'div', className, children, ...rest } = props;
|
||||
// Render props
|
||||
if (typeof children === 'function') {
|
||||
return children(computeBoxProps(props));
|
||||
}
|
||||
const computedClassName =
|
||||
typeof className === 'string' ? className + ' ' + computeBoxClassName(rest) : computeBoxClassName(rest);
|
||||
const computedProps = computeBoxProps(rest);
|
||||
// Render a wrapper element
|
||||
return createVNode(
|
||||
VNodeFlags.HtmlElement,
|
||||
as,
|
||||
computedClassName,
|
||||
children,
|
||||
ChildFlags.UnknownChildren,
|
||||
computedProps,
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
Box.defaultHooks = pureComponentHooks;
|
||||
287
tgui/packages/tgui_ch/components/Button.js
Normal file
287
tgui/packages/tgui_ch/components/Button.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { KEY_ENTER, KEY_ESCAPE, KEY_SPACE } from 'common/keycodes';
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { createLogger } from '../logging';
|
||||
import { Box, computeBoxClassName, computeBoxProps } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
const logger = createLogger('Button');
|
||||
|
||||
export const Button = (props) => {
|
||||
const {
|
||||
className,
|
||||
fluid,
|
||||
icon,
|
||||
iconRotation,
|
||||
iconSpin,
|
||||
iconColor,
|
||||
iconPosition,
|
||||
iconSize, // VOREStation Addition
|
||||
color,
|
||||
disabled,
|
||||
selected,
|
||||
tooltip,
|
||||
tooltipPosition,
|
||||
ellipsis,
|
||||
compact,
|
||||
circular,
|
||||
content,
|
||||
children,
|
||||
onclick,
|
||||
onClick,
|
||||
...rest
|
||||
} = props;
|
||||
const hasContent = !!(content || children);
|
||||
// A warning about the lowercase onclick
|
||||
if (onclick) {
|
||||
logger.warn(
|
||||
`Lowercase 'onclick' is not supported on Button and lowercase` +
|
||||
` prop names are discouraged in general. Please use a camelCase` +
|
||||
`'onClick' instead and read: ` +
|
||||
`https://infernojs.org/docs/guides/event-handling`
|
||||
);
|
||||
}
|
||||
rest.onClick = (e) => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
// IE8: Use "unselectable" because "user-select" doesn't work.
|
||||
if (Byond.IS_LTE_IE8) {
|
||||
rest.unselectable = true;
|
||||
}
|
||||
let buttonContent = (
|
||||
<div
|
||||
className={classes([
|
||||
'Button',
|
||||
fluid && 'Button--fluid',
|
||||
disabled && 'Button--disabled',
|
||||
selected && 'Button--selected',
|
||||
hasContent && 'Button--hasContent',
|
||||
ellipsis && 'Button--ellipsis',
|
||||
circular && 'Button--circular',
|
||||
compact && 'Button--compact',
|
||||
iconPosition && 'Button--iconPosition--' + iconPosition,
|
||||
color && typeof color === 'string' ? 'Button--color--' + color : 'Button--color--default',
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
tabIndex={!disabled && '0'}
|
||||
onKeyDown={(e) => {
|
||||
if (props.captureKeys === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyCode = window.event ? e.which : e.keyCode;
|
||||
// Simulate a click when pressing space or enter.
|
||||
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
|
||||
e.preventDefault();
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Refocus layout on pressing escape.
|
||||
if (keyCode === KEY_ESCAPE) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
{...computeBoxProps(rest)}>
|
||||
{icon && iconPosition !== 'right' && (
|
||||
<Icon name={icon} color={iconColor} rotation={iconRotation} spin={iconSpin} />
|
||||
)}
|
||||
{content}
|
||||
{children}
|
||||
{icon && iconPosition === 'right' && (
|
||||
<Icon
|
||||
name={icon}
|
||||
color={iconColor}
|
||||
rotation={iconRotation}
|
||||
spin={iconSpin}
|
||||
fontSize={iconSize} // VOREStation Addition
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
buttonContent = (
|
||||
<Tooltip content={tooltip} position={tooltipPosition}>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return buttonContent;
|
||||
};
|
||||
|
||||
Button.defaultHooks = pureComponentHooks;
|
||||
|
||||
export const ButtonCheckbox = (props) => {
|
||||
const { checked, ...rest } = props;
|
||||
return <Button color="transparent" icon={checked ? 'check-square-o' : 'square-o'} selected={checked} {...rest} />;
|
||||
};
|
||||
|
||||
Button.Checkbox = ButtonCheckbox;
|
||||
|
||||
export class ButtonConfirm extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
clickedOnce: false,
|
||||
};
|
||||
this.handleClick = () => {
|
||||
if (this.state.clickedOnce) {
|
||||
this.setClickedOnce(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setClickedOnce(clickedOnce) {
|
||||
this.setState({
|
||||
clickedOnce,
|
||||
});
|
||||
if (clickedOnce) {
|
||||
setTimeout(() => window.addEventListener('click', this.handleClick));
|
||||
} else {
|
||||
window.removeEventListener('click', this.handleClick);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
confirmContent = 'Confirm?',
|
||||
confirmColor = 'bad',
|
||||
confirmIcon,
|
||||
icon,
|
||||
color,
|
||||
content,
|
||||
onClick,
|
||||
...rest
|
||||
} = this.props;
|
||||
return (
|
||||
<Button
|
||||
content={this.state.clickedOnce ? confirmContent : content}
|
||||
icon={this.state.clickedOnce ? confirmIcon : icon}
|
||||
color={this.state.clickedOnce ? confirmColor : color}
|
||||
onClick={() => (this.state.clickedOnce ? onClick() : this.setClickedOnce(true))}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.Confirm = ButtonConfirm;
|
||||
|
||||
export class ButtonInput extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
inInput: false,
|
||||
};
|
||||
}
|
||||
|
||||
setInInput(inInput) {
|
||||
this.setState({
|
||||
inInput,
|
||||
});
|
||||
if (this.inputRef) {
|
||||
const input = this.inputRef.current;
|
||||
if (inInput) {
|
||||
input.value = this.props.currentValue || '';
|
||||
try {
|
||||
input.focus();
|
||||
input.select();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commitResult(e) {
|
||||
if (this.inputRef) {
|
||||
const input = this.inputRef.current;
|
||||
const hasValue = input.value !== '';
|
||||
if (hasValue) {
|
||||
this.props.onCommit(e, input.value);
|
||||
return;
|
||||
} else {
|
||||
if (!this.props.defaultValue) {
|
||||
return;
|
||||
}
|
||||
this.props.onCommit(e, this.props.defaultValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fluid,
|
||||
content,
|
||||
icon,
|
||||
iconRotation,
|
||||
iconSpin,
|
||||
tooltip,
|
||||
tooltipPosition,
|
||||
color = 'default',
|
||||
placeholder,
|
||||
maxLength,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
let buttonContent = (
|
||||
<Box
|
||||
className={classes(['Button', fluid && 'Button--fluid', 'Button--color--' + color])}
|
||||
{...rest}
|
||||
onClick={() => this.setInInput(true)}>
|
||||
{icon && <Icon name={icon} rotation={iconRotation} spin={iconSpin} />}
|
||||
<div>{content}</div>
|
||||
<input
|
||||
ref={this.inputRef}
|
||||
className="NumberInput__input"
|
||||
style={{
|
||||
'display': !this.state.inInput ? 'none' : undefined,
|
||||
'text-align': 'left',
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!this.state.inInput) {
|
||||
return;
|
||||
}
|
||||
this.setInInput(false);
|
||||
this.commitResult(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === KEY_ENTER) {
|
||||
this.setInInput(false);
|
||||
this.commitResult(e);
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KEY_ESCAPE) {
|
||||
this.setInInput(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
buttonContent = (
|
||||
<Tooltip content={tooltip} position={tooltipPosition}>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return buttonContent;
|
||||
}
|
||||
}
|
||||
|
||||
Button.Input = ButtonInput;
|
||||
128
tgui/packages/tgui_ch/components/ByondUi.js
Normal file
128
tgui/packages/tgui_ch/components/ByondUi.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { shallowDiffers } from 'common/react';
|
||||
import { debounce } from 'common/timer';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { createLogger } from '../logging';
|
||||
import { computeBoxProps } from './Box';
|
||||
|
||||
const logger = createLogger('ByondUi');
|
||||
|
||||
// Stack of currently allocated BYOND UI element ids.
|
||||
const byondUiStack = [];
|
||||
|
||||
const createByondUiElement = (elementId) => {
|
||||
// Reserve an index in the stack
|
||||
const index = byondUiStack.length;
|
||||
byondUiStack.push(null);
|
||||
// Get a unique id
|
||||
const id = elementId || 'byondui_' + index;
|
||||
logger.log(`allocated '${id}'`);
|
||||
// Return a control structure
|
||||
return {
|
||||
render: (params) => {
|
||||
logger.log(`rendering '${id}'`);
|
||||
byondUiStack[index] = id;
|
||||
Byond.winset(id, params);
|
||||
},
|
||||
unmount: () => {
|
||||
logger.log(`unmounting '${id}'`);
|
||||
byondUiStack[index] = null;
|
||||
Byond.winset(id, {
|
||||
parent: '',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
// Cleanly unmount all visible UI elements
|
||||
for (let index = 0; index < byondUiStack.length; index++) {
|
||||
const id = byondUiStack[index];
|
||||
if (typeof id === 'string') {
|
||||
logger.log(`unmounting '${id}' (beforeunload)`);
|
||||
byondUiStack[index] = null;
|
||||
Byond.winset(id, {
|
||||
parent: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the bounding box of the DOM element in display-pixels.
|
||||
*/
|
||||
const getBoundingBox = (element) => {
|
||||
const pixelRatio = window.devicePixelRatio ?? 1;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
pos: [rect.left * pixelRatio, rect.top * pixelRatio],
|
||||
size: [(rect.right - rect.left) * pixelRatio, (rect.bottom - rect.top) * pixelRatio],
|
||||
};
|
||||
};
|
||||
|
||||
export class ByondUi extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.containerRef = createRef();
|
||||
this.byondUiElement = createByondUiElement(props.params?.id);
|
||||
this.handleResize = debounce(() => {
|
||||
this.forceUpdate();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const { params: prevParams = {}, ...prevRest } = this.props;
|
||||
const { params: nextParams = {}, ...nextRest } = nextProps;
|
||||
return shallowDiffers(prevParams, nextParams) || shallowDiffers(prevRest, nextRest);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// IE8: It probably works, but fuck you anyway.
|
||||
if (Byond.IS_LTE_IE10) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
this.componentDidUpdate();
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// IE8: It probably works, but fuck you anyway.
|
||||
if (Byond.IS_LTE_IE10) {
|
||||
return;
|
||||
}
|
||||
const { params = {} } = this.props;
|
||||
const box = getBoundingBox(this.containerRef.current);
|
||||
logger.debug('bounding box', box);
|
||||
this.byondUiElement.render({
|
||||
parent: Byond.windowId,
|
||||
...params,
|
||||
pos: box.pos[0] + ',' + box.pos[1],
|
||||
size: box.size[0] + 'x' + box.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// IE8: It probably works, but fuck you anyway.
|
||||
if (Byond.IS_LTE_IE10) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
this.byondUiElement.unmount();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { params, ...rest } = this.props;
|
||||
return (
|
||||
<div ref={this.containerRef} {...computeBoxProps(rest)}>
|
||||
{/* Filler */}
|
||||
<div style={{ 'min-height': '22px' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
127
tgui/packages/tgui_ch/components/Chart.js
vendored
Normal file
127
tgui/packages/tgui_ch/components/Chart.js
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { map, zipWith } from 'common/collections';
|
||||
import { pureComponentHooks } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
|
||||
const normalizeData = (data, scale, rangeX, rangeY) => {
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const min = zipWith(Math.min)(...data);
|
||||
const max = zipWith(Math.max)(...data);
|
||||
if (rangeX !== undefined) {
|
||||
min[0] = rangeX[0];
|
||||
max[0] = rangeX[1];
|
||||
}
|
||||
if (rangeY !== undefined) {
|
||||
min[1] = rangeY[0];
|
||||
max[1] = rangeY[1];
|
||||
}
|
||||
const normalized = map((point) => {
|
||||
return zipWith((value, min, max, scale) => {
|
||||
return ((value - min) / (max - min)) * scale;
|
||||
})(point, min, max, scale);
|
||||
})(data);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const dataToPolylinePoints = (data) => {
|
||||
let points = '';
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const point = data[i];
|
||||
points += point[0] + ',' + point[1] + ' ';
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
class LineChart extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.ref = createRef();
|
||||
this.state = {
|
||||
// Initial guess
|
||||
viewBox: [600, 200],
|
||||
};
|
||||
this.handleResize = () => {
|
||||
const element = this.ref.current;
|
||||
this.setState({
|
||||
viewBox: [element.offsetWidth, element.offsetHeight],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
data = [],
|
||||
rangeX,
|
||||
rangeY,
|
||||
fillColor = 'none',
|
||||
strokeColor = '#ffffff',
|
||||
strokeWidth = 2,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { viewBox } = this.state;
|
||||
const normalized = normalizeData(data, viewBox, rangeX, rangeY);
|
||||
// Push data outside viewBox and form a fillable polygon
|
||||
if (normalized.length > 0) {
|
||||
const first = normalized[0];
|
||||
const last = normalized[normalized.length - 1];
|
||||
normalized.push([viewBox[0] + strokeWidth, last[1]]);
|
||||
normalized.push([viewBox[0] + strokeWidth, -strokeWidth]);
|
||||
normalized.push([-strokeWidth, -strokeWidth]);
|
||||
normalized.push([-strokeWidth, first[1]]);
|
||||
}
|
||||
const points = dataToPolylinePoints(normalized);
|
||||
return (
|
||||
<Box position="relative" {...rest}>
|
||||
{(props) => (
|
||||
<div ref={this.ref} {...props}>
|
||||
<svg
|
||||
viewBox={`0 0 ${viewBox[0]} ${viewBox[1]}`}
|
||||
preserveAspectRatio="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<polyline
|
||||
transform={`scale(1, -1) translate(0, -${viewBox[1]})`}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LineChart.defaultHooks = pureComponentHooks;
|
||||
|
||||
const Stub = (props) => null;
|
||||
|
||||
// IE8: No inline svg support
|
||||
export const Chart = {
|
||||
Line: Byond.IS_LTE_IE8 ? Stub : LineChart,
|
||||
};
|
||||
43
tgui/packages/tgui_ch/components/Collapsible.js
Normal file
43
tgui/packages/tgui_ch/components/Collapsible.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Component } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
|
||||
export class Collapsible extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { open } = props;
|
||||
this.state = {
|
||||
open: open || false,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { open } = this.state;
|
||||
const { children, color = 'default', title, buttons, ...rest } = props;
|
||||
return (
|
||||
<Box mb={1}>
|
||||
<div className="Table">
|
||||
<div className="Table__cell">
|
||||
<Button
|
||||
fluid
|
||||
color={color}
|
||||
icon={open ? 'chevron-down' : 'chevron-right'}
|
||||
onClick={() => this.setState({ open: !open })}
|
||||
{...rest}>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
{buttons && <div className="Table__cell Table__cell--collapsing">{buttons}</div>}
|
||||
</div>
|
||||
{open && <Box mt={1}>{children}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
21
tgui/packages/tgui_ch/components/ColorBox.js
Normal file
21
tgui/packages/tgui_ch/components/ColorBox.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
export const ColorBox = (props) => {
|
||||
const { content, children, className, color, backgroundColor, ...rest } = props;
|
||||
rest.color = content ? null : 'transparent';
|
||||
rest.backgroundColor = color || backgroundColor;
|
||||
return (
|
||||
<div className={classes(['ColorBox', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
|
||||
{content || '.'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ColorBox.defaultHooks = pureComponentHooks;
|
||||
17
tgui/packages/tgui_ch/components/Dimmer.js
Normal file
17
tgui/packages/tgui_ch/components/Dimmer.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { Box } from './Box';
|
||||
|
||||
export const Dimmer = (props) => {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<Box className={classes(['Dimmer', ...className])} {...rest}>
|
||||
<div className="Dimmer__inner">{children}</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
20
tgui/packages/tgui_ch/components/Divider.js
Normal file
20
tgui/packages/tgui_ch/components/Divider.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
|
||||
export const Divider = (props) => {
|
||||
const { vertical, hidden } = props;
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Divider',
|
||||
hidden && 'Divider--hidden',
|
||||
vertical ? 'Divider--vertical' : 'Divider--horizontal',
|
||||
])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
271
tgui/packages/tgui_ch/components/DraggableControl.js
Normal file
271
tgui/packages/tgui_ch/components/DraggableControl.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp } from 'common/math';
|
||||
import { pureComponentHooks } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
|
||||
const DEFAULT_UPDATE_RATE = 400;
|
||||
|
||||
/**
|
||||
* Reduces screen offset to a single number based on the matrix provided.
|
||||
*/
|
||||
const getScalarScreenOffset = (e, matrix) => {
|
||||
return e.screenX * matrix[0] + e.screenY * matrix[1];
|
||||
};
|
||||
|
||||
export class DraggableControl extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
value: props.value,
|
||||
dragging: false,
|
||||
editing: false,
|
||||
internalValue: null,
|
||||
origin: null,
|
||||
suppressingFlicker: false,
|
||||
};
|
||||
|
||||
// Suppresses flickering while the value propagates through the backend
|
||||
this.flickerTimer = null;
|
||||
this.suppressFlicker = () => {
|
||||
const { suppressFlicker } = this.props;
|
||||
if (suppressFlicker > 0) {
|
||||
this.setState({
|
||||
suppressingFlicker: true,
|
||||
});
|
||||
clearTimeout(this.flickerTimer);
|
||||
this.flickerTimer = setTimeout(
|
||||
() =>
|
||||
this.setState({
|
||||
suppressingFlicker: false,
|
||||
}),
|
||||
suppressFlicker
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.handleDragStart = (e) => {
|
||||
const { value, dragMatrix } = this.props;
|
||||
const { editing } = this.state;
|
||||
if (editing) {
|
||||
return;
|
||||
}
|
||||
document.body.style['pointer-events'] = 'none';
|
||||
this.ref = e.target;
|
||||
this.setState({
|
||||
dragging: false,
|
||||
origin: getScalarScreenOffset(e, dragMatrix),
|
||||
value,
|
||||
internalValue: value,
|
||||
});
|
||||
this.timer = setTimeout(() => {
|
||||
this.setState({
|
||||
dragging: true,
|
||||
});
|
||||
}, 250);
|
||||
this.dragInterval = setInterval(() => {
|
||||
const { dragging, value } = this.state;
|
||||
const { onDrag } = this.props;
|
||||
if (dragging && onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
}, this.props.updateRate || DEFAULT_UPDATE_RATE);
|
||||
document.addEventListener('mousemove', this.handleDragMove);
|
||||
document.addEventListener('mouseup', this.handleDragEnd);
|
||||
};
|
||||
|
||||
this.handleDragMove = (e) => {
|
||||
const { minValue, maxValue, step, stepPixelSize, dragMatrix } = this.props;
|
||||
this.setState((prevState) => {
|
||||
const state = { ...prevState };
|
||||
const offset = getScalarScreenOffset(e, dragMatrix) - state.origin;
|
||||
if (prevState.dragging) {
|
||||
const stepOffset = Number.isFinite(minValue) ? minValue % step : 0;
|
||||
// Translate mouse movement to value
|
||||
// Give it some headroom (by increasing clamp range by 1 step)
|
||||
state.internalValue = clamp(
|
||||
state.internalValue + (offset * step) / stepPixelSize,
|
||||
minValue - step,
|
||||
maxValue + step
|
||||
);
|
||||
// Clamp the final value
|
||||
state.value = clamp(state.internalValue - (state.internalValue % step) + stepOffset, minValue, maxValue);
|
||||
state.origin = getScalarScreenOffset(e, dragMatrix);
|
||||
} else if (Math.abs(offset) > 4) {
|
||||
state.dragging = true;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
this.handleDragEnd = (e) => {
|
||||
const { onChange, onDrag } = this.props;
|
||||
const { dragging, value, internalValue } = this.state;
|
||||
document.body.style['pointer-events'] = 'auto';
|
||||
clearTimeout(this.timer);
|
||||
clearInterval(this.dragInterval);
|
||||
this.setState({
|
||||
dragging: false,
|
||||
editing: !dragging,
|
||||
origin: null,
|
||||
});
|
||||
document.removeEventListener('mousemove', this.handleDragMove);
|
||||
document.removeEventListener('mouseup', this.handleDragEnd);
|
||||
if (dragging) {
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
} else if (this.inputRef) {
|
||||
const input = this.inputRef.current;
|
||||
input.value = internalValue;
|
||||
// IE8: Dies when trying to focus a hidden element
|
||||
// (Error: Object does not support this action)
|
||||
try {
|
||||
input.focus();
|
||||
input.select();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dragging, editing, value: intermediateValue, suppressingFlicker } = this.state;
|
||||
const {
|
||||
animated,
|
||||
value,
|
||||
unit,
|
||||
minValue,
|
||||
maxValue,
|
||||
unclamped,
|
||||
format,
|
||||
onChange,
|
||||
onDrag,
|
||||
children,
|
||||
// Input props
|
||||
height,
|
||||
lineHeight,
|
||||
fontSize,
|
||||
} = this.props;
|
||||
let displayValue = value;
|
||||
if (dragging || suppressingFlicker) {
|
||||
displayValue = intermediateValue;
|
||||
}
|
||||
// Setup a display element
|
||||
// Shows a formatted number based on what we are currently doing
|
||||
// with the draggable surface.
|
||||
const renderDisplayElement = (value) => value + (unit ? ' ' + unit : '');
|
||||
const displayElement =
|
||||
(animated && !dragging && !suppressingFlicker && (
|
||||
<AnimatedNumber value={displayValue} format={format}>
|
||||
{renderDisplayElement}
|
||||
</AnimatedNumber>
|
||||
)) ||
|
||||
renderDisplayElement(format ? format(displayValue) : displayValue);
|
||||
// Setup an input element
|
||||
// Handles direct input via the keyboard
|
||||
const inputElement = (
|
||||
<input
|
||||
ref={this.inputRef}
|
||||
className="NumberInput__input"
|
||||
style={{
|
||||
display: !editing ? 'none' : undefined,
|
||||
height: height,
|
||||
'line-height': lineHeight,
|
||||
'font-size': fontSize,
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
let value;
|
||||
if (unclamped) {
|
||||
value = parseFloat(e.target.value);
|
||||
} else {
|
||||
value = clamp(parseFloat(e.target.value), minValue, maxValue);
|
||||
}
|
||||
if (Number.isNaN(value)) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
editing: false,
|
||||
value,
|
||||
});
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13) {
|
||||
let value;
|
||||
if (unclamped) {
|
||||
value = parseFloat(e.target.value);
|
||||
} else {
|
||||
value = clamp(parseFloat(e.target.value), minValue, maxValue);
|
||||
}
|
||||
if (Number.isNaN(value)) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
editing: false,
|
||||
value,
|
||||
});
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// Return a part of the state for higher-level components to use.
|
||||
return children({
|
||||
dragging,
|
||||
editing,
|
||||
value,
|
||||
displayValue,
|
||||
displayElement,
|
||||
inputElement,
|
||||
handleDragStart: this.handleDragStart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DraggableControl.defaultHooks = pureComponentHooks;
|
||||
DraggableControl.defaultProps = {
|
||||
minValue: -Infinity,
|
||||
maxValue: +Infinity,
|
||||
step: 1,
|
||||
stepPixelSize: 1,
|
||||
suppressFlicker: 50,
|
||||
dragMatrix: [1, 0],
|
||||
};
|
||||
145
tgui/packages/tgui_ch/components/Dropdown.js
Normal file
145
tgui/packages/tgui_ch/components/Dropdown.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { Component } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export class Dropdown extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selected: props.selected,
|
||||
open: false,
|
||||
};
|
||||
this.handleClick = () => {
|
||||
if (this.state.open) {
|
||||
this.setOpen(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
setOpen(open) {
|
||||
this.setState({ open: open });
|
||||
if (open) {
|
||||
setTimeout(() => window.addEventListener('click', this.handleClick));
|
||||
this.menuRef.focus();
|
||||
} else {
|
||||
window.removeEventListener('click', this.handleClick);
|
||||
}
|
||||
}
|
||||
|
||||
setSelected(selected) {
|
||||
this.setState({
|
||||
selected: selected,
|
||||
});
|
||||
this.setOpen(false);
|
||||
this.props.onSelected(selected);
|
||||
}
|
||||
|
||||
buildMenu() {
|
||||
const { options = [], placeholder } = this.props; // VOREStation edit
|
||||
const ops = options.map((option) => (
|
||||
<Box
|
||||
key={option}
|
||||
className="Dropdown__menuentry"
|
||||
onClick={() => {
|
||||
this.setSelected(option);
|
||||
}}>
|
||||
{option}
|
||||
</Box>
|
||||
));
|
||||
// VOREStation addition start
|
||||
if (placeholder) {
|
||||
ops.unshift(
|
||||
<div
|
||||
key={placeholder}
|
||||
className="Dropdown__menuentry"
|
||||
onClick={() => {
|
||||
this.setSelected(null);
|
||||
}}>
|
||||
-- {placeholder} --
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// VOREStation addition end
|
||||
return ops.length ? ops : 'No Options Found';
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const {
|
||||
icon,
|
||||
iconRotation,
|
||||
iconSpin,
|
||||
color = 'default',
|
||||
over,
|
||||
noscroll,
|
||||
nochevron,
|
||||
width,
|
||||
onClick,
|
||||
selected,
|
||||
disabled,
|
||||
displayText,
|
||||
placeholder, // VOREStation Addition
|
||||
...boxProps
|
||||
} = props;
|
||||
const { className, ...rest } = boxProps;
|
||||
|
||||
const adjustedOpen = over ? !this.state.open : this.state.open;
|
||||
|
||||
const menu = this.state.open ? (
|
||||
<div
|
||||
ref={(menu) => {
|
||||
this.menuRef = menu;
|
||||
}}
|
||||
tabIndex="-1"
|
||||
style={{
|
||||
'width': width,
|
||||
}}
|
||||
className={classes([(noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu', over && 'Dropdown__over'])}>
|
||||
{this.buildMenu()}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="Dropdown">
|
||||
<Box
|
||||
width={width}
|
||||
className={classes([
|
||||
'Dropdown__control',
|
||||
'Button',
|
||||
'Button--color--' + color,
|
||||
disabled && 'Button--disabled',
|
||||
className,
|
||||
])}
|
||||
{...rest}
|
||||
onClick={() => {
|
||||
if (disabled && !this.state.open) {
|
||||
return;
|
||||
}
|
||||
this.setOpen(!this.state.open);
|
||||
}}>
|
||||
{icon && <Icon name={icon} rotation={iconRotation} spin={iconSpin} mr={1} />}
|
||||
<span className="Dropdown__selected-text">
|
||||
{displayText ? displayText : this.state.selected || placeholder /* VOREStation Edit */}
|
||||
</span>
|
||||
{!!nochevron || (
|
||||
<span className="Dropdown__arrow-button">
|
||||
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
110
tgui/packages/tgui_ch/components/Flex.tsx
Normal file
110
tgui/packages/tgui_ch/components/Flex.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
|
||||
import { BoxProps, computeBoxClassName, computeBoxProps, unit } from './Box';
|
||||
|
||||
export type FlexProps = BoxProps & {
|
||||
direction?: string | BooleanLike;
|
||||
wrap?: string | BooleanLike;
|
||||
align?: string | BooleanLike;
|
||||
alignContent?: string | BooleanLike; // VOREStation Addition
|
||||
justify?: string | BooleanLike;
|
||||
inline?: BooleanLike;
|
||||
};
|
||||
|
||||
export const computeFlexClassName = (props: FlexProps) => {
|
||||
return classes([
|
||||
'Flex',
|
||||
props.inline && 'Flex--inline',
|
||||
Byond.IS_LTE_IE10 && 'Flex--iefix',
|
||||
Byond.IS_LTE_IE10 && props.direction === 'column' && 'Flex--iefix--column',
|
||||
computeBoxClassName(props),
|
||||
]);
|
||||
};
|
||||
|
||||
export const computeFlexProps = (props: FlexProps) => {
|
||||
const {
|
||||
className,
|
||||
direction,
|
||||
wrap,
|
||||
align,
|
||||
alignContent, // VOREStation Addition
|
||||
justify,
|
||||
inline,
|
||||
...rest
|
||||
} = props;
|
||||
return computeBoxProps({
|
||||
style: {
|
||||
...rest.style,
|
||||
'flex-direction': direction,
|
||||
'flex-wrap': wrap === true ? 'wrap' : wrap,
|
||||
'align-items': align,
|
||||
'align-content': alignContent, // VOREStation Addition
|
||||
'justify-content': justify,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
};
|
||||
|
||||
export const Flex = (props) => {
|
||||
const { className, ...rest } = props;
|
||||
return <div className={classes([className, computeFlexClassName(rest)])} {...computeFlexProps(rest)} />;
|
||||
};
|
||||
|
||||
Flex.defaultHooks = pureComponentHooks;
|
||||
|
||||
export type FlexItemProps = BoxProps & {
|
||||
grow?: number;
|
||||
order?: number;
|
||||
shrink?: number;
|
||||
basis?: string | BooleanLike;
|
||||
align?: string | BooleanLike;
|
||||
};
|
||||
|
||||
export const computeFlexItemClassName = (props: FlexItemProps) => {
|
||||
return classes(['Flex__item', Byond.IS_LTE_IE10 && 'Flex__item--iefix', computeBoxClassName(props)]);
|
||||
};
|
||||
|
||||
export const computeFlexItemProps = (props: FlexItemProps) => {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
grow,
|
||||
order,
|
||||
shrink,
|
||||
// IE11: Always set basis to specified width, which fixes certain
|
||||
// bugs when rendering tables inside the flex.
|
||||
basis = props.width,
|
||||
align,
|
||||
...rest
|
||||
} = props;
|
||||
return computeBoxProps({
|
||||
style: {
|
||||
...style,
|
||||
'flex-grow': grow !== undefined && Number(grow),
|
||||
'flex-shrink': shrink !== undefined && Number(shrink),
|
||||
'flex-basis': unit(basis),
|
||||
'order': order,
|
||||
'align-self': align,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
};
|
||||
|
||||
const FlexItem = (props) => {
|
||||
const { className, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={classes([className, computeFlexItemClassName(props), computeBoxClassName(props)])}
|
||||
{...computeFlexItemProps(rest)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FlexItem.defaultHooks = pureComponentHooks;
|
||||
|
||||
Flex.Item = FlexItem;
|
||||
38
tgui/packages/tgui_ch/components/Grid.js
Normal file
38
tgui/packages/tgui_ch/components/Grid.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Table } from './Table';
|
||||
import { pureComponentHooks } from 'common/react';
|
||||
|
||||
/** @deprecated */
|
||||
export const Grid = (props) => {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<Table {...rest}>
|
||||
<Table.Row>{children}</Table.Row>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
Grid.defaultHooks = pureComponentHooks;
|
||||
|
||||
/** @deprecated */
|
||||
export const GridColumn = (props) => {
|
||||
const { size = 1, style, ...rest } = props;
|
||||
return (
|
||||
<Table.Cell
|
||||
style={{
|
||||
width: size + '%',
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Grid.defaultHooks = pureComponentHooks;
|
||||
|
||||
Grid.Column = GridColumn;
|
||||
56
tgui/packages/tgui_ch/components/Icon.js
Normal file
56
tgui/packages/tgui_ch/components/Icon.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @author Original Aleksej Komarov
|
||||
* @author Changes ThePotato97
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
const FA_OUTLINE_REGEX = /-o$/;
|
||||
|
||||
export const Icon = (props) => {
|
||||
const { name, size, spin, className, rotation, inverse, ...rest } = props;
|
||||
|
||||
if (size) {
|
||||
if (!rest.style) {
|
||||
rest.style = {};
|
||||
}
|
||||
rest.style['font-size'] = size * 100 + '%';
|
||||
}
|
||||
if (typeof rotation === 'number') {
|
||||
if (!rest.style) {
|
||||
rest.style = {};
|
||||
}
|
||||
rest.style['transform'] = `rotate(${rotation}deg)`;
|
||||
}
|
||||
|
||||
const boxProps = computeBoxProps(rest);
|
||||
|
||||
let iconClass = '';
|
||||
if (name.startsWith('tg-')) {
|
||||
// tgfont icon
|
||||
iconClass = name;
|
||||
} else {
|
||||
// font awesome icon
|
||||
const faRegular = FA_OUTLINE_REGEX.test(name);
|
||||
const faName = name.replace(FA_OUTLINE_REGEX, '');
|
||||
iconClass = (faRegular ? 'far ' : 'fas ') + 'fa-' + faName + (spin ? ' fa-spin' : '');
|
||||
}
|
||||
return <i className={classes(['Icon', iconClass, className, computeBoxClassName(rest)])} {...boxProps} />;
|
||||
};
|
||||
|
||||
Icon.defaultHooks = pureComponentHooks;
|
||||
|
||||
export const IconStack = (props) => {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<span class={classes(['IconStack', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
Icon.Stack = IconStack;
|
||||
155
tgui/packages/tgui_ch/components/InfinitePlane.js
Normal file
155
tgui/packages/tgui_ch/components/InfinitePlane.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { computeBoxProps } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { Button } from './Button';
|
||||
import { Component } from 'inferno';
|
||||
|
||||
const ZOOM_MIN_VAL = 0.5;
|
||||
const ZOOM_MAX_VAL = 1.5;
|
||||
|
||||
const ZOOM_INCREMENT = 0.1;
|
||||
|
||||
export class InfinitePlane extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
mouseDown: false,
|
||||
|
||||
left: 0,
|
||||
top: 0,
|
||||
|
||||
lastLeft: 0,
|
||||
lastTop: 0,
|
||||
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
|
||||
this.doOffsetMouse = this.doOffsetMouse.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
window.addEventListener('mousedown', this.doOffsetMouse);
|
||||
window.addEventListener('mousemove', this.doOffsetMouse);
|
||||
window.addEventListener('mouseup', this.doOffsetMouse);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
window.removeEventListener('mousedown', this.doOffsetMouse);
|
||||
window.removeEventListener('mousemove', this.doOffsetMouse);
|
||||
window.removeEventListener('mouseup', this.doOffsetMouse);
|
||||
}
|
||||
|
||||
doOffsetMouse(event) {
|
||||
const { zoom } = this.state;
|
||||
event.screenZoomX = event.screenX * Math.pow(zoom, -1);
|
||||
event.screenZoomY = event.screenY * Math.pow(zoom, -1);
|
||||
}
|
||||
|
||||
handleMouseDown(event) {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
mouseDown: true,
|
||||
lastLeft: event.clientX - state.left,
|
||||
lastTop: event.clientY - state.top,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.setState({
|
||||
mouseDown: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleMouseMove(event) {
|
||||
if (this.state.mouseDown) {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
left: event.clientX - state.lastLeft,
|
||||
top: event.clientY - state.lastTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, backgroundImage, imageWidth, ...rest } = this.props;
|
||||
const { left, top, zoom } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.ref}
|
||||
{...computeBoxProps({
|
||||
...rest,
|
||||
style: {
|
||||
...rest.style,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
})}>
|
||||
<div
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
style={{
|
||||
'position': 'fixed',
|
||||
'height': '100%',
|
||||
'width': '100%',
|
||||
'background-image': `url("${backgroundImage}")`,
|
||||
'background-position': `${left}px ${top}px`,
|
||||
'background-repeat': 'repeat',
|
||||
'background-size': `${zoom * imageWidth}px`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseMove={this.handleMouseMove}
|
||||
style={{
|
||||
'position': 'fixed',
|
||||
'transform': `translate(${left}px, ${top}px) scale(${zoom})`,
|
||||
'transform-origin': 'top left',
|
||||
'height': '100%',
|
||||
'width': '100%',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Stack position="absolute" width="100%">
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="minus"
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
zoom: Math.max(zoom - ZOOM_INCREMENT, ZOOM_MIN_VAL),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={1}>
|
||||
<ProgressBar minValue={ZOOM_MIN_VAL} value={zoom} maxValue={ZOOM_MAX_VAL}>
|
||||
{zoom}x
|
||||
</ProgressBar>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="plus"
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
zoom: Math.min(zoom + ZOOM_INCREMENT, ZOOM_MAX_VAL),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
141
tgui/packages/tgui_ch/components/Input.js
Normal file
141
tgui/packages/tgui_ch/components/Input.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
|
||||
|
||||
export const toInputValue = (value) => (typeof value !== 'number' && typeof value !== 'string' ? '' : String(value));
|
||||
|
||||
export class Input extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
editing: false,
|
||||
};
|
||||
this.handleInput = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onInput } = this.props;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
if (onInput) {
|
||||
onInput(e, e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleFocus = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { autoSelect } = this.props;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
if (autoSelect) {
|
||||
e.target.select();
|
||||
}
|
||||
};
|
||||
this.handleBlur = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onChange } = this.props;
|
||||
if (editing) {
|
||||
this.setEditing(false);
|
||||
if (onChange) {
|
||||
onChange(e, e.target.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.handleKeyDown = (e) => {
|
||||
const { onInput, onChange, onEnter } = this.props;
|
||||
if (e.keyCode === KEY_ENTER) {
|
||||
this.setEditing(false);
|
||||
if (onChange) {
|
||||
onChange(e, e.target.value);
|
||||
}
|
||||
if (onInput) {
|
||||
onInput(e, e.target.value);
|
||||
}
|
||||
if (onEnter) {
|
||||
onEnter(e, e.target.value);
|
||||
}
|
||||
if (this.props.selfClear) {
|
||||
e.target.value = '';
|
||||
} else {
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KEY_ESCAPE) {
|
||||
if (this.props.onEscape) {
|
||||
this.props.onEscape(e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setEditing(false);
|
||||
e.target.value = toInputValue(this.props.value);
|
||||
e.target.blur();
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const nextValue = this.props.value;
|
||||
const input = this.inputRef.current;
|
||||
if (input) {
|
||||
input.value = toInputValue(nextValue);
|
||||
}
|
||||
|
||||
if (this.props.autoFocus || this.props.autoSelect) {
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
|
||||
if (this.props.autoSelect) {
|
||||
input.select();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { editing } = this.state;
|
||||
const prevValue = prevProps.value;
|
||||
const nextValue = this.props.value;
|
||||
const input = this.inputRef.current;
|
||||
if (input && !editing && prevValue !== nextValue) {
|
||||
input.value = toInputValue(nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
setEditing(editing) {
|
||||
this.setState({ editing });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
// Input only props
|
||||
const { selfClear, onInput, onChange, onEnter, value, maxLength, placeholder, ...boxProps } = props;
|
||||
// Box props
|
||||
const { className, fluid, monospace, ...rest } = boxProps;
|
||||
return (
|
||||
<Box
|
||||
className={classes(['Input', fluid && 'Input--fluid', monospace && 'Input--monospace', className])}
|
||||
{...rest}>
|
||||
<div className="Input__baseline">.</div>
|
||||
<input
|
||||
ref={this.inputRef}
|
||||
className="Input__input"
|
||||
placeholder={placeholder}
|
||||
onInput={this.handleInput}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
39
tgui/packages/tgui_ch/components/KeyListener.tsx
Normal file
39
tgui/packages/tgui_ch/components/KeyListener.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component } from 'inferno';
|
||||
import { KeyEvent } from '../events';
|
||||
import { listenForKeyEvents } from '../hotkeys';
|
||||
|
||||
type KeyListenerProps = Partial<{
|
||||
onKey: (key: KeyEvent) => void;
|
||||
onKeyDown: (key: KeyEvent) => void;
|
||||
onKeyUp: (key: KeyEvent) => void;
|
||||
}>;
|
||||
|
||||
export class KeyListener extends Component<KeyListenerProps> {
|
||||
dispose: () => void;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.dispose = listenForKeyEvents((key) => {
|
||||
if (this.props.onKey) {
|
||||
this.props.onKey(key);
|
||||
}
|
||||
|
||||
if (key.isDown() && this.props.onKeyDown) {
|
||||
this.props.onKeyDown(key);
|
||||
}
|
||||
|
||||
if (key.isUp() && this.props.onKeyUp) {
|
||||
this.props.onKeyUp(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
114
tgui/packages/tgui_ch/components/Knob.js
Normal file
114
tgui/packages/tgui_ch/components/Knob.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { keyOfMatchingRange, scale } from 'common/math';
|
||||
import { classes } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
import { DraggableControl } from './DraggableControl';
|
||||
import { NumberInput } from './NumberInput';
|
||||
|
||||
export const Knob = (props) => {
|
||||
// IE8: I don't want to support a yet another component on IE8.
|
||||
// IE8: It also can't handle SVG.
|
||||
if (Byond.IS_LTE_IE8) {
|
||||
return <NumberInput {...props} />;
|
||||
}
|
||||
const {
|
||||
// Draggable props (passthrough)
|
||||
animated,
|
||||
format,
|
||||
maxValue,
|
||||
minValue,
|
||||
unclamped,
|
||||
onChange,
|
||||
onDrag,
|
||||
step,
|
||||
stepPixelSize,
|
||||
suppressFlicker,
|
||||
unit,
|
||||
value,
|
||||
// Own props
|
||||
className,
|
||||
style,
|
||||
fillValue,
|
||||
color,
|
||||
ranges = {},
|
||||
size = 1,
|
||||
bipolar,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
return (
|
||||
<DraggableControl
|
||||
dragMatrix={[0, -1]}
|
||||
{...{
|
||||
animated,
|
||||
format,
|
||||
maxValue,
|
||||
minValue,
|
||||
unclamped,
|
||||
onChange,
|
||||
onDrag,
|
||||
step,
|
||||
stepPixelSize,
|
||||
suppressFlicker,
|
||||
unit,
|
||||
value,
|
||||
}}>
|
||||
{(control) => {
|
||||
const { dragging, editing, value, displayValue, displayElement, inputElement, handleDragStart } = control;
|
||||
const scaledFillValue = scale(fillValue ?? displayValue, minValue, maxValue);
|
||||
const scaledDisplayValue = scale(displayValue, minValue, maxValue);
|
||||
const effectiveColor = color || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
|
||||
const rotation = Math.min((scaledDisplayValue - 0.5) * 270, 225);
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Knob',
|
||||
'Knob--color--' + effectiveColor,
|
||||
bipolar && 'Knob--bipolar',
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps({
|
||||
style: {
|
||||
'font-size': size + 'em',
|
||||
...style,
|
||||
},
|
||||
...rest,
|
||||
})}
|
||||
onMouseDown={handleDragStart}>
|
||||
<div className="Knob__circle">
|
||||
<div
|
||||
className="Knob__cursorBox"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}>
|
||||
<div className="Knob__cursor" />
|
||||
</div>
|
||||
</div>
|
||||
{dragging && <div className="Knob__popupValue">{displayElement}</div>}
|
||||
<svg className="Knob__ring Knob__ringTrackPivot" viewBox="0 0 100 100">
|
||||
<circle className="Knob__ringTrack" cx="50" cy="50" r="50" />
|
||||
</svg>
|
||||
<svg className="Knob__ring Knob__ringFillPivot" viewBox="0 0 100 100">
|
||||
<circle
|
||||
className="Knob__ringFill"
|
||||
style={{
|
||||
'stroke-dashoffset': Math.max(((bipolar ? 2.75 : 2.0) - scaledFillValue * 1.5) * Math.PI * 50, 0),
|
||||
}}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="50"
|
||||
/>
|
||||
</svg>
|
||||
{inputElement}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</DraggableControl>
|
||||
);
|
||||
};
|
||||
31
tgui/packages/tgui_ch/components/LabeledControls.js
Normal file
31
tgui/packages/tgui_ch/components/LabeledControls.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Flex } from './Flex';
|
||||
|
||||
export const LabeledControls = (props) => {
|
||||
const { children, wrap, ...rest } = props;
|
||||
return (
|
||||
<Flex mx={-0.5} wrap={wrap} align="stretch" justify="space-between" {...rest}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const LabeledControlsItem = (props) => {
|
||||
const { label, children, mx = 1, ...rest } = props;
|
||||
return (
|
||||
<Flex.Item mx={mx}>
|
||||
<Flex height="100%" direction="column" align="center" textAlign="center" justify="space-between" {...rest}>
|
||||
<Flex.Item />
|
||||
<Flex.Item>{children}</Flex.Item>
|
||||
<Flex.Item color="label">{label}</Flex.Item>
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
);
|
||||
};
|
||||
|
||||
LabeledControls.Item = LabeledControlsItem;
|
||||
100
tgui/packages/tgui_ch/components/LabeledList.tsx
Normal file
100
tgui/packages/tgui_ch/components/LabeledList.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
|
||||
import { InfernoNode } from 'inferno';
|
||||
import { Box, unit } from './Box';
|
||||
import { Divider } from './Divider';
|
||||
|
||||
type LabeledListProps = {
|
||||
children?: any;
|
||||
};
|
||||
|
||||
export const LabeledList = (props: LabeledListProps) => {
|
||||
const { children } = props;
|
||||
return <table className="LabeledList">{children}</table>;
|
||||
};
|
||||
|
||||
LabeledList.defaultHooks = pureComponentHooks;
|
||||
|
||||
type LabeledListItemProps = {
|
||||
className?: string | BooleanLike;
|
||||
label?: string | BooleanLike;
|
||||
labelColor?: string | BooleanLike;
|
||||
color?: string | BooleanLike;
|
||||
textAlign?: string | BooleanLike;
|
||||
verticalAlign?: string | BooleanLike; // VOREStation Addition
|
||||
buttons?: InfernoNode;
|
||||
/** @deprecated */
|
||||
content?: any;
|
||||
children?: InfernoNode;
|
||||
};
|
||||
|
||||
const LabeledListItem = (props: LabeledListItemProps) => {
|
||||
const {
|
||||
className,
|
||||
label,
|
||||
labelColor = 'label',
|
||||
color,
|
||||
textAlign,
|
||||
verticalAlign, // VOREStation Addition
|
||||
buttons,
|
||||
content,
|
||||
children,
|
||||
...rest // VOREStation Addition
|
||||
} = props;
|
||||
return (
|
||||
<tr className={classes(['LabeledList__row', className])}>
|
||||
<Box
|
||||
as="td"
|
||||
verticalAlign={verticalAlign} // VOREStation Addition
|
||||
color={labelColor}
|
||||
className={classes(['LabeledList__cell', 'LabeledList__label'])}>
|
||||
{label ? label + ':' : null}
|
||||
</Box>
|
||||
<Box
|
||||
as="td"
|
||||
color={color}
|
||||
textAlign={textAlign}
|
||||
verticalAlign={verticalAlign} // VOREStation Addition
|
||||
className={classes(['LabeledList__cell', 'LabeledList__content'])}
|
||||
colSpan={buttons ? undefined : 2}
|
||||
{...rest} /* VOREStation Addition*/
|
||||
>
|
||||
{content}
|
||||
{children}
|
||||
</Box>
|
||||
{buttons && <td className="LabeledList__cell LabeledList__buttons">{buttons}</td>}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
LabeledListItem.defaultHooks = pureComponentHooks;
|
||||
|
||||
type LabeledListDividerProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LabeledListDivider = (props: LabeledListDividerProps) => {
|
||||
const padding = props.size ? unit(Math.max(0, props.size - 1)) : 0;
|
||||
return (
|
||||
<tr className="LabeledList__row">
|
||||
<td
|
||||
colSpan={3}
|
||||
style={{
|
||||
'padding-top': padding,
|
||||
'padding-bottom': padding,
|
||||
}}>
|
||||
<Divider />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
LabeledListDivider.defaultHooks = pureComponentHooks;
|
||||
|
||||
LabeledList.Item = LabeledListItem;
|
||||
LabeledList.Divider = LabeledListDivider;
|
||||
36
tgui/packages/tgui_ch/components/Modal.js
Normal file
36
tgui/packages/tgui_ch/components/Modal.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
import { Dimmer } from './Dimmer';
|
||||
|
||||
export const Modal = (props) => {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
onEnter, // VOREStation Addition
|
||||
...rest
|
||||
} = props;
|
||||
// VOREStation Addition Start
|
||||
let handleKeyDown;
|
||||
if (onEnter) {
|
||||
handleKeyDown = (e) => {
|
||||
let key = e.which || e.keyCode;
|
||||
if (key === 13) {
|
||||
onEnter(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
// VOREStation Addition End
|
||||
return (
|
||||
<Dimmer onKeyDown={handleKeyDown /* VOREStation Edit */}>
|
||||
<div className={classes(['Modal', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
|
||||
{children}
|
||||
</div>
|
||||
</Dimmer>
|
||||
);
|
||||
};
|
||||
219
tgui/packages/tgui_ch/components/NanoMap.js
Normal file
219
tgui/packages/tgui_ch/components/NanoMap.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Component } from 'inferno';
|
||||
import { Box, Button, Icon, Tooltip, LabeledList, Slider } from '.';
|
||||
import { useBackend } from '../backend';
|
||||
|
||||
const pauseEvent = (e) => {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.cancelBubble = true;
|
||||
e.returnValue = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const zoomScale = 280;
|
||||
|
||||
export class NanoMap extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// Auto center based on window size
|
||||
const Xcenter = window.innerWidth / 2 - 256;
|
||||
const Ycenter = window.innerHeight / 2 - 256;
|
||||
|
||||
this.state = {
|
||||
offsetX: Xcenter,
|
||||
offsetY: Ycenter,
|
||||
transform: 'none',
|
||||
dragging: false,
|
||||
originX: null,
|
||||
originY: null,
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
// Dragging
|
||||
this.handleDragStart = (e) => {
|
||||
this.ref = e.target;
|
||||
this.setState({
|
||||
dragging: false,
|
||||
originX: e.screenX,
|
||||
originY: e.screenY,
|
||||
});
|
||||
document.addEventListener('mousemove', this.handleDragMove);
|
||||
document.addEventListener('mouseup', this.handleDragEnd);
|
||||
pauseEvent(e);
|
||||
};
|
||||
|
||||
this.handleDragMove = (e) => {
|
||||
this.setState((prevState) => {
|
||||
const state = { ...prevState };
|
||||
const newOffsetX = e.screenX - state.originX;
|
||||
const newOffsetY = e.screenY - state.originY;
|
||||
if (prevState.dragging) {
|
||||
state.offsetX += newOffsetX;
|
||||
state.offsetY += newOffsetY;
|
||||
state.originX = e.screenX;
|
||||
state.originY = e.screenY;
|
||||
} else {
|
||||
state.dragging = true;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
pauseEvent(e);
|
||||
};
|
||||
|
||||
this.handleDragEnd = (e) => {
|
||||
this.setState({
|
||||
dragging: false,
|
||||
originX: null,
|
||||
originY: null,
|
||||
});
|
||||
document.removeEventListener('mousemove', this.handleDragMove);
|
||||
document.removeEventListener('mouseup', this.handleDragEnd);
|
||||
pauseEvent(e);
|
||||
};
|
||||
|
||||
this.handleOnClick = (e) => {
|
||||
let byondX = e.offsetX / this.state.zoom / zoomScale;
|
||||
let byondY = 1 - e.offsetY / this.state.zoom / zoomScale; // Byond origin is bottom left, this is top left
|
||||
|
||||
e.byondX = byondX;
|
||||
e.byondY = byondY;
|
||||
if (typeof this.props.onClick === 'function') {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
this.handleZoom = (_e, value) => {
|
||||
this.setState((state) => {
|
||||
const newZoom = Math.min(Math.max(value, 1), 8);
|
||||
let zoomDiff = (newZoom - state.zoom) * 1.5;
|
||||
state.zoom = newZoom;
|
||||
|
||||
let newOffsetX = state.offsetX - 262 * zoomDiff;
|
||||
if (newOffsetX < -500) {
|
||||
newOffsetX = -500;
|
||||
}
|
||||
if (newOffsetX > 500) {
|
||||
newOffsetX = 500;
|
||||
}
|
||||
|
||||
let newOffsetY = state.offsetY - 256 * zoomDiff;
|
||||
if (newOffsetY < -200) {
|
||||
newOffsetY = -200;
|
||||
}
|
||||
if (newOffsetY > 200) {
|
||||
newOffsetY = 200;
|
||||
}
|
||||
|
||||
state.offsetX = newOffsetX;
|
||||
state.offsetY = newOffsetY;
|
||||
if (props.onZoom) {
|
||||
props.onZoom(state.zoom);
|
||||
}
|
||||
return state;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { config } = useBackend(this.context);
|
||||
const { dragging, offsetX, offsetY, zoom = 1 } = this.state;
|
||||
const { children } = this.props;
|
||||
|
||||
const mapUrl = config.map + '_nanomap_z' + config.mapZLevel + '.png';
|
||||
// (x * zoom), x Needs to be double the turf- map size. (for virgo, 140x140)
|
||||
const mapSize = zoomScale * zoom + 'px';
|
||||
const newStyle = {
|
||||
width: mapSize,
|
||||
height: mapSize,
|
||||
'margin-top': offsetY + 'px',
|
||||
'margin-left': offsetX + 'px',
|
||||
'overflow': 'hidden',
|
||||
'position': 'relative',
|
||||
'background-image': 'url(' + mapUrl + ')',
|
||||
'background-size': 'cover',
|
||||
'background-repeat': 'no-repeat',
|
||||
'text-align': 'center',
|
||||
'cursor': dragging ? 'move' : 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="NanoMap__container">
|
||||
<Box style={newStyle} textAlign="center" onMouseDown={this.handleDragStart} onClick={this.handleOnClick}>
|
||||
<Box>{children}</Box>
|
||||
</Box>
|
||||
<NanoMapZoomer zoom={zoom} onZoom={this.handleZoom} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const NanoMapMarker = (props, context) => {
|
||||
const { x, y, zoom = 1, icon, tooltip, color, onClick } = props;
|
||||
|
||||
const handleOnClick = (e) => {
|
||||
pauseEvent(e);
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const rx = x * 2 * zoom - zoom - 3;
|
||||
const ry = y * 2 * zoom - zoom - 3;
|
||||
return (
|
||||
<div>
|
||||
<Box
|
||||
position="absolute"
|
||||
className="NanoMap__marker"
|
||||
lineHeight="0"
|
||||
bottom={ry + 'px'}
|
||||
left={rx + 'px'}
|
||||
onMouseDown={handleOnClick}>
|
||||
<Icon name={icon} color={color} fontSize="6px" />
|
||||
<Tooltip content={tooltip} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NanoMap.Marker = NanoMapMarker;
|
||||
|
||||
const NanoMapZoomer = (props, context) => {
|
||||
const { act, config, data } = useBackend(context);
|
||||
return (
|
||||
<Box className="NanoMap__zoomer">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Zoom">
|
||||
<Slider
|
||||
minValue="1"
|
||||
maxValue="8"
|
||||
stepPixelSize="10"
|
||||
format={(v) => v + 'x'}
|
||||
value={props.zoom}
|
||||
onDrag={(e, v) => props.onZoom(e, v)}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Z-Level">
|
||||
{data.map_levels
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map((level) => (
|
||||
<Button
|
||||
key={level}
|
||||
selected={~~level === ~~config.mapZLevel}
|
||||
content={level}
|
||||
onClick={() => {
|
||||
act('setZLevel', { 'mapZLevel': level });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
NanoMap.Zoomer = NanoMapZoomer;
|
||||
27
tgui/packages/tgui_ch/components/NoticeBox.js
Normal file
27
tgui/packages/tgui_ch/components/NoticeBox.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { Box } from './Box';
|
||||
|
||||
export const NoticeBox = (props) => {
|
||||
const { className, color, info, warning, success, danger, ...rest } = props;
|
||||
return (
|
||||
<Box
|
||||
className={classes([
|
||||
'NoticeBox',
|
||||
color && 'NoticeBox--color--' + color,
|
||||
info && 'NoticeBox--type--info',
|
||||
success && 'NoticeBox--type--success',
|
||||
danger && 'NoticeBox--type--danger',
|
||||
className,
|
||||
])}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
NoticeBox.defaultHooks = pureComponentHooks;
|
||||
262
tgui/packages/tgui_ch/components/NumberInput.js
Normal file
262
tgui/packages/tgui_ch/components/NumberInput.js
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp } from 'common/math';
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import { Box } from './Box';
|
||||
|
||||
const DEFAULT_UPDATE_RATE = 400;
|
||||
|
||||
export class NumberInput extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { value } = props;
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
value,
|
||||
dragging: false,
|
||||
editing: false,
|
||||
internalValue: null,
|
||||
origin: null,
|
||||
suppressingFlicker: false,
|
||||
};
|
||||
|
||||
// Suppresses flickering while the value propagates through the backend
|
||||
this.flickerTimer = null;
|
||||
this.suppressFlicker = () => {
|
||||
const { suppressFlicker } = this.props;
|
||||
if (suppressFlicker > 0) {
|
||||
this.setState({
|
||||
suppressingFlicker: true,
|
||||
});
|
||||
clearTimeout(this.flickerTimer);
|
||||
this.flickerTimer = setTimeout(
|
||||
() =>
|
||||
this.setState({
|
||||
suppressingFlicker: false,
|
||||
}),
|
||||
suppressFlicker
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.handleDragStart = (e) => {
|
||||
const { value } = this.props;
|
||||
const { editing } = this.state;
|
||||
if (editing) {
|
||||
return;
|
||||
}
|
||||
document.body.style['pointer-events'] = 'none';
|
||||
this.ref = e.target;
|
||||
this.setState({
|
||||
dragging: false,
|
||||
origin: e.screenY,
|
||||
value,
|
||||
internalValue: value,
|
||||
});
|
||||
this.timer = setTimeout(() => {
|
||||
this.setState({
|
||||
dragging: true,
|
||||
});
|
||||
}, 250);
|
||||
this.dragInterval = setInterval(() => {
|
||||
const { dragging, value } = this.state;
|
||||
const { onDrag } = this.props;
|
||||
if (dragging && onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
}, this.props.updateRate || DEFAULT_UPDATE_RATE);
|
||||
document.addEventListener('mousemove', this.handleDragMove);
|
||||
document.addEventListener('mouseup', this.handleDragEnd);
|
||||
};
|
||||
|
||||
this.handleDragMove = (e) => {
|
||||
const { minValue, maxValue, step, stepPixelSize } = this.props;
|
||||
this.setState((prevState) => {
|
||||
const state = { ...prevState };
|
||||
const offset = state.origin - e.screenY;
|
||||
if (prevState.dragging) {
|
||||
const stepOffset = Number.isFinite(minValue) ? minValue % step : 0;
|
||||
// Translate mouse movement to value
|
||||
// Give it some headroom (by increasing clamp range by 1 step)
|
||||
state.internalValue = clamp(
|
||||
state.internalValue + (offset * step) / stepPixelSize,
|
||||
minValue - step,
|
||||
maxValue + step
|
||||
);
|
||||
// Clamp the final value
|
||||
state.value = clamp(state.internalValue - (state.internalValue % step) + stepOffset, minValue, maxValue);
|
||||
state.origin = e.screenY;
|
||||
} else if (Math.abs(offset) > 4) {
|
||||
state.dragging = true;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
};
|
||||
|
||||
this.handleDragEnd = (e) => {
|
||||
const { onChange, onDrag } = this.props;
|
||||
const { dragging, value, internalValue } = this.state;
|
||||
document.body.style['pointer-events'] = 'auto';
|
||||
clearTimeout(this.timer);
|
||||
clearInterval(this.dragInterval);
|
||||
this.setState({
|
||||
dragging: false,
|
||||
editing: !dragging,
|
||||
origin: null,
|
||||
});
|
||||
document.removeEventListener('mousemove', this.handleDragMove);
|
||||
document.removeEventListener('mouseup', this.handleDragEnd);
|
||||
if (dragging) {
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
} else if (this.inputRef) {
|
||||
const input = this.inputRef.current;
|
||||
input.value = internalValue;
|
||||
// IE8: Dies when trying to focus a hidden element
|
||||
// (Error: Object does not support this action)
|
||||
try {
|
||||
input.focus();
|
||||
input.select();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dragging, editing, value: intermediateValue, suppressingFlicker } = this.state;
|
||||
const {
|
||||
className,
|
||||
fluid,
|
||||
animated,
|
||||
value,
|
||||
unit,
|
||||
minValue,
|
||||
maxValue,
|
||||
height,
|
||||
width,
|
||||
lineHeight,
|
||||
fontSize,
|
||||
format,
|
||||
onChange,
|
||||
onDrag,
|
||||
} = this.props;
|
||||
let displayValue = value;
|
||||
if (dragging || suppressingFlicker) {
|
||||
displayValue = intermediateValue;
|
||||
}
|
||||
// IE8: Use an "unselectable" prop because "user-select" doesn't work.
|
||||
const renderContentElement = (value) => (
|
||||
<div className="NumberInput__content" unselectable={Byond.IS_LTE_IE8}>
|
||||
{value + (unit ? ' ' + unit : '')}
|
||||
</div>
|
||||
);
|
||||
const contentElement =
|
||||
(animated && !dragging && !suppressingFlicker && (
|
||||
<AnimatedNumber value={displayValue} format={format}>
|
||||
{renderContentElement}
|
||||
</AnimatedNumber>
|
||||
)) ||
|
||||
renderContentElement(format ? format(displayValue) : displayValue);
|
||||
return (
|
||||
<Box
|
||||
className={classes(['NumberInput', fluid && 'NumberInput--fluid', className])}
|
||||
minWidth={width}
|
||||
minHeight={height}
|
||||
lineHeight={lineHeight}
|
||||
fontSize={fontSize}
|
||||
onMouseDown={this.handleDragStart}>
|
||||
<div className="NumberInput__barContainer">
|
||||
<div
|
||||
className="NumberInput__bar"
|
||||
style={{
|
||||
height: clamp(((displayValue - minValue) / (maxValue - minValue)) * 100, 0, 100) + '%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{contentElement}
|
||||
<input
|
||||
ref={this.inputRef}
|
||||
className="NumberInput__input"
|
||||
style={{
|
||||
display: !editing ? 'none' : undefined,
|
||||
height: height,
|
||||
'line-height': lineHeight,
|
||||
'font-size': fontSize,
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!editing) {
|
||||
return;
|
||||
}
|
||||
const value = clamp(parseFloat(e.target.value), minValue, maxValue);
|
||||
if (Number.isNaN(value)) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
editing: false,
|
||||
value,
|
||||
});
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13) {
|
||||
const value = clamp(parseFloat(e.target.value), minValue, maxValue);
|
||||
if (Number.isNaN(value)) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
editing: false,
|
||||
value,
|
||||
});
|
||||
this.suppressFlicker();
|
||||
if (onChange) {
|
||||
onChange(e, value);
|
||||
}
|
||||
if (onDrag) {
|
||||
onDrag(e, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === 27) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NumberInput.defaultHooks = pureComponentHooks;
|
||||
NumberInput.defaultProps = {
|
||||
minValue: -Infinity,
|
||||
maxValue: +Infinity,
|
||||
step: 1,
|
||||
stepPixelSize: 1,
|
||||
suppressFlicker: 50,
|
||||
};
|
||||
74
tgui/packages/tgui_ch/components/Popper.tsx
Normal file
74
tgui/packages/tgui_ch/components/Popper.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createPopper } from '@popperjs/core';
|
||||
import { ArgumentsOf } from 'common/types';
|
||||
import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
|
||||
|
||||
type PopperProps = {
|
||||
popperContent: InfernoNode;
|
||||
options?: ArgumentsOf<typeof createPopper>[2];
|
||||
additionalStyles?: CSSProperties;
|
||||
};
|
||||
|
||||
export class Popper extends Component<PopperProps> {
|
||||
static id: number = 0;
|
||||
|
||||
renderedContent: HTMLDivElement;
|
||||
popperInstance: ReturnType<typeof createPopper>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
Popper.id += 1;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { additionalStyles, options } = this.props;
|
||||
|
||||
this.renderedContent = document.createElement('div');
|
||||
if (additionalStyles) {
|
||||
for (const [attribute, value] of Object.entries(additionalStyles)) {
|
||||
this.renderedContent.style[attribute] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.renderPopperContent(() => {
|
||||
document.body.appendChild(this.renderedContent);
|
||||
|
||||
// HACK: We don't want to create a wrapper, as it could break the layout
|
||||
// of consumers, so we do the inferno equivalent of `findDOMNode(this)`.
|
||||
// This is usually bad as refs are usually better, but refs did
|
||||
// not work in this case, as they weren't propagating correctly.
|
||||
// A previous attempt was made as a render prop that passed an ID,
|
||||
// but this made consuming use too unwieldly.
|
||||
// This code is copied from `findDOMNode` in inferno-extras.
|
||||
// Because this component is written in TypeScript, we will know
|
||||
// immediately if this internal variable is removed.
|
||||
const domNode = findDOMfromVNode(this.$LI, true);
|
||||
if (!domNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popperInstance = createPopper(domNode, this.renderedContent, options);
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.renderPopperContent(() => this.popperInstance?.update());
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.popperInstance?.destroy();
|
||||
render(null, this.renderedContent, () => {
|
||||
this.renderedContent.remove();
|
||||
});
|
||||
}
|
||||
|
||||
renderPopperContent(callback: () => void) {
|
||||
// `render` errors when given false, so we convert it to `null`,
|
||||
// which is supported.
|
||||
render(this.props.popperContent || null, this.renderedContent, callback, this.context);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
36
tgui/packages/tgui_ch/components/ProgressBar.js
Normal file
36
tgui/packages/tgui_ch/components/ProgressBar.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01, scale, keyOfMatchingRange, toFixed } from 'common/math';
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
export const ProgressBar = (props) => {
|
||||
const { className, value, minValue = 0, maxValue = 1, color, ranges = {}, children, ...rest } = props;
|
||||
const scaledValue = scale(value, minValue, maxValue);
|
||||
const hasContent = children !== undefined;
|
||||
const effectiveColor = color || keyOfMatchingRange(value, ranges) || 'default';
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'ProgressBar',
|
||||
'ProgressBar--color--' + effectiveColor,
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps(rest)}>
|
||||
<div
|
||||
className="ProgressBar__fill ProgressBar__fill--animated"
|
||||
style={{
|
||||
width: clamp01(scaledValue) * 100 + '%',
|
||||
}}
|
||||
/>
|
||||
<div className="ProgressBar__content">{hasContent ? children : toFixed(scaledValue * 100) + '%'}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressBar.defaultHooks = pureComponentHooks;
|
||||
150
tgui/packages/tgui_ch/components/RestrictedInput.js
Normal file
150
tgui/packages/tgui_ch/components/RestrictedInput.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { classes } from 'common/react';
|
||||
import { clamp } from 'common/math';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
|
||||
|
||||
const DEFAULT_MIN = 0;
|
||||
const DEFAULT_MAX = 10000;
|
||||
|
||||
/**
|
||||
* Takes a string input and parses integers from it.
|
||||
* If none: Minimum is set.
|
||||
* Else: Clamps it to the given range.
|
||||
*/
|
||||
const getClampedNumber = (value, minValue, maxValue) => {
|
||||
const minimum = minValue || DEFAULT_MIN;
|
||||
const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX;
|
||||
if (!value || !value.length) {
|
||||
return String(minimum);
|
||||
}
|
||||
// let parsedValue = parseInt(value.replace(/\D/g, ''), 10);
|
||||
let parsedValue = parseFloat(value);
|
||||
if (isNaN(parsedValue)) {
|
||||
return String(minimum);
|
||||
} else {
|
||||
return String(clamp(parsedValue, minimum, maximum));
|
||||
}
|
||||
};
|
||||
|
||||
export class RestrictedInput extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
editing: false,
|
||||
};
|
||||
this.handleBlur = (e) => {
|
||||
const { editing } = this.state;
|
||||
if (editing) {
|
||||
this.setEditing(false);
|
||||
}
|
||||
};
|
||||
this.handleChange = (e) => {
|
||||
const { maxValue, minValue, onChange } = this.props;
|
||||
e.target.value = getClampedNumber(e.target.value, minValue, maxValue);
|
||||
if (onChange) {
|
||||
onChange(e, +e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleFocus = (e) => {
|
||||
const { editing } = this.state;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
};
|
||||
this.handleInput = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onInput } = this.props;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
if (onInput) {
|
||||
onInput(e, +e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleKeyDown = (e) => {
|
||||
const { maxValue, minValue, onChange, onEnter } = this.props;
|
||||
if (e.keyCode === KEY_ENTER) {
|
||||
const safeNum = getClampedNumber(e.target.value, minValue, maxValue);
|
||||
this.setEditing(false);
|
||||
if (onChange) {
|
||||
onChange(e, +safeNum);
|
||||
}
|
||||
if (onEnter) {
|
||||
onEnter(e, +safeNum);
|
||||
}
|
||||
e.target.blur();
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KEY_ESCAPE) {
|
||||
if (this.props.onEscape) {
|
||||
this.props.onEscape(e);
|
||||
return;
|
||||
}
|
||||
this.setEditing(false);
|
||||
e.target.value = this.props.value;
|
||||
e.target.blur();
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { maxValue, minValue } = this.props;
|
||||
const nextValue = this.props.value?.toString();
|
||||
const input = this.inputRef.current;
|
||||
if (input) {
|
||||
input.value = getClampedNumber(nextValue, minValue, maxValue);
|
||||
}
|
||||
if (this.props.autoFocus || this.props.autoSelect) {
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
|
||||
if (this.props.autoSelect) {
|
||||
input.select();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, _) {
|
||||
const { maxValue, minValue } = this.props;
|
||||
const { editing } = this.state;
|
||||
const prevValue = prevProps.value?.toString();
|
||||
const nextValue = this.props.value?.toString();
|
||||
const input = this.inputRef.current;
|
||||
if (input && !editing) {
|
||||
if (nextValue !== prevValue && nextValue !== input.value) {
|
||||
input.value = getClampedNumber(nextValue, minValue, maxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEditing(editing) {
|
||||
this.setState({ editing });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { onChange, onEnter, onInput, value, ...boxProps } = props;
|
||||
const { className, fluid, monospace, ...rest } = boxProps;
|
||||
return (
|
||||
<Box
|
||||
className={classes(['Input', fluid && 'Input--fluid', monospace && 'Input--monospace', className])}
|
||||
{...rest}>
|
||||
<div className="Input__baseline">.</div>
|
||||
<input
|
||||
className="Input__input"
|
||||
onChange={this.handleChange}
|
||||
onInput={this.handleInput}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.inputRef}
|
||||
type="number"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
82
tgui/packages/tgui_ch/components/RoundGauge.js
Normal file
82
tgui/packages/tgui_ch/components/RoundGauge.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 bobbahbrown (https://github.com/bobbahbrown)
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01, keyOfMatchingRange, scale } from 'common/math';
|
||||
import { classes } from 'common/react';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import { Box, computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
export const RoundGauge = (props) => {
|
||||
// Support for IE8 is for losers sorry B)
|
||||
if (Byond.IS_LTE_IE8) {
|
||||
return <AnimatedNumber {...props} />;
|
||||
}
|
||||
|
||||
const { value, minValue = 1, maxValue = 1, ranges, alertAfter, format, size = 1, className, style, ...rest } = props;
|
||||
|
||||
const scaledValue = scale(value, minValue, maxValue);
|
||||
const clampedValue = clamp01(scaledValue);
|
||||
let scaledRanges = ranges ? {} : { 'primary': [0, 1] };
|
||||
if (ranges) {
|
||||
Object.keys(ranges).forEach((x) => {
|
||||
const range = ranges[x];
|
||||
scaledRanges[x] = [scale(range[0], minValue, maxValue), scale(range[1], minValue, maxValue)];
|
||||
});
|
||||
}
|
||||
|
||||
let alertColor = null;
|
||||
if (alertAfter < value) {
|
||||
alertColor = keyOfMatchingRange(clampedValue, scaledRanges);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box inline>
|
||||
<div
|
||||
className={classes(['RoundGauge', className, computeBoxClassName(rest)])}
|
||||
{...computeBoxProps({
|
||||
style: {
|
||||
'font-size': size + 'em',
|
||||
...style,
|
||||
},
|
||||
...rest,
|
||||
})}>
|
||||
<svg viewBox="0 0 100 50">
|
||||
{alertAfter && (
|
||||
<g className={classes(['RoundGauge__alert', alertColor ? `active RoundGauge__alert--${alertColor}` : ''])}>
|
||||
<path d="M48.211,14.578C48.55,13.9 49.242,13.472 50,13.472C50.758,13.472 51.45,13.9 51.789,14.578C54.793,20.587 60.795,32.589 63.553,38.106C63.863,38.726 63.83,39.462 63.465,40.051C63.101,40.641 62.457,41 61.764,41C55.996,41 44.004,41 38.236,41C37.543,41 36.899,40.641 36.535,40.051C36.17,39.462 36.137,38.726 36.447,38.106C39.205,32.589 45.207,20.587 48.211,14.578ZM50,34.417C51.426,34.417 52.583,35.574 52.583,37C52.583,38.426 51.426,39.583 50,39.583C48.574,39.583 47.417,38.426 47.417,37C47.417,35.574 48.574,34.417 50,34.417ZM50,32.75C50,32.75 53,31.805 53,22.25C53,20.594 51.656,19.25 50,19.25C48.344,19.25 47,20.594 47,22.25C47,31.805 50,32.75 50,32.75Z" />
|
||||
</g>
|
||||
)}
|
||||
<g>
|
||||
<circle className="RoundGauge__ringTrack" cx="50" cy="50" r="45" />
|
||||
</g>
|
||||
<g>
|
||||
{Object.keys(scaledRanges).map((x, i) => {
|
||||
const col_ranges = scaledRanges[x];
|
||||
return (
|
||||
<circle
|
||||
className={`RoundGauge__ringFill RoundGauge--color--${x}`}
|
||||
key={i}
|
||||
style={{
|
||||
'stroke-dashoffset': Math.max((2.0 - (col_ranges[1] - col_ranges[0])) * Math.PI * 50, 0),
|
||||
}}
|
||||
transform={`rotate(${180 + 180 * col_ranges[0]} 50 50)`}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g className="RoundGauge__needle" transform={`rotate(${clampedValue * 180 - 90} 50 50)`}>
|
||||
<polygon className="RoundGauge__needleLine" points="46,50 50,0 54,50" />
|
||||
<circle className="RoundGauge__needleMiddle" cx="50" cy="50" r="8" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<AnimatedNumber value={value} format={format} size={size} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
107
tgui/packages/tgui_ch/components/Section.tsx
Normal file
107
tgui/packages/tgui_ch/components/Section.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { canRender, classes } from 'common/react';
|
||||
import { Component, createRef, InfernoNode, RefObject } from 'inferno';
|
||||
import { addScrollableNode, removeScrollableNode } from '../events';
|
||||
import { BoxProps, computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
interface SectionProps extends BoxProps {
|
||||
className?: string;
|
||||
title?: string | InfernoElement<string>;
|
||||
buttons?: InfernoNode;
|
||||
fill?: boolean;
|
||||
fitted?: boolean;
|
||||
scrollable?: boolean;
|
||||
flexGrow?: boolean; // VOREStation Addition
|
||||
noTopPadding?: boolean; // VOREStation Addition
|
||||
stretchContents?: boolean; // VOREStation Addition
|
||||
/** @deprecated This property no longer works, please remove it. */
|
||||
level?: boolean;
|
||||
/** @deprecated Please use `scrollable` property */
|
||||
overflowY?: any;
|
||||
}
|
||||
|
||||
export class Section extends Component<SectionProps> {
|
||||
scrollableRef: RefObject<HTMLDivElement>;
|
||||
scrollable: boolean;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.scrollableRef = createRef();
|
||||
this.scrollable = props.scrollable;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.scrollable) {
|
||||
addScrollableNode(this.scrollableRef.current);
|
||||
}
|
||||
if (this.props.autoFocus) {
|
||||
setTimeout(() => {
|
||||
if (this.scrollableRef.current) {
|
||||
return this.scrollableRef.current.focus();
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.scrollable) {
|
||||
removeScrollableNode(this.scrollableRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
title,
|
||||
buttons,
|
||||
fill,
|
||||
fitted,
|
||||
scrollable,
|
||||
flexGrow, // VOREStation Addition
|
||||
noTopPadding, // VOREStation Addition
|
||||
stretchContents, // VOREStation Addition
|
||||
children,
|
||||
...rest
|
||||
} = this.props;
|
||||
const hasTitle = canRender(title) || canRender(buttons);
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Section',
|
||||
Byond.IS_LTE_IE8 && 'Section--iefix',
|
||||
fill && 'Section--fill',
|
||||
fitted && 'Section--fitted',
|
||||
scrollable && 'Section--scrollable',
|
||||
flexGrow && 'Section--flex', // VOREStation Addition
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps(rest)}>
|
||||
{hasTitle && (
|
||||
<div className="Section__title">
|
||||
<span className="Section__titleText">{title}</span>
|
||||
<div className="Section__buttons">{buttons}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="Section__rest">
|
||||
{/* Vorestation Edit Start */}
|
||||
<div
|
||||
ref={this.scrollableRef}
|
||||
className={classes([
|
||||
'Section__content',
|
||||
!!stretchContents && 'Section__content--stretchContents',
|
||||
!!noTopPadding && 'Section__content--noTopPadding',
|
||||
])}>
|
||||
{children}
|
||||
</div>
|
||||
{/* Vorestation Edit End */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
103
tgui/packages/tgui_ch/components/Slider.js
Normal file
103
tgui/packages/tgui_ch/components/Slider.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp01, keyOfMatchingRange, scale } from 'common/math';
|
||||
import { classes } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
import { DraggableControl } from './DraggableControl';
|
||||
import { NumberInput } from './NumberInput';
|
||||
|
||||
export const Slider = (props) => {
|
||||
// IE8: I don't want to support a yet another component on IE8.
|
||||
if (Byond.IS_LTE_IE8) {
|
||||
return <NumberInput {...props} />;
|
||||
}
|
||||
const {
|
||||
// Draggable props (passthrough)
|
||||
animated,
|
||||
format,
|
||||
maxValue,
|
||||
minValue,
|
||||
onChange,
|
||||
onDrag,
|
||||
step,
|
||||
stepPixelSize,
|
||||
suppressFlicker,
|
||||
unit,
|
||||
value,
|
||||
// Own props
|
||||
className,
|
||||
fillValue,
|
||||
color,
|
||||
ranges = {},
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const hasContent = children !== undefined;
|
||||
return (
|
||||
<DraggableControl
|
||||
dragMatrix={[1, 0]}
|
||||
{...{
|
||||
animated,
|
||||
format,
|
||||
maxValue,
|
||||
minValue,
|
||||
onChange,
|
||||
onDrag,
|
||||
step,
|
||||
stepPixelSize,
|
||||
suppressFlicker,
|
||||
unit,
|
||||
value,
|
||||
}}>
|
||||
{(control) => {
|
||||
const { dragging, editing, value, displayValue, displayElement, inputElement, handleDragStart } = control;
|
||||
const hasFillValue = fillValue !== undefined && fillValue !== null;
|
||||
const scaledValue = scale(value, minValue, maxValue);
|
||||
const scaledFillValue = scale(fillValue ?? displayValue, minValue, maxValue);
|
||||
const scaledDisplayValue = scale(displayValue, minValue, maxValue);
|
||||
const effectiveColor = color || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Slider',
|
||||
'ProgressBar',
|
||||
'ProgressBar--color--' + effectiveColor,
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps(rest)}
|
||||
onMouseDown={handleDragStart}>
|
||||
<div
|
||||
className={classes(['ProgressBar__fill', hasFillValue && 'ProgressBar__fill--animated'])}
|
||||
style={{
|
||||
width: clamp01(scaledFillValue) * 100 + '%',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="ProgressBar__fill"
|
||||
style={{
|
||||
width: clamp01(Math.min(scaledFillValue, scaledDisplayValue)) * 100 + '%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="Slider__cursorOffset"
|
||||
style={{
|
||||
width: clamp01(scaledDisplayValue) * 100 + '%',
|
||||
}}>
|
||||
<div className="Slider__cursor" />
|
||||
<div className="Slider__pointer" />
|
||||
{dragging && <div className="Slider__popupValue">{displayElement}</div>}
|
||||
</div>
|
||||
<div className="ProgressBar__content">{hasContent ? children : displayElement}</div>
|
||||
{inputElement}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</DraggableControl>
|
||||
);
|
||||
};
|
||||
57
tgui/packages/tgui_ch/components/Stack.tsx
Normal file
57
tgui/packages/tgui_ch/components/Stack.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2021 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { RefObject } from 'inferno';
|
||||
import { Flex, FlexItemProps, FlexProps } from './Flex';
|
||||
|
||||
interface StackProps extends FlexProps {
|
||||
vertical?: boolean;
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export const Stack = (props: StackProps) => {
|
||||
const { className, vertical, fill, ...rest } = props;
|
||||
return (
|
||||
<Flex
|
||||
className={classes([
|
||||
'Stack',
|
||||
fill && 'Stack--fill',
|
||||
vertical ? 'Stack--vertical' : 'Stack--horizontal',
|
||||
className,
|
||||
])}
|
||||
direction={vertical ? 'column' : 'row'}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type StackItemProps = FlexProps & {
|
||||
innerRef?: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const StackItem = (props: StackItemProps) => {
|
||||
const { className, innerRef, ...rest } = props;
|
||||
return <Flex.Item className={classes(['Stack__item', className])} ref={innerRef} {...rest} />;
|
||||
};
|
||||
|
||||
Stack.Item = StackItem;
|
||||
|
||||
interface StackDividerProps extends FlexItemProps {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const StackDivider = (props: StackDividerProps) => {
|
||||
const { className, hidden, ...rest } = props;
|
||||
return (
|
||||
<Flex.Item
|
||||
className={classes(['Stack__item', 'Stack__divider', hidden && 'Stack__divider--hidden', className])}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Stack.Divider = StackDivider;
|
||||
54
tgui/packages/tgui_ch/components/Table.js
Normal file
54
tgui/packages/tgui_ch/components/Table.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes, pureComponentHooks } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
|
||||
export const Table = (props) => {
|
||||
const { className, collapsing, children, ...rest } = props;
|
||||
return (
|
||||
<table
|
||||
className={classes(['Table', collapsing && 'Table--collapsing', className, computeBoxClassName(rest)])}
|
||||
{...computeBoxProps(rest)}>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
Table.defaultHooks = pureComponentHooks;
|
||||
|
||||
export const TableRow = (props) => {
|
||||
const { className, header, ...rest } = props;
|
||||
return (
|
||||
<tr
|
||||
className={classes(['Table__row', header && 'Table__row--header', className, computeBoxClassName(props)])}
|
||||
{...computeBoxProps(rest)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TableRow.defaultHooks = pureComponentHooks;
|
||||
|
||||
export const TableCell = (props) => {
|
||||
const { className, collapsing, header, ...rest } = props;
|
||||
return (
|
||||
<td
|
||||
className={classes([
|
||||
'Table__cell',
|
||||
collapsing && 'Table__cell--collapsing',
|
||||
header && 'Table__cell--header',
|
||||
className,
|
||||
computeBoxClassName(props),
|
||||
])}
|
||||
{...computeBoxProps(rest)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TableCell.defaultHooks = pureComponentHooks;
|
||||
|
||||
Table.Row = TableRow;
|
||||
Table.Cell = TableCell;
|
||||
54
tgui/packages/tgui_ch/components/Tabs.js
Normal file
54
tgui/packages/tgui_ch/components/Tabs.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { canRender, classes } from 'common/react';
|
||||
import { computeBoxClassName, computeBoxProps } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export const Tabs = (props) => {
|
||||
const { className, vertical, fill, fluid, children, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Tabs',
|
||||
vertical ? 'Tabs--vertical' : 'Tabs--horizontal',
|
||||
fill && 'Tabs--fill',
|
||||
fluid && 'Tabs--fluid',
|
||||
className,
|
||||
computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps(rest)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tab = (props) => {
|
||||
const { className, selected, color, icon, leftSlot, rightSlot, children, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={classes([
|
||||
'Tab',
|
||||
'Tabs__Tab',
|
||||
'Tab--color--' + color,
|
||||
selected && 'Tab--selected',
|
||||
className,
|
||||
...computeBoxClassName(rest),
|
||||
])}
|
||||
{...computeBoxProps(rest)}>
|
||||
{(canRender(leftSlot) && <div className="Tab__left">{leftSlot}</div>) ||
|
||||
(!!icon && (
|
||||
<div className="Tab__left">
|
||||
<Icon name={icon} />
|
||||
</div>
|
||||
))}
|
||||
<div className="Tab__text">{children}</div>
|
||||
{canRender(rightSlot) && <div className="Tab__right">{rightSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.Tab = Tab;
|
||||
234
tgui/packages/tgui_ch/components/TextArea.js
Normal file
234
tgui/packages/tgui_ch/components/TextArea.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @author Warlockd
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { classes } from 'common/react';
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { Box } from './Box';
|
||||
import { toInputValue } from './Input';
|
||||
import { KEY_ENTER, KEY_ESCAPE, KEY_TAB } from 'common/keycodes';
|
||||
|
||||
export class TextArea extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.textareaRef = props.innerRef || createRef();
|
||||
// CHOMPedit
|
||||
this.state = {
|
||||
editing: false,
|
||||
scrolledAmount: 0,
|
||||
};
|
||||
const { dontUseTabForIndent = false } = props;
|
||||
this.handleOnInput = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onInput } = this.props;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
if (onInput) {
|
||||
onInput(e, e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleOnChange = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onChange } = this.props;
|
||||
if (editing) {
|
||||
this.setEditing(false);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e, e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleKeyPress = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onKeyPress } = this.props;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
if (onKeyPress) {
|
||||
onKeyPress(e, e.target.value);
|
||||
}
|
||||
};
|
||||
this.handleKeyDown = (e) => {
|
||||
const { editing } = this.state;
|
||||
// CHOMPedit
|
||||
const { onChange, onInput, onEnter, onKey } = this.props;
|
||||
if (e.keyCode === KEY_ENTER) {
|
||||
this.setEditing(false);
|
||||
if (onChange) {
|
||||
onChange(e, e.target.value);
|
||||
}
|
||||
if (onInput) {
|
||||
onInput(e, e.target.value);
|
||||
}
|
||||
if (onEnter) {
|
||||
onEnter(e, e.target.value);
|
||||
}
|
||||
if (this.props.selfClear) {
|
||||
e.target.value = '';
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KEY_ESCAPE) {
|
||||
if (this.props.onEscape) {
|
||||
this.props.onEscape(e);
|
||||
}
|
||||
this.setEditing(false);
|
||||
if (this.props.selfClear) {
|
||||
e.target.value = '';
|
||||
} else {
|
||||
e.target.value = toInputValue(this.props.value);
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
// CHOMPedit
|
||||
// Custom key handler
|
||||
if (onKey) {
|
||||
onKey(e, e.target.value);
|
||||
}
|
||||
if (!dontUseTabForIndent) {
|
||||
const keyCode = e.keyCode || e.which;
|
||||
if (keyCode === KEY_TAB) {
|
||||
e.preventDefault();
|
||||
const { value, selectionStart, selectionEnd } = e.target;
|
||||
e.target.value = value.substring(0, selectionStart) + '\t' + value.substring(selectionEnd);
|
||||
e.target.selectionEnd = selectionStart + 1;
|
||||
// CHOMPedit
|
||||
if (onInput) {
|
||||
onInput(e, e.target.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.handleFocus = (e) => {
|
||||
const { editing } = this.state;
|
||||
if (!editing) {
|
||||
this.setEditing(true);
|
||||
}
|
||||
};
|
||||
this.handleBlur = (e) => {
|
||||
const { editing } = this.state;
|
||||
const { onChange } = this.props;
|
||||
if (editing) {
|
||||
this.setEditing(false);
|
||||
if (onChange) {
|
||||
onChange(e, e.target.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
// CHOMPedit Start
|
||||
this.handleScroll = (e) => {
|
||||
const { displayedValue } = this.props;
|
||||
const input = this.textareaRef.current;
|
||||
if (displayedValue && input) {
|
||||
this.setState({
|
||||
scrolledAmount: input.scrollTop,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const nextValue = this.props.value;
|
||||
const input = this.textareaRef.current;
|
||||
if (input) {
|
||||
input.value = toInputValue(nextValue);
|
||||
}
|
||||
if (this.props.autoFocus || this.props.autoSelect) {
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
|
||||
if (this.props.autoSelect) {
|
||||
input.select();
|
||||
}
|
||||
}, 1);
|
||||
// CHOMPedit End
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const prevValue = prevProps.value;
|
||||
const nextValue = this.props.value;
|
||||
const input = this.textareaRef.current;
|
||||
if (input && typeof nextValue === 'string' && prevValue !== nextValue) {
|
||||
input.value = toInputValue(nextValue);
|
||||
}
|
||||
}
|
||||
|
||||
setEditing(editing) {
|
||||
this.setState({ editing });
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.textareaRef.current && this.textareaRef.current.value;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Input only props
|
||||
const {
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onEnter,
|
||||
value,
|
||||
maxLength,
|
||||
placeholder,
|
||||
// CHOMPedit Start
|
||||
scrollbar,
|
||||
noborder,
|
||||
displayedValue,
|
||||
...boxProps
|
||||
} = this.props;
|
||||
// Box props
|
||||
const { className, fluid, nowrap, ...rest } = boxProps;
|
||||
const { scrolledAmount } = this.state;
|
||||
return (
|
||||
<Box
|
||||
className={classes(['TextArea', fluid && 'TextArea--fluid', noborder && 'TextArea--noborder', className])}
|
||||
{...rest}>
|
||||
{!!displayedValue && (
|
||||
<Box position="absolute" width="100%" height="100%" overflow="hidden">
|
||||
<div
|
||||
className={classes(['TextArea__textarea', 'TextArea__textarea_custom'])}
|
||||
style={{
|
||||
'transform': `translateY(-${scrolledAmount}px)`,
|
||||
}}>
|
||||
{displayedValue}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
<textarea
|
||||
ref={this.textareaRef}
|
||||
className={classes([
|
||||
'TextArea__textarea',
|
||||
scrollbar && 'TextArea__textarea--scrollable',
|
||||
nowrap && 'TextArea__nowrap',
|
||||
])}
|
||||
placeholder={placeholder}
|
||||
onChange={this.handleOnChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
onInput={this.handleOnInput}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
onScroll={this.handleScroll}
|
||||
maxLength={maxLength}
|
||||
style={{
|
||||
'color': displayedValue ? 'rgba(0, 0, 0, 0)' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
{/* CHOMPedit End */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
61
tgui/packages/tgui_ch/components/TimeDisplay.js
Normal file
61
tgui/packages/tgui_ch/components/TimeDisplay.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { formatTime } from '../format';
|
||||
import { Component } from 'inferno';
|
||||
|
||||
// AnimatedNumber Copypaste
|
||||
const isSafeNumber = (value) => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value);
|
||||
};
|
||||
|
||||
export class TimeDisplay extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.timer = null;
|
||||
this.last_seen_value = undefined;
|
||||
this.state = {
|
||||
value: 0,
|
||||
};
|
||||
// Set initial state with value provided in props
|
||||
if (isSafeNumber(props.value)) {
|
||||
this.state.value = Number(props.value);
|
||||
this.last_seen_value = Number(props.value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.auto !== undefined) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = setInterval(() => this.tick(), 1000); // every 1 s
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
let current = Number(this.state.value);
|
||||
if (this.props.value !== this.last_seen_value) {
|
||||
this.last_seen_value = this.props.value;
|
||||
current = this.props.value;
|
||||
}
|
||||
const mod = this.props.auto === 'up' ? 10 : -10; // Time down by default.
|
||||
const value = Math.max(0, current + mod); // one sec tick
|
||||
this.setState({ value });
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.auto !== undefined) {
|
||||
this.timer = setInterval(() => this.tick(), 1000); // every 1 s
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
render() {
|
||||
const val = this.state.value;
|
||||
// Directly display weird stuff
|
||||
if (!isSafeNumber(val)) {
|
||||
return this.state.value || null;
|
||||
}
|
||||
|
||||
return formatTime(val);
|
||||
}
|
||||
}
|
||||
138
tgui/packages/tgui_ch/components/Tooltip.tsx
Normal file
138
tgui/packages/tgui_ch/components/Tooltip.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createPopper, Placement, VirtualElement } from '@popperjs/core';
|
||||
import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
|
||||
|
||||
type TooltipProps = {
|
||||
children?: InfernoNode;
|
||||
content: InfernoNode;
|
||||
position?: Placement;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
hovered: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
modifiers: [
|
||||
{
|
||||
name: 'eventListeners',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NULL_RECT = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
export class Tooltip extends Component<TooltipProps, TooltipState> {
|
||||
// Mounting poppers is really laggy because popper.js is very slow.
|
||||
// Thus, instead of using the Popper component, Tooltip creates ONE popper
|
||||
// and stores every tooltip inside that.
|
||||
// This means you can never have two tooltips at once, for instance.
|
||||
static renderedTooltip: HTMLDivElement | undefined;
|
||||
static singletonPopper: ReturnType<typeof createPopper> | undefined;
|
||||
static currentHoveredElement: Element | undefined;
|
||||
static virtualElement: VirtualElement = {
|
||||
getBoundingClientRect: () => (Tooltip.currentHoveredElement?.getBoundingClientRect() as DOMRect) ?? NULL_RECT,
|
||||
};
|
||||
|
||||
getDOMNode() {
|
||||
// HACK: We don't want to create a wrapper, as it could break the layout
|
||||
// of consumers, so we do the inferno equivalent of `findDOMNode(this)`.
|
||||
// My attempt to avoid this was a render prop that passed in
|
||||
// callbacks to onmouseenter and onmouseleave, but this was unwiedly
|
||||
// to consumers, specifically buttons.
|
||||
// This code is copied from `findDOMNode` in inferno-extras.
|
||||
// Because this component is written in TypeScript, we will know
|
||||
// immediately if this internal variable is removed.
|
||||
return findDOMfromVNode(this.$LI, true);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const domNode = this.getDOMNode();
|
||||
|
||||
if (!domNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
domNode.addEventListener('mouseenter', () => {
|
||||
let renderedTooltip = Tooltip.renderedTooltip;
|
||||
if (renderedTooltip === undefined) {
|
||||
renderedTooltip = document.createElement('div');
|
||||
renderedTooltip.className = 'Tooltip';
|
||||
document.body.appendChild(renderedTooltip);
|
||||
Tooltip.renderedTooltip = renderedTooltip;
|
||||
}
|
||||
|
||||
Tooltip.currentHoveredElement = domNode;
|
||||
|
||||
renderedTooltip.style.opacity = '1';
|
||||
|
||||
this.renderPopperContent();
|
||||
});
|
||||
|
||||
domNode.addEventListener('mouseleave', () => {
|
||||
this.fadeOut();
|
||||
});
|
||||
}
|
||||
|
||||
fadeOut() {
|
||||
if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Tooltip.currentHoveredElement = undefined;
|
||||
Tooltip.renderedTooltip!.style.opacity = '0';
|
||||
}
|
||||
renderPopperContent() {
|
||||
const renderedTooltip = Tooltip.renderedTooltip;
|
||||
if (!renderedTooltip) {
|
||||
return;
|
||||
}
|
||||
|
||||
render(
|
||||
<span>{this.props.content}</span>,
|
||||
renderedTooltip,
|
||||
() => {
|
||||
let singletonPopper = Tooltip.singletonPopper;
|
||||
if (singletonPopper === undefined) {
|
||||
singletonPopper = createPopper(Tooltip.virtualElement, renderedTooltip!, {
|
||||
...DEFAULT_OPTIONS,
|
||||
placement: this.props.position || 'auto',
|
||||
});
|
||||
|
||||
Tooltip.singletonPopper = singletonPopper;
|
||||
} else {
|
||||
singletonPopper.setOptions({
|
||||
...DEFAULT_OPTIONS,
|
||||
placement: this.props.position || 'auto',
|
||||
});
|
||||
|
||||
singletonPopper.update();
|
||||
}
|
||||
},
|
||||
this.context
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderPopperContent();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.fadeOut();
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
33
tgui/packages/tgui_ch/components/TrackOutsideClicks.tsx
Normal file
33
tgui/packages/tgui_ch/components/TrackOutsideClicks.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, createRef } from 'inferno';
|
||||
|
||||
export class TrackOutsideClicks extends Component<{
|
||||
onOutsideClick: () => void;
|
||||
}> {
|
||||
ref = createRef<HTMLDivElement>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.handleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
}
|
||||
|
||||
handleOutsideClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ref.current && !this.ref.current.contains(event.target)) {
|
||||
this.props.onOutsideClick();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div ref={this.ref}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
||||
46
tgui/packages/tgui_ch/components/index.js
Normal file
46
tgui/packages/tgui_ch/components/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { AnimatedNumber } from './AnimatedNumber';
|
||||
export { Autofocus } from './Autofocus';
|
||||
export { Blink } from './Blink';
|
||||
export { BlockQuote } from './BlockQuote';
|
||||
export { Box } from './Box';
|
||||
export { Button } from './Button';
|
||||
export { ByondUi } from './ByondUi';
|
||||
export { Chart } from './Chart';
|
||||
export { Collapsible } from './Collapsible';
|
||||
export { ColorBox } from './ColorBox';
|
||||
export { Dimmer } from './Dimmer';
|
||||
export { Divider } from './Divider';
|
||||
export { DraggableControl } from './DraggableControl';
|
||||
export { Dropdown } from './Dropdown';
|
||||
export { Flex } from './Flex';
|
||||
export { Grid } from './Grid';
|
||||
export { Icon } from './Icon';
|
||||
export { InfinitePlane } from './InfinitePlane';
|
||||
export { Input } from './Input';
|
||||
export { KeyListener } from './KeyListener';
|
||||
export { Knob } from './Knob';
|
||||
export { LabeledControls } from './LabeledControls';
|
||||
export { LabeledList } from './LabeledList';
|
||||
export { Modal } from './Modal';
|
||||
export { NanoMap } from './NanoMap';
|
||||
export { NoticeBox } from './NoticeBox';
|
||||
export { NumberInput } from './NumberInput';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
export { Popper } from './Popper';
|
||||
export { RestrictedInput } from './RestrictedInput';
|
||||
export { RoundGauge } from './RoundGauge';
|
||||
export { Section } from './Section';
|
||||
export { Slider } from './Slider';
|
||||
export { Stack } from './Stack';
|
||||
export { Table } from './Table';
|
||||
export { Tabs } from './Tabs';
|
||||
export { TextArea } from './TextArea';
|
||||
export { TimeDisplay } from './TimeDisplay';
|
||||
export { TrackOutsideClicks } from './TrackOutsideClicks';
|
||||
export { Tooltip } from './Tooltip';
|
||||
294
tgui/packages/tgui_ch/constants.js
Normal file
294
tgui/packages/tgui_ch/constants.js
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// UI states, which are mirrored from the BYOND code.
|
||||
export const UI_INTERACTIVE = 2;
|
||||
export const UI_UPDATE = 1;
|
||||
export const UI_DISABLED = 0;
|
||||
export const UI_CLOSE = -1;
|
||||
|
||||
// All game related colors are stored here
|
||||
export const COLORS = {
|
||||
// Department colors
|
||||
department: {
|
||||
captain: '#c06616',
|
||||
security: '#e74c3c',
|
||||
medbay: '#3498db',
|
||||
science: '#9b59b6',
|
||||
engineering: '#f1c40f',
|
||||
cargo: '#f39c12',
|
||||
centcom: '#00c100',
|
||||
other: '#c38312',
|
||||
},
|
||||
// VOREStation Addition begin
|
||||
manifest: {
|
||||
command: '#3333FF',
|
||||
security: '#8e0000',
|
||||
medical: '#006600',
|
||||
engineering: '#b27300',
|
||||
science: '#a65ba6',
|
||||
cargo: '#bb9040',
|
||||
planetside: '#555555',
|
||||
civilian: '#a32800',
|
||||
miscellaneous: '#666666',
|
||||
silicon: '#222222',
|
||||
},
|
||||
// VOREStation Addition end
|
||||
// Damage type colors
|
||||
damageType: {
|
||||
oxy: '#3498db',
|
||||
toxin: '#2ecc71',
|
||||
burn: '#e67e22',
|
||||
brute: '#e74c3c',
|
||||
},
|
||||
// reagent / chemistry related colours
|
||||
reagent: {
|
||||
acidicbuffer: '#fbc314',
|
||||
basicbuffer: '#3853a4',
|
||||
},
|
||||
};
|
||||
|
||||
// Colors defined in CSS
|
||||
export const CSS_COLORS = [
|
||||
'black',
|
||||
'white',
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'olive',
|
||||
'green',
|
||||
'teal',
|
||||
'blue',
|
||||
'violet',
|
||||
'purple',
|
||||
'pink',
|
||||
'brown',
|
||||
'grey',
|
||||
'good',
|
||||
'average',
|
||||
'bad',
|
||||
'label',
|
||||
];
|
||||
|
||||
// VOREStation Edit Start
|
||||
// If you ever add a new radio channel, you can either manually update this, or
|
||||
// go use /client/verb/generate_tgui_radio_constants() in communications.dm.
|
||||
export const RADIO_CHANNELS = [
|
||||
{
|
||||
'name': 'Mercenary',
|
||||
'freq': 1213,
|
||||
'color': '#6D3F40',
|
||||
},
|
||||
{
|
||||
'name': 'Raider',
|
||||
'freq': 1277,
|
||||
'color': '#6D3F40',
|
||||
},
|
||||
{
|
||||
'name': 'Special Ops',
|
||||
'freq': 1341,
|
||||
'color': '#5C5C8A',
|
||||
},
|
||||
{
|
||||
'name': 'AI Private',
|
||||
'freq': 1343,
|
||||
'color': '#FF00FF',
|
||||
},
|
||||
{
|
||||
'name': 'Response Team',
|
||||
'freq': 1345,
|
||||
'color': '#5C5C8A',
|
||||
},
|
||||
{
|
||||
'name': 'Supply',
|
||||
'freq': 1347,
|
||||
'color': '#5F4519',
|
||||
},
|
||||
{
|
||||
'name': 'Service',
|
||||
'freq': 1349,
|
||||
'color': '#6eaa2c',
|
||||
},
|
||||
{
|
||||
'name': 'Science',
|
||||
'freq': 1351,
|
||||
'color': '#993399',
|
||||
},
|
||||
{
|
||||
'name': 'Command',
|
||||
'freq': 1353,
|
||||
'color': '#193A7A',
|
||||
},
|
||||
{
|
||||
'name': 'Medical',
|
||||
'freq': 1355,
|
||||
'color': '#008160',
|
||||
},
|
||||
{
|
||||
'name': 'Engineering',
|
||||
'freq': 1357,
|
||||
'color': '#A66300',
|
||||
},
|
||||
{
|
||||
'name': 'Security',
|
||||
'freq': 1359,
|
||||
'color': '#A30000',
|
||||
},
|
||||
{
|
||||
'name': 'Explorer',
|
||||
'freq': 1361,
|
||||
'color': '#555555',
|
||||
},
|
||||
{
|
||||
'name': 'Talon',
|
||||
'freq': 1363,
|
||||
'color': '#555555',
|
||||
},
|
||||
{
|
||||
'name': 'Common',
|
||||
'freq': 1459,
|
||||
'color': '#008000',
|
||||
},
|
||||
{
|
||||
'name': 'Entertainment',
|
||||
'freq': 1461,
|
||||
'color': '#339966',
|
||||
},
|
||||
{
|
||||
'name': 'Security(I)',
|
||||
'freq': 1475,
|
||||
'color': '#008000',
|
||||
},
|
||||
{
|
||||
'name': 'Medical(I)',
|
||||
'freq': 1485,
|
||||
'color': '#008000',
|
||||
},
|
||||
];
|
||||
|
||||
const GASES = [
|
||||
{
|
||||
'id': 'oxygen',
|
||||
'name': 'Oxygen',
|
||||
'label': 'O₂',
|
||||
'color': 'blue',
|
||||
},
|
||||
{
|
||||
'id': 'n2',
|
||||
'name': 'Nitrogen',
|
||||
'label': 'N₂',
|
||||
'color': 'red',
|
||||
},
|
||||
{
|
||||
'id': 'carbon dioxide',
|
||||
'name': 'Carbon Dioxide',
|
||||
'label': 'CO₂',
|
||||
'color': 'grey',
|
||||
},
|
||||
{
|
||||
'id': 'phoron',
|
||||
'name': 'Phoron',
|
||||
'label': 'Phoron',
|
||||
'color': 'pink',
|
||||
},
|
||||
{
|
||||
'id': 'water_vapor',
|
||||
'name': 'Water Vapor',
|
||||
'label': 'H₂O',
|
||||
'color': 'grey',
|
||||
},
|
||||
{
|
||||
'id': 'nob',
|
||||
'name': 'Hyper-noblium',
|
||||
'label': 'Hyper-nob',
|
||||
'color': 'teal',
|
||||
},
|
||||
{
|
||||
'id': 'n2o',
|
||||
'name': 'Nitrous Oxide',
|
||||
'label': 'N₂O',
|
||||
'color': 'red',
|
||||
},
|
||||
{
|
||||
'id': 'no2',
|
||||
'name': 'Nitryl',
|
||||
'label': 'NO₂',
|
||||
'color': 'brown',
|
||||
},
|
||||
{
|
||||
'id': 'tritium',
|
||||
'name': 'Tritium',
|
||||
'label': 'Tritium',
|
||||
'color': 'green',
|
||||
},
|
||||
{
|
||||
'id': 'bz',
|
||||
'name': 'BZ',
|
||||
'label': 'BZ',
|
||||
'color': 'purple',
|
||||
},
|
||||
{
|
||||
'id': 'stim',
|
||||
'name': 'Stimulum',
|
||||
'label': 'Stimulum',
|
||||
'color': 'purple',
|
||||
},
|
||||
{
|
||||
'id': 'pluox',
|
||||
'name': 'Pluoxium',
|
||||
'label': 'Pluoxium',
|
||||
'color': 'blue',
|
||||
},
|
||||
{
|
||||
'id': 'miasma',
|
||||
'name': 'Miasma',
|
||||
'label': 'Miasma',
|
||||
'color': 'olive',
|
||||
},
|
||||
{
|
||||
'id': 'hydrogen',
|
||||
'name': 'Hydrogen',
|
||||
'label': 'H₂',
|
||||
'color': 'white',
|
||||
},
|
||||
{
|
||||
'id': 'other',
|
||||
'name': 'Other',
|
||||
'label': 'Other',
|
||||
'color': 'white',
|
||||
},
|
||||
{
|
||||
'id': 'pressure',
|
||||
'name': 'Pressure',
|
||||
'label': 'Pressure',
|
||||
'color': 'average',
|
||||
},
|
||||
{
|
||||
'id': 'temperature',
|
||||
'name': 'Temperature',
|
||||
'label': 'Temperature',
|
||||
'color': 'yellow',
|
||||
},
|
||||
];
|
||||
|
||||
// VOREStation Edit End
|
||||
|
||||
export const getGasLabel = (gasId, fallbackValue) => {
|
||||
const gasSearchString = String(gasId).toLowerCase();
|
||||
const gas = GASES.find((gas) => gas.id === gasSearchString || gas.name.toLowerCase() === gasSearchString);
|
||||
return (gas && gas.label) || fallbackValue || gasId;
|
||||
};
|
||||
|
||||
export const getGasColor = (gasId) => {
|
||||
const gasSearchString = String(gasId).toLowerCase();
|
||||
const gas = GASES.find((gas) => gas.id === gasSearchString || gas.name.toLowerCase() === gasSearchString);
|
||||
return gas && gas.color;
|
||||
};
|
||||
|
||||
// VOREStation Addition start
|
||||
/** 0.0 Degrees Celsius in Kelvin */
|
||||
export const T0C = 273.15;
|
||||
// VOREStation Addition end
|
||||
50
tgui/packages/tgui_ch/debug/KitchenSink.js
Normal file
50
tgui/packages/tgui_ch/debug/KitchenSink.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useLocalState } from '../backend';
|
||||
import { Flex, Section, Tabs } from '../components';
|
||||
import { Pane, Window } from '../layouts';
|
||||
|
||||
const r = require.context('../stories', false, /\.stories\.js$/);
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* meta: {
|
||||
* title: string,
|
||||
* render: () => any,
|
||||
* },
|
||||
* }[]}
|
||||
*/
|
||||
const getStories = () => r.keys().map((path) => r(path));
|
||||
|
||||
export const KitchenSink = (props, context) => {
|
||||
const { panel } = props;
|
||||
const [theme] = useLocalState(context, 'kitchenSinkTheme');
|
||||
const [pageIndex, setPageIndex] = useLocalState(context, 'pageIndex', 0);
|
||||
const stories = getStories();
|
||||
const story = stories[pageIndex];
|
||||
const Layout = panel ? Pane : Window;
|
||||
return (
|
||||
<Layout title="Kitchen Sink" width={600} height={500} theme={theme}>
|
||||
<Flex height="100%">
|
||||
<Flex.Item m={1} mr={0}>
|
||||
<Section fill fitted>
|
||||
<Tabs vertical>
|
||||
{stories.map((story, i) => (
|
||||
<Tabs.Tab key={i} color="transparent" selected={i === pageIndex} onClick={() => setPageIndex(i)}>
|
||||
{story.meta.title}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
<Flex.Item position="relative" grow={1}>
|
||||
<Layout.Content scrollable>{story.meta.render()}</Layout.Content>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
11
tgui/packages/tgui_ch/debug/actions.js
Normal file
11
tgui/packages/tgui_ch/debug/actions.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { createAction } from 'common/redux';
|
||||
|
||||
export const toggleKitchenSink = createAction('debug/toggleKitchenSink');
|
||||
export const toggleDebugLayout = createAction('debug/toggleDebugLayout');
|
||||
export const openExternalBrowser = createAction('debug/openExternalBrowser');
|
||||
10
tgui/packages/tgui_ch/debug/hooks.js
Normal file
10
tgui/packages/tgui_ch/debug/hooks.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { useSelector } from 'common/redux';
|
||||
import { selectDebug } from './selectors';
|
||||
|
||||
export const useDebug = (context) => useSelector(context, selectDebug);
|
||||
10
tgui/packages/tgui_ch/debug/index.js
Normal file
10
tgui/packages/tgui_ch/debug/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export { useDebug } from './hooks';
|
||||
export { KitchenSink } from './KitchenSink';
|
||||
export { debugMiddleware, relayMiddleware } from './middleware';
|
||||
export { debugReducer } from './reducer';
|
||||
77
tgui/packages/tgui_ch/debug/middleware.js
Normal file
77
tgui/packages/tgui_ch/debug/middleware.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { KEY_BACKSPACE, KEY_F10, KEY_F11, KEY_F12 } from 'common/keycodes';
|
||||
import { globalEvents } from '../events';
|
||||
import { acquireHotKey } from '../hotkeys';
|
||||
import { openExternalBrowser, toggleDebugLayout, toggleKitchenSink } from './actions';
|
||||
|
||||
const relayedTypes = ['backend/update', 'chat/message'];
|
||||
|
||||
export const debugMiddleware = (store) => {
|
||||
acquireHotKey(KEY_F11);
|
||||
acquireHotKey(KEY_F12);
|
||||
globalEvents.on('keydown', (key) => {
|
||||
if (key.code === KEY_F11) {
|
||||
store.dispatch(toggleDebugLayout());
|
||||
}
|
||||
if (key.code === KEY_F12) {
|
||||
store.dispatch(toggleKitchenSink());
|
||||
}
|
||||
if (key.ctrl && key.alt && key.code === KEY_BACKSPACE) {
|
||||
// NOTE: We need to call this in a timeout, because we need a clean
|
||||
// stack in order for this to be a fatal error.
|
||||
setTimeout(() => {
|
||||
throw new Error(
|
||||
'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' +
|
||||
' fucko boingo! The code monkeys at our headquarters are' +
|
||||
' working VEWY HAWD to fix this!'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
return (next) => (action) => next(action);
|
||||
};
|
||||
|
||||
export const relayMiddleware = (store) => {
|
||||
const devServer = require('tgui-dev-server/link/client.cjs');
|
||||
const externalBrowser = location.search === '?external';
|
||||
if (externalBrowser) {
|
||||
devServer.subscribe((msg) => {
|
||||
const { type, payload } = msg;
|
||||
if (type === 'relay' && payload.windowId === Byond.windowId) {
|
||||
store.dispatch({
|
||||
...payload.action,
|
||||
relayed: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
acquireHotKey(KEY_F10);
|
||||
globalEvents.on('keydown', (key) => {
|
||||
if (key === KEY_F10) {
|
||||
store.dispatch(openExternalBrowser());
|
||||
}
|
||||
});
|
||||
}
|
||||
return (next) => (action) => {
|
||||
const { type, payload, relayed } = action;
|
||||
if (type === openExternalBrowser.type) {
|
||||
window.open(location.href + '?external', '_blank');
|
||||
return;
|
||||
}
|
||||
if (relayedTypes.includes(type) && !relayed && !externalBrowser) {
|
||||
devServer.sendMessage({
|
||||
type: 'relay',
|
||||
payload: {
|
||||
windowId: Byond.windowId,
|
||||
action,
|
||||
},
|
||||
});
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
22
tgui/packages/tgui_ch/debug/reducer.js
Normal file
22
tgui/packages/tgui_ch/debug/reducer.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const debugReducer = (state = {}, action) => {
|
||||
const { type, payload } = action;
|
||||
if (type === 'debug/toggleKitchenSink') {
|
||||
return {
|
||||
...state,
|
||||
kitchenSink: !state.kitchenSink,
|
||||
};
|
||||
}
|
||||
if (type === 'debug/toggleDebugLayout') {
|
||||
return {
|
||||
...state,
|
||||
debugLayout: !state.debugLayout,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
};
|
||||
7
tgui/packages/tgui_ch/debug/selectors.js
Normal file
7
tgui/packages/tgui_ch/debug/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export const selectDebug = (state) => state.debug;
|
||||
225
tgui/packages/tgui_ch/drag.js
Normal file
225
tgui/packages/tgui_ch/drag.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { storage } from 'common/storage';
|
||||
import { vecAdd, vecSubtract, vecMultiply, vecScale } from 'common/vector';
|
||||
import { createLogger } from './logging';
|
||||
|
||||
const logger = createLogger('drag');
|
||||
const pixelRatio = window.devicePixelRatio ?? 1;
|
||||
|
||||
let windowKey = Byond.windowId;
|
||||
let dragging = false;
|
||||
let resizing = false;
|
||||
let screenOffset = [0, 0];
|
||||
let screenOffsetPromise;
|
||||
let dragPointOffset;
|
||||
let resizeMatrix;
|
||||
let initialSize;
|
||||
let size;
|
||||
|
||||
export const setWindowKey = (key) => {
|
||||
windowKey = key;
|
||||
};
|
||||
|
||||
const getWindowPosition = () => [window.screenLeft * pixelRatio, window.screenTop * pixelRatio];
|
||||
|
||||
const getWindowSize = () => [window.innerWidth * pixelRatio, window.innerHeight * pixelRatio];
|
||||
|
||||
const setWindowPosition = (vec) => {
|
||||
const byondPos = vecAdd(vec, screenOffset);
|
||||
return Byond.winset(Byond.windowId, {
|
||||
pos: byondPos[0] + ',' + byondPos[1],
|
||||
});
|
||||
};
|
||||
|
||||
const setWindowSize = (vec) => {
|
||||
return Byond.winset(Byond.windowId, {
|
||||
size: vec[0] + 'x' + vec[1],
|
||||
});
|
||||
};
|
||||
|
||||
const getScreenPosition = () => [0 - screenOffset[0], 0 - screenOffset[1]];
|
||||
|
||||
const getScreenSize = () => [window.screen.availWidth * pixelRatio, window.screen.availHeight * pixelRatio];
|
||||
|
||||
/**
|
||||
* Moves an item to the top of the recents array, and keeps its length
|
||||
* limited to the number in `limit` argument.
|
||||
*
|
||||
* Uses a strict equality check for comparisons.
|
||||
*
|
||||
* Returns new recents and an item which was trimmed.
|
||||
*/
|
||||
const touchRecents = (recents, touchedItem, limit = 50) => {
|
||||
const nextRecents = [touchedItem];
|
||||
let trimmedItem;
|
||||
for (let i = 0; i < recents.length; i++) {
|
||||
const item = recents[i];
|
||||
if (item === touchedItem) {
|
||||
continue;
|
||||
}
|
||||
if (nextRecents.length < limit) {
|
||||
nextRecents.push(item);
|
||||
} else {
|
||||
trimmedItem = item;
|
||||
}
|
||||
}
|
||||
return [nextRecents, trimmedItem];
|
||||
};
|
||||
|
||||
const storeWindowGeometry = async () => {
|
||||
logger.log('storing geometry');
|
||||
const geometry = {
|
||||
pos: getWindowPosition(),
|
||||
size: getWindowSize(),
|
||||
};
|
||||
storage.set(windowKey, geometry);
|
||||
// Update the list of stored geometries
|
||||
const [geometries, trimmedKey] = touchRecents((await storage.get('geometries')) || [], windowKey);
|
||||
if (trimmedKey) {
|
||||
storage.remove(trimmedKey);
|
||||
}
|
||||
storage.set('geometries', geometries);
|
||||
};
|
||||
|
||||
export const recallWindowGeometry = async (options = {}) => {
|
||||
// Only recall geometry in fancy mode
|
||||
const geometry = options.fancy && (await storage.get(windowKey));
|
||||
if (geometry) {
|
||||
logger.log('recalled geometry:', geometry);
|
||||
}
|
||||
// options.pos is assumed to already be in display-pixels
|
||||
let pos = geometry?.pos || options.pos;
|
||||
let size = options.size;
|
||||
// Convert size from css-pixels to display-pixels
|
||||
if (size) {
|
||||
size = [size[0] * pixelRatio, size[1] * pixelRatio];
|
||||
}
|
||||
// Wait until screen offset gets resolved
|
||||
await screenOffsetPromise;
|
||||
const areaAvailable = getScreenSize();
|
||||
// Set window size
|
||||
if (size) {
|
||||
// Constraint size to not exceed available screen area.
|
||||
size = [Math.min(areaAvailable[0], size[0]), Math.min(areaAvailable[1], size[1])];
|
||||
setWindowSize(size);
|
||||
}
|
||||
// Set window position
|
||||
if (pos) {
|
||||
// Constraint window position if monitor lock was set in preferences.
|
||||
if (size && options.locked) {
|
||||
pos = constraintPosition(pos, size)[1];
|
||||
}
|
||||
setWindowPosition(pos);
|
||||
}
|
||||
// Set window position at the center of the screen.
|
||||
else if (size) {
|
||||
pos = vecAdd(vecScale(areaAvailable, 0.5), vecScale(size, -0.5), vecScale(screenOffset, -1.0));
|
||||
setWindowPosition(pos);
|
||||
}
|
||||
};
|
||||
|
||||
export const setupDrag = async () => {
|
||||
// Calculate screen offset caused by the windows taskbar
|
||||
let windowPosition = getWindowPosition();
|
||||
|
||||
screenOffsetPromise = Byond.winget(Byond.windowId, 'pos').then((pos) => [
|
||||
pos.x - windowPosition[0],
|
||||
pos.y - windowPosition[1],
|
||||
]);
|
||||
screenOffset = await screenOffsetPromise;
|
||||
logger.debug('screen offset', screenOffset);
|
||||
};
|
||||
|
||||
/**
|
||||
* Constraints window position to safe screen area, accounting for safe
|
||||
* margins which could be a system taskbar.
|
||||
*/
|
||||
const constraintPosition = (pos, size) => {
|
||||
const screenPos = getScreenPosition();
|
||||
const screenSize = getScreenSize();
|
||||
const nextPos = [pos[0], pos[1]];
|
||||
let relocated = false;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const leftBoundary = screenPos[i];
|
||||
const rightBoundary = screenPos[i] + screenSize[i];
|
||||
if (pos[i] < leftBoundary) {
|
||||
nextPos[i] = leftBoundary;
|
||||
relocated = true;
|
||||
} else if (pos[i] + size[i] > rightBoundary) {
|
||||
nextPos[i] = rightBoundary - size[i];
|
||||
relocated = true;
|
||||
}
|
||||
}
|
||||
return [relocated, nextPos];
|
||||
};
|
||||
|
||||
export const dragStartHandler = (event) => {
|
||||
logger.log('drag start');
|
||||
dragging = true;
|
||||
let windowPosition = getWindowPosition();
|
||||
dragPointOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
|
||||
// Focus click target
|
||||
event.target?.focus();
|
||||
document.addEventListener('mousemove', dragMoveHandler);
|
||||
document.addEventListener('mouseup', dragEndHandler);
|
||||
dragMoveHandler(event);
|
||||
};
|
||||
|
||||
const dragEndHandler = (event) => {
|
||||
logger.log('drag end');
|
||||
dragMoveHandler(event);
|
||||
document.removeEventListener('mousemove', dragMoveHandler);
|
||||
document.removeEventListener('mouseup', dragEndHandler);
|
||||
dragging = false;
|
||||
storeWindowGeometry();
|
||||
};
|
||||
|
||||
const dragMoveHandler = (event) => {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setWindowPosition(vecSubtract([event.screenX, event.screenY], dragPointOffset));
|
||||
};
|
||||
|
||||
export const resizeStartHandler = (x, y) => (event) => {
|
||||
resizeMatrix = [x, y];
|
||||
logger.log('resize start', resizeMatrix);
|
||||
resizing = true;
|
||||
dragPointOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
|
||||
initialSize = getWindowSize();
|
||||
// Focus click target
|
||||
event.target?.focus();
|
||||
document.addEventListener('mousemove', resizeMoveHandler);
|
||||
document.addEventListener('mouseup', resizeEndHandler);
|
||||
resizeMoveHandler(event);
|
||||
};
|
||||
|
||||
const resizeEndHandler = (event) => {
|
||||
logger.log('resize end', size);
|
||||
resizeMoveHandler(event);
|
||||
document.removeEventListener('mousemove', resizeMoveHandler);
|
||||
document.removeEventListener('mouseup', resizeEndHandler);
|
||||
resizing = false;
|
||||
storeWindowGeometry();
|
||||
};
|
||||
|
||||
const resizeMoveHandler = (event) => {
|
||||
if (!resizing) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const currentOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
|
||||
const delta = vecSubtract(currentOffset, dragPointOffset);
|
||||
// Extra 1x1 area is added to ensure the browser can see the cursor
|
||||
size = vecAdd(initialSize, vecMultiply(resizeMatrix, delta), [1, 1]);
|
||||
// Sane window size values
|
||||
size[0] = Math.max(size[0], 150 * pixelRatio);
|
||||
size[1] = Math.max(size[1], 50 * pixelRatio);
|
||||
setWindowSize(size);
|
||||
};
|
||||
219
tgui/packages/tgui_ch/events.js
Normal file
219
tgui/packages/tgui_ch/events.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Normalized browser focus events and BYOND-specific focus helpers.
|
||||
*
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'common/events';
|
||||
import { KEY_ALT, KEY_CTRL, KEY_F1, KEY_F12, KEY_SHIFT } from 'common/keycodes';
|
||||
|
||||
export const globalEvents = new EventEmitter();
|
||||
let ignoreWindowFocus = false;
|
||||
|
||||
export const setupGlobalEvents = (options = {}) => {
|
||||
ignoreWindowFocus = !!options.ignoreWindowFocus;
|
||||
};
|
||||
|
||||
// Window focus
|
||||
// --------------------------------------------------------
|
||||
|
||||
let windowFocusTimeout;
|
||||
let windowFocused = true;
|
||||
|
||||
const setWindowFocus = (value, delayed) => {
|
||||
// Pretend to always be in focus.
|
||||
if (ignoreWindowFocus) {
|
||||
windowFocused = true;
|
||||
return;
|
||||
}
|
||||
if (windowFocusTimeout) {
|
||||
clearTimeout(windowFocusTimeout);
|
||||
windowFocusTimeout = null;
|
||||
}
|
||||
if (delayed) {
|
||||
windowFocusTimeout = setTimeout(() => setWindowFocus(value));
|
||||
return;
|
||||
}
|
||||
if (windowFocused !== value) {
|
||||
windowFocused = value;
|
||||
globalEvents.emit(value ? 'window-focus' : 'window-blur');
|
||||
globalEvents.emit('window-focus-change', value);
|
||||
}
|
||||
};
|
||||
|
||||
// Focus stealing
|
||||
// --------------------------------------------------------
|
||||
|
||||
let focusStolenBy = null;
|
||||
|
||||
export const canStealFocus = (node) => {
|
||||
const tag = String(node.tagName).toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea';
|
||||
};
|
||||
|
||||
const stealFocus = (node) => {
|
||||
releaseStolenFocus();
|
||||
focusStolenBy = node;
|
||||
focusStolenBy.addEventListener('blur', releaseStolenFocus);
|
||||
};
|
||||
|
||||
const releaseStolenFocus = () => {
|
||||
if (focusStolenBy) {
|
||||
focusStolenBy.removeEventListener('blur', releaseStolenFocus);
|
||||
focusStolenBy = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Focus follows the mouse
|
||||
// --------------------------------------------------------
|
||||
|
||||
let focusedNode = null;
|
||||
let lastVisitedNode = null;
|
||||
const trackedNodes = [];
|
||||
|
||||
export const addScrollableNode = (node) => {
|
||||
trackedNodes.push(node);
|
||||
};
|
||||
|
||||
export const removeScrollableNode = (node) => {
|
||||
const index = trackedNodes.indexOf(node);
|
||||
if (index >= 0) {
|
||||
trackedNodes.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const focusNearestTrackedParent = (node) => {
|
||||
if (focusStolenBy || !windowFocused) {
|
||||
return;
|
||||
}
|
||||
const body = document.body;
|
||||
while (node && node !== body) {
|
||||
if (trackedNodes.includes(node)) {
|
||||
// NOTE: Contains is a DOM4 method - VORESTATION REMOVAL
|
||||
// if (node.contains(focusedNode)) {
|
||||
// return;
|
||||
// }
|
||||
focusedNode = node;
|
||||
node.focus();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
const node = e.target;
|
||||
if (node !== lastVisitedNode) {
|
||||
lastVisitedNode = node;
|
||||
focusNearestTrackedParent(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus event hooks
|
||||
// --------------------------------------------------------
|
||||
|
||||
window.addEventListener('focusin', (e) => {
|
||||
lastVisitedNode = null;
|
||||
focusedNode = e.target;
|
||||
setWindowFocus(true);
|
||||
if (canStealFocus(e.target)) {
|
||||
stealFocus(e.target);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('focusout', (e) => {
|
||||
lastVisitedNode = null;
|
||||
setWindowFocus(false, true);
|
||||
});
|
||||
|
||||
window.addEventListener('blur', (e) => {
|
||||
lastVisitedNode = null;
|
||||
setWindowFocus(false, true);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
setWindowFocus(false);
|
||||
});
|
||||
|
||||
// Key events
|
||||
// --------------------------------------------------------
|
||||
|
||||
const keyHeldByCode = {};
|
||||
|
||||
export class KeyEvent {
|
||||
constructor(e, type, repeat) {
|
||||
this.event = e;
|
||||
this.type = type;
|
||||
this.code = window.event ? e.which : e.keyCode;
|
||||
this.ctrl = e.ctrlKey;
|
||||
this.shift = e.shiftKey;
|
||||
this.alt = e.altKey;
|
||||
this.repeat = !!repeat;
|
||||
}
|
||||
|
||||
hasModifierKeys() {
|
||||
return this.ctrl || this.alt || this.shift;
|
||||
}
|
||||
|
||||
isModifierKey() {
|
||||
return this.code === KEY_CTRL || this.code === KEY_SHIFT || this.code === KEY_ALT;
|
||||
}
|
||||
|
||||
isDown() {
|
||||
return this.type === 'keydown';
|
||||
}
|
||||
|
||||
isUp() {
|
||||
return this.type === 'keyup';
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this._str) {
|
||||
return this._str;
|
||||
}
|
||||
this._str = '';
|
||||
if (this.ctrl) {
|
||||
this._str += 'Ctrl+';
|
||||
}
|
||||
if (this.alt) {
|
||||
this._str += 'Alt+';
|
||||
}
|
||||
if (this.shift) {
|
||||
this._str += 'Shift+';
|
||||
}
|
||||
if (this.code >= 48 && this.code <= 90) {
|
||||
this._str += String.fromCharCode(this.code);
|
||||
} else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
|
||||
this._str += 'F' + (this.code - 111);
|
||||
} else {
|
||||
this._str += '[' + this.code + ']';
|
||||
}
|
||||
return this._str;
|
||||
}
|
||||
}
|
||||
|
||||
// IE8: Keydown event is only available on document.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (canStealFocus(e.target)) {
|
||||
return;
|
||||
}
|
||||
const code = e.keyCode;
|
||||
const key = new KeyEvent(e, 'keydown', keyHeldByCode[code]);
|
||||
globalEvents.emit('keydown', key);
|
||||
globalEvents.emit('key', key);
|
||||
keyHeldByCode[code] = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (canStealFocus(e.target)) {
|
||||
return;
|
||||
}
|
||||
const code = e.keyCode;
|
||||
const key = new KeyEvent(e, 'keyup');
|
||||
globalEvents.emit('keyup', key);
|
||||
globalEvents.emit('key', key);
|
||||
keyHeldByCode[code] = false;
|
||||
});
|
||||
25
tgui/packages/tgui_ch/focus.ts
Normal file
25
tgui/packages/tgui_ch/focus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Various focus helpers.
|
||||
*
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Moves focus to the BYOND map window.
|
||||
*/
|
||||
export const focusMap = () => {
|
||||
Byond.winset('mapwindow.map', {
|
||||
focus: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus to the browser window.
|
||||
*/
|
||||
export const focusWindow = () => {
|
||||
Byond.winset(Byond.windowId, {
|
||||
focus: true,
|
||||
});
|
||||
};
|
||||
183
tgui/packages/tgui_ch/format.js
Normal file
183
tgui/packages/tgui_ch/format.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clamp, round, toFixed } from 'common/math';
|
||||
|
||||
const SI_SYMBOLS = [
|
||||
'f', // femto
|
||||
'p', // pico
|
||||
'n', // nano
|
||||
'μ', // micro
|
||||
'm', // milli
|
||||
// NOTE: This is a space for a reason. When we right align si numbers,
|
||||
// in monospace mode, we want to units and numbers stay in their respective
|
||||
// columns. If rendering in HTML mode, this space will collapse into
|
||||
// a single space anyway.
|
||||
' ',
|
||||
'k', // kilo
|
||||
'M', // mega
|
||||
'G', // giga
|
||||
'T', // tera
|
||||
'P', // peta
|
||||
'E', // exa
|
||||
'Z', // zetta
|
||||
'Y', // yotta
|
||||
'R', // ronna
|
||||
'Q', // quecca
|
||||
'F',
|
||||
'N',
|
||||
'H',
|
||||
];
|
||||
|
||||
const SI_BASE_INDEX = SI_SYMBOLS.indexOf(' ');
|
||||
|
||||
/**
|
||||
* Formats a number to a human readable form, by reducing it to SI units.
|
||||
* TODO: This is quite a shit code and shit math, needs optimization.
|
||||
*/
|
||||
export const formatSiUnit = (value, minBase1000 = -SI_BASE_INDEX, unit = '') => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
const realBase10 = Math.floor(Math.log10(value));
|
||||
const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10));
|
||||
const realBase1000 = Math.floor(realBase10 / 3);
|
||||
const base1000 = Math.floor(base10 / 3);
|
||||
const symbolIndex = clamp(SI_BASE_INDEX + base1000, 0, SI_SYMBOLS.length);
|
||||
const symbol = SI_SYMBOLS[symbolIndex];
|
||||
const scaledNumber = value / Math.pow(1000, base1000);
|
||||
const scaledPrecision = realBase1000 > minBase1000 ? 2 + base1000 * 3 - base10 : 0;
|
||||
// TODO: Make numbers bigger than precision value show
|
||||
// up to 2 decimal numbers.
|
||||
const finalString = toFixed(scaledNumber, scaledPrecision) + ' ' + symbol + unit;
|
||||
return finalString.trim();
|
||||
};
|
||||
|
||||
export const formatPower = (value, minBase1000 = 0) => {
|
||||
return formatSiUnit(value, minBase1000, 'W');
|
||||
};
|
||||
|
||||
export const formatMoney = (value, precision = 0) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
// Round the number and make it fixed precision
|
||||
let fixed = round(value, precision);
|
||||
if (precision > 0) {
|
||||
fixed = toFixed(value, precision);
|
||||
}
|
||||
fixed = String(fixed);
|
||||
// Place thousand separators
|
||||
const length = fixed.length;
|
||||
let indexOfPoint = fixed.indexOf('.');
|
||||
if (indexOfPoint === -1) {
|
||||
indexOfPoint = length;
|
||||
}
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (i > 0 && i < indexOfPoint && (indexOfPoint - i) % 3 === 0) {
|
||||
// Thin space
|
||||
result += '\u2009';
|
||||
}
|
||||
result += fixed.charAt(i);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a floating point number as a number on the decibel scale.
|
||||
*/
|
||||
export const formatDb = (value) => {
|
||||
const db = (20 * Math.log(value)) / Math.log(10);
|
||||
const sign = db >= 0 ? '+' : '–';
|
||||
let formatted = Math.abs(db);
|
||||
if (formatted === Infinity) {
|
||||
formatted = 'Inf';
|
||||
} else {
|
||||
formatted = toFixed(formatted, 2);
|
||||
}
|
||||
return sign + formatted + ' dB';
|
||||
};
|
||||
|
||||
const SI_BASE_TEN_UNIT = [
|
||||
'',
|
||||
'· 10³', // kilo
|
||||
'· 10⁶', // mega
|
||||
'· 10⁹', // giga
|
||||
'· 10¹²', // tera
|
||||
'· 10¹⁵', // peta
|
||||
'· 10¹⁸', // exa
|
||||
'· 10²¹', // zetta
|
||||
'· 10²⁴', // yotta
|
||||
'· 10²⁷', // ronna
|
||||
'· 10³⁰', // quecca
|
||||
'· 10³³',
|
||||
'· 10³⁶',
|
||||
'· 10³⁹',
|
||||
];
|
||||
|
||||
const SI_BASE_TEN_INDEX = SI_BASE_TEN_UNIT.indexOf(' ');
|
||||
|
||||
/**
|
||||
* Formats a number to a human readable form, by reducing it to SI units.
|
||||
* TODO: This is quite a shit code and shit math, needs optimization.
|
||||
*/
|
||||
export const formatSiBaseTenUnit = (value, minBase1000 = -SI_BASE_TEN_INDEX, unit = '') => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
const realBase10 = Math.floor(Math.log10(value));
|
||||
const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10));
|
||||
const realBase1000 = Math.floor(realBase10 / 3);
|
||||
const base1000 = Math.floor(base10 / 3);
|
||||
const symbolIndex = clamp(SI_BASE_TEN_INDEX + base1000, 0, SI_BASE_TEN_UNIT.length);
|
||||
const symbol = SI_BASE_TEN_UNIT[symbolIndex];
|
||||
const scaledNumber = value / Math.pow(1000, base1000);
|
||||
const scaledPrecision = realBase1000 > minBase1000 ? 2 + base1000 * 3 - base10 : 0;
|
||||
// TODO: Make numbers bigger than precision value show
|
||||
// up to 2 decimal numbers.
|
||||
const finalString = toFixed(scaledNumber, scaledPrecision) + ' ' + symbol + ' ' + unit;
|
||||
return finalString.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats decisecond count into HH::MM::SS display by default
|
||||
* "short" format does not pad and adds hms suffixes
|
||||
*/
|
||||
export const formatTime = (val, formatType) => {
|
||||
// THERE IS AS YET INSUFFICIENT DATA FOR A MEANINGFUL ANSWER
|
||||
// HH:MM:SS
|
||||
// 00:02:13
|
||||
const seconds = toFixed(Math.floor((val / 10) % 60));
|
||||
const minutes = toFixed(Math.floor((val / (10 * 60)) % 60));
|
||||
const hours = toFixed(Math.floor((val / (10 * 60 * 60)) % 24));
|
||||
switch (formatType) {
|
||||
case 'short': {
|
||||
const hours_truncated = hours > 0 ? `${hours}h` : '';
|
||||
const minutes_truncated = minutes > 0 ? `${minutes}m` : '';
|
||||
const seconds_truncated = seconds > 0 ? `${seconds}s` : '';
|
||||
return `${hours_truncated}${minutes_truncated}${seconds_truncated}`;
|
||||
}
|
||||
default: {
|
||||
const seconds_padded = seconds.padStart(2, '0');
|
||||
const minutes_padded = minutes.padStart(2, '0');
|
||||
const hours_padded = hours.padStart(2, '0');
|
||||
return `${hours_padded}:${minutes_padded}:${seconds_padded}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* VOREStation Addition Start */
|
||||
export const formatCommaNumber = (value) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
// From http://stackoverflow.com/questions/2901102/how-to-print-a-number-with-commas-as-thousands-separators-in-javascript
|
||||
let parts = value.toString().split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
};
|
||||
/* VOREStation Addition End */
|
||||
212
tgui/packages/tgui_ch/hotkeys.ts
Normal file
212
tgui/packages/tgui_ch/hotkeys.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import * as keycodes from 'common/keycodes';
|
||||
import { globalEvents, KeyEvent } from './events';
|
||||
import { createLogger } from './logging';
|
||||
|
||||
const logger = createLogger('hotkeys');
|
||||
|
||||
// BYOND macros, in `key: command` format.
|
||||
const byondMacros: Record<string, string> = {};
|
||||
|
||||
// Default set of acquired keys, which will not be sent to BYOND.
|
||||
const hotKeysAcquired = [
|
||||
keycodes.KEY_ESCAPE,
|
||||
keycodes.KEY_ENTER,
|
||||
keycodes.KEY_SPACE,
|
||||
keycodes.KEY_TAB,
|
||||
keycodes.KEY_CTRL,
|
||||
keycodes.KEY_SHIFT,
|
||||
keycodes.KEY_UP,
|
||||
keycodes.KEY_DOWN,
|
||||
keycodes.KEY_LEFT,
|
||||
keycodes.KEY_RIGHT,
|
||||
keycodes.KEY_F5,
|
||||
];
|
||||
|
||||
// State of passed-through keys.
|
||||
const keyState: Record<string, boolean> = {};
|
||||
|
||||
// Custom listeners for key events
|
||||
const keyListeners: ((key: KeyEvent) => void)[] = [];
|
||||
|
||||
/**
|
||||
* Converts a browser keycode to BYOND keycode.
|
||||
*/
|
||||
const keyCodeToByond = (keyCode: number) => {
|
||||
if (keyCode === 16) return 'Shift';
|
||||
if (keyCode === 17) return 'Ctrl';
|
||||
if (keyCode === 18) return 'Alt';
|
||||
if (keyCode === 33) return 'Northeast';
|
||||
if (keyCode === 34) return 'Southeast';
|
||||
if (keyCode === 35) return 'Southwest';
|
||||
if (keyCode === 36) return 'Northwest';
|
||||
if (keyCode === 37) return 'West';
|
||||
if (keyCode === 38) return 'North';
|
||||
if (keyCode === 39) return 'East';
|
||||
if (keyCode === 40) return 'South';
|
||||
if (keyCode === 45) return 'Insert';
|
||||
if (keyCode === 46) return 'Delete';
|
||||
if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) {
|
||||
return String.fromCharCode(keyCode);
|
||||
}
|
||||
if (keyCode >= 96 && keyCode <= 105) {
|
||||
return 'Numpad' + (keyCode - 96);
|
||||
}
|
||||
if (keyCode >= 112 && keyCode <= 123) {
|
||||
return 'F' + (keyCode - 111);
|
||||
}
|
||||
if (keyCode === 188) return ',';
|
||||
if (keyCode === 189) return '-';
|
||||
if (keyCode === 190) return '.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard passthrough logic. This allows you to keep doing things
|
||||
* in game while the browser window is focused.
|
||||
*/
|
||||
const handlePassthrough = (key: KeyEvent) => {
|
||||
const keyString = String(key);
|
||||
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
|
||||
if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
// Prevent passthrough on Ctrl+F
|
||||
if (keyString === 'Ctrl+F') {
|
||||
return;
|
||||
}
|
||||
// NOTE: Alt modifier is pretty bad and sticky in IE11.
|
||||
if (key.event.defaultPrevented || key.isModifierKey() || hotKeysAcquired.includes(key.code)) {
|
||||
return;
|
||||
}
|
||||
const byondKeyCode = keyCodeToByond(key.code);
|
||||
if (!byondKeyCode) {
|
||||
return;
|
||||
}
|
||||
// Macro
|
||||
const macro = byondMacros[byondKeyCode];
|
||||
if (macro) {
|
||||
logger.debug('macro', macro);
|
||||
return Byond.command(macro);
|
||||
}
|
||||
// KeyDown
|
||||
if (key.isDown() && !keyState[byondKeyCode]) {
|
||||
keyState[byondKeyCode] = true;
|
||||
const command = `TguiKeyDown "${byondKeyCode}"`;
|
||||
logger.debug(command);
|
||||
return Byond.command(command);
|
||||
}
|
||||
// KeyUp
|
||||
if (key.isUp() && keyState[byondKeyCode]) {
|
||||
keyState[byondKeyCode] = false;
|
||||
const command = `TguiKeyUp "${byondKeyCode}"`;
|
||||
logger.debug(command);
|
||||
return Byond.command(command);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquires a lock on the hotkey, which prevents it from being
|
||||
* passed through to BYOND.
|
||||
*/
|
||||
export const acquireHotKey = (keyCode: number) => {
|
||||
hotKeysAcquired.push(keyCode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes the hotkey available to BYOND again.
|
||||
*/
|
||||
export const releaseHotKey = (keyCode: number) => {
|
||||
const index = hotKeysAcquired.indexOf(keyCode);
|
||||
if (index >= 0) {
|
||||
hotKeysAcquired.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const releaseHeldKeys = () => {
|
||||
for (let byondKeyCode of Object.keys(keyState)) {
|
||||
if (keyState[byondKeyCode]) {
|
||||
keyState[byondKeyCode] = false;
|
||||
logger.log(`releasing key "${byondKeyCode}"`);
|
||||
Byond.command(`TguiKeyUp "${byondKeyCode}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type ByondSkinMacro = {
|
||||
command: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const setupHotKeys = () => {
|
||||
// Read macros
|
||||
Byond.winget('default.*').then((data: Record<string, string>) => {
|
||||
// Group each macro by ref
|
||||
const groupedByRef: Record<string, ByondSkinMacro> = {};
|
||||
for (let key of Object.keys(data)) {
|
||||
const keyPath = key.split('.');
|
||||
const ref = keyPath[1];
|
||||
const prop = keyPath[2];
|
||||
if (ref && prop) {
|
||||
// This piece of code imperatively adds each property to a
|
||||
// ByondSkinMacro object in the order we meet it, which is hard
|
||||
// to express safely in typescript.
|
||||
if (!groupedByRef[ref]) {
|
||||
groupedByRef[ref] = {} as any;
|
||||
}
|
||||
groupedByRef[ref][prop] = data[key];
|
||||
}
|
||||
}
|
||||
// Insert macros
|
||||
const escapedQuotRegex = /\\"/g;
|
||||
const unescape = (str: string) => str.substring(1, str.length - 1).replace(escapedQuotRegex, '"');
|
||||
for (let ref of Object.keys(groupedByRef)) {
|
||||
const macro = groupedByRef[ref];
|
||||
const byondKeyName = unescape(macro.name);
|
||||
byondMacros[byondKeyName] = unescape(macro.command);
|
||||
}
|
||||
logger.debug('loaded macros', byondMacros);
|
||||
});
|
||||
// Setup event handlers
|
||||
globalEvents.on('window-blur', () => {
|
||||
releaseHeldKeys();
|
||||
});
|
||||
globalEvents.on('key', (key: KeyEvent) => {
|
||||
for (const keyListener of keyListeners) {
|
||||
keyListener(key);
|
||||
}
|
||||
|
||||
handlePassthrough(key);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers for any key events, such as key down or key up.
|
||||
* This should be preferred over directly connecting to keydown/keyup
|
||||
* as it lets tgui prevent the key from reaching BYOND.
|
||||
*
|
||||
* If using in a component, prefer KeyListener, which automatically handles
|
||||
* stopping listening when unmounting.
|
||||
*
|
||||
* @param callback The function to call whenever a key event occurs
|
||||
* @returns A callback to stop listening
|
||||
*/
|
||||
export const listenForKeyEvents = (callback: (key: KeyEvent) => void): (() => void) => {
|
||||
keyListeners.push(callback);
|
||||
|
||||
let removed = false;
|
||||
|
||||
return () => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
|
||||
removed = true;
|
||||
keyListeners.splice(keyListeners.indexOf(callback), 1);
|
||||
};
|
||||
};
|
||||
12
tgui/packages/tgui_ch/http.ts
Normal file
12
tgui/packages/tgui_ch/http.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* An equivalent to `fetch`, except will automatically retry.
|
||||
*/
|
||||
export const fetchRetry = (url: string, options?: RequestInit, retryTimer: number = 1000): Promise<Response> => {
|
||||
return fetch(url, options).catch(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
fetchRetry(url, options, retryTimer).then(resolve);
|
||||
}, retryTimer);
|
||||
});
|
||||
});
|
||||
};
|
||||
71
tgui/packages/tgui_ch/index.js
Normal file
71
tgui/packages/tgui_ch/index.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// Themes
|
||||
import './styles/main.scss';
|
||||
import './styles/themes/abductor.scss';
|
||||
import './styles/themes/cardtable.scss';
|
||||
import './styles/themes/hackerman.scss';
|
||||
import './styles/themes/malfunction.scss';
|
||||
import './styles/themes/neutral.scss';
|
||||
import './styles/themes/ntos.scss';
|
||||
import './styles/themes/paper.scss';
|
||||
import './styles/themes/pda-retro.scss';
|
||||
import './styles/themes/retro.scss';
|
||||
import './styles/themes/syndicate.scss';
|
||||
import './styles/themes/wizard.scss';
|
||||
import './styles/themes/abstract.scss';
|
||||
|
||||
import { perf } from 'common/perf';
|
||||
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
|
||||
import { setupHotKeys } from './hotkeys';
|
||||
import { captureExternalLinks } from './links';
|
||||
import { createRenderer } from './renderer';
|
||||
import { configureStore, StoreProvider } from './store';
|
||||
import { setupGlobalEvents } from './events';
|
||||
|
||||
perf.mark('inception', window.performance?.timing?.navigationStart);
|
||||
perf.mark('init');
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
const renderApp = createRenderer(() => {
|
||||
const { getRoutedComponent } = require('./routes');
|
||||
const Component = getRoutedComponent(store);
|
||||
return (
|
||||
<StoreProvider store={store}>
|
||||
<Component />
|
||||
</StoreProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const setupApp = () => {
|
||||
// Delay setup
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupApp);
|
||||
return;
|
||||
}
|
||||
|
||||
setupGlobalEvents();
|
||||
setupHotKeys();
|
||||
captureExternalLinks();
|
||||
|
||||
// Re-render UI on store updates
|
||||
store.subscribe(renderApp);
|
||||
|
||||
// Dispatch incoming messages as store actions
|
||||
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
|
||||
|
||||
// Enable hot module reloading
|
||||
if (module.hot) {
|
||||
setupHotReloading();
|
||||
module.hot.accept(['./components', './debug', './layouts', './routes'], () => {
|
||||
renderApp();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setupApp();
|
||||
114
tgui/packages/tgui_ch/interfaces/AICard.js
Normal file
114
tgui/packages/tgui_ch/interfaces/AICard.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, ProgressBar, LabeledList, Box, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AICard = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { has_ai, integrity, backup_capacitor, flushing, has_laws, laws, wireless, radio } = data;
|
||||
|
||||
if (has_ai === 0) {
|
||||
return (
|
||||
<Window width={600} height={470} resizable>
|
||||
<Window.Content>
|
||||
<Section title="Stored AI">
|
||||
<Box>
|
||||
<h3>No AI detected.</h3>
|
||||
</Box>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
} else {
|
||||
let integrityColor = null; // Handles changing color of the integrity bar
|
||||
if (integrity >= 75) {
|
||||
integrityColor = 'green';
|
||||
} else if (integrity >= 25) {
|
||||
integrityColor = 'yellow';
|
||||
} else {
|
||||
integrityColor = 'red';
|
||||
}
|
||||
|
||||
let powerColor = null;
|
||||
if (backup_capacitor >= 75) {
|
||||
powerColor = 'green';
|
||||
}
|
||||
if (backup_capacitor >= 25) {
|
||||
powerColor = 'yellow';
|
||||
} else {
|
||||
powerColor = 'red';
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={600} height={470} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Stored AI">
|
||||
<Box bold display="inline-block">
|
||||
<h3>{name}</h3>
|
||||
</Box>
|
||||
<Box>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Integrity">
|
||||
<ProgressBar color={integrityColor} value={integrity / 100} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Power">
|
||||
<ProgressBar color={powerColor} value={backup_capacitor / 100} />
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Box>
|
||||
<Box color="red">
|
||||
<h2>{flushing === 1 ? 'Wipe of AI in progress...' : ''}</h2>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<Section title="Laws">
|
||||
{(!!has_laws && (
|
||||
<Box>
|
||||
{laws.map((value, key) => (
|
||||
<Box key={key} display="inline-block">
|
||||
{value}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)) || ( // Else, no laws.
|
||||
<Box color="red">
|
||||
<h3>No laws detected.</h3>
|
||||
</Box>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Actions">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Wireless Activity">
|
||||
<Button
|
||||
icon={wireless ? 'check' : 'times'}
|
||||
content={wireless ? 'Enabled' : 'Disabled'}
|
||||
color={wireless ? 'green' : 'red'}
|
||||
onClick={() => act('wireless')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Subspace Transceiver">
|
||||
<Button
|
||||
icon={radio ? 'check' : 'times'}
|
||||
content={radio ? 'Enabled' : 'Disabled'}
|
||||
color={radio ? 'green' : 'red'}
|
||||
onClick={() => act('radio')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="AI Power">
|
||||
<Button.Confirm
|
||||
icon="radiation"
|
||||
confirmIcon="radiation"
|
||||
disabled={flushing || integrity === 0}
|
||||
confirmColor="red"
|
||||
content="Shutdown"
|
||||
onClick={() => act('wipe')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
};
|
||||
283
tgui/packages/tgui_ch/interfaces/APC.js
Normal file
283
tgui/packages/tgui_ch/interfaces/APC.js
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Dimmer, Icon, LabeledList, ProgressBar, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
|
||||
import { FullscreenNotice } from './common/FullscreenNotice';
|
||||
|
||||
export const APC = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
let body = <ApcContent />;
|
||||
|
||||
if (data.gridCheck) {
|
||||
body = <GridCheck />;
|
||||
} else if (data.failTime) {
|
||||
body = <ApcFailure />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={450} height={475} resizable>
|
||||
<Window.Content scrollable>{body}</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const powerStatusMap = {
|
||||
2: {
|
||||
color: 'good',
|
||||
externalPowerText: 'External Power',
|
||||
chargingText: 'Fully Charged',
|
||||
},
|
||||
1: {
|
||||
color: 'average',
|
||||
externalPowerText: 'Low External Power',
|
||||
chargingText: 'Charging',
|
||||
},
|
||||
0: {
|
||||
color: 'bad',
|
||||
externalPowerText: 'No External Power',
|
||||
chargingText: 'Not Charging',
|
||||
},
|
||||
};
|
||||
|
||||
const malfMap = {
|
||||
1: {
|
||||
icon: 'terminal',
|
||||
content: 'Override Programming',
|
||||
action: 'hack',
|
||||
},
|
||||
// 2: {
|
||||
// icon: 'caret-square-down',
|
||||
// content: 'Shunt Core Process',
|
||||
// action: 'occupy',
|
||||
// },
|
||||
// 3: {
|
||||
// icon: 'caret-square-left',
|
||||
// content: 'Return to Main Core',
|
||||
// action: 'deoccupy',
|
||||
// },
|
||||
// 4: {
|
||||
// icon: 'caret-square-down',
|
||||
// content: 'Shunt Core Process',
|
||||
// action: 'occupy',
|
||||
// },
|
||||
};
|
||||
|
||||
const ApcContent = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const locked = data.locked && !data.siliconUser;
|
||||
const normallyLocked = data.normallyLocked;
|
||||
const externalPowerStatus = powerStatusMap[data.externalPower] || powerStatusMap[0];
|
||||
const chargingStatus = powerStatusMap[data.chargingStatus] || powerStatusMap[0];
|
||||
const channelArray = data.powerChannels || [];
|
||||
// const malfStatus = malfMap[data.malfStatus] || null;
|
||||
const adjustedCellChange = data.powerCellStatus / 100;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<InterfaceLockNoticeBox
|
||||
deny={data.emagged}
|
||||
denialMessage={
|
||||
<Fragment>
|
||||
<Box color="bad" fontSize="1.5rem">
|
||||
Fault in ID authenticator.
|
||||
</Box>
|
||||
<Box color="bad">Please contact maintenance for service.</Box>
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
<Section title="Power Status">
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="Main Breaker"
|
||||
color={externalPowerStatus.color}
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.isOperating ? 'power-off' : 'times'}
|
||||
content={data.isOperating ? 'On' : 'Off'}
|
||||
selected={data.isOperating && !locked}
|
||||
color={data.isOperating ? '' : 'bad'}
|
||||
disabled={locked}
|
||||
onClick={() => act('breaker')}
|
||||
/>
|
||||
}>
|
||||
[ {externalPowerStatus.externalPowerText} ]
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Power Cell">
|
||||
<ProgressBar color="good" value={adjustedCellChange} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Charge Mode"
|
||||
color={chargingStatus.color}
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.chargeMode ? 'sync' : 'times'}
|
||||
content={data.chargeMode ? 'Auto' : 'Off'}
|
||||
selected={data.chargeMode}
|
||||
disabled={locked}
|
||||
onClick={() => act('charge')}
|
||||
/>
|
||||
}>
|
||||
[ {chargingStatus.chargingText} ]
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<Section title="Power Channels">
|
||||
<LabeledList>
|
||||
{channelArray.map((channel) => {
|
||||
const { topicParams } = channel;
|
||||
return (
|
||||
<LabeledList.Item
|
||||
key={channel.title}
|
||||
label={channel.title}
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Box inline mx={2} color={channel.status >= 2 ? 'good' : 'bad'}>
|
||||
{channel.status >= 2 ? 'On' : 'Off'}
|
||||
</Box>
|
||||
<Button
|
||||
icon="sync"
|
||||
content="Auto"
|
||||
selected={!locked && (channel.status === 1 || channel.status === 3)}
|
||||
disabled={locked}
|
||||
onClick={() => act('channel', topicParams.auto)}
|
||||
/>
|
||||
<Button
|
||||
icon="power-off"
|
||||
content="On"
|
||||
selected={!locked && channel.status === 2}
|
||||
disabled={locked}
|
||||
onClick={() => act('channel', topicParams.on)}
|
||||
/>
|
||||
<Button
|
||||
icon="times"
|
||||
content="Off"
|
||||
selected={!locked && channel.status === 0}
|
||||
disabled={locked}
|
||||
onClick={() => act('channel', topicParams.off)}
|
||||
/>
|
||||
</Fragment>
|
||||
}>
|
||||
{channel.powerLoad} W
|
||||
</LabeledList.Item>
|
||||
);
|
||||
})}
|
||||
<LabeledList.Item label="Total Load">
|
||||
{data.totalCharging ? (
|
||||
<b>
|
||||
{data.totalLoad} W (+ {data.totalCharging} W charging)
|
||||
</b>
|
||||
) : (
|
||||
<b>{data.totalLoad} W</b>
|
||||
)}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<Section
|
||||
title="Misc"
|
||||
buttons={
|
||||
!!data.siliconUser && <Button icon="lightbulb-o" content="Overload" onClick={() => act('overload')} />
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="Cover Lock"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.coverLocked ? 'lock' : 'unlock'}
|
||||
content={data.coverLocked ? 'Engaged' : 'Disengaged'}
|
||||
selected={data.coverLocked}
|
||||
disabled={locked}
|
||||
onClick={() => act('cover')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<LabeledList.Item
|
||||
label="Night Shift Lighting"
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
content="Disabled"
|
||||
selected={data.nightshiftSetting === 2}
|
||||
onClick={() =>
|
||||
act('nightshift', {
|
||||
nightshift: 2,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
content="Automatic"
|
||||
selected={data.nightshiftSetting === 1}
|
||||
onClick={() =>
|
||||
act('nightshift', {
|
||||
nightshift: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
content="Enabled"
|
||||
selected={data.nightshiftSetting === 3}
|
||||
onClick={() =>
|
||||
act('nightshift', {
|
||||
nightshift: 3,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
<LabeledList.Item
|
||||
label="Emergency Lighting"
|
||||
buttons={
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
content={data.emergencyLights ? 'Enabled' : 'Disabled'}
|
||||
selected={data.emergencyLights}
|
||||
onClick={() => act('emergency_lighting')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const GridCheck = (props, context) => {
|
||||
return (
|
||||
<FullscreenNotice title="System Failure">
|
||||
<Box fontSize="1.5rem" bold>
|
||||
<Icon name="exclamation-triangle" verticalAlign="middle" size={3} mr="1rem" />
|
||||
</Box>
|
||||
<Box fontSize="1.5rem" bold>
|
||||
Power surge detected, grid check in effect...
|
||||
</Box>
|
||||
</FullscreenNotice>
|
||||
);
|
||||
};
|
||||
|
||||
const ApcFailure = (props, context) => {
|
||||
const { data, act } = useBackend(context);
|
||||
|
||||
let rebootOptions = <Button icon="repeat" content="Restart Now" color="good" onClick={() => act('reboot')} />;
|
||||
|
||||
if (data.locked && !data.siliconUser) {
|
||||
rebootOptions = <Box color="bad">Swipe an ID card for manual reboot.</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dimmer textAlign="center">
|
||||
<Box color="bad">
|
||||
<h1>SYSTEM FAILURE</h1>
|
||||
</Box>
|
||||
<Box color="average">
|
||||
<h2>I/O regulators malfunction detected! Waiting for system reboot...</h2>
|
||||
</Box>
|
||||
<Box color="good">Automatic reboot in {data.failTime} seconds...</Box>
|
||||
<Box mt={4}>{rebootOptions}</Box>
|
||||
</Dimmer>
|
||||
);
|
||||
};
|
||||
184
tgui/packages/tgui_ch/interfaces/AccountsTerminal.js
Normal file
184
tgui/packages/tgui_ch/interfaces/AccountsTerminal.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useBackend, useSharedState } from '../backend';
|
||||
import { Box, Button, LabeledList, Input, Section, Table, Tabs } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AccountsTerminal = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { id_inserted, id_card, access_level, machine_id } = data;
|
||||
|
||||
return (
|
||||
<Window width={400} height={640}>
|
||||
<Window.Content scrollable>
|
||||
<Section>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Machine" color="average">
|
||||
{machine_id}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="ID">
|
||||
<Button
|
||||
icon={id_inserted ? 'eject' : 'sign-in-alt'}
|
||||
fluid
|
||||
content={id_card}
|
||||
onClick={() => act('insert_card')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
{access_level > 0 && <AccountTerminalContent />}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountTerminalContent = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { creating_new_account, detailed_account_view } = data;
|
||||
|
||||
return (
|
||||
<Section title="Menu">
|
||||
<Tabs>
|
||||
<Tabs.Tab
|
||||
selected={!creating_new_account && !detailed_account_view}
|
||||
icon="home"
|
||||
onClick={() => act('view_accounts_list')}>
|
||||
Home
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab selected={creating_new_account} icon="cog" onClick={() => act('create_account')}>
|
||||
New Account
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab disabled={creating_new_account} icon="print" onClick={() => act('print')}>
|
||||
Print
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
{(creating_new_account && <NewAccountView />) || (detailed_account_view && <DetailedView />) || <ListView />}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const NewAccountView = (props, context) => {
|
||||
const { act } = useBackend(context);
|
||||
|
||||
const [holder, setHolder] = useSharedState(context, 'holder', '');
|
||||
const [newMoney, setMoney] = useSharedState(context, 'money', '');
|
||||
|
||||
return (
|
||||
<Section title="Create Account" level={2}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Account Holder">
|
||||
<Input value={holder} fluid onInput={(e, val) => setHolder(val)} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Initial Deposit">
|
||||
<Input value={newMoney} fluid onInput={(e, val) => setMoney(val)} />
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Button
|
||||
disabled={!holder || !newMoney}
|
||||
mt={1}
|
||||
fluid
|
||||
icon="plus"
|
||||
onClick={() =>
|
||||
act('finalise_create_account', {
|
||||
holder_name: holder,
|
||||
starting_funds: newMoney,
|
||||
})
|
||||
}
|
||||
content="Create"
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const DetailedView = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { access_level, station_account_number, account_number, owner_name, money, suspended, transactions } = data;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Account Details"
|
||||
level={2}
|
||||
buttons={<Button icon="ban" selected={suspended} content="Suspend" onClick={() => act('toggle_suspension')} />}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Account Number">#{account_number}</LabeledList.Item>
|
||||
<LabeledList.Item label="Holder">{owner_name}</LabeledList.Item>
|
||||
<LabeledList.Item label="Balance">{money}₮</LabeledList.Item>
|
||||
<LabeledList.Item label="Status" color={suspended ? 'bad' : 'good'}>
|
||||
{suspended ? 'SUSPENDED' : 'Active'}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Section title="CentCom Administrator" level={2} mt={1}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Payroll">
|
||||
<Button.Confirm
|
||||
color="bad"
|
||||
fluid
|
||||
icon="ban"
|
||||
confirmIcon="ban"
|
||||
content="Revoke"
|
||||
confirmContent="This cannot be undone."
|
||||
disabled={account_number === station_account_number}
|
||||
onClick={() => act('revoke_payroll')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
{access_level >= 2 && (
|
||||
<Section title="Silent Funds Transfer" level={2}>
|
||||
<Button icon="plus" onClick={() => act('add_funds')} content="Add Funds" />
|
||||
<Button icon="plus" onClick={() => act('remove_funds')} content="Remove Funds" />
|
||||
</Section>
|
||||
)}
|
||||
<Section title="Transactions" level={2} mt={1}>
|
||||
<Table>
|
||||
<Table.Row header>
|
||||
<Table.Cell>Timestamp</Table.Cell>
|
||||
<Table.Cell>Target</Table.Cell>
|
||||
<Table.Cell>Reason</Table.Cell>
|
||||
<Table.Cell>Value</Table.Cell>
|
||||
<Table.Cell>Terminal</Table.Cell>
|
||||
</Table.Row>
|
||||
{transactions.map((trans, i) => (
|
||||
<Table.Row key={i}>
|
||||
<Table.Cell>
|
||||
{trans.date} {trans.time}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{trans.target_name}</Table.Cell>
|
||||
<Table.Cell>{trans.purpose}</Table.Cell>
|
||||
<Table.Cell>{trans.amount}₮</Table.Cell>
|
||||
<Table.Cell>{trans.source_terminal}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const ListView = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { accounts } = data;
|
||||
|
||||
return (
|
||||
<Section title="NanoTrasen Accounts" level={2}>
|
||||
{(accounts.length && (
|
||||
<LabeledList>
|
||||
{accounts.map((acc) => (
|
||||
<LabeledList.Item
|
||||
label={acc.owner_name + acc.suspended}
|
||||
color={acc.suspended ? 'bad' : null}
|
||||
key={acc.account_index}>
|
||||
<Button
|
||||
fluid
|
||||
content={'#' + acc.account_number}
|
||||
onClick={() => act('view_account_detail', { 'account_index': acc.account_index })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
))}
|
||||
</LabeledList>
|
||||
)) || <Box color="bad">There are no accounts available.</Box>}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
88
tgui/packages/tgui_ch/interfaces/AdminShuttleController.tsx
Normal file
88
tgui/packages/tgui_ch/interfaces/AdminShuttleController.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { sortBy } from 'common/collections';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, Section, Table } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type Data = {
|
||||
shuttles: Shuttle[];
|
||||
overmap_ships: OvermapShip[];
|
||||
};
|
||||
|
||||
type Shuttle = {
|
||||
ref: string;
|
||||
name: string;
|
||||
current_location;
|
||||
status;
|
||||
};
|
||||
|
||||
type OvermapShip = {
|
||||
ref: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const AdminShuttleController = () => {
|
||||
return (
|
||||
<Window width={600} height={600} resizable>
|
||||
<Window.Content scrollable>
|
||||
<ShuttleList />
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShuttleList = (props, context) => {
|
||||
const { act, data } = useBackend<Data>(context);
|
||||
|
||||
const { shuttles, overmap_ships } = data;
|
||||
|
||||
return (
|
||||
<Section noTopPadding>
|
||||
<Section title="Classic Shuttles">
|
||||
<Table>
|
||||
{sortBy((f: Shuttle) => f.name)(shuttles).map((shuttle) => (
|
||||
<Table.Row key={shuttle.ref}>
|
||||
<Table.Cell collapsing>
|
||||
<Button m={0} content="JMP" onClick={() => act('adminobserve', { ref: shuttle.ref })} />
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>
|
||||
<Button m={0} content="Fly" onClick={() => act('classicmove', { ref: shuttle.ref })} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{shuttle.name}</Table.Cell>
|
||||
<Table.Cell>{shuttle.current_location}</Table.Cell>
|
||||
<Table.Cell>{shuttleStatusToWords(shuttle.status)}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
<Section title="Overmap Ships">
|
||||
<Table>
|
||||
{sortBy((f: OvermapShip) => f.name?.toLowerCase() || f.name || f.ref)(overmap_ships).map((ship) => (
|
||||
<Table.Row key={ship.ref}>
|
||||
<Table.Cell collapsing>
|
||||
<Button content="JMP" onClick={() => act('adminobserve', { ref: ship.ref })} />
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>
|
||||
<Button content="Control" onClick={() => act('overmap_control', { ref: ship.ref })} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{ship.name}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
/* Helpers */
|
||||
const shuttleStatusToWords = (status) => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'Idle';
|
||||
case 1:
|
||||
return 'Warmup';
|
||||
case 2:
|
||||
return 'Transit';
|
||||
default:
|
||||
return 'UNK';
|
||||
}
|
||||
};
|
||||
68
tgui/packages/tgui_ch/interfaces/AdminTicketPanel.tsx
Normal file
68
tgui/packages/tgui_ch/interfaces/AdminTicketPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* eslint react/no-danger: "off" */
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, LabeledList, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const State = {
|
||||
'open': 'Open',
|
||||
'resolved': 'Resolved',
|
||||
'closed': 'Closed',
|
||||
'unknown': 'Unknown',
|
||||
};
|
||||
|
||||
type Data = {
|
||||
id: number;
|
||||
title: string;
|
||||
name: string;
|
||||
state: string;
|
||||
opened_at: number;
|
||||
closed_at: number;
|
||||
opened_at_date: string;
|
||||
closed_at_date: string;
|
||||
actions: string;
|
||||
log: string[];
|
||||
};
|
||||
|
||||
export const AdminTicketPanel = (props, context) => {
|
||||
const { act, data } = useBackend<Data>(context);
|
||||
const { id, title, name, state, opened_at, closed_at, opened_at_date, closed_at_date, actions, log } = data;
|
||||
return (
|
||||
<Window width={900} height={600}>
|
||||
<Window.Content scrollable>
|
||||
<Section
|
||||
title={'Ticket #' + id}
|
||||
buttons={
|
||||
<Box nowrap>
|
||||
<Button icon="pen" content="Rename Ticket" onClick={() => act('retitle')} />{' '}
|
||||
<Button content="Legacy UI" onClick={() => act('legacy')} />
|
||||
</Box>
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Admin Help Ticket">
|
||||
#{id}: <div dangerouslySetInnerHTML={{ __html: name }} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="State">{State[state]}</LabeledList.Item>
|
||||
{State[state] === State.open ? (
|
||||
<LabeledList.Item label="Opened At">
|
||||
{opened_at_date} ({Math.round((opened_at / 600) * 10) / 10} minutes ago.)
|
||||
</LabeledList.Item>
|
||||
) : (
|
||||
<LabeledList.Item label="Closed At">
|
||||
{closed_at_date} ({Math.round((closed_at / 600) * 10) / 10} minutes ago.){' '}
|
||||
<Button content="Reopen" onClick={() => act('reopen')} />
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
<LabeledList.Item label="Actions">
|
||||
<div dangerouslySetInnerHTML={{ __html: actions }} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Log">
|
||||
{Object.keys(log).map((L) => (
|
||||
<div dangerouslySetInnerHTML={{ __html: log[L] }} />
|
||||
))}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
46
tgui/packages/tgui_ch/interfaces/AgentCard.tsx
Normal file
46
tgui/packages/tgui_ch/interfaces/AgentCard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BooleanLike } from 'common/react';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, Section, Table } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type Data = {
|
||||
entries: { name: string; value: string }[];
|
||||
electronic_warfare: BooleanLike;
|
||||
};
|
||||
|
||||
export const AgentCard = (props, context) => {
|
||||
const { act, data } = useBackend<Data>(context);
|
||||
|
||||
const { entries, electronic_warfare } = data;
|
||||
|
||||
return (
|
||||
<Window width={550} height={400} theme="syndicate">
|
||||
<Window.Content>
|
||||
<Section title="Info">
|
||||
<Table>
|
||||
{entries.map((a) => (
|
||||
<Table.Row key={a.name}>
|
||||
<Table.Cell>
|
||||
<Button onClick={() => act(a.name.toLowerCase().replace(/ /g, ''))} icon="cog" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{a.name}</Table.Cell>
|
||||
<Table.Cell>{a.value}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
<Section title="Electronic Warfare">
|
||||
<Button.Checkbox
|
||||
checked={electronic_warfare}
|
||||
content={
|
||||
electronic_warfare
|
||||
? 'Electronic warfare is enabled. This will prevent you from being tracked by the AI.'
|
||||
: 'Electronic warfare disabled.'
|
||||
}
|
||||
onClick={() => act('electronic_warfare')}
|
||||
/>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
192
tgui/packages/tgui_ch/interfaces/AiAirlock.js
Normal file
192
tgui/packages/tgui_ch/interfaces/AiAirlock.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const dangerMap = {
|
||||
2: {
|
||||
color: 'good',
|
||||
localStatusText: 'Optimal',
|
||||
},
|
||||
1: {
|
||||
color: 'average',
|
||||
localStatusText: 'Caution',
|
||||
},
|
||||
0: {
|
||||
color: 'bad',
|
||||
localStatusText: 'Offline',
|
||||
},
|
||||
};
|
||||
|
||||
export const AiAirlock = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const statusMain = dangerMap[data.power.main] || dangerMap[0];
|
||||
const statusBackup = dangerMap[data.power.backup] || dangerMap[0];
|
||||
const statusElectrify = dangerMap[data.shock] || dangerMap[0];
|
||||
return (
|
||||
<Window width={500} height={390}>
|
||||
<Window.Content>
|
||||
<Section title="Power Status">
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="Main"
|
||||
color={statusMain.color}
|
||||
buttons={
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
disabled={!data.power.main}
|
||||
content="Disrupt"
|
||||
onClick={() => act('disrupt-main')}
|
||||
/>
|
||||
}>
|
||||
{data.power.main ? 'Online' : 'Offline'}{' '}
|
||||
{((!data.wires.main_1 || !data.wires.main_2) && '[Wires have been cut!]') ||
|
||||
(data.power.main_timeleft > 0 && `[${data.power.main_timeleft}s]`)}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Backup"
|
||||
color={statusBackup.color}
|
||||
buttons={
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
disabled={!data.power.backup}
|
||||
content="Disrupt"
|
||||
onClick={() => act('disrupt-backup')}
|
||||
/>
|
||||
}>
|
||||
{data.power.backup ? 'Online' : 'Offline'}{' '}
|
||||
{((!data.wires.backup_1 || !data.wires.backup_2) && '[Wires have been cut!]') ||
|
||||
(data.power.backup_timeleft > 0 && `[${data.power.backup_timeleft}s]`)}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Electrify"
|
||||
color={statusElectrify.color}
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Button
|
||||
icon="wrench"
|
||||
disabled={!(data.wires.shock && data.shock === 0)}
|
||||
content="Restore"
|
||||
onClick={() => act('shock-restore')}
|
||||
/>
|
||||
<Button
|
||||
icon="bolt"
|
||||
disabled={!data.wires.shock}
|
||||
content="Temporary"
|
||||
onClick={() => act('shock-temp')}
|
||||
/>
|
||||
<Button
|
||||
icon="bolt"
|
||||
disabled={!data.wires.shock}
|
||||
content="Permanent"
|
||||
onClick={() => act('shock-perm')}
|
||||
/>
|
||||
</Fragment>
|
||||
}>
|
||||
{data.shock === 2 ? 'Safe' : 'Electrified'}{' '}
|
||||
{(!data.wires.shock && '[Wires have been cut!]') ||
|
||||
(data.shock_timeleft > 0 && `[${data.shock_timeleft}s]`) ||
|
||||
(data.shock_timeleft === -1 && '[Permanent]')}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<Section title="Access and Door Control">
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="ID Scan"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.id_scanner ? 'power-off' : 'times'}
|
||||
content={data.id_scanner ? 'Enabled' : 'Disabled'}
|
||||
selected={data.id_scanner}
|
||||
disabled={!data.wires.id_scanner}
|
||||
onClick={() => act('idscan-toggle')}
|
||||
/>
|
||||
}>
|
||||
{!data.wires.id_scanner && '[Wires have been cut!]'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Divider />
|
||||
<LabeledList.Item
|
||||
label="Door Bolts"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.locked ? 'lock' : 'unlock'}
|
||||
content={data.locked ? 'Lowered' : 'Raised'}
|
||||
selected={data.locked}
|
||||
disabled={!data.wires.bolts}
|
||||
onClick={() => act('bolt-toggle')}
|
||||
/>
|
||||
}>
|
||||
{!data.wires.bolts && '[Wires have been cut!]'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Door Bolt Lights"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.lights ? 'power-off' : 'times'}
|
||||
content={data.lights ? 'Enabled' : 'Disabled'}
|
||||
selected={data.lights}
|
||||
disabled={!data.wires.lights}
|
||||
onClick={() => act('light-toggle')}
|
||||
/>
|
||||
}>
|
||||
{!data.wires.lights && '[Wires have been cut!]'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Door Force Sensors"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.safe ? 'power-off' : 'times'}
|
||||
content={data.safe ? 'Enabled' : 'Disabled'}
|
||||
selected={data.safe}
|
||||
disabled={!data.wires.safe}
|
||||
onClick={() => act('safe-toggle')}
|
||||
/>
|
||||
}>
|
||||
{!data.wires.safe && '[Wires have been cut!]'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Door Timing Safety"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.speed ? 'power-off' : 'times'}
|
||||
content={data.speed ? 'Enabled' : 'Disabled'}
|
||||
selected={data.speed}
|
||||
disabled={!data.wires.timing}
|
||||
onClick={() => act('speed-toggle')}
|
||||
/>
|
||||
}>
|
||||
{!data.wires.timing && '[Wires have been cut!]'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Divider />
|
||||
<LabeledList.Item
|
||||
label="Door Control"
|
||||
color="bad"
|
||||
buttons={
|
||||
<Button
|
||||
icon={data.opened ? 'sign-out-alt' : 'sign-in-alt'}
|
||||
content={data.opened ? 'Open' : 'Closed'}
|
||||
selected={data.opened}
|
||||
disabled={data.locked || data.welded}
|
||||
onClick={() => act('open-close')}
|
||||
/>
|
||||
}>
|
||||
{!!(data.locked || data.welded) && (
|
||||
<span>
|
||||
[Door is {data.locked ? 'bolted' : ''}
|
||||
{data.locked && data.welded ? ' and ' : ''}
|
||||
{data.welded ? 'welded' : ''}!]
|
||||
</span>
|
||||
)}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
77
tgui/packages/tgui_ch/interfaces/AiRestorer.js
Normal file
77
tgui/packages/tgui_ch/interfaces/AiRestorer.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, LabeledList, NoticeBox, ProgressBar, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AiRestorer = () => {
|
||||
return (
|
||||
<Window width={370} height={360} resizable>
|
||||
<Window.Content scrollable>
|
||||
<AiRestorerContent />
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiRestorerContent = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { AI_present, error, name, laws, isDead, restoring, health, ejectable } = data;
|
||||
return (
|
||||
<Fragment>
|
||||
{error && <NoticeBox textAlign="center">{error}</NoticeBox>}
|
||||
{!!ejectable && (
|
||||
<Button
|
||||
fluid
|
||||
icon="eject"
|
||||
content={AI_present ? name : '----------'}
|
||||
disabled={!AI_present}
|
||||
onClick={() => act('PRG_eject')}
|
||||
/>
|
||||
)}
|
||||
{!!AI_present && (
|
||||
<Section
|
||||
title={ejectable ? 'System Status' : name}
|
||||
buttons={
|
||||
<Box inline bold color={isDead ? 'bad' : 'good'}>
|
||||
{isDead ? 'Nonfunctional' : 'Functional'}
|
||||
</Box>
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Integrity">
|
||||
<ProgressBar
|
||||
value={health}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
ranges={{
|
||||
good: [70, Infinity],
|
||||
average: [50, 70],
|
||||
bad: [-Infinity, 50],
|
||||
}}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
{!!restoring && (
|
||||
<Box bold textAlign="center" fontSize="20px" color="good" mt={1}>
|
||||
RECONSTRUCTION IN PROGRESS
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
fluid
|
||||
icon="plus"
|
||||
content="Begin Reconstruction"
|
||||
disabled={restoring}
|
||||
mt={1}
|
||||
onClick={() => act('PRG_beginReconstruction')}
|
||||
/>
|
||||
<Section title="Laws" level={2}>
|
||||
{laws.map((law) => (
|
||||
<Box key={law} className="candystripe">
|
||||
{law}
|
||||
</Box>
|
||||
))}
|
||||
</Section>
|
||||
</Section>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
68
tgui/packages/tgui_ch/interfaces/AiSupermatter.js
Normal file
68
tgui/packages/tgui_ch/interfaces/AiSupermatter.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Icon, LabeledList, ProgressBar, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { FullscreenNotice } from './common/FullscreenNotice';
|
||||
|
||||
export const AiSupermatter = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
|
||||
const { integrity_percentage, ambient_temp, ambient_pressure, detonating } = data;
|
||||
|
||||
let body = <AiSupermatterContent />;
|
||||
if (detonating) {
|
||||
body = <AiSupermatterDetonation />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={500} height={300}>
|
||||
<Window.Content>{body}</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const AiSupermatterDetonation = (props, context) => (
|
||||
<FullscreenNotice title="DETONATION IMMINENT">
|
||||
<Box fontSize="1.5rem" bold color="bad">
|
||||
<Icon color="bad" name="exclamation-triangle" verticalAlign="middle" size={3} mr="1rem" />
|
||||
<Box color="bad">CRYSTAL DELAMINATING</Box>
|
||||
<Box color="bad">Evacuate area immediately</Box>
|
||||
</Box>
|
||||
</FullscreenNotice>
|
||||
);
|
||||
|
||||
const AiSupermatterContent = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
|
||||
const { integrity_percentage, ambient_temp, ambient_pressure } = data;
|
||||
|
||||
return (
|
||||
<Section title="Status">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Crystal Integrity">
|
||||
<ProgressBar
|
||||
value={integrity_percentage}
|
||||
maxValue={100}
|
||||
ranges={{
|
||||
good: [90, Infinity],
|
||||
average: [25, 90],
|
||||
bad: [-Infinity, 25],
|
||||
}}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Environment Temperature">
|
||||
<ProgressBar
|
||||
value={ambient_temp}
|
||||
maxValue={10000}
|
||||
ranges={{
|
||||
bad: [5000, Infinity],
|
||||
average: [4000, 5000],
|
||||
good: [-Infinity, 4000],
|
||||
}}>
|
||||
{ambient_temp} K
|
||||
</ProgressBar>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Environment Pressure">{ambient_pressure} kPa</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
260
tgui/packages/tgui_ch/interfaces/AirAlarm.js
Normal file
260
tgui/packages/tgui_ch/interfaces/AirAlarm.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { toFixed } from 'common/math';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, LabeledList, Section } from '../components';
|
||||
import { getGasLabel, getGasColor } from '../constants';
|
||||
import { Window } from '../layouts';
|
||||
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
|
||||
import { Vent, Scrubber } from './common/AtmosControls';
|
||||
|
||||
export const AirAlarm = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const locked = data.locked && !data.siliconUser && !data.remoteUser;
|
||||
return (
|
||||
<Window width={440} height={650} resizable>
|
||||
<Window.Content scrollable>
|
||||
<InterfaceLockNoticeBox />
|
||||
<AirAlarmStatus />
|
||||
<AirAlarmUnlockedControl />
|
||||
{!locked && <AirAlarmControl />}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const AirAlarmStatus = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
const entries = (data.environment_data || []).filter((entry) => entry.value >= 0.01);
|
||||
const dangerMap = {
|
||||
0: {
|
||||
color: 'good',
|
||||
localStatusText: 'Optimal',
|
||||
},
|
||||
1: {
|
||||
color: 'average',
|
||||
localStatusText: 'Caution',
|
||||
},
|
||||
2: {
|
||||
color: 'bad',
|
||||
localStatusText: 'Danger (Internals Required)',
|
||||
},
|
||||
};
|
||||
const localStatus = dangerMap[data.danger_level] || dangerMap[0];
|
||||
return (
|
||||
<Section title="Air Status">
|
||||
<LabeledList>
|
||||
{(entries.length > 0 && (
|
||||
<Fragment>
|
||||
{entries.map((entry) => {
|
||||
const status = dangerMap[entry.danger_level] || dangerMap[0];
|
||||
return (
|
||||
<LabeledList.Item key={entry.name} label={getGasLabel(entry.name)} color={status.color}>
|
||||
{toFixed(entry.value, 2)}
|
||||
{entry.unit}
|
||||
</LabeledList.Item>
|
||||
);
|
||||
})}
|
||||
<LabeledList.Item label="Local status" color={localStatus.color}>
|
||||
{localStatus.localStatusText}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Area status" color={data.atmos_alarm || data.fire_alarm ? 'bad' : 'good'}>
|
||||
{(data.atmos_alarm && 'Atmosphere Alarm') || (data.fire_alarm && 'Fire Alarm') || 'Nominal'}
|
||||
</LabeledList.Item>
|
||||
</Fragment>
|
||||
)) || (
|
||||
<LabeledList.Item label="Warning" color="bad">
|
||||
Cannot obtain air sample for analysis.
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
{!!data.emagged && (
|
||||
<LabeledList.Item label="Warning" color="bad">
|
||||
Safety measures offline. Device may exhibit abnormal behavior.
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AirAlarmUnlockedControl = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { target_temperature, rcon } = data;
|
||||
return (
|
||||
<Section title="Comfort Settings">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Remote Control">
|
||||
<Button selected={rcon === 1} content="Off" onClick={() => act('rcon', { 'rcon': 1 })} />
|
||||
<Button selected={rcon === 2} content="Auto" onClick={() => act('rcon', { 'rcon': 2 })} />
|
||||
<Button selected={rcon === 3} content="On" onClick={() => act('rcon', { 'rcon': 3 })} />
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Thermostat">
|
||||
<Button content={target_temperature} onClick={() => act('temperature')} />
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AIR_ALARM_ROUTES = {
|
||||
home: {
|
||||
title: 'Air Controls',
|
||||
component: () => AirAlarmControlHome,
|
||||
},
|
||||
vents: {
|
||||
title: 'Vent Controls',
|
||||
component: () => AirAlarmControlVents,
|
||||
},
|
||||
scrubbers: {
|
||||
title: 'Scrubber Controls',
|
||||
component: () => AirAlarmControlScrubbers,
|
||||
},
|
||||
modes: {
|
||||
title: 'Operating Mode',
|
||||
component: () => AirAlarmControlModes,
|
||||
},
|
||||
thresholds: {
|
||||
title: 'Alarm Thresholds',
|
||||
component: () => AirAlarmControlThresholds,
|
||||
},
|
||||
};
|
||||
|
||||
const AirAlarmControl = (props, context) => {
|
||||
const [screen, setScreen] = useLocalState(context, 'screen');
|
||||
const route = AIR_ALARM_ROUTES[screen] || AIR_ALARM_ROUTES.home;
|
||||
const Component = route.component();
|
||||
return (
|
||||
<Section
|
||||
title={route.title}
|
||||
buttons={screen && <Button icon="arrow-left" content="Back" onClick={() => setScreen()} />}>
|
||||
<Component />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
// Home screen
|
||||
// --------------------------------------------------------
|
||||
|
||||
const AirAlarmControlHome = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const [screen, setScreen] = useLocalState(context, 'screen');
|
||||
const { mode, atmos_alarm } = data;
|
||||
return (
|
||||
<Fragment>
|
||||
<Button
|
||||
icon={atmos_alarm ? 'exclamation-triangle' : 'exclamation'}
|
||||
color={atmos_alarm && 'caution'}
|
||||
content="Area Atmosphere Alarm"
|
||||
onClick={() => act(atmos_alarm ? 'reset' : 'alarm')}
|
||||
/>
|
||||
<Box mt={1} />
|
||||
<Button
|
||||
icon={mode === 3 ? 'exclamation-triangle' : 'exclamation'}
|
||||
color={mode === 3 && 'danger'}
|
||||
content="Panic Siphon"
|
||||
onClick={() =>
|
||||
act('mode', {
|
||||
mode: mode === 3 ? 1 : 3,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box mt={2} />
|
||||
<Button icon="sign-out-alt" content="Vent Controls" onClick={() => setScreen('vents')} />
|
||||
<Box mt={1} />
|
||||
<Button icon="filter" content="Scrubber Controls" onClick={() => setScreen('scrubbers')} />
|
||||
<Box mt={1} />
|
||||
<Button icon="cog" content="Operating Mode" onClick={() => setScreen('modes')} />
|
||||
<Box mt={1} />
|
||||
<Button icon="chart-bar" content="Alarm Thresholds" onClick={() => setScreen('thresholds')} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// Vents
|
||||
// --------------------------------------------------------
|
||||
|
||||
const AirAlarmControlVents = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
const { vents } = data;
|
||||
if (!vents || vents.length === 0) {
|
||||
return 'Nothing to show';
|
||||
}
|
||||
return vents.map((vent) => <Vent key={vent.id_tag} vent={vent} />);
|
||||
};
|
||||
|
||||
// Scrubbers
|
||||
// --------------------------------------------------------
|
||||
|
||||
const AirAlarmControlScrubbers = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
const { scrubbers } = data;
|
||||
if (!scrubbers || scrubbers.length === 0) {
|
||||
return 'Nothing to show';
|
||||
}
|
||||
return scrubbers.map((scrubber) => <Scrubber key={scrubber.id_tag} scrubber={scrubber} />);
|
||||
};
|
||||
|
||||
// Modes
|
||||
// --------------------------------------------------------
|
||||
|
||||
const AirAlarmControlModes = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { modes } = data;
|
||||
if (!modes || modes.length === 0) {
|
||||
return 'Nothing to show';
|
||||
}
|
||||
return modes.map((mode) => (
|
||||
<Fragment key={mode.mode}>
|
||||
<Button
|
||||
icon={mode.selected ? 'check-square-o' : 'square-o'}
|
||||
selected={mode.selected}
|
||||
color={mode.selected && mode.danger && 'danger'}
|
||||
content={mode.name}
|
||||
onClick={() => act('mode', { mode: mode.mode })}
|
||||
/>
|
||||
<Box mt={1} />
|
||||
</Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
// Thresholds
|
||||
// --------------------------------------------------------
|
||||
|
||||
const AirAlarmControlThresholds = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { thresholds } = data;
|
||||
return (
|
||||
<table className="LabeledList" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<td />
|
||||
<td className="color-bad">min2</td>
|
||||
<td className="color-average">min1</td>
|
||||
<td className="color-average">max1</td>
|
||||
<td className="color-bad">max2</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{thresholds.map((threshold) => (
|
||||
<tr key={threshold.name}>
|
||||
<td className="LabeledList__label">
|
||||
<span className={'color-' + getGasColor(threshold.name)}>{getGasLabel(threshold.name)}</span>
|
||||
</td>
|
||||
{threshold.settings.map((setting) => (
|
||||
<td key={setting.val}>
|
||||
<Button
|
||||
content={toFixed(setting.selected, 2)}
|
||||
onClick={() =>
|
||||
act('threshold', {
|
||||
env: setting.env,
|
||||
var: setting.val,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
129
tgui/packages/tgui_ch/interfaces/AlertModal.tsx
Normal file
129
tgui/packages/tgui_ch/interfaces/AlertModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Loader } from './common/Loader';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { KEY_ENTER, KEY_ESCAPE, KEY_LEFT, KEY_RIGHT, KEY_SPACE, KEY_TAB } from '../../common/keycodes';
|
||||
import { Autofocus, Box, Button, Flex, Section, Stack } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type AlertModalData = {
|
||||
autofocus: boolean;
|
||||
buttons: string[];
|
||||
large_buttons: boolean;
|
||||
message: string;
|
||||
swapped_buttons: boolean;
|
||||
timeout: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const KEY_DECREMENT = -1;
|
||||
const KEY_INCREMENT = 1;
|
||||
|
||||
export const AlertModal = (props, context) => {
|
||||
const { act, data } = useBackend<AlertModalData>(context);
|
||||
const { autofocus, buttons = [], large_buttons, message = '', timeout, title } = data;
|
||||
const [selected, setSelected] = useLocalState<number>(context, 'selected', 0);
|
||||
// Dynamically sets window dimensions
|
||||
const windowHeight =
|
||||
115 + (message.length > 30 ? Math.ceil(message.length / 4) : 0) + (message.length && large_buttons ? 5 : 0);
|
||||
const windowWidth = 325 + (buttons.length > 2 ? 55 : 0);
|
||||
const onKey = (direction: number) => {
|
||||
if (selected === 0 && direction === KEY_DECREMENT) {
|
||||
setSelected(buttons.length - 1);
|
||||
} else if (selected === buttons.length - 1 && direction === KEY_INCREMENT) {
|
||||
setSelected(0);
|
||||
} else {
|
||||
setSelected(selected + direction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window height={windowHeight} title={title} width={windowWidth}>
|
||||
{!!timeout && <Loader value={timeout} />}
|
||||
<Window.Content
|
||||
onKeyDown={(e) => {
|
||||
const keyCode = window.event ? e.which : e.keyCode;
|
||||
/**
|
||||
* Simulate a click when pressing space or enter,
|
||||
* allow keyboard navigation, override tab behavior
|
||||
*/
|
||||
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
|
||||
act('choose', { choice: buttons[selected] });
|
||||
} else if (keyCode === KEY_ESCAPE) {
|
||||
act('cancel');
|
||||
} else if (keyCode === KEY_LEFT) {
|
||||
e.preventDefault();
|
||||
onKey(KEY_DECREMENT);
|
||||
} else if (keyCode === KEY_TAB || keyCode === KEY_RIGHT) {
|
||||
e.preventDefault();
|
||||
onKey(KEY_INCREMENT);
|
||||
}
|
||||
}}>
|
||||
<Section fill>
|
||||
<Stack fill vertical>
|
||||
<Stack.Item grow m={1}>
|
||||
<Box color="label" overflow="hidden">
|
||||
{message}
|
||||
</Box>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{!!autofocus && <Autofocus />}
|
||||
<ButtonDisplay selected={selected} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a list of buttons ordered by user prefs.
|
||||
* Technically this handles more than 2 buttons, but you
|
||||
* should just be using a list input in that case.
|
||||
*/
|
||||
const ButtonDisplay = (props, context) => {
|
||||
const { data } = useBackend<AlertModalData>(context);
|
||||
const { buttons = [], large_buttons, swapped_buttons } = data;
|
||||
const { selected } = props;
|
||||
|
||||
return (
|
||||
<Flex align="center" direction={!swapped_buttons ? 'row-reverse' : 'row'} fill justify="space-around" wrap>
|
||||
{buttons?.map((button, index) =>
|
||||
!!large_buttons && buttons.length < 3 ? (
|
||||
<Flex.Item grow key={index}>
|
||||
<AlertButton button={button} id={index.toString()} selected={selected === index} />
|
||||
</Flex.Item>
|
||||
) : (
|
||||
<Flex.Item key={index}>
|
||||
<AlertButton button={button} id={index.toString()} selected={selected === index} />
|
||||
</Flex.Item>
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a button with variable sizing.
|
||||
*/
|
||||
const AlertButton = (props, context) => {
|
||||
const { act, data } = useBackend<AlertModalData>(context);
|
||||
const { large_buttons } = data;
|
||||
const { button, selected } = props;
|
||||
const buttonWidth = button.length > 7 ? button.length : 7;
|
||||
|
||||
return (
|
||||
<Button
|
||||
fluid={!!large_buttons}
|
||||
height={!!large_buttons && 2}
|
||||
onClick={() => act('choose', { choice: button })}
|
||||
m={0.5}
|
||||
pl={2}
|
||||
pr={2}
|
||||
pt={large_buttons ? 0.33 : 0}
|
||||
selected={selected}
|
||||
textAlign="center"
|
||||
width={!large_buttons && buttonWidth}>
|
||||
{!large_buttons ? button : button.toUpperCase()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
82
tgui/packages/tgui_ch/interfaces/AlgaeFarm.js
Normal file
82
tgui/packages/tgui_ch/interfaces/AlgaeFarm.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, NoticeBox, LabeledList, ProgressBar, Section, Table } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { capitalize } from 'common/string';
|
||||
|
||||
export const AlgaeFarm = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { usePower, materials, last_flow_rate, last_power_draw, inputDir, outputDir, input, output, errorText } = data;
|
||||
|
||||
return (
|
||||
<Window width={500} height={300} resizable>
|
||||
<Window.Content>
|
||||
{errorText && (
|
||||
<NoticeBox warning>
|
||||
<Box display="inline-block" verticalAlign="middle">
|
||||
{errorText}
|
||||
</Box>
|
||||
</NoticeBox>
|
||||
)}
|
||||
<Section
|
||||
title="Status"
|
||||
buttons={
|
||||
<Button icon="power-off" content="Processing" selected={usePower === 2} onClick={() => act('toggle')} />
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Flow Rate">{last_flow_rate} L/s</LabeledList.Item>
|
||||
<LabeledList.Item label="Power Draw">{last_power_draw} W</LabeledList.Item>
|
||||
<LabeledList.Divider size={1} />
|
||||
{materials.map((material) => (
|
||||
<LabeledList.Item key={material.name} label={capitalize(material.display)}>
|
||||
<ProgressBar width="80%" value={material.qty} maxValue={material.max}>
|
||||
{material.qty}/{material.max}
|
||||
</ProgressBar>
|
||||
<Button
|
||||
ml={1}
|
||||
content="Eject"
|
||||
onClick={() =>
|
||||
act('ejectMaterial', {
|
||||
mat: material.name,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
))}
|
||||
</LabeledList>
|
||||
<Table mt={1}>
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Section title={'Gas Input (' + inputDir + ')'}>
|
||||
{input ? (
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Total Pressure">{input.pressure} kPa</LabeledList.Item>
|
||||
<LabeledList.Item label={input.name}>
|
||||
{input.percent}% ({input.moles} moles)
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
) : (
|
||||
<Box color="bad">No connection detected.</Box>
|
||||
)}
|
||||
</Section>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Section title={'Gas Output (' + outputDir + ')'}>
|
||||
{output ? (
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Total Pressure">{output.pressure} kPa</LabeledList.Item>
|
||||
<LabeledList.Item label={output.name}>
|
||||
{output.percent}% ({output.moles} moles)
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
) : (
|
||||
<Box color="bad">No connection detected.</Box>
|
||||
)}
|
||||
</Section>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
415
tgui/packages/tgui_ch/interfaces/AppearanceChanger.js
Normal file
415
tgui/packages/tgui_ch/interfaces/AppearanceChanger.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import { sortBy } from 'common/collections';
|
||||
import { capitalize, decodeHtmlEntities } from 'common/string';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, ByondUi, Flex, LabeledList, Section, Tabs, ColorBox } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AppearanceChanger = (props, context) => {
|
||||
const { act, config, data } = useBackend(context);
|
||||
|
||||
const {
|
||||
name,
|
||||
specimen,
|
||||
gender,
|
||||
gender_id,
|
||||
hair_style,
|
||||
facial_hair_style,
|
||||
ear_style,
|
||||
tail_style,
|
||||
wing_style,
|
||||
markings,
|
||||
change_race,
|
||||
change_gender,
|
||||
change_eye_color,
|
||||
change_skin_tone,
|
||||
change_skin_color,
|
||||
change_hair_color,
|
||||
change_facial_hair_color,
|
||||
change_hair,
|
||||
change_facial_hair,
|
||||
mapRef,
|
||||
} = data;
|
||||
|
||||
const { title } = config;
|
||||
|
||||
const change_color =
|
||||
change_eye_color || change_skin_tone || change_skin_color || change_hair_color || change_facial_hair_color;
|
||||
|
||||
let firstAccesibleTab = -1;
|
||||
if (change_race) {
|
||||
firstAccesibleTab = 0;
|
||||
} else if (change_gender) {
|
||||
firstAccesibleTab = 1;
|
||||
} else if (change_color) {
|
||||
firstAccesibleTab = 2;
|
||||
} else if (change_hair) {
|
||||
firstAccesibleTab = 4;
|
||||
} else if (change_facial_hair) {
|
||||
firstAccesibleTab = 5;
|
||||
}
|
||||
|
||||
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', firstAccesibleTab);
|
||||
|
||||
return (
|
||||
<Window width={700} height={650} title={decodeHtmlEntities(title)}>
|
||||
<Window.Content>
|
||||
<Section title="Reflection">
|
||||
<Flex>
|
||||
<Flex.Item grow={1}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Name">{name}</LabeledList.Item>
|
||||
<LabeledList.Item label="Species" color={!change_race ? 'grey' : null}>
|
||||
{specimen}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Biological Sex" color={!change_gender ? 'grey' : null}>
|
||||
{gender ? capitalize(gender) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Gender Identity" color={!change_color ? 'grey' : null}>
|
||||
{gender_id ? capitalize(gender_id) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Hair Style" color={!change_hair ? 'grey' : null}>
|
||||
{hair_style ? capitalize(hair_style) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Facial Hair Style" color={!change_facial_hair ? 'grey' : null}>
|
||||
{facial_hair_style ? capitalize(facial_hair_style) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Ear Style" color={!change_hair ? 'grey' : null}>
|
||||
{ear_style ? capitalize(ear_style) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Tail Style" color={!change_hair ? 'grey' : null}>
|
||||
{tail_style ? capitalize(tail_style) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Wing Style" color={!change_hair ? 'grey' : null}>
|
||||
{wing_style ? capitalize(wing_style) : 'Not Set'}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<ByondUi
|
||||
style={{
|
||||
width: '256px',
|
||||
height: '256px',
|
||||
}}
|
||||
params={{
|
||||
id: mapRef,
|
||||
type: 'map',
|
||||
}}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Section>
|
||||
<Tabs>
|
||||
{change_race ? (
|
||||
<Tabs.Tab selected={tabIndex === 0} onClick={() => setTabIndex(0)}>
|
||||
Race
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{change_gender ? (
|
||||
<Tabs.Tab selected={tabIndex === 1} onClick={() => setTabIndex(1)}>
|
||||
Gender & Sex
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{change_color ? (
|
||||
<Tabs.Tab selected={tabIndex === 2} onClick={() => setTabIndex(2)}>
|
||||
Colors
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{change_hair ? (
|
||||
<Fragment>
|
||||
<Tabs.Tab selected={tabIndex === 3} onClick={() => setTabIndex(3)}>
|
||||
Hair
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab selected={tabIndex === 5} onClick={() => setTabIndex(5)}>
|
||||
Ear
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab selected={tabIndex === 6} onClick={() => setTabIndex(6)}>
|
||||
Tail
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab selected={tabIndex === 7} onClick={() => setTabIndex(7)}>
|
||||
Wing
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab selected={tabIndex === 8} onClick={() => setTabIndex(8)}>
|
||||
Markings
|
||||
</Tabs.Tab>
|
||||
</Fragment>
|
||||
) : null}
|
||||
{change_facial_hair ? (
|
||||
<Tabs.Tab selected={tabIndex === 4} onClick={() => setTabIndex(4)}>
|
||||
Facial Hair
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
</Tabs>
|
||||
<Box height="43%">
|
||||
{change_race && tabIndex === 0 ? <AppearanceChangerSpecies /> : null}
|
||||
{change_gender && tabIndex === 1 ? <AppearanceChangerGender /> : null}
|
||||
{change_color && tabIndex === 2 ? <AppearanceChangerColors /> : null}
|
||||
{change_hair && tabIndex === 3 ? <AppearanceChangerHair /> : null}
|
||||
{change_facial_hair && tabIndex === 4 ? <AppearanceChangerFacialHair /> : null}
|
||||
{change_hair && tabIndex === 5 ? <AppearanceChangerEars /> : null}
|
||||
{change_hair && tabIndex === 6 ? <AppearanceChangerTails /> : null}
|
||||
{change_hair && tabIndex === 7 ? <AppearanceChangerWings /> : null}
|
||||
{change_hair && tabIndex === 8 ? <AppearanceChangerMarkings /> : null}
|
||||
</Box>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerSpecies = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { species, specimen } = data;
|
||||
|
||||
const sortedSpecies = sortBy((val) => val.specimen)(species || []);
|
||||
|
||||
return (
|
||||
<Section title="Species" fill scrollable>
|
||||
{sortedSpecies.map((spec) => (
|
||||
<Button
|
||||
key={spec.specimen}
|
||||
content={spec.specimen}
|
||||
selected={specimen === spec.specimen}
|
||||
onClick={() => act('race', { race: spec.specimen })}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerGender = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { gender, gender_id, genders, id_genders } = data;
|
||||
|
||||
return (
|
||||
<Section title="Gender & Sex" fill scrollable>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Biological Sex">
|
||||
{genders.map((g) => (
|
||||
<Button
|
||||
key={g.gender_key}
|
||||
selected={g.gender_key === gender}
|
||||
content={g.gender_name}
|
||||
onClick={() => act('gender', { 'gender': g.gender_key })}
|
||||
/>
|
||||
))}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Gender Identity">
|
||||
{id_genders.map((g) => (
|
||||
<Button
|
||||
key={g.gender_key}
|
||||
selected={g.gender_key === gender_id}
|
||||
content={g.gender_name}
|
||||
onClick={() => act('gender_id', { 'gender_id': g.gender_key })}
|
||||
/>
|
||||
))}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerColors = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const {
|
||||
change_eye_color,
|
||||
change_skin_tone,
|
||||
change_skin_color,
|
||||
change_hair_color,
|
||||
change_facial_hair_color,
|
||||
eye_color,
|
||||
skin_color,
|
||||
hair_color,
|
||||
facial_hair_color,
|
||||
ears_color,
|
||||
ears2_color,
|
||||
tail_color,
|
||||
tail2_color,
|
||||
wing_color,
|
||||
wing2_color,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<Section title="Colors" fill scrollable>
|
||||
{change_eye_color ? (
|
||||
<Box>
|
||||
<ColorBox color={eye_color} mr={1} />
|
||||
<Button content="Change Eye Color" onClick={() => act('eye_color')} />
|
||||
</Box>
|
||||
) : null}
|
||||
{change_skin_tone ? (
|
||||
<Box>
|
||||
<Button content="Change Skin Tone" onClick={() => act('skin_tone')} />
|
||||
</Box>
|
||||
) : null}
|
||||
{change_skin_color ? (
|
||||
<Box>
|
||||
<ColorBox color={skin_color} mr={1} />
|
||||
<Button content="Change Skin Color" onClick={() => act('skin_color')} />
|
||||
</Box>
|
||||
) : null}
|
||||
{change_hair_color ? (
|
||||
<Fragment>
|
||||
<Box>
|
||||
<ColorBox color={hair_color} mr={1} />
|
||||
<Button content="Change Hair Color" onClick={() => act('hair_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={ears_color} mr={1} />
|
||||
<Button content="Change Ears Color" onClick={() => act('ears_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={ears2_color} mr={1} />
|
||||
<Button content="Change Secondary Ears Color" onClick={() => act('ears2_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={tail_color} mr={1} />
|
||||
<Button content="Change Tail Color" onClick={() => act('tail_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={tail2_color} mr={1} />
|
||||
<Button content="Change Secondary Tail Color" onClick={() => act('tail2_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={wing_color} mr={1} />
|
||||
<Button content="Change Wing Color" onClick={() => act('wing_color')} />
|
||||
</Box>
|
||||
<Box>
|
||||
<ColorBox color={wing2_color} mr={1} />
|
||||
<Button content="Change Secondary Wing Color" onClick={() => act('wing2_color')} />
|
||||
</Box>
|
||||
</Fragment>
|
||||
) : null}
|
||||
{change_facial_hair_color ? (
|
||||
<Box>
|
||||
<ColorBox color={facial_hair_color} mr={1} />
|
||||
<Button content="Change Facial Hair Color" onClick={() => act('facial_hair_color')} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerHair = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { hair_style, hair_styles } = data;
|
||||
|
||||
return (
|
||||
<Section title="Hair" fill scrollable>
|
||||
{hair_styles.map((hair) => (
|
||||
<Button
|
||||
key={hair.hairstyle}
|
||||
onClick={() => act('hair', { hair: hair.hairstyle })}
|
||||
selected={hair.hairstyle === hair_style}
|
||||
content={hair.hairstyle}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerFacialHair = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { facial_hair_style, facial_hair_styles } = data;
|
||||
|
||||
return (
|
||||
<Section title="Facial Hair" fill scrollable>
|
||||
{facial_hair_styles.map((hair) => (
|
||||
<Button
|
||||
key={hair.facialhairstyle}
|
||||
onClick={() => act('facial_hair', { facial_hair: hair.facialhairstyle })}
|
||||
selected={hair.facialhairstyle === facial_hair_style}
|
||||
content={hair.facialhairstyle}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerEars = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { ear_style, ear_styles } = data;
|
||||
|
||||
return (
|
||||
<Section title="Ears" fill scrollable>
|
||||
<Button onClick={() => act('ear', { clear: true })} selected={ear_style === null} content="-- Not Set --" />
|
||||
{sortBy((e) => e.name.toLowerCase())(ear_styles).map((ear) => (
|
||||
<Button
|
||||
key={ear.instance}
|
||||
onClick={() => act('ear', { ref: ear.instance })}
|
||||
selected={ear.name === ear_style}
|
||||
content={ear.name}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerTails = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { tail_style, tail_styles } = data;
|
||||
|
||||
return (
|
||||
<Section title="Tails" fill scrollable>
|
||||
<Button onClick={() => act('tail', { clear: true })} selected={tail_style === null} content="-- Not Set --" />
|
||||
{sortBy((e) => e.name.toLowerCase())(tail_styles).map((tail) => (
|
||||
<Button
|
||||
key={tail.instance}
|
||||
onClick={() => act('tail', { ref: tail.instance })}
|
||||
selected={tail.name === tail_style}
|
||||
content={tail.name}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerWings = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { wing_style, wing_styles } = data;
|
||||
|
||||
return (
|
||||
<Section title="Wings" fill scrollable>
|
||||
<Button onClick={() => act('wing', { clear: true })} selected={wing_style === null} content="-- Not Set --" />
|
||||
{sortBy((e) => e.name.toLowerCase())(wing_styles).map((wing) => (
|
||||
<Button
|
||||
key={wing.instance}
|
||||
onClick={() => act('wing', { ref: wing.instance })}
|
||||
selected={wing.name === wing_style}
|
||||
content={wing.name}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const AppearanceChangerMarkings = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { markings } = data;
|
||||
|
||||
return (
|
||||
<Section title="Markings" fill scrollable>
|
||||
<Box>
|
||||
<Button content="Add Marking" onClick={() => act('marking', { todo: 1, name: 'na' })} />
|
||||
</Box>
|
||||
<LabeledList>
|
||||
{markings.map((m) => (
|
||||
<LabeledList.Item key={m.marking_name} label={m.marking_name}>
|
||||
<ColorBox color={m.marking_color} mr={1} />
|
||||
<Button content="Change Color" onClick={() => act('marking', { todo: 4, name: m.marking_name })} />
|
||||
<Button content="-" onClick={() => act('marking', { todo: 0, name: m.marking_name })} />
|
||||
<Button content="Move down" onClick={() => act('marking', { todo: 3, name: m.marking_name })} />
|
||||
<Button content="Move up" onClick={() => act('marking', { todo: 2, name: m.marking_name })} />
|
||||
</LabeledList.Item>
|
||||
))}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
107
tgui/packages/tgui_ch/interfaces/ArcadeBattle.js
Normal file
107
tgui/packages/tgui_ch/interfaces/ArcadeBattle.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Flex, LabeledList, ProgressBar, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const ArcadeBattle = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { name, temp, enemyAction, enemyName, playerHP, playerMP, enemyHP, enemyMP, gameOver } = data;
|
||||
|
||||
return (
|
||||
<Window width={400} height={240} resizable>
|
||||
<Window.Content>
|
||||
<Section title={enemyName} textAlign="center">
|
||||
<Section color="label">
|
||||
<Box>{temp}</Box>
|
||||
<Box>{!gameOver && enemyAction}</Box>
|
||||
</Section>
|
||||
<Flex spacing={1}>
|
||||
<Flex.Item>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Player Health">
|
||||
<ProgressBar
|
||||
value={playerHP}
|
||||
minValue={0}
|
||||
maxValue={30}
|
||||
ranges={{
|
||||
olive: [31, Infinity],
|
||||
good: [20, 31],
|
||||
average: [10, 20],
|
||||
bad: [-Infinity, 10],
|
||||
}}>
|
||||
{playerHP}HP
|
||||
</ProgressBar>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Player Magic">
|
||||
<ProgressBar
|
||||
value={playerMP}
|
||||
minValue={0}
|
||||
maxValue={10}
|
||||
ranges={{
|
||||
purple: [11, Infinity],
|
||||
violet: [3, 11],
|
||||
bad: [-Infinity, 3],
|
||||
}}>
|
||||
{playerMP}MP
|
||||
</ProgressBar>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Enemy HP">
|
||||
<ProgressBar
|
||||
value={enemyHP}
|
||||
minValue={0}
|
||||
maxValue={45}
|
||||
ranges={{
|
||||
olive: [31, Infinity],
|
||||
good: [20, 31],
|
||||
average: [10, 20],
|
||||
bad: [-Infinity, 10],
|
||||
}}>
|
||||
{enemyHP}HP
|
||||
</ProgressBar>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
{(gameOver && <Button fluid mt={1} color="green" content="New Game" onClick={() => act('newgame')} />) || (
|
||||
<Flex mt={2} justify="space-between" spacing={1}>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="fist-raised"
|
||||
tooltip="Go in for the kill!"
|
||||
tooltipPosition="top"
|
||||
onClick={() => act('attack')}
|
||||
content="Attack!"
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="band-aid"
|
||||
tooltip="Heal yourself!"
|
||||
tooltipPosition="top"
|
||||
onClick={() => act('heal')}
|
||||
content="Heal!"
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="magic"
|
||||
tooltip="Recharge your magic!"
|
||||
tooltipPosition="top"
|
||||
onClick={() => act('charge')}
|
||||
content="Recharge!"
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
)}
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
82
tgui/packages/tgui_ch/interfaces/AreaScrubberControl.js
Normal file
82
tgui/packages/tgui_ch/interfaces/AreaScrubberControl.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Flex, Button, Box, LabeledList, Section } from '../components';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Window } from '../layouts';
|
||||
import { toTitleCase } from 'common/string';
|
||||
|
||||
export const AreaScrubberControl = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const [showArea, setShowArea] = useLocalState(context, 'showArea', false);
|
||||
|
||||
const { scrubbers } = data;
|
||||
|
||||
if (!scrubbers) {
|
||||
return (
|
||||
<Section title="Error">
|
||||
<Box color="bad">No Scrubbers Detected.</Box>
|
||||
<Button fluid icon="search" content="Scan" onClick={() => act('scan')} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={600} height={400} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section>
|
||||
<Flex wrap="wrap">
|
||||
<Flex.Item m="2px" basis="49%">
|
||||
<Button textAlign="center" fluid icon="search" content="Scan" onClick={() => act('scan')} />
|
||||
</Flex.Item>
|
||||
<Flex.Item m="2px" basis="49%" grow={1}>
|
||||
<Button
|
||||
textAlign="center"
|
||||
fluid
|
||||
icon="layer-group"
|
||||
content="Show Areas"
|
||||
selected={showArea}
|
||||
onClick={() => setShowArea(!showArea)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item m="2px" basis="49%">
|
||||
<Button textAlign="center" fluid icon="toggle-on" content="All On" onClick={() => act('allon')} />
|
||||
</Flex.Item>
|
||||
<Flex.Item m="2px" basis="49%" grow={1}>
|
||||
<Button textAlign="center" fluid icon="toggle-off" content="All Off" onClick={() => act('alloff')} />
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
<Flex wrap="wrap">
|
||||
{scrubbers.map((scrubber) => (
|
||||
<Flex.Item m="2px" key={scrubber.id} basis="32%">
|
||||
<BigScrubber scrubber={scrubber} showArea={showArea} />
|
||||
</Flex.Item>
|
||||
))}
|
||||
</Flex>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const BigScrubber = (props, context) => {
|
||||
const { act } = useBackend(context);
|
||||
|
||||
const { scrubber, showArea } = props;
|
||||
|
||||
return (
|
||||
<Section title={scrubber.name}>
|
||||
<Button
|
||||
fluid
|
||||
icon="power-off"
|
||||
content={scrubber.on ? 'Enabled' : 'Disabled'}
|
||||
selected={scrubber.on}
|
||||
onClick={() => act('toggle', { id: scrubber.id })}
|
||||
/>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Pressure">{scrubber.pressure} kPa</LabeledList.Item>
|
||||
<LabeledList.Item label="Flow Rate">{scrubber.flow_rate} L/s</LabeledList.Item>
|
||||
<LabeledList.Item label="Load">{scrubber.load} W</LabeledList.Item>
|
||||
{showArea && <LabeledList.Item label="Area">{toTitleCase(scrubber.area)}</LabeledList.Item>}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
34
tgui/packages/tgui_ch/interfaces/AssemblyInfrared.tsx
Normal file
34
tgui/packages/tgui_ch/interfaces/AssemblyInfrared.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BooleanLike } from 'common/react';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
type Data = {
|
||||
on: BooleanLike;
|
||||
visible: BooleanLike;
|
||||
};
|
||||
|
||||
export const AssemblyInfrared = (props, context) => {
|
||||
const { act, data } = useBackend<Data>(context);
|
||||
const { on, visible } = data;
|
||||
return (
|
||||
<Window>
|
||||
<Window.Content>
|
||||
<Section title="Infrared Unit">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Laser">
|
||||
<Button icon="power-off" fluid selected={on} onClick={() => act('state')}>
|
||||
{on ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Visibility">
|
||||
<Button icon="eye" fluid selected={visible} onClick={() => act('visible')}>
|
||||
{visible ? 'Able to be seen' : 'Invisible'}
|
||||
</Button>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
55
tgui/packages/tgui_ch/interfaces/AssemblyProx.js
Normal file
55
tgui/packages/tgui_ch/interfaces/AssemblyProx.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { round } from 'common/math';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, NumberInput, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { formatTime } from '../format';
|
||||
|
||||
export const AssemblyProx = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { timing, time, range, maxRange, scanning } = data;
|
||||
return (
|
||||
<Window>
|
||||
<Window.Content>
|
||||
<Section title="Timing Unit">
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="Timer"
|
||||
buttons={
|
||||
<Button icon="stopwatch" selected={timing} onClick={() => act('timing')}>
|
||||
{timing ? 'Counting Down' : 'Disabled'}
|
||||
</Button>
|
||||
}>
|
||||
<NumberInput
|
||||
animated
|
||||
fluid
|
||||
value={time / 10}
|
||||
minValue={0}
|
||||
maxValue={600}
|
||||
format={(val) => formatTime(round(val))}
|
||||
onDrag={(e, val) => act('set_time', { time: val })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<Section title="Prox Unit">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Range">
|
||||
<NumberInput
|
||||
minValue={1}
|
||||
value={range}
|
||||
maxValue={maxRange}
|
||||
onDrag={(e, val) => act('range', { range: val })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Armed">
|
||||
<Button mr={1} icon={scanning ? 'lock' : 'lock-open'} selected={scanning} onClick={() => act('scanning')}>
|
||||
{scanning ? 'ARMED' : 'Unarmed'}
|
||||
</Button>
|
||||
Movement sensor is active when armed!
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
37
tgui/packages/tgui_ch/interfaces/AssemblyTimer.js
Normal file
37
tgui/packages/tgui_ch/interfaces/AssemblyTimer.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { round } from 'common/math';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, NumberInput, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { formatTime } from '../format';
|
||||
|
||||
export const AssemblyTimer = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { timing, time } = data;
|
||||
return (
|
||||
<Window>
|
||||
<Window.Content>
|
||||
<Section title="Timing Unit">
|
||||
<LabeledList>
|
||||
<LabeledList.Item
|
||||
label="Timer"
|
||||
buttons={
|
||||
<Button icon="stopwatch" selected={timing} onClick={() => act('timing')}>
|
||||
{timing ? 'Counting Down' : 'Disabled'}
|
||||
</Button>
|
||||
}>
|
||||
<NumberInput
|
||||
animated
|
||||
fluid
|
||||
value={time / 10}
|
||||
minValue={0}
|
||||
maxValue={600}
|
||||
format={(val) => formatTime(round(val))}
|
||||
onDrag={(e, val) => act('set_time', { time: val })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
41
tgui/packages/tgui_ch/interfaces/AtmosAlertConsole.js
Normal file
41
tgui/packages/tgui_ch/interfaces/AtmosAlertConsole.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AtmosAlertConsole = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const priorityAlerts = data.priority_alarms || [];
|
||||
const minorAlerts = data.minor_alarms || [];
|
||||
return (
|
||||
<Window width={350} height={300} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Alarms">
|
||||
<ul>
|
||||
{priorityAlerts.length === 0 && <li className="color-good">No Priority Alerts</li>}
|
||||
{priorityAlerts.map((alert) => (
|
||||
<li key={alert.name}>
|
||||
<Button
|
||||
icon="times"
|
||||
content={alert.name}
|
||||
color="bad"
|
||||
onClick={() => act('clear', { ref: alert.ref })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{minorAlerts.length === 0 && <li className="color-good">No Minor Alerts</li>}
|
||||
{minorAlerts.map((alert) => (
|
||||
<li key={alert.name}>
|
||||
<Button
|
||||
icon="times"
|
||||
content={alert.name}
|
||||
color="average"
|
||||
onClick={() => act('clear', { ref: alert.ref })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
83
tgui/packages/tgui_ch/interfaces/AtmosControl.js
Normal file
83
tgui/packages/tgui_ch/interfaces/AtmosControl.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { sortBy } from 'common/collections';
|
||||
import { Window } from '../layouts';
|
||||
import { Fragment } from 'inferno';
|
||||
import { Button, Box, Tabs, Icon, Section, NanoMap } from '../components';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { createLogger } from '../logging';
|
||||
const logger = createLogger('fuck');
|
||||
|
||||
export const AtmosControl = (props, context) => {
|
||||
return (
|
||||
<Window width={600} height={440} resizable>
|
||||
<Window.Content scrollable>
|
||||
<AtmosControlContent />
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const AtmosControlContent = (props, context) => {
|
||||
const { act, data, config } = useBackend(context);
|
||||
|
||||
let sortedAlarms = sortBy((alarm) => alarm.name)(data.alarms || []);
|
||||
|
||||
// sortedAlarms = sortedAlarms.slice(1, 3);
|
||||
|
||||
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', 0);
|
||||
const [zoom, setZoom] = useLocalState(context, 'zoom', 1);
|
||||
|
||||
let body;
|
||||
// Alarms View
|
||||
if (tabIndex === 0) {
|
||||
body = (
|
||||
<Section title="Alarms">
|
||||
{sortedAlarms.map((alarm) => (
|
||||
<Button
|
||||
key={alarm.name}
|
||||
content={alarm.name}
|
||||
color={alarm.danger === 2 ? 'bad' : alarm.danger === 1 ? 'average' : ''}
|
||||
onClick={() => act('alarm', { 'alarm': alarm.ref })}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
} else if (tabIndex === 1) {
|
||||
// Please note, if you ever change the zoom values,
|
||||
// you MUST update styles/components/Tooltip.scss
|
||||
// and change the @for scss to match.
|
||||
body = (
|
||||
<Box height="526px" mb="0.5rem" overflow="hidden">
|
||||
<NanoMap onZoom={(v) => setZoom(v)}>
|
||||
{sortedAlarms
|
||||
.filter((x) => ~~x.z === ~~config.mapZLevel)
|
||||
.map((cm) => (
|
||||
<NanoMap.Marker
|
||||
key={cm.ref}
|
||||
x={cm.x}
|
||||
y={cm.y}
|
||||
zoom={zoom}
|
||||
icon="bell"
|
||||
tooltip={cm.name}
|
||||
color={cm.danger ? 'red' : 'green'}
|
||||
onClick={() => act('alarm', { 'alarm': cm.ref })}
|
||||
/>
|
||||
))}
|
||||
</NanoMap>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Tabs>
|
||||
<Tabs.Tab key="AlarmView" selected={0 === tabIndex} onClick={() => setTabIndex(0)}>
|
||||
<Icon name="table" /> Alarm View
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab key="MapView" selected={1 === tabIndex} onClick={() => setTabIndex(1)}>
|
||||
<Icon name="map-marked-alt" /> Map View
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
<Box m={2}>{body}</Box>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
69
tgui/packages/tgui_ch/interfaces/AtmosFilter.js
Normal file
69
tgui/packages/tgui_ch/interfaces/AtmosFilter.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, NumberInput, Section, AnimatedNumber, Box } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AtmosFilter = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const filterTypes = data.filter_types || [];
|
||||
return (
|
||||
<Window width={390} height={187} resizable>
|
||||
<Window.Content>
|
||||
<Section>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Power">
|
||||
<Button
|
||||
icon={data.on ? 'power-off' : 'times'}
|
||||
content={data.on ? 'On' : 'Off'}
|
||||
selected={data.on}
|
||||
onClick={() => act('power')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Transfer Rate">
|
||||
<Box inline mr={1}>
|
||||
<AnimatedNumber value={data.last_flow_rate} format={(val) => val + ' L/s'} />
|
||||
</Box>
|
||||
<NumberInput
|
||||
animated
|
||||
value={parseFloat(data.rate)}
|
||||
width="63px"
|
||||
unit="L/s"
|
||||
minValue={0}
|
||||
maxValue={200}
|
||||
onDrag={(e, value) =>
|
||||
act('rate', {
|
||||
rate: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
ml={1}
|
||||
icon="plus"
|
||||
content="Max"
|
||||
disabled={data.rate === data.max_rate}
|
||||
onClick={() =>
|
||||
act('rate', {
|
||||
rate: 'max',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Filter">
|
||||
{filterTypes.map((filter) => (
|
||||
<Button
|
||||
key={filter.name}
|
||||
selected={filter.selected}
|
||||
content={filter.name}
|
||||
onClick={() =>
|
||||
act('filter', {
|
||||
filterset: filter.f_type,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
88
tgui/packages/tgui_ch/interfaces/AtmosMixer.js
Normal file
88
tgui/packages/tgui_ch/interfaces/AtmosMixer.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, LabeledList, NumberInput, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const AtmosMixer = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
return (
|
||||
<Window width={370} height={195} resizable>
|
||||
<Window.Content>
|
||||
<Section>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Power">
|
||||
<Button
|
||||
icon={data.on ? 'power-off' : 'times'}
|
||||
content={data.on ? 'On' : 'Off'}
|
||||
selected={data.on}
|
||||
onClick={() => act('power')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Output Pressure">
|
||||
<NumberInput
|
||||
animated
|
||||
value={parseFloat(data.set_pressure)}
|
||||
unit="kPa"
|
||||
width="75px"
|
||||
minValue={0}
|
||||
maxValue={data.max_pressure}
|
||||
step={10}
|
||||
onChange={(e, value) =>
|
||||
act('pressure', {
|
||||
pressure: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
ml={1}
|
||||
icon="plus"
|
||||
content="Max"
|
||||
disabled={data.set_pressure === data.max_pressure}
|
||||
onClick={() =>
|
||||
act('pressure', {
|
||||
pressure: 'max',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Divider size={1} />
|
||||
<LabeledList.Item color="label">
|
||||
<u>Concentrations</u>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label={'Node 1 (' + data.node1_dir + ')'}>
|
||||
<NumberInput
|
||||
animated
|
||||
value={data.node1_concentration}
|
||||
unit="%"
|
||||
width="60px"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
stepPixelSize={2}
|
||||
onDrag={(e, value) =>
|
||||
act('node1', {
|
||||
concentration: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label={'Node 2 (' + data.node2_dir + ')'}>
|
||||
<NumberInput
|
||||
animated
|
||||
value={data.node2_concentration}
|
||||
unit="%"
|
||||
width="60px"
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
stepPixelSize={2}
|
||||
onDrag={(e, value) =>
|
||||
act('node2', {
|
||||
concentration: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
103
tgui/packages/tgui_ch/interfaces/Autolathe.js
Normal file
103
tgui/packages/tgui_ch/interfaces/Autolathe.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { flow } from 'common/fp';
|
||||
import { filter, sortBy } from 'common/collections';
|
||||
import { useBackend, useSharedState } from '../backend';
|
||||
import { Box, Button, Flex, Input, Section, Dropdown } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { Materials } from './ExosuitFabricator';
|
||||
import { createSearch, toTitleCase } from 'common/string';
|
||||
|
||||
const canBeMade = (recipe, materials, mult = 1) => {
|
||||
if (recipe.requirements === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let recipeRequiredMaterials = Object.keys(recipe.requirements);
|
||||
|
||||
for (let mat_id of recipeRequiredMaterials) {
|
||||
let material = materials.find((val) => val.name === mat_id);
|
||||
if (!material) {
|
||||
continue; // yes, if we cannot find the material, we just ignore it :V
|
||||
}
|
||||
if (material.amount < recipe.requirements[mat_id] * mult) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const Autolathe = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { recipes, busy, materials, categories } = data;
|
||||
|
||||
const [category, setCategory] = useSharedState(context, 'category', 0);
|
||||
|
||||
const [searchText, setSearchText] = useSharedState(context, 'search_text', '');
|
||||
|
||||
const testSearch = createSearch(searchText, (recipe) => recipe.name);
|
||||
|
||||
const recipesToShow = flow([
|
||||
filter((recipe) => recipe.category === categories[category]),
|
||||
searchText && filter(testSearch),
|
||||
sortBy((recipe) => recipe.name.toLowerCase()),
|
||||
])(recipes);
|
||||
|
||||
return (
|
||||
<Window width={550} height={700}>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Materials">
|
||||
<Materials disableEject />
|
||||
</Section>
|
||||
<Section
|
||||
title="Recipes"
|
||||
buttons={
|
||||
<Dropdown
|
||||
width="190px"
|
||||
options={categories}
|
||||
selected={categories[category]}
|
||||
onSelected={(val) => setCategory(categories.indexOf(val))}
|
||||
/>
|
||||
}>
|
||||
<Input fluid placeholder="Search for..." onInput={(e, v) => setSearchText(v)} mb={1} />
|
||||
{recipesToShow.map((recipe) => (
|
||||
<Flex justify="space-between" align="center" key={recipe.ref}>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
color={(recipe.hidden && 'red') || null}
|
||||
icon="hammer"
|
||||
iconSpin={busy === recipe.name}
|
||||
disabled={!canBeMade(recipe, materials, 1)}
|
||||
onClick={() => act('make', { make: recipe.ref })}>
|
||||
{toTitleCase(recipe.name)}
|
||||
</Button>
|
||||
{(!recipe.is_stack && (
|
||||
<Box as="span">
|
||||
<Button
|
||||
color={(recipe.hidden && 'red') || null}
|
||||
disabled={!canBeMade(recipe, materials, 5)}
|
||||
onClick={() => act('make', { make: recipe.ref, multiplier: 5 })}>
|
||||
x5
|
||||
</Button>
|
||||
<Button
|
||||
color={(recipe.hidden && 'red') || null}
|
||||
disabled={!canBeMade(recipe, materials, 10)}
|
||||
onClick={() => act('make', { make: recipe.ref, multiplier: 10 })}>
|
||||
x10
|
||||
</Button>
|
||||
</Box>
|
||||
)) ||
|
||||
null}
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
{(recipe.requirements &&
|
||||
Object.keys(recipe.requirements)
|
||||
.map((mat) => toTitleCase(mat) + ': ' + recipe.requirements[mat])
|
||||
.join(', ')) || <Box>No resources required.</Box>}
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
))}
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
61
tgui/packages/tgui_ch/interfaces/Batteryrack.js
Normal file
61
tgui/packages/tgui_ch/interfaces/Batteryrack.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { AnimatedNumber, Box, Button, LabeledList, ProgressBar, Section, Table } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const Batteryrack = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { mode, transfer_max, output_load, input_load, equalise, blink_tick, cells_max, cells_cur, cells_list } = data;
|
||||
|
||||
return (
|
||||
<Window width={500} height={430} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Controls">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Current Mode">
|
||||
{(mode === 1 && <Box color="good">OUTPUT ONLY</Box>) ||
|
||||
(mode === 2 && <Box color="good">INPUT ONLY</Box>) ||
|
||||
(mode === 3 && <Box color="good">INPUT AND OUTPUT</Box>) || <Box color="bad">OFFLINE</Box>}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Input Status">
|
||||
<AnimatedNumber value={input_load} /> / {transfer_max} W
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Output Status">
|
||||
<AnimatedNumber value={output_load} /> / {transfer_max} W
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Control Panel">
|
||||
<Button content="OFF" selected={mode === 0} onClick={() => act('disable')} />
|
||||
<Button content="OUT" selected={mode === 1} onClick={() => act('enable', { enable: 1 })} />
|
||||
<Button content="IN" selected={mode === 2} onClick={() => act('enable', { enable: 2 })} />
|
||||
<Button content="IN/OUT" selected={mode === 3} onClick={() => act('enable', { enable: 3 })} />
|
||||
{(equalise && (
|
||||
<Button content="EQ" color={blink_tick ? 'red' : 'yellow'} onClick={() => act('equaliseoff')} />
|
||||
)) || <Button content="EQ" onClick={() => act('equaliseon')} />}
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<Section title="Cells">
|
||||
<Table>
|
||||
{cells_list.map((cell) => (
|
||||
<Table.Row key={cell.slot}>
|
||||
<Table.Cell collapsing>Cell {cell.slot}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ProgressBar
|
||||
value={cell.used ? cell.percentage : 100}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
color={cell.used ? 'good' : 'bad'}>
|
||||
{cell.used ? cell.percentage + '%' : 'N/C'}
|
||||
</ProgressBar>
|
||||
</Table.Cell>
|
||||
<Table.Cell collapsing>
|
||||
<Button icon="eject" disabled={!cell.used} onClick={() => act('ejectcell', { ejectcell: cell.id })} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
50
tgui/packages/tgui_ch/interfaces/BeaconLocator.js
Normal file
50
tgui/packages/tgui_ch/interfaces/BeaconLocator.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { toFixed, round } from 'common/math';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Icon, LabeledList, NumberInput, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const BeaconLocator = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { scan_ticks, degrees, rawfreq, minFrequency, maxFrequency } = data;
|
||||
|
||||
return (
|
||||
<Window width={300} height={220}>
|
||||
<Window.Content>
|
||||
<Section title="Beacon Locator">
|
||||
{(scan_ticks && <Box color="label">Scanning...</Box>) || null}
|
||||
{(degrees && (
|
||||
<Box textAlign="center">
|
||||
<Box textAlign="center">
|
||||
<Icon size={4} name="arrow-up" rotation={degrees} />
|
||||
</Box>
|
||||
Locked on. Follow the arrow.
|
||||
</Box>
|
||||
)) || <Box color="average">No lock.</Box>}
|
||||
<Button mt={1} mb={1} fluid icon="broadcast-tower" onClick={() => act('reset_tracking')}>
|
||||
Reset tracker
|
||||
</Button>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Frequency">
|
||||
<NumberInput
|
||||
animated
|
||||
unit="kHz"
|
||||
step={0.2}
|
||||
stepPixelSize={10}
|
||||
minValue={minFrequency / 10}
|
||||
maxValue={maxFrequency / 10}
|
||||
value={rawfreq / 10}
|
||||
format={(value) => toFixed(value, 1)}
|
||||
onDrag={(e, value) =>
|
||||
act('setFrequency', {
|
||||
freq: round(value * 10),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
162
tgui/packages/tgui_ch/interfaces/Biogenerator.js
Normal file
162
tgui/packages/tgui_ch/interfaces/Biogenerator.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createSearch } from 'common/string';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Collapsible, Dropdown, Flex, Input, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { refocusLayout } from '../layouts';
|
||||
|
||||
const sortTypes = {
|
||||
'Alphabetical': (a, b) => a - b,
|
||||
'By availability': (a, b) => -(a.affordable - b.affordable),
|
||||
'By price': (a, b) => a.price - b.price,
|
||||
};
|
||||
|
||||
export const Biogenerator = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
return (
|
||||
<Window width={400} height={450} resizable>
|
||||
<Window.Content className="Layout__content--flexColumn" scrollable>
|
||||
{(data.processing && <Section title="Processing">The biogenerator is processing reagents!</Section>) || (
|
||||
<Fragment>
|
||||
<Section>
|
||||
{data.points} points available.
|
||||
<Button ml={1} icon="blender" onClick={() => act('activate')}>
|
||||
Activate
|
||||
</Button>
|
||||
<Button ml={1} icon="eject" disabled={!data.beaker} onClick={() => act('detach')}>
|
||||
Eject Beaker
|
||||
</Button>
|
||||
</Section>
|
||||
<BiogeneratorSearch />
|
||||
<BiogeneratorItems />
|
||||
</Fragment>
|
||||
)}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const BiogeneratorItems = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { points, items } = data;
|
||||
// Search thingies
|
||||
const [searchText, _setSearchText] = useLocalState(context, 'search', '');
|
||||
const [sortOrder, _setSortOrder] = useLocalState(context, 'sort', 'Alphabetical');
|
||||
const [descending, _setDescending] = useLocalState(context, 'descending', false);
|
||||
const searcher = createSearch(searchText, (item) => {
|
||||
return item[0];
|
||||
});
|
||||
|
||||
let has_contents = false;
|
||||
let contents = Object.entries(items).map((kv, _i) => {
|
||||
let items_in_cat = Object.entries(kv[1])
|
||||
.filter(searcher)
|
||||
.map((kv2) => {
|
||||
kv2[1].affordable = points >= kv2[1].price / data.build_eff;
|
||||
return kv2[1];
|
||||
})
|
||||
.sort(sortTypes[sortOrder]);
|
||||
if (items_in_cat.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (descending) {
|
||||
items_in_cat = items_in_cat.reverse();
|
||||
}
|
||||
|
||||
has_contents = true;
|
||||
return <BiogeneratorItemsCategory key={kv[0]} title={kv[0]} items={items_in_cat} />;
|
||||
});
|
||||
return (
|
||||
<Flex.Item grow="1" overflow="auto">
|
||||
<Section onClick={(e) => refocusLayout()}>
|
||||
{has_contents ? contents : <Box color="label">No items matching your criteria was found!</Box>}
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const BiogeneratorSearch = (props, context) => {
|
||||
const [_searchText, setSearchText] = useLocalState(context, 'search', '');
|
||||
const [_sortOrder, setSortOrder] = useLocalState(context, 'sort', '');
|
||||
const [descending, setDescending] = useLocalState(context, 'descending', false);
|
||||
return (
|
||||
<Box mb="0.5rem">
|
||||
<Flex width="100%">
|
||||
<Flex.Item grow="1" mr="0.5rem">
|
||||
<Input placeholder="Search by item name.." width="100%" onInput={(_e, value) => setSearchText(value)} />
|
||||
</Flex.Item>
|
||||
<Flex.Item basis="30%">
|
||||
<Dropdown
|
||||
selected="Alphabetical"
|
||||
options={Object.keys(sortTypes)}
|
||||
width="100%"
|
||||
lineHeight="19px"
|
||||
onSelected={(v) => setSortOrder(v)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
icon={descending ? 'arrow-down' : 'arrow-up'}
|
||||
height="19px"
|
||||
tooltip={descending ? 'Descending order' : 'Ascending order'}
|
||||
tooltipPosition="bottom-end"
|
||||
ml="0.5rem"
|
||||
onClick={() => setDescending(!descending)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const canBuyItem = (item, data) => {
|
||||
if (!item.affordable) {
|
||||
return false;
|
||||
}
|
||||
if (item.reagent && !data.beaker) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const BiogeneratorItemsCategory = (properties, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { title, items, ...rest } = properties;
|
||||
return (
|
||||
<Collapsible open title={title} {...rest}>
|
||||
{items.map((item) => (
|
||||
<Box key={item.name}>
|
||||
<Box
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
lineHeight="20px"
|
||||
style={{
|
||||
float: 'left',
|
||||
}}>
|
||||
{item.name}
|
||||
</Box>
|
||||
<Button
|
||||
disabled={!canBuyItem(item, data)}
|
||||
content={(item.price / data.build_eff).toLocaleString('en-US')}
|
||||
width="15%"
|
||||
textAlign="center"
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
onClick={() =>
|
||||
act('purchase', {
|
||||
cat: title,
|
||||
name: item.name,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
clear: 'both',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
287
tgui/packages/tgui_ch/interfaces/BodyDesigner.js
Normal file
287
tgui/packages/tgui_ch/interfaces/BodyDesigner.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { capitalize } from 'common/string';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, ByondUi, Button, Flex, LabeledList, Section, ColorBox } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const BodyDesigner = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { menu, disk, diskStored, activeBodyRecord } = data;
|
||||
|
||||
let body = MenuToTemplate[menu];
|
||||
|
||||
return (
|
||||
<Window width={400} height={650}>
|
||||
<Window.Content>
|
||||
{disk ? (
|
||||
<Box>
|
||||
<Button icon="save" content="Save To Disk" onClick={() => act('savetodisk')} disabled={!activeBodyRecord} />
|
||||
<Button icon="save" content="Load From Disk" onClick={() => act('loadfromdisk')} disabled={!diskStored} />
|
||||
<Button icon="eject" content="Eject" onClick={() => act('ejectdisk')} />
|
||||
</Box>
|
||||
) : null}
|
||||
{body}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyDesignerMain = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
return (
|
||||
<Section title="Database Functions">
|
||||
<Button icon="eye" content="View Individual Body Records" onClick={() => act('menu', { menu: 'Body Records' })} />
|
||||
<Button icon="eye" content="View Stock Body Records" onClick={() => act('menu', { menu: 'Stock Records' })} />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyDesignerBodyRecords = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { bodyrecords } = data;
|
||||
return (
|
||||
<Section
|
||||
title="Body Records"
|
||||
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
|
||||
{bodyrecords.map((record) => (
|
||||
<Button
|
||||
icon="eye"
|
||||
key={record.name}
|
||||
content={record.name}
|
||||
onClick={() => act('view_brec', { view_brec: record.recref })}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyDesignerStockRecords = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { stock_bodyrecords } = data;
|
||||
return (
|
||||
<Section
|
||||
title="Stock Records"
|
||||
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
|
||||
{stock_bodyrecords.map((record) => (
|
||||
<Button
|
||||
icon="eye"
|
||||
key={record}
|
||||
content={record}
|
||||
onClick={() => act('view_stock_brec', { view_stock_brec: record })}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyDesignerSpecificRecord = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { activeBodyRecord, mapRef } = data;
|
||||
return activeBodyRecord ? (
|
||||
<Flex direction="column">
|
||||
<Flex.Item basis="165px">
|
||||
<Section
|
||||
title="Specific Record"
|
||||
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Name">{activeBodyRecord.real_name}</LabeledList.Item>
|
||||
<LabeledList.Item label="Species">{activeBodyRecord.speciesname}</LabeledList.Item>
|
||||
<LabeledList.Item label="Bio. Sex">
|
||||
<Button
|
||||
icon="pen"
|
||||
content={capitalize(activeBodyRecord.gender)}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: 'bio_gender',
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Synthetic">{activeBodyRecord.synthetic}</LabeledList.Item>
|
||||
<LabeledList.Item label="Mind Compat">
|
||||
{activeBodyRecord.locked}
|
||||
<Button
|
||||
ml={1}
|
||||
icon="eye"
|
||||
content="View OOC Notes"
|
||||
disabled={!activeBodyRecord.booc}
|
||||
onClick={() => act('boocnotes')}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
<Flex.Item basis="130px">
|
||||
<ByondUi
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '128px',
|
||||
}}
|
||||
params={{
|
||||
id: mapRef,
|
||||
type: 'map',
|
||||
}}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item basis="300px">
|
||||
<Section title="Customize" height="300px" style={{ overflow: 'auto' }}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Scale">
|
||||
<Button
|
||||
icon="pen"
|
||||
content={activeBodyRecord.scale}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: 'size_multiplier',
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
{Object.keys(activeBodyRecord.styles).map((key) => {
|
||||
const style = activeBodyRecord.styles[key];
|
||||
return (
|
||||
<LabeledList.Item key={key} label={key}>
|
||||
{style.styleHref ? (
|
||||
<Button
|
||||
icon="pen"
|
||||
content={style.style}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: style.styleHref,
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{style.colorHref ? (
|
||||
<Box>
|
||||
<Button
|
||||
icon="pen"
|
||||
content={style.color}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: style.colorHref,
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ColorBox
|
||||
verticalAlign="top"
|
||||
width="32px"
|
||||
height="20px"
|
||||
color={style.color}
|
||||
style={{
|
||||
border: '1px solid #fff',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{style.colorHref2 ? (
|
||||
<Box>
|
||||
<Button
|
||||
icon="pen"
|
||||
content={style.color2}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: style.colorHref2,
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ColorBox
|
||||
verticalAlign="top"
|
||||
width="32px"
|
||||
height="20px"
|
||||
color={style.color2}
|
||||
style={{
|
||||
border: '1px solid #fff',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</LabeledList.Item>
|
||||
);
|
||||
})}
|
||||
<LabeledList.Item label="Body Markings">
|
||||
<Button
|
||||
icon="plus"
|
||||
content="Add Marking"
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: 'marking_style',
|
||||
target_value: 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Flex wrap="wrap" justify="center" align="center">
|
||||
{Object.keys(activeBodyRecord.markings).map((key) => {
|
||||
const marking = activeBodyRecord.markings[key];
|
||||
return (
|
||||
<Flex.Item basis="100%" key={key}>
|
||||
<Flex>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
mr={0.2}
|
||||
fluid
|
||||
icon="times"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: 'marking_remove',
|
||||
target_value: key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
backgroundColor={marking}
|
||||
content={key}
|
||||
onClick={() =>
|
||||
act('href_conversion', {
|
||||
target_href: 'marking_color',
|
||||
target_value: key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Flex.Item>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box color="bad">ERROR: Record Not Found!</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyDesignerOOCNotes = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { activeBodyRecord } = data;
|
||||
return (
|
||||
<Section
|
||||
title="Body OOC Notes (This is OOC!)"
|
||||
height="100%"
|
||||
scrollable
|
||||
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Specific Record' })} />}
|
||||
style={{ 'word-break': 'break-all' }}>
|
||||
{(activeBodyRecord && activeBodyRecord.booc) || 'ERROR: Body record not found!'}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuToTemplate = {
|
||||
'Main': <BodyDesignerMain />,
|
||||
'Body Records': <BodyDesignerBodyRecords />,
|
||||
'Stock Records': <BodyDesignerStockRecords />,
|
||||
'Specific Record': <BodyDesignerSpecificRecord />,
|
||||
'OOC Notes': <BodyDesignerOOCNotes />,
|
||||
};
|
||||
446
tgui/packages/tgui_ch/interfaces/BodyScanner.js
Normal file
446
tgui/packages/tgui_ch/interfaces/BodyScanner.js
Normal file
@@ -0,0 +1,446 @@
|
||||
import { round } from 'common/math';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { AnimatedNumber, Box, Button, Flex, Icon, LabeledList, ProgressBar, Section, Table, Tooltip } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const stats = [
|
||||
['good', 'Alive'],
|
||||
['average', 'Unconscious'],
|
||||
['bad', 'DEAD'],
|
||||
];
|
||||
|
||||
const abnormalities = [
|
||||
[
|
||||
'hasBorer',
|
||||
'bad',
|
||||
(occupant) => 'Large growth detected in frontal lobe,' + ' possibly cancerous. Surgical removal is recommended.',
|
||||
],
|
||||
['hasVirus', 'bad', (occupant) => 'Viral pathogen detected in blood stream.'],
|
||||
['blind', 'average', (occupant) => 'Cataracts detected.'],
|
||||
['colourblind', 'average', (occupant) => 'Photoreceptor abnormalities detected.'],
|
||||
['nearsighted', 'average', (occupant) => 'Retinal misalignment detected.'],
|
||||
/* VOREStation Add */
|
||||
[
|
||||
'humanPrey',
|
||||
'average',
|
||||
(occupant) => {
|
||||
return 'Foreign Humanoid(s) detected: ' + occupant.humanPrey;
|
||||
},
|
||||
],
|
||||
[
|
||||
'livingPrey',
|
||||
'average',
|
||||
(occupant) => {
|
||||
return 'Foreign Creature(s) detected: ' + occupant.livingPrey;
|
||||
},
|
||||
],
|
||||
[
|
||||
'objectPrey',
|
||||
'average',
|
||||
(occupant) => {
|
||||
return 'Foreign Object(s) detected: ' + occupant.objectPrey;
|
||||
},
|
||||
],
|
||||
/* VOREStation Add End */
|
||||
];
|
||||
|
||||
const damages = [
|
||||
['Respiratory', 'oxyLoss'],
|
||||
['Brain', 'brainLoss'],
|
||||
['Toxin', 'toxLoss'],
|
||||
['Radiation', 'radLoss'],
|
||||
['Brute', 'bruteLoss'],
|
||||
['Genetic', 'cloneLoss'],
|
||||
['Burn', 'fireLoss'],
|
||||
['Paralysis', 'paralysis'],
|
||||
];
|
||||
|
||||
const damageRange = {
|
||||
average: [0.25, 0.5],
|
||||
bad: [0.5, Infinity],
|
||||
};
|
||||
|
||||
const mapTwoByTwo = (a, c) => {
|
||||
let result = [];
|
||||
for (let i = 0; i < a.length; i += 2) {
|
||||
result.push(c(a[i], a[i + 1], i));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const reduceOrganStatus = (A) => {
|
||||
return A.length > 0
|
||||
? A.reduce((a, s) =>
|
||||
a === null ? (
|
||||
s
|
||||
) : (
|
||||
<Fragment>
|
||||
{a}
|
||||
{!!s && <Box>{s}</Box>}
|
||||
</Fragment>
|
||||
)
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const germStatus = (i) => {
|
||||
if (i > 100) {
|
||||
if (i < 300) {
|
||||
return 'mild infection';
|
||||
}
|
||||
if (i < 400) {
|
||||
return 'mild infection+';
|
||||
}
|
||||
if (i < 500) {
|
||||
return 'mild infection++';
|
||||
}
|
||||
if (i < 700) {
|
||||
return 'acute infection';
|
||||
}
|
||||
if (i < 800) {
|
||||
return 'acute infection+';
|
||||
}
|
||||
if (i < 900) {
|
||||
return 'acute infection++';
|
||||
}
|
||||
if (i >= 900) {
|
||||
return 'septic';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const BodyScanner = (props, context) => {
|
||||
const { data } = useBackend(context);
|
||||
const { occupied, occupant = {} } = data;
|
||||
const body = occupied ? <BodyScannerMain occupant={occupant} /> : <BodyScannerEmpty />;
|
||||
return (
|
||||
<Window width={690} height={600} resizable>
|
||||
<Window.Content scrollable className="Layout__content--flexColumn">
|
||||
{body}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMain = (props) => {
|
||||
const { occupant } = props;
|
||||
return (
|
||||
<Box>
|
||||
<BodyScannerMainOccupant occupant={occupant} />
|
||||
<BodyScannerMainReagents occupant={occupant} />
|
||||
<BodyScannerMainAbnormalities occupant={occupant} />
|
||||
<BodyScannerMainDamage occupant={occupant} />
|
||||
<BodyScannerMainOrgansExternal organs={occupant.extOrgan} />
|
||||
<BodyScannerMainOrgansInternal organs={occupant.intOrgan} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainOccupant = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { occupant } = data;
|
||||
return (
|
||||
<Section
|
||||
title="Occupant"
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Button icon="user-slash" onClick={() => act('ejectify')}>
|
||||
Eject
|
||||
</Button>
|
||||
<Button icon="print" onClick={() => act('print_p')}>
|
||||
Print Report
|
||||
</Button>
|
||||
</Fragment>
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Name">{occupant.name}</LabeledList.Item>
|
||||
<LabeledList.Item label="Health">
|
||||
<ProgressBar
|
||||
min="0"
|
||||
max={occupant.maxHealth}
|
||||
value={occupant.health / occupant.maxHealth}
|
||||
ranges={{
|
||||
good: [0.5, Infinity],
|
||||
average: [0, 0.5],
|
||||
bad: [-Infinity, 0],
|
||||
}}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Status" color={stats[occupant.stat][0]}>
|
||||
{stats[occupant.stat][1]}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Temperature">
|
||||
<AnimatedNumber value={round(occupant.bodyTempC, 0)} />
|
||||
°C,
|
||||
<AnimatedNumber value={round(occupant.bodyTempF, 0)} />
|
||||
°F
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Blood Volume">
|
||||
<AnimatedNumber value={round(occupant.blood.volume, 0)} /> units (
|
||||
<AnimatedNumber value={round(occupant.blood.percent, 0)} />
|
||||
%)
|
||||
</LabeledList.Item>
|
||||
{/* VOREStation Add */}
|
||||
<LabeledList.Item label="Weight">
|
||||
{round(data.occupant.weight) + 'lbs, ' + round(data.occupant.weight / 2.20463) + 'kgs'}
|
||||
</LabeledList.Item>
|
||||
{/* VOREStation Add End */}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainReagents = (props) => {
|
||||
const { occupant } = props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Section title="Blood Reagents">
|
||||
{occupant.reagents ? (
|
||||
<Table>
|
||||
<Table.Row header>
|
||||
<Table.Cell>Reagent</Table.Cell>
|
||||
<Table.Cell textAlign="right">Amount</Table.Cell>
|
||||
</Table.Row>
|
||||
{occupant.reagents.map((reagent) => (
|
||||
<Table.Row key={reagent.name}>
|
||||
<Table.Cell>{reagent.name}</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
{reagent.amount} Units {reagent.overdose ? <Box color="bad">OVERDOSING</Box> : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<Box color="good">No Blood Reagents Detected</Box>
|
||||
)}
|
||||
</Section>
|
||||
<Section title="Stomach Reagents">
|
||||
{occupant.ingested ? (
|
||||
<Table>
|
||||
<Table.Row header>
|
||||
<Table.Cell>Reagent</Table.Cell>
|
||||
<Table.Cell textAlign="right">Amount</Table.Cell>
|
||||
</Table.Row>
|
||||
{occupant.ingested.map((reagent) => (
|
||||
<Table.Row key={reagent.name}>
|
||||
<Table.Cell>{reagent.name}</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
{reagent.amount} Units {reagent.overdose ? <Box color="bad">OVERDOSING</Box> : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<Box color="good">No Stomach Reagents Detected</Box>
|
||||
)}
|
||||
</Section>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainAbnormalities = (props) => {
|
||||
const { occupant } = props;
|
||||
|
||||
let hasAbnormalities =
|
||||
occupant.hasBorer || occupant.blind || occupant.colourblind || occupant.nearsighted || occupant.hasVirus;
|
||||
|
||||
/* VOREStation Add */
|
||||
hasAbnormalities = hasAbnormalities || occupant.humanPrey || occupant.livingPrey || occupant.objectPrey;
|
||||
/* VOREStation Add End */
|
||||
|
||||
if (!hasAbnormalities) {
|
||||
return (
|
||||
<Section title="Abnormalities">
|
||||
<Box color="label">No abnormalities found.</Box>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Abnormalities">
|
||||
{abnormalities.map((a, i) => {
|
||||
if (occupant[a[0]]) {
|
||||
return (
|
||||
<Box color={a[1]} bold={a[1] === 'bad'}>
|
||||
{a[2](occupant)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainDamage = (props) => {
|
||||
const { occupant } = props;
|
||||
return (
|
||||
<Section title="Damage">
|
||||
<Table>
|
||||
{mapTwoByTwo(damages, (d1, d2, i) => (
|
||||
<Fragment>
|
||||
<Table.Row color="label">
|
||||
<Table.Cell>{d1[0]}:</Table.Cell>
|
||||
<Table.Cell>{!!d2 && d2[0] + ':'}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<BodyScannerMainDamageBar value={occupant[d1[1]]} marginBottom={i < damages.length - 2} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>{!!d2 && <BodyScannerMainDamageBar value={occupant[d2[1]]} />}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Fragment>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainDamageBar = (props) => {
|
||||
return (
|
||||
<ProgressBar
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.value / 100}
|
||||
mt="0.5rem"
|
||||
mb={!!props.marginBottom && '0.5rem'}
|
||||
ranges={damageRange}>
|
||||
{round(props.value, 0)}
|
||||
</ProgressBar>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainOrgansExternal = (props) => {
|
||||
if (props.organs.length === 0) {
|
||||
return (
|
||||
<Section title="External Organs">
|
||||
<Box color="label">N/A</Box>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="External Organs">
|
||||
<Table>
|
||||
<Table.Row header>
|
||||
<Table.Cell>Name</Table.Cell>
|
||||
<Table.Cell textAlign="center">Damage</Table.Cell>
|
||||
<Table.Cell textAlign="right">Injuries</Table.Cell>
|
||||
</Table.Row>
|
||||
{props.organs.map((o, i) => (
|
||||
<Table.Row key={i} textTransform="capitalize">
|
||||
<Table.Cell width="33%">{o.name}</Table.Cell>
|
||||
<Table.Cell textAlign="center" q>
|
||||
<ProgressBar
|
||||
min="0"
|
||||
max={o.maxHealth}
|
||||
mt={i > 0 && '0.5rem'}
|
||||
value={o.totalLoss / 100}
|
||||
ranges={damageRange}>
|
||||
<Box float="left" inline>
|
||||
{!!o.bruteLoss && (
|
||||
<Box inline position="relative">
|
||||
<Icon name="bone" />
|
||||
{round(o.bruteLoss, 0)}
|
||||
<Tooltip position="top" content="Brute damage" />
|
||||
</Box>
|
||||
)}
|
||||
{!!o.fireLoss && (
|
||||
<Box inline position="relative">
|
||||
<Icon name="fire" />
|
||||
{round(o.fireLoss, 0)}
|
||||
<Tooltip position="top" content="Burn damage" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box inline>{round(o.totalLoss, 0)}</Box>
|
||||
</ProgressBar>
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right" width="33%">
|
||||
<Box color="average" inline>
|
||||
{reduceOrganStatus([
|
||||
o.internalBleeding && 'Internal bleeding',
|
||||
!!o.status.bleeding && 'External bleeding',
|
||||
o.lungRuptured && 'Ruptured lung',
|
||||
o.destroyed && 'Destroyed',
|
||||
!!o.status.broken && o.status.broken,
|
||||
germStatus(o.germ_level),
|
||||
!!o.open && 'Open incision',
|
||||
])}
|
||||
</Box>
|
||||
<Box inline>
|
||||
{reduceOrganStatus([
|
||||
!!o.status.splinted && 'Splinted',
|
||||
!!o.status.robotic && 'Robotic',
|
||||
!!o.status.dead && <Box color="bad">DEAD</Box>,
|
||||
])}
|
||||
{reduceOrganStatus(o.implants.map((s) => (s.known ? s.name : 'Unknown object')))}
|
||||
</Box>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerMainOrgansInternal = (props) => {
|
||||
if (props.organs.length === 0) {
|
||||
return (
|
||||
<Section title="Internal Organs">
|
||||
<Box color="label">N/A</Box>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Internal Organs">
|
||||
<Table>
|
||||
<Table.Row header>
|
||||
<Table.Cell>Name</Table.Cell>
|
||||
<Table.Cell textAlign="center">Damage</Table.Cell>
|
||||
<Table.Cell textAlign="right">Injuries</Table.Cell>
|
||||
</Table.Row>
|
||||
{props.organs.map((o, i) => (
|
||||
<Table.Row key={i} textTransform="capitalize">
|
||||
<Table.Cell width="33%">{o.name}</Table.Cell>
|
||||
<Table.Cell textAlign="center">
|
||||
<ProgressBar min="0" max={o.maxHealth} value={o.damage / 100} mt={i > 0 && '0.5rem'} ranges={damageRange}>
|
||||
{round(o.damage, 0)}
|
||||
</ProgressBar>
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right" width="33%">
|
||||
<Box color="average" inline>
|
||||
{reduceOrganStatus([germStatus(o.germ_level), !!o.inflamed && 'Appendicitis detected.'])}
|
||||
</Box>
|
||||
<Box inline>
|
||||
{reduceOrganStatus([
|
||||
o.robotic === 1 && 'Robotic',
|
||||
o.robotic === 2 && 'Assisted',
|
||||
!!o.dead && <Box color="bad">DEAD</Box>,
|
||||
])}
|
||||
</Box>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyScannerEmpty = () => {
|
||||
return (
|
||||
<Section textAlign="center" flexGrow="1">
|
||||
<Flex height="100%">
|
||||
<Flex.Item grow="1" align="center" color="label">
|
||||
<Icon name="user-slash" mb="0.5rem" size="5" />
|
||||
<br />
|
||||
No occupant detected.
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
159
tgui/packages/tgui_ch/interfaces/BombTester.js
Normal file
159
tgui/packages/tgui_ch/interfaces/BombTester.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Component } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Icon, LabeledList, Section, Slider } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const BombTester = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { simulating, mode, tank1, tank1ref, tank2, tank2ref, canister, sim_canister_output } = data;
|
||||
|
||||
return (
|
||||
<Window width={450} height={400}>
|
||||
<Window.Content>
|
||||
{(simulating && <BombTesterSimulation />) || (
|
||||
<Section title="Virtual Explosive Simulator v2.01">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Mode">
|
||||
<Button onClick={() => act('set_mode', { mode: 1 })} selected={mode === 1}>
|
||||
Single Tank
|
||||
</Button>
|
||||
<Button onClick={() => act('set_mode', { mode: 2 })} selected={mode === 2}>
|
||||
Transfer Valve
|
||||
</Button>
|
||||
<Button onClick={() => act('set_mode', { mode: 3 })} selected={mode === 3}>
|
||||
Canister
|
||||
</Button>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Primary Slot">
|
||||
{(tank1 && (
|
||||
<Button onClick={() => act('remove_tank', { ref: tank1ref })} icon="eject">
|
||||
{tank1}
|
||||
</Button>
|
||||
)) || (
|
||||
<Button onClick={() => act('add_tank', { slot: 1 })} icon="upload">
|
||||
Insert Tank
|
||||
</Button>
|
||||
)}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Secondary Slot">
|
||||
{(tank2 && (
|
||||
<Button onClick={() => act('remove_tank', { ref: tank2ref })} icon="eject">
|
||||
{tank2}
|
||||
</Button>
|
||||
)) || (
|
||||
<Button onClick={() => act('add_tank', { slot: 2 })} icon="upload">
|
||||
Insert Tank
|
||||
</Button>
|
||||
)}
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item
|
||||
label="Connected Canister"
|
||||
buttons={
|
||||
<Button onClick={() => act('canister_scan')} icon="search">
|
||||
Scan
|
||||
</Button>
|
||||
}>
|
||||
{(canister && <Box color="label">{canister}</Box>) || <Box color="bad">No tank connected.</Box>}
|
||||
</LabeledList.Item>
|
||||
{canister && (
|
||||
<LabeledList.Item label="Canister Release Pressure">
|
||||
<Slider
|
||||
minValue={0}
|
||||
value={sim_canister_output}
|
||||
maxValue={1013.25}
|
||||
onDrag={(e, val) => act('set_can_pressure', { pressure: val })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
)}
|
||||
</LabeledList>
|
||||
<Button mt={2} color="red" icon="bomb" fontSize={2} onClick={() => act('start_sim')} fluid>
|
||||
Begin Simulation
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
class BombTesterSimulation extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const BOUND_X = 340;
|
||||
const BOUND_Y = 205;
|
||||
const MOVEMENT_SPEED = 2;
|
||||
|
||||
let startRight = Math.random() > 0.5;
|
||||
let startBottom = Math.random() > 0.5;
|
||||
|
||||
this.state = {
|
||||
x: startRight ? BOUND_X : 0,
|
||||
y: startBottom ? BOUND_Y : 0,
|
||||
reverseX: false,
|
||||
reverseY: false,
|
||||
};
|
||||
|
||||
this.process = setInterval(() => {
|
||||
this.setState((prevState) => {
|
||||
const state = { ...prevState };
|
||||
if (state.reverseX) {
|
||||
if (state.x - MOVEMENT_SPEED < -5) {
|
||||
state.reverseX = false;
|
||||
state.x += MOVEMENT_SPEED;
|
||||
} else {
|
||||
state.x -= MOVEMENT_SPEED;
|
||||
}
|
||||
} else {
|
||||
if (state.x + MOVEMENT_SPEED > BOUND_X) {
|
||||
state.reverseX = true;
|
||||
state.x -= MOVEMENT_SPEED;
|
||||
} else {
|
||||
state.x += MOVEMENT_SPEED;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.reverseY) {
|
||||
if (state.y - MOVEMENT_SPEED < -20) {
|
||||
state.reverseY = false;
|
||||
state.y += MOVEMENT_SPEED;
|
||||
} else {
|
||||
state.y -= MOVEMENT_SPEED;
|
||||
}
|
||||
} else {
|
||||
if (state.y + MOVEMENT_SPEED > BOUND_Y) {
|
||||
state.reverseY = true;
|
||||
state.y -= MOVEMENT_SPEED;
|
||||
} else {
|
||||
state.y += MOVEMENT_SPEED;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}, 1);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.process);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { x, y } = this.state;
|
||||
|
||||
const newStyle = {
|
||||
position: 'relative',
|
||||
'left': x + 'px',
|
||||
'top': y + 'px',
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Simulation in progress!" fill>
|
||||
<Box position="absolute" style={{ overflow: 'hidden', width: '100%', height: '100%' }}>
|
||||
<Icon style={newStyle} name="bomb" size={10} color="red" />
|
||||
</Box>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
tgui/packages/tgui_ch/interfaces/BotanyEditor.js
Normal file
55
tgui/packages/tgui_ch/interfaces/BotanyEditor.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, LabeledList, Section, NoticeBox } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const BotanyEditor = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { activity, degradation, disk, sourceName, locus, loaded } = data;
|
||||
|
||||
if (activity) {
|
||||
return (
|
||||
<Window width={470} height={500} resizable>
|
||||
<Window.Content scrollable>
|
||||
<NoticeBox info>Scanning...</NoticeBox>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={470} height={500} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Buffered Genetic Data">
|
||||
{(disk && (
|
||||
<Box>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Source">{sourceName}</LabeledList.Item>
|
||||
<LabeledList.Item label="Gene Decay">{degradation}%</LabeledList.Item>
|
||||
<LabeledList.Item label="Locus">{locus}</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Button mt={1} icon="eject" onClick={() => act('eject_disk')}>
|
||||
Eject Loaded Disk
|
||||
</Button>
|
||||
</Box>
|
||||
)) || <NoticeBox warning>No disk loaded.</NoticeBox>}
|
||||
</Section>
|
||||
<Section title="Loaded Material">
|
||||
{(loaded && (
|
||||
<Box>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Target">{loaded}</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Button mt={1} icon="cog" onClick={() => act('apply_gene')}>
|
||||
Apply Gene Mods
|
||||
</Button>
|
||||
<Button mt={1} icon="eject" onClick={() => act('eject_packet')}>
|
||||
Eject Target
|
||||
</Button>
|
||||
</Box>
|
||||
)) || <NoticeBox warning>No target seed packet loaded.</NoticeBox>}
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
88
tgui/packages/tgui_ch/interfaces/BotanyIsolator.js
Normal file
88
tgui/packages/tgui_ch/interfaces/BotanyIsolator.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, LabeledList, Section, NoticeBox } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const BotanyIsolator = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { geneMasks, activity, degradation, disk, loaded, hasGenetics, sourceName } = data;
|
||||
|
||||
if (activity) {
|
||||
return (
|
||||
<Window width={470} height={500} resizable>
|
||||
<Window.Content scrollable>
|
||||
<NoticeBox info>Scanning...</NoticeBox>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Window width={470} height={500} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section title="Buffered Genetic Data">
|
||||
{(hasGenetics && (
|
||||
<Box>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Source">{sourceName}</LabeledList.Item>
|
||||
<LabeledList.Item label="Gene decay">{degradation}%</LabeledList.Item>
|
||||
{(disk &&
|
||||
geneMasks.length &&
|
||||
geneMasks.map((mask) => (
|
||||
<LabeledList.Item key={mask.mask} label={mask.mask}>
|
||||
<Button mb={-1} icon="download" onClick={() => act('get_gene', { get_gene: mask.tag })}>
|
||||
Extract
|
||||
</Button>
|
||||
</LabeledList.Item>
|
||||
))) ||
|
||||
null}
|
||||
</LabeledList>
|
||||
{(disk && (
|
||||
<Box mt={1}>
|
||||
<Button icon="eject" onClick={() => act('eject_disk')}>
|
||||
Eject Loaded Disk
|
||||
</Button>
|
||||
<Button icon="trash" onClick={() => act('clear_buffer')}>
|
||||
Clear Genetic Buffer
|
||||
</Button>
|
||||
</Box>
|
||||
)) || (
|
||||
<NoticeBox mt={1} warning>
|
||||
No disk inserted.
|
||||
</NoticeBox>
|
||||
)}
|
||||
</Box>
|
||||
)) || (
|
||||
<Box>
|
||||
<NoticeBox warning>No Data Buffered.</NoticeBox>
|
||||
{(disk && (
|
||||
<Button icon="eject" onClick={() => act('eject_disk')}>
|
||||
Eject Loaded Disk
|
||||
</Button>
|
||||
)) || (
|
||||
<NoticeBox mt={1} warning>
|
||||
No disk inserted.
|
||||
</NoticeBox>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Section>
|
||||
<Section title="Loaded Material">
|
||||
{(loaded && (
|
||||
<Box>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Packet Loaded">{loaded}</LabeledList.Item>
|
||||
</LabeledList>
|
||||
<Button mt={1} icon="cog" onClick={() => act('scan_genome')}>
|
||||
Process Genome
|
||||
</Button>
|
||||
<Button icon="eject" onClick={() => act('eject_packet')}>
|
||||
Eject Packet
|
||||
</Button>
|
||||
</Box>
|
||||
)) || <NoticeBox warning>No packet loaded.</NoticeBox>}
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
73
tgui/packages/tgui_ch/interfaces/BrigTimer.js
Normal file
73
tgui/packages/tgui_ch/interfaces/BrigTimer.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { round } from 'common/math';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Button, Section, NumberInput, Flex } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { formatTime } from '../format';
|
||||
|
||||
export const BrigTimer = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
return (
|
||||
<Window width={300} height={138} resizable>
|
||||
<Window.Content scrollable>
|
||||
<Section
|
||||
title="Cell Timer"
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Button
|
||||
icon="clock-o"
|
||||
content={data.timing ? 'Stop' : 'Start'}
|
||||
selected={data.timing}
|
||||
onClick={() => act(data.timing ? 'stop' : 'start')}
|
||||
/>
|
||||
{(data.flash_found && (
|
||||
<Button
|
||||
icon="lightbulb-o"
|
||||
content={data.flash_charging ? 'Recharging' : 'Flash'}
|
||||
disabled={data.flash_charging}
|
||||
onClick={() => act('flash')}
|
||||
/>
|
||||
)) ||
|
||||
null}
|
||||
</Fragment>
|
||||
}>
|
||||
<NumberInput
|
||||
animated
|
||||
fluid
|
||||
value={data.time_left / 10}
|
||||
minValue={0}
|
||||
maxValue={data.max_time_left / 10}
|
||||
format={(val) => formatTime(round(val))}
|
||||
onDrag={(e, val) => act('time', { time: val })}
|
||||
/>
|
||||
<Flex mt={1}>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="hourglass-start"
|
||||
content={'Add ' + formatTime(data.preset_short / 10)}
|
||||
onClick={() => act('preset', { preset: 'short' })}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="hourglass-start"
|
||||
content={'Add ' + formatTime(data.preset_medium / 10)}
|
||||
onClick={() => act('preset', { preset: 'medium' })}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item grow={1}>
|
||||
<Button
|
||||
fluid
|
||||
icon="hourglass-start"
|
||||
content={'Add ' + formatTime(data.preset_long / 10)}
|
||||
onClick={() => act('preset', { preset: 'long' })}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
129
tgui/packages/tgui_ch/interfaces/CameraConsole.js
Normal file
129
tgui/packages/tgui_ch/interfaces/CameraConsole.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { filter, sortBy } from 'common/collections';
|
||||
import { flow } from 'common/fp';
|
||||
import { classes } from 'common/react';
|
||||
import { createSearch } from 'common/string';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Button, ByondUi, Input, Section, Dropdown } from '../components';
|
||||
import { refocusLayout, Window } from '../layouts';
|
||||
|
||||
/**
|
||||
* Returns previous and next camera names relative to the currently
|
||||
* active camera.
|
||||
*/
|
||||
const prevNextCamera = (cameras, activeCamera) => {
|
||||
if (!activeCamera) {
|
||||
return [];
|
||||
}
|
||||
const index = cameras.findIndex((camera) => camera.name === activeCamera.name);
|
||||
return [cameras[index - 1]?.name, cameras[index + 1]?.name];
|
||||
};
|
||||
|
||||
/**
|
||||
* Camera selector.
|
||||
*
|
||||
* Filters cameras, applies search terms and sorts the alphabetically.
|
||||
*/
|
||||
const selectCameras = (cameras, searchText = '', networkFilter = '') => {
|
||||
const testSearch = createSearch(searchText, (camera) => camera.name);
|
||||
let fl = flow([
|
||||
// Null camera filter
|
||||
filter((camera) => camera?.name),
|
||||
// Optional search term
|
||||
searchText && filter(testSearch),
|
||||
// Optional network filter
|
||||
networkFilter && filter((camera) => camera.networks.includes(networkFilter)),
|
||||
// Slightly expensive, but way better than sorting in BYOND
|
||||
sortBy((camera) => camera.name),
|
||||
])(cameras);
|
||||
return fl;
|
||||
};
|
||||
|
||||
export const CameraConsole = (props, context) => {
|
||||
return (
|
||||
<Window width={870} height={708} resizable>
|
||||
<CameraConsoleContent />
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
export const CameraConsoleContent = (props, context) => {
|
||||
const { act, data, config } = useBackend(context);
|
||||
|
||||
const { mapRef, activeCamera } = data;
|
||||
const cameras = selectCameras(data.cameras);
|
||||
const [prevCameraName, nextCameraName] = prevNextCamera(cameras, activeCamera);
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="CameraConsole__left">
|
||||
<Window.Content scrollable>
|
||||
<CameraConsoleSearch />
|
||||
</Window.Content>
|
||||
</div>
|
||||
<div className="CameraConsole__right">
|
||||
<div className="CameraConsole__toolbar">
|
||||
<b>Camera: </b>
|
||||
{(activeCamera && activeCamera.name) || '—'}
|
||||
</div>
|
||||
<div className="CameraConsole__toolbarRight">
|
||||
<Button icon="chevron-left" onClick={() => act('pan', { dir: 8 })} />
|
||||
<Button icon="chevron-up" onClick={() => act('pan', { dir: 1 })} />
|
||||
<Button icon="chevron-right" onClick={() => act('pan', { dir: 4 })} />
|
||||
<Button icon="chevron-down" onClick={() => act('pan', { dir: 2 })} />
|
||||
</div>
|
||||
<ByondUi
|
||||
className="CameraConsole__map"
|
||||
params={{
|
||||
id: mapRef,
|
||||
type: 'map',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export const CameraConsoleSearch = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const [searchText, setSearchText] = useLocalState(context, 'searchText', '');
|
||||
const [networkFilter, setNetworkFilter] = useLocalState(context, 'networkFilter', '');
|
||||
const { activeCamera, allNetworks } = data;
|
||||
allNetworks.sort();
|
||||
const cameras = selectCameras(data.cameras, searchText, networkFilter);
|
||||
return (
|
||||
<Fragment>
|
||||
<Input fluid mb={1} placeholder="Search for a camera" onInput={(e, value) => setSearchText(value)} />
|
||||
<Dropdown
|
||||
mb={1}
|
||||
width="177px"
|
||||
options={allNetworks}
|
||||
placeholder="No Filter"
|
||||
onSelected={(value) => setNetworkFilter(value)}
|
||||
/>
|
||||
<Section>
|
||||
{cameras.map((camera) => (
|
||||
// We're not using the component here because performance
|
||||
// would be absolutely abysmal (50+ ms for each re-render).
|
||||
<div
|
||||
key={camera.name}
|
||||
title={camera.name}
|
||||
className={classes([
|
||||
'Button',
|
||||
'Button--fluid',
|
||||
'Button--color--transparent',
|
||||
'Button--ellipsis',
|
||||
activeCamera && camera.name === activeCamera.name && 'Button--selected',
|
||||
])}
|
||||
onClick={() => {
|
||||
refocusLayout();
|
||||
act('switch_camera', {
|
||||
name: camera.name,
|
||||
});
|
||||
}}>
|
||||
{camera.name}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
124
tgui/packages/tgui_ch/interfaces/Canister.js
Normal file
124
tgui/packages/tgui_ch/interfaces/Canister.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { toFixed } from 'common/math';
|
||||
import { useBackend } from '../backend';
|
||||
import { AnimatedNumber, Box, Button, Icon, Knob, LabeledControls, LabeledList, Section, Tooltip } from '../components';
|
||||
import { formatSiUnit } from '../format';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
export const Canister = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const {
|
||||
connected,
|
||||
can_relabel,
|
||||
pressure,
|
||||
releasePressure,
|
||||
defaultReleasePressure,
|
||||
minReleasePressure,
|
||||
maxReleasePressure,
|
||||
valveOpen,
|
||||
holding,
|
||||
} = data;
|
||||
return (
|
||||
<Window width={360} height={242} resizable>
|
||||
<Window.Content>
|
||||
<Section
|
||||
title="Canister"
|
||||
buttons={
|
||||
<Button icon="pencil-alt" disabled={!can_relabel} content="Relabel" onClick={() => act('relabel')} />
|
||||
}>
|
||||
<LabeledControls>
|
||||
<LabeledControls.Item minWidth="66px" label="Tank Pressure">
|
||||
<AnimatedNumber
|
||||
value={pressure}
|
||||
format={(value) => {
|
||||
if (value < 10000) {
|
||||
return toFixed(value) + ' kPa';
|
||||
}
|
||||
return formatSiUnit(value * 1000, 1, 'Pa');
|
||||
}}
|
||||
/>
|
||||
</LabeledControls.Item>
|
||||
<LabeledControls.Item label="Regulator">
|
||||
<Box position="relative" left="-8px">
|
||||
<Knob
|
||||
forcedInputWidth="60px"
|
||||
size={1.25}
|
||||
color={!!valveOpen && 'yellow'}
|
||||
value={releasePressure}
|
||||
unit="kPa"
|
||||
minValue={minReleasePressure}
|
||||
maxValue={maxReleasePressure}
|
||||
stepPixelSize={1}
|
||||
onDrag={(e, value) =>
|
||||
act('pressure', {
|
||||
pressure: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
fluid
|
||||
position="absolute"
|
||||
top="-2px"
|
||||
right="-20px"
|
||||
color="transparent"
|
||||
icon="fast-forward"
|
||||
onClick={() =>
|
||||
act('pressure', {
|
||||
pressure: maxReleasePressure,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
fluid
|
||||
position="absolute"
|
||||
top="16px"
|
||||
right="-20px"
|
||||
color="transparent"
|
||||
icon="undo"
|
||||
onClick={() =>
|
||||
act('pressure', {
|
||||
pressure: defaultReleasePressure,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</LabeledControls.Item>
|
||||
<LabeledControls.Item label="Valve">
|
||||
<Button
|
||||
my={0.5}
|
||||
width="50px"
|
||||
lineHeight={2}
|
||||
fontSize="11px"
|
||||
color={valveOpen ? (holding ? 'caution' : 'danger') : null}
|
||||
content={valveOpen ? 'Open' : 'Closed'}
|
||||
onClick={() => act('valve')}
|
||||
/>
|
||||
</LabeledControls.Item>
|
||||
<LabeledControls.Item mr={1} label="Port">
|
||||
<Box position="relative">
|
||||
<Icon size={1.25} name={connected ? 'plug' : 'times'} color={connected ? 'good' : 'bad'} />
|
||||
<Tooltip content={connected ? 'Connected' : 'Disconnected'} position="top" />
|
||||
</Box>
|
||||
</LabeledControls.Item>
|
||||
</LabeledControls>
|
||||
</Section>
|
||||
<Section
|
||||
title="Holding Tank"
|
||||
buttons={
|
||||
!!holding && (
|
||||
<Button icon="eject" color={valveOpen && 'danger'} content="Eject" onClick={() => act('eject')} />
|
||||
)
|
||||
}>
|
||||
{!!holding && (
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Label">{holding.name}</LabeledList.Item>
|
||||
<LabeledList.Item label="Pressure">
|
||||
<AnimatedNumber value={holding.pressure} /> kPa
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
)}
|
||||
{!holding && <Box color="average">No Holding Tank</Box>}
|
||||
</Section>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
98
tgui/packages/tgui_ch/interfaces/Canvas.js
Normal file
98
tgui/packages/tgui_ch/interfaces/Canvas.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, createRef } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const PX_PER_UNIT = 24;
|
||||
|
||||
class PaintCanvas extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.canvasRef = createRef();
|
||||
this.onCVClick = props.onCanvasClick;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.drawCanvas(this.props);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.drawCanvas(this.props);
|
||||
}
|
||||
|
||||
drawCanvas(propSource) {
|
||||
const ctx = this.canvasRef.current.getContext('2d');
|
||||
const grid = propSource.value;
|
||||
const x_size = grid.length;
|
||||
if (!x_size) {
|
||||
return;
|
||||
}
|
||||
const y_size = grid[0].length;
|
||||
const x_scale = Math.round(this.canvasRef.current.width / x_size);
|
||||
const y_scale = Math.round(this.canvasRef.current.height / y_size);
|
||||
ctx.save();
|
||||
ctx.scale(x_scale, y_scale);
|
||||
for (let x = 0; x < grid.length; x++) {
|
||||
const element = grid[x];
|
||||
for (let y = 0; y < element.length; y++) {
|
||||
const color = element[y];
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
clickwrapper(event) {
|
||||
const x_size = this.props.value.length;
|
||||
if (!x_size) {
|
||||
return;
|
||||
}
|
||||
const y_size = this.props.value[0].length;
|
||||
const x_scale = this.canvasRef.current.width / x_size;
|
||||
const y_scale = this.canvasRef.current.height / y_size;
|
||||
const x = Math.floor(event.offsetX / x_scale) + 1;
|
||||
const y = Math.floor(event.offsetY / y_scale) + 1;
|
||||
this.onCVClick(x, y);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { res = 1, value, dotsize = PX_PER_UNIT, ...rest } = this.props;
|
||||
const [width, height] = getImageSize(value);
|
||||
return (
|
||||
<canvas
|
||||
ref={this.canvasRef}
|
||||
width={width * dotsize || 300}
|
||||
height={height * dotsize || 300}
|
||||
{...rest}
|
||||
onClick={(e) => this.clickwrapper(e)}>
|
||||
Canvas failed to render.
|
||||
</canvas>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getImageSize = (value) => {
|
||||
const width = value.length;
|
||||
const height = width !== 0 ? value[0].length : 0;
|
||||
return [width, height];
|
||||
};
|
||||
|
||||
export const Canvas = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const dotsize = PX_PER_UNIT;
|
||||
const [width, height] = getImageSize(data.grid);
|
||||
return (
|
||||
<Window width={Math.min(700, width * dotsize + 72)} height={Math.min(700, height * dotsize + 72)}>
|
||||
<Window.Content>
|
||||
<Box textAlign="center">
|
||||
<PaintCanvas value={data.grid} dotsize={dotsize} onCanvasClick={(x, y) => act('paint', { x, y })} />
|
||||
<Box>
|
||||
{!data.finalized && <Button.Confirm onClick={() => act('finalize')} content="Finalize" />}
|
||||
{data.name}
|
||||
</Box>
|
||||
</Box>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
141
tgui/packages/tgui_ch/interfaces/CasinoPrizeDispenserCh.js
Normal file
141
tgui/packages/tgui_ch/interfaces/CasinoPrizeDispenserCh.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createSearch } from 'common/string';
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Collapsible, Dropdown, Flex, Input, Section } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
import { refocusLayout } from '../layouts';
|
||||
|
||||
const sortTypes = {
|
||||
'Alphabetical': (a, b) => a - b,
|
||||
'By availability': (a, b) => -(a.affordable - b.affordable),
|
||||
'By price': (a, b) => a.price - b.price,
|
||||
};
|
||||
|
||||
export const CasinoPrizeDispenserCh = () => {
|
||||
return (
|
||||
<Window width={400} height={450} resizable>
|
||||
<Window.Content className="Layout__content--flexColumn" scrollable>
|
||||
<Fragment>
|
||||
<CasinoPrizeDispenserChSearch />
|
||||
<CasinoPrizeDispenserChItems />
|
||||
</Fragment>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const CasinoPrizeDispenserChSearch = (props, context) => {
|
||||
const [_searchText, setSearchText] = useLocalState(context, 'search', '');
|
||||
const [_sortOrder, setSortOrder] = useLocalState(context, 'sort', '');
|
||||
const [descending, setDescending] = useLocalState(context, 'descending', false);
|
||||
return (
|
||||
<Box mb="0.5rem">
|
||||
<Flex width="100%">
|
||||
<Flex.Item grow="1" mr="0.5rem">
|
||||
<Input placeholder="Search by item name.." width="100%" onInput={(_e, value) => setSearchText(value)} />
|
||||
</Flex.Item>
|
||||
<Flex.Item basis="30%">
|
||||
<Dropdown
|
||||
selected="Alphabetical"
|
||||
options={Object.keys(sortTypes)}
|
||||
width="100%"
|
||||
lineHeight="19px"
|
||||
onSelected={(v) => setSortOrder(v)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
<Flex.Item>
|
||||
<Button
|
||||
icon={descending ? 'arrow-down' : 'arrow-up'}
|
||||
height="19px"
|
||||
tooltip={descending ? 'Descending order' : 'Ascending order'}
|
||||
tooltipPosition="bottom-end"
|
||||
ml="0.5rem"
|
||||
onClick={() => setDescending(!descending)}
|
||||
/>
|
||||
</Flex.Item>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const CasinoPrizeDispenserChItems = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { points, items } = data;
|
||||
// Search thingies
|
||||
const [searchText, _setSearchText] = useLocalState(context, 'search', '');
|
||||
const [sortOrder, _setSortOrder] = useLocalState(context, 'sort', 'Alphabetical');
|
||||
const [descending, _setDescending] = useLocalState(context, 'descending', false);
|
||||
const searcher = createSearch(searchText, (item) => {
|
||||
return item[0];
|
||||
});
|
||||
|
||||
let has_contents = false;
|
||||
let contents = Object.entries(items).map((kv, _i) => {
|
||||
let items_in_cat = Object.entries(kv[1])
|
||||
.filter(searcher)
|
||||
.map((kv2) => {
|
||||
kv2[1].affordable = points >= kv2[1].price;
|
||||
return kv2[1];
|
||||
})
|
||||
.sort(sortTypes[sortOrder]);
|
||||
if (items_in_cat.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (descending) {
|
||||
items_in_cat = items_in_cat.reverse();
|
||||
}
|
||||
|
||||
has_contents = true;
|
||||
return <CasinoPrizeDispenserChItemsCategory key={kv[0]} title={kv[0]} items={items_in_cat} />;
|
||||
});
|
||||
return (
|
||||
<Flex.Item grow="1" overflow="auto">
|
||||
<Section onClick={(e) => refocusLayout()}>
|
||||
{has_contents ? contents : <Box color="label">No items matching your criteria was found!</Box>}
|
||||
</Section>
|
||||
</Flex.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const CasinoPrizeDispenserChItemsCategory = (properties, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { title, items, ...rest } = properties;
|
||||
return (
|
||||
<Collapsible open title={title} {...rest}>
|
||||
{items.map((item) => (
|
||||
<Box key={item.name}>
|
||||
<Box
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
lineHeight="20px"
|
||||
style={{
|
||||
float: 'left',
|
||||
}}>
|
||||
{item.name}
|
||||
</Box>
|
||||
<Button
|
||||
content={item.price.toLocaleString('en-US')}
|
||||
width="15%"
|
||||
textAlign="center"
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
onClick={() =>
|
||||
act('purchase', {
|
||||
cat: title,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
restriction: item.restriction,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
clear: 'both',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
238
tgui/packages/tgui_ch/interfaces/CharacterDirectory.js
Normal file
238
tgui/packages/tgui_ch/interfaces/CharacterDirectory.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend, useLocalState } from '../backend';
|
||||
import { Box, Button, Icon, LabeledList, Section, Table } from '../components';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const getTagColor = (tag) => {
|
||||
switch (tag) {
|
||||
case 'Unset':
|
||||
return 'label';
|
||||
case 'Pred':
|
||||
return 'red';
|
||||
case 'Pred-Pref':
|
||||
return 'orange';
|
||||
case 'Prey':
|
||||
return 'blue';
|
||||
case 'Prey-Pref':
|
||||
return 'green';
|
||||
case 'Switch':
|
||||
return 'yellow';
|
||||
case 'Non-Vore':
|
||||
return 'black';
|
||||
}
|
||||
};
|
||||
|
||||
export const CharacterDirectory = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { personalVisibility, personalTag, personalGenderTag, personalSexualityTag, personalErpTag, personalEventTag } =
|
||||
data;
|
||||
|
||||
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
|
||||
|
||||
const [overwritePrefs, setOverwritePrefs] = useLocalState(context, 'overwritePrefs', false);
|
||||
|
||||
return (
|
||||
<Window width={816} height={722} resizeable>
|
||||
<Window.Content scrollable>
|
||||
{(overlay && <ViewCharacter />) || (
|
||||
<Fragment>
|
||||
<Section
|
||||
title="Settings and Preferences"
|
||||
buttons={
|
||||
<Fragment>
|
||||
<Box color="label" inline>
|
||||
Save to current preferences slot:
|
||||
</Box>
|
||||
<Button
|
||||
icon={overwritePrefs ? 'toggle-on' : 'toggle-off'}
|
||||
selected={overwritePrefs}
|
||||
content={overwritePrefs ? 'On' : 'Off'}
|
||||
onClick={() => setOverwritePrefs(!overwritePrefs)}
|
||||
/>
|
||||
</Fragment>
|
||||
}>
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Visibility">
|
||||
<Button
|
||||
fluid
|
||||
content={personalVisibility ? 'Shown' : 'Not Shown'}
|
||||
onClick={() => act('setVisible', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Vore Tag">
|
||||
<Button
|
||||
fluid
|
||||
content={personalTag}
|
||||
onClick={() => act('setTag', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Gender">
|
||||
<Button
|
||||
fluid
|
||||
content={personalGenderTag}
|
||||
onClick={() => act('setGenderTag', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Sexuality">
|
||||
<Button
|
||||
fluid
|
||||
content={personalSexualityTag}
|
||||
onClick={() => act('setSexualityTag', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="ERP Tag">
|
||||
<Button
|
||||
fluid
|
||||
content={personalErpTag}
|
||||
onClick={() => act('setErpTag', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Event Pref">
|
||||
<Button
|
||||
fluid
|
||||
content={personalEventTag}
|
||||
onClick={() => act('setEventTag', { overwrite_prefs: overwritePrefs })}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Advertisement">
|
||||
<Button fluid content="Edit Ad" onClick={() => act('editAd', { overwrite_prefs: overwritePrefs })} />
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
<CharacterDirectoryList />
|
||||
</Fragment>
|
||||
)}
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewCharacter = (props, context) => {
|
||||
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={overlay.name}
|
||||
buttons={<Button icon="arrow-left" content="Back" onClick={() => setOverlay(null)} />}>
|
||||
<Section level={2} title="Species">
|
||||
<Box>{overlay.species}</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Vore Tag">
|
||||
<Box p={1} backgroundColor={getTagColor(overlay.tag)}>
|
||||
{overlay.tag}
|
||||
</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Gender">
|
||||
<Box>{overlay.gendertag}</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Sexuality">
|
||||
<Box>{overlay.sexualitytag}</Box>
|
||||
</Section>
|
||||
<Section level={2} title="ERP Tag">
|
||||
<Box>{overlay.erptag}</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Event Pref">
|
||||
<Box>{overlay.eventtag}</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Character Ad">
|
||||
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
|
||||
{overlay.character_ad || 'Unset.'}
|
||||
</Box>
|
||||
</Section>
|
||||
<Section level={2} title="OOC Notes">
|
||||
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
|
||||
{overlay.ooc_notes || 'Unset.'}
|
||||
</Box>
|
||||
</Section>
|
||||
<Section level={2} title="Flavor Text">
|
||||
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
|
||||
{overlay.flavor_text || 'Unset.'}
|
||||
</Box>
|
||||
</Section>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const CharacterDirectoryList = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { directory } = data;
|
||||
|
||||
const [sortId, _setSortId] = useLocalState(context, 'sortId', 'name');
|
||||
const [sortOrder, _setSortOrder] = useLocalState(context, 'sortOrder', 'name');
|
||||
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
|
||||
|
||||
return (
|
||||
<Section title="Directory" buttons={<Button icon="sync" content="Refresh" onClick={() => act('refresh')} />}>
|
||||
<Table>
|
||||
<Table.Row bold>
|
||||
<SortButton id="name">Name</SortButton>
|
||||
<SortButton id="species">Species</SortButton>
|
||||
<SortButton id="tag">Vore Tag</SortButton>
|
||||
<SortButton id="gendertag">Gender</SortButton>
|
||||
<SortButton id="sexualitytag">Sexuality</SortButton>
|
||||
<SortButton id="erptag">ERP Tag</SortButton>
|
||||
<SortButton id="eventtag">Event Pref</SortButton>
|
||||
<Table.Cell collapsing textAlign="right">
|
||||
View
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{directory
|
||||
.sort((a, b) => {
|
||||
const i = sortOrder ? 1 : -1;
|
||||
return a[sortId].localeCompare(b[sortId]) * i;
|
||||
})
|
||||
.map((character, i) => (
|
||||
<Table.Row key={i} backgroundColor={getTagColor(character.tag)}>
|
||||
<Table.Cell p={1}>{character.name}</Table.Cell>
|
||||
<Table.Cell>{character.species}</Table.Cell>
|
||||
<Table.Cell>{character.tag}</Table.Cell>
|
||||
<Table.Cell>{character.gendertag}</Table.Cell>
|
||||
<Table.Cell>{character.sexualitytag}</Table.Cell>
|
||||
<Table.Cell>{character.erptag}</Table.Cell>
|
||||
<Table.Cell>{character.eventtag}</Table.Cell>
|
||||
<Table.Cell collapsing textAlign="right">
|
||||
<Button
|
||||
onClick={() => setOverlay(character)}
|
||||
color="transparent"
|
||||
icon="sticky-note"
|
||||
mr={1}
|
||||
content="View"
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const SortButton = (props, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
|
||||
const { id, children } = props;
|
||||
|
||||
// Hey, same keys mean same data~
|
||||
const [sortId, setSortId] = useLocalState(context, 'sortId', 'name');
|
||||
const [sortOrder, setSortOrder] = useLocalState(context, 'sortOrder', 'name');
|
||||
|
||||
return (
|
||||
<Table.Cell collapsing>
|
||||
<Button
|
||||
width="100%"
|
||||
color={sortId !== id && 'transparent'}
|
||||
onClick={() => {
|
||||
if (sortId === id) {
|
||||
setSortOrder(!sortOrder);
|
||||
} else {
|
||||
setSortId(id);
|
||||
setSortOrder(true);
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
{sortId === id && <Icon name={sortOrder ? 'sort-up' : 'sort-down'} ml="0.25rem;" />}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
);
|
||||
};
|
||||
160
tgui/packages/tgui_ch/interfaces/ChemDispenser.js
Normal file
160
tgui/packages/tgui_ch/interfaces/ChemDispenser.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Fragment } from 'inferno';
|
||||
import { useBackend } from '../backend';
|
||||
import { Box, Button, Flex, LabeledList, Slider, Section } from '../components';
|
||||
import { BeakerContents } from '../interfaces/common/BeakerContents';
|
||||
import { Window } from '../layouts';
|
||||
|
||||
const dispenseAmounts = [5, 10, 20, 30, 40, 60];
|
||||
const removeAmounts = [1, 5, 10];
|
||||
|
||||
export const ChemDispenser = (props, context) => {
|
||||
return (
|
||||
<Window width={390} height={655} resizable>
|
||||
<Window.Content className="Layout__content--flexColumn">
|
||||
<ChemDispenserSettings />
|
||||
<ChemDispenserChemicals />
|
||||
<ChemDispenserBeaker />
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
|
||||
const ChemDispenserSettings = (properties, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { amount } = data;
|
||||
return (
|
||||
<Section title="Settings" flex="content">
|
||||
<LabeledList>
|
||||
<LabeledList.Item label="Dispense" verticalAlign="middle">
|
||||
<Flex direction="row" wrap="wrap" spacing="1">
|
||||
{dispenseAmounts.map((a, i) => (
|
||||
<Flex.Item key={i} grow="1">
|
||||
<Button
|
||||
textAlign="center"
|
||||
selected={amount === a}
|
||||
content={a + 'u'}
|
||||
m="0"
|
||||
fluid
|
||||
onClick={() =>
|
||||
act('amount', {
|
||||
amount: a,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Flex.Item>
|
||||
))}
|
||||
</Flex>
|
||||
</LabeledList.Item>
|
||||
<LabeledList.Item label="Custom Amount">
|
||||
<Slider
|
||||
step={1}
|
||||
stepPixelSize={5}
|
||||
value={amount}
|
||||
minValue={1}
|
||||
maxValue={120}
|
||||
onDrag={(e, value) =>
|
||||
act('amount', {
|
||||
amount: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
</LabeledList>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const ChemDispenserChemicals = (properties, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { chemicals = [] } = data;
|
||||
const flexFillers = [];
|
||||
for (let i = 0; i < (chemicals.length + 1) % 3; i++) {
|
||||
flexFillers.push(true);
|
||||
}
|
||||
return (
|
||||
<Section title={data.glass ? 'Drink Dispenser' : 'Chemical Dispenser'} flexGrow="1">
|
||||
<Flex direction="row" wrap="wrap" height="100%" align="flex-start">
|
||||
{chemicals.map((c, i) => (
|
||||
<Flex.Item key={i} grow="1" m={0.2} basis="40%" height="20px">
|
||||
<Button
|
||||
icon="arrow-circle-down"
|
||||
width="100%"
|
||||
height="100%"
|
||||
align="flex-start"
|
||||
content={c.title + ' (' + c.amount + ')'}
|
||||
onClick={() =>
|
||||
act('dispense', {
|
||||
reagent: c.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Flex.Item>
|
||||
))}
|
||||
{flexFillers.map((_, i) => (
|
||||
<Flex.Item key={i} grow="1" basis="25%" height="20px" />
|
||||
))}
|
||||
</Flex>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const ChemDispenserBeaker = (properties, context) => {
|
||||
const { act, data } = useBackend(context);
|
||||
const { isBeakerLoaded, beakerCurrentVolume, beakerMaxVolume, beakerContents = [] } = data;
|
||||
return (
|
||||
<Section
|
||||
title="Beaker"
|
||||
flex="content"
|
||||
minHeight="25%"
|
||||
buttons={
|
||||
<Box>
|
||||
{!!isBeakerLoaded && (
|
||||
<Box inline color="label" mr={2}>
|
||||
{beakerCurrentVolume} / {beakerMaxVolume} units
|
||||
</Box>
|
||||
)}
|
||||
<Button icon="eject" content="Eject" disabled={!isBeakerLoaded} onClick={() => act('ejectBeaker')} />
|
||||
</Box>
|
||||
}>
|
||||
<BeakerContents
|
||||
beakerLoaded={isBeakerLoaded}
|
||||
beakerContents={beakerContents}
|
||||
buttons={(chemical) => (
|
||||
<Fragment>
|
||||
<Button
|
||||
content="Isolate"
|
||||
icon="compress-arrows-alt"
|
||||
onClick={() =>
|
||||
act('remove', {
|
||||
reagent: chemical.id,
|
||||
amount: -1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{removeAmounts.map((a, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
content={a}
|
||||
onClick={() =>
|
||||
act('remove', {
|
||||
reagent: chemical.id,
|
||||
amount: a,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
content="ALL"
|
||||
onClick={() =>
|
||||
act('remove', {
|
||||
reagent: chemical.id,
|
||||
amount: chemical.volume,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user