Merge branch 'dev' into add-ns-getRecentScripts

This commit is contained in:
smolgumball
2022-01-26 19:07:02 -07:00
183 changed files with 6613 additions and 36639 deletions
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
interface IProps {
children: React.ReactNode;
content: React.ReactNode;
}
export function BypassWrapper(props: IProps): React.ReactElement {
if (!props.content) return <>{props.children}</>;
return <>{props.content}</>;
}
+2
View File
@@ -9,6 +9,7 @@ interface IProps {
onClose: () => void;
onConfirm: () => void;
confirmationText: string | React.ReactNode;
additionalButton?: React.ReactNode;
}
export function ConfirmationModal(props: IProps): React.ReactElement {
@@ -23,6 +24,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement {
>
Confirm
</Button>
{props.additionalButton && <>{props.additionalButton}</>}
</>
</Modal>
);
+5 -1
View File
@@ -5,6 +5,7 @@ import Button from "@mui/material/Button";
import { Tooltip } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { pushDisableRestore } from '../../Electron';
interface IProps {
color?: "primary" | "warning" | "error";
@@ -21,7 +22,10 @@ export function DeleteGameButton({ color = "primary" }: IProps): React.ReactElem
onConfirm={() => {
setModalOpened(false);
deleteGame()
.then(() => setTimeout(() => location.reload(), 1000))
.then(() => {
pushDisableRestore();
setTimeout(() => location.reload(), 1000);
})
.catch((r) => console.error(`Could not delete game: ${r}`));
}}
open={modalOpened}
+4 -2
View File
@@ -1,11 +1,13 @@
import { AlertEvents } from "./AlertManager";
import React from "react";
import { SxProps } from "@mui/system";
import { Typography } from "@mui/material";
export function dialogBoxCreate(txt: string | JSX.Element): void {
export function dialogBoxCreate(txt: string | JSX.Element, styles?: SxProps): void {
if (typeof txt !== "string") {
AlertEvents.emit(txt);
} else {
AlertEvents.emit(<span dangerouslySetInnerHTML={{ __html: txt }} />);
AlertEvents.emit(<Typography component="span" sx={styles} dangerouslySetInnerHTML={{ __html: txt }} />);
}
}
+162 -205
View File
@@ -22,21 +22,23 @@ import TextField from "@mui/material/TextField";
import DownloadIcon from "@mui/icons-material/Download";
import UploadIcon from "@mui/icons-material/Upload";
import SaveIcon from "@mui/icons-material/Save";
import PaletteIcon from "@mui/icons-material/Palette";
import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal";
import { dialogBoxCreate } from "./DialogBox";
import { ConfirmationModal } from "./ConfirmationModal";
import { ThemeEditorModal } from "./ThemeEditorModal";
import { StyleEditorModal } from "./StyleEditorModal";
import { SnackbarEvents } from "./Snackbar";
import { Settings } from "../../Settings/Settings";
import { save } from "../../db";
import { formatTime } from "../../utils/helpers/formatTime";
import { OptionSwitch } from "./OptionSwitch";
import { DeleteGameButton } from "./DeleteGameButton";
import { SoftResetButton } from "./SoftResetButton";
import { IRouter } from "../Router";
import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton";
import { StyleEditorButton } from "../../Themes/ui/StyleEditorButton";
import { formatTime } from "../../utils/helpers/formatTime";
import { OptionSwitch } from "./OptionSwitch";
import { ImportData, saveObject } from "../../SaveObject";
import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -50,18 +52,13 @@ const useStyles = makeStyles((theme: Theme) =>
interface IProps {
player: IPlayer;
router: IRouter;
save: () => void;
export: () => void;
forceKill: () => void;
softReset: () => void;
}
interface ImportData {
base64: string;
parsed: any;
exportDate?: Date;
}
export function GameOptionsRoot(props: IProps): React.ReactElement {
const classes = useStyles();
const importInput = useRef<HTMLInputElement>(null);
@@ -75,8 +72,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat);
const [locale, setLocale] = useState(Settings.Locale);
const [diagnosticOpen, setDiagnosticOpen] = useState(false);
const [themeEditorOpen, setThemeEditorOpen] = useState(false);
const [styleEditorOpen, setStyleEditorOpen] = useState(false);
const [importSaveOpen, setImportSaveOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null);
@@ -127,78 +122,35 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
ii.click();
}
function onImport(event: React.ChangeEvent<HTMLInputElement>): void {
const files = event.target.files;
if (files === null) return;
const file = files[0];
if (!file) {
dialogBoxCreate("Invalid file selected");
return;
}
const reader = new FileReader();
reader.onload = function (this: FileReader, e: ProgressEvent<FileReader>) {
const target = e.target;
if (target === null) {
console.error("error importing file");
return;
}
const result = target.result;
if (typeof result !== "string" || result === null) {
console.error("FileReader event was not type string");
return;
}
const contents = result;
let newSave;
try {
newSave = window.atob(contents);
newSave = newSave.trim();
} catch (error) {
console.log(error); // We'll handle below
}
if (!newSave || newSave === "") {
SnackbarEvents.emit("Save game had not content or was not base64 encoded", "error", 5000);
return;
}
let parsedSave;
try {
parsedSave = JSON.parse(newSave);
} catch (error) {
console.log(error); // We'll handle below
}
if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) {
SnackbarEvents.emit("Save game did not seem valid", "error", 5000);
return;
}
const data: ImportData = {
base64: contents,
parsed: parsedSave,
};
const timestamp = parsedSave.data.SaveTimestamp;
if (timestamp && timestamp !== "0") {
data.exportDate = new Date(parseInt(timestamp, 10));
}
async function onImport(event: React.ChangeEvent<HTMLInputElement>): Promise<void> {
try {
const base64Save = await saveObject.getImportStringFromFile(event.target.files);
const data = await saveObject.getImportDataFromString(base64Save);
setImportData(data);
setImportSaveOpen(true);
};
reader.readAsText(file);
} catch (ex: any) {
SnackbarEvents.emit(ex.toString(), "error", 5000);
}
}
function confirmedImportGame(): void {
async function confirmedImportGame(): Promise<void> {
if (!importData) return;
try {
await saveObject.importGame(importData.base64);
} catch (ex: any) {
SnackbarEvents.emit(ex.toString(), "error", 5000);
}
setImportSaveOpen(false);
save(importData.base64).then(() => {
setImportData(null);
setTimeout(() => location.reload(), 1000);
});
setImportData(null);
}
function compareSaveGame(): void {
if (!importData) return;
props.router.toImportSave(importData.base64);
setImportSaveOpen(false);
setImportData(null);
}
return (
@@ -211,123 +163,115 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
<Grid item xs={12} sm={6}>
<List>
<ListItem>
<Tooltip
title={
<Typography>
The minimum number of milliseconds it takes to execute an operation in Netscript. Setting this too
low can result in poor performance if you have many scripts running.
</Typography>
}
>
<Typography>.script exec time (ms)</Typography>
</Tooltip>
<Slider
value={execTime}
onChange={handleExecTimeChange}
step={1}
min={5}
max={100}
valueLabelDisplay="auto"
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>
The maximum number of recently killed script entries being tracked. Setting this too high can cause
the game to use a lot of memory.
</Typography>
}
>
<Typography>Recently killed scripts size</Typography>
</Tooltip>
<Slider
value={recentScriptsSize}
onChange={handleRecentScriptsSizeChange}
step={25}
min={25}
max={500}
valueLabelDisplay="auto"
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>
The maximum number of lines a script's logs can hold. Setting this too high can cause the game to
use a lot of memory if you have many scripts running.
</Typography>
}
>
<Typography>Netscript log size</Typography>
</Tooltip>
<Slider
value={logSize}
onChange={handleLogSizeChange}
step={20}
min={20}
max={500}
valueLabelDisplay="auto"
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>
The maximum number of entries that can be written to a port using Netscript's write() function.
Setting this too high can cause the game to use a lot of memory.
</Typography>
}
>
<Typography>Netscript port size</Typography>
</Tooltip>
<Slider
value={portSize}
onChange={handlePortSizeChange}
step={1}
min={20}
max={100}
valueLabelDisplay="auto"
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>
The maximum number of entries that can be written to the terminal. Setting this too high can cause
the game to use a lot of memory.
</Typography>
}
>
<Typography>Terminal capacity</Typography>
</Tooltip>
<Slider
value={terminalSize}
onChange={handleTerminalSizeChange}
step={50}
min={50}
max={500}
valueLabelDisplay="auto"
marks
/>
</ListItem>
<ListItem>
<Tooltip
title={
<Typography>The time (in seconds) between each autosave. Set to 0 to disable autosave.</Typography>
}
>
<Typography>Autosave interval (s)</Typography>
</Tooltip>
<Slider
value={autosaveInterval}
onChange={handleAutosaveIntervalChange}
step={30}
min={0}
max={600}
valueLabelDisplay="auto"
marks
/>
<Box display="grid" sx={{ width: "fit-content", gridTemplateColumns: "1fr 3.5fr", gap: 1 }}>
<Tooltip
title={
<Typography>
The minimum number of milliseconds it takes to execute an operation in Netscript. Setting this too
low can result in poor performance if you have many scripts running.
</Typography>
}
>
<Typography>.script exec time (ms)</Typography>
</Tooltip>
<Slider
value={execTime}
onChange={handleExecTimeChange}
step={1}
min={5}
max={100}
valueLabelDisplay="auto"
/>
<Tooltip
title={
<Typography>
The maximum number of recently killed script entries being tracked. Setting this too high can
cause the game to use a lot of memory.
</Typography>
}
>
<Typography>Recently killed scripts size</Typography>
</Tooltip>
<Slider
value={recentScriptsSize}
onChange={handleRecentScriptsSizeChange}
step={25}
min={25}
max={500}
valueLabelDisplay="auto"
/>
<Tooltip
title={
<Typography>
The maximum number of lines a script's logs can hold. Setting this too high can cause the game to
use a lot of memory if you have many scripts running.
</Typography>
}
>
<Typography>Netscript log size</Typography>
</Tooltip>
<Slider
value={logSize}
onChange={handleLogSizeChange}
step={20}
min={20}
max={500}
valueLabelDisplay="auto"
/>
<Tooltip
title={
<Typography>
The maximum number of entries that can be written to a port using Netscript's write() function.
Setting this too high can cause the game to use a lot of memory.
</Typography>
}
>
<Typography>Netscript port size</Typography>
</Tooltip>
<Slider
value={portSize}
onChange={handlePortSizeChange}
step={1}
min={20}
max={100}
valueLabelDisplay="auto"
/>
<Tooltip
title={
<Typography>
The maximum number of entries that can be written to the terminal. Setting this too high can cause
the game to use a lot of memory.
</Typography>
}
>
<Typography>Terminal capacity</Typography>
</Tooltip>
<Slider
value={terminalSize}
onChange={handleTerminalSizeChange}
step={50}
min={50}
max={500}
valueLabelDisplay="auto"
marks
/>
<Tooltip
title={
<Typography>The time (in seconds) between each autosave. Set to 0 to disable autosave.</Typography>
}
>
<Typography>Autosave interval (s)</Typography>
</Tooltip>
<Slider
value={autosaveInterval}
onChange={handleAutosaveIntervalChange}
step={30}
min={0}
max={600}
valueLabelDisplay="auto"
marks
/>
</Box>
</ListItem>
<ListItem>
<OptionSwitch
@@ -616,6 +560,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
open={importSaveOpen}
onClose={() => setImportSaveOpen(false)}
onConfirm={() => confirmedImportGame()}
additionalButton={<Button onClick={compareSaveGame}>Compare Save</Button>}
confirmationText={
<>
Importing a new game will <strong>completely wipe</strong> the current data!
@@ -624,15 +569,24 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
Make sure to have a backup of your current save file before importing.
<br />
The file you are attempting to import seems valid.
<br />
<br />
{importData?.exportDate && (
{(importData?.playerData?.lastSave ?? 0) > 0 && (
<>
The export date of the save file is <strong>{importData?.exportDate.toString()}</strong>
<br />
<br />
The export date of the save file is{" "}
<strong>{new Date(importData?.playerData?.lastSave ?? 0).toLocaleString()}</strong>
</>
)}
{(importData?.playerData?.totalPlaytime ?? 0) > 0 && (
<>
<br />
<br />
Total play time of imported game:{" "}
{convertTimeMsToTimeElapsedString(importData?.playerData?.totalPlaytime ?? 0)}
</>
)}
<br />
<br />
</>
}
/>
@@ -668,9 +622,14 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
<Button onClick={() => setDiagnosticOpen(true)}>Diagnose files</Button>
</Tooltip>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<Button onClick={() => setThemeEditorOpen(true)}>Theme editor</Button>
<Button onClick={() => setStyleEditorOpen(true)}>Style editor</Button>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
<Tooltip title="Head to the theme browser to see a collection of prebuilt themes.">
<Button startIcon={<PaletteIcon />} onClick={() => props.router.toThemeBrowser()}>
Theme Browser
</Button>
</Tooltip>
<ThemeEditorButton router={props.router} />
<StyleEditorButton />
</Box>
<Box>
<Link href="https://github.com/danielyxie/bitburner/issues/new" target="_blank">
@@ -695,8 +654,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
</Box>
</Grid>
<FileDiagnosticModal open={diagnosticOpen} onClose={() => setDiagnosticOpen(false)} />
<ThemeEditorModal open={themeEditorOpen} onClose={() => setThemeEditorOpen(false)} />
<StyleEditorModal open={styleEditorOpen} onClose={() => setStyleEditorOpen(false)} />
</div>
);
}
+351
View File
@@ -0,0 +1,351 @@
import React, { useEffect, useState } from "react";
import {
Paper,
Table,
TableHead,
TableRow,
TableBody,
TableContainer,
TableCell,
Typography,
Tooltip,
Box,
Button,
ButtonGroup,
} from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import createStyles from "@mui/styles/createStyles";
import { Theme } from "@mui/material/styles";
import ThumbUpAlt from "@mui/icons-material/ThumbUpAlt";
import ThumbDownAlt from "@mui/icons-material/ThumbDownAlt";
import DirectionsRunIcon from "@mui/icons-material/DirectionsRun";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import WarningIcon from "@mui/icons-material/Warning";
import { ImportData, saveObject } from "../../SaveObject";
import { Settings } from "../../Settings/Settings";
import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions";
import { numeralWrapper } from "../numeralFormat";
import { ConfirmationModal } from "./ConfirmationModal";
import { pushImportResult } from "../../Electron";
import { IRouter } from "../Router";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: theme.spacing(2),
maxWidth: "1000px",
"& .MuiTable-root": {
"& .MuiTableCell-root": {
borderBottom: `1px solid ${Settings.theme.welllight}`,
},
"& .MuiTableHead-root .MuiTableRow-root": {
backgroundColor: Settings.theme.backgroundsecondary,
"& .MuiTableCell-root": {
color: Settings.theme.primary,
fontWeight: "bold",
},
},
"& .MuiTableBody-root": {
"& .MuiTableRow-root:nth-of-type(odd)": {
backgroundColor: Settings.theme.well,
"& .MuiTableCell-root": {
color: Settings.theme.primarylight,
},
},
"& .MuiTableRow-root:nth-of-type(even)": {
backgroundColor: Settings.theme.backgroundsecondary,
"& .MuiTableCell-root": {
color: Settings.theme.primarylight,
},
},
},
},
},
}),
);
function ComparisonIcon({ isBetter }: { isBetter: boolean }): JSX.Element {
if (isBetter) {
return (
<Tooltip
title={
<>
Imported value is <b>larger</b>!
</>
}
>
<ThumbUpAlt color="success" />
</Tooltip>
);
} else {
return (
<Tooltip
title={
<>
Imported value is <b>smaller</b>!
</>
}
>
<ThumbDownAlt color="error" />
</Tooltip>
);
}
}
export interface IProps {
importString: string;
automatic: boolean;
router: IRouter;
}
let initialAutosave = 0;
export function ImportSaveRoot(props: IProps): JSX.Element {
const classes = useStyles();
const [importData, setImportData] = useState<ImportData | undefined>();
const [currentData, setCurrentData] = useState<ImportData | undefined>();
const [importModalOpen, setImportModalOpen] = useState(false);
const [headback, setHeadback] = useState(false);
function handleGoBack(): void {
Settings.AutosaveInterval = initialAutosave;
pushImportResult(false);
props.router.allowRouting(true);
setHeadback(true)
}
async function handleImport(): Promise<void> {
await saveObject.importGame(props.importString, true);
pushImportResult(true);
}
useEffect(() => {
// We want to disable autosave while we're in this mode
initialAutosave = Settings.AutosaveInterval;
Settings.AutosaveInterval = 0;
props.router.allowRouting(false);
}, []);
useEffect(() => {
if (headback) props.router.toTerminal();
}, [headback]);
useEffect(() => {
async function fetchData(): Promise<void> {
const dataBeingImported = await saveObject.getImportDataFromString(props.importString);
const dataCurrentlyInGame = await saveObject.getImportDataFromString(saveObject.getSaveString(true));
setImportData(dataBeingImported);
setCurrentData(dataCurrentlyInGame);
return Promise.resolve();
}
if (props.importString) fetchData();
}, [props.importString]);
if (!importData || !currentData) return <></>;
return (
<Box className={classes.root}>
<Typography variant="h4" sx={{ mb: 2 }}>
Import Save Comparison
</Typography>
{props.automatic && (
<Typography sx={{ mb: 2 }}>
We've found a <b>NEWER save</b> that you may want to use instead.
</Typography>
)}
<Typography variant="body1" sx={{ mb: 2 }}>
Your current game's data is on the left and the data that will be imported is on the right.
<br />
Please double check everything is fine before proceeding!
</Typography>
<TableContainer color="secondary" component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>Current Game</TableCell>
<TableCell>Being Imported</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Game Identifier</TableCell>
<TableCell>{currentData.playerData?.identifier ?? "n/a"}</TableCell>
<TableCell>{importData.playerData?.identifier ?? "n/a"}</TableCell>
<TableCell>
{importData.playerData?.identifier !== currentData.playerData?.identifier && (
<Tooltip title="These are two different games!">
<WarningIcon color="warning" />
</Tooltip>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Playtime</TableCell>
<TableCell>{convertTimeMsToTimeElapsedString(currentData.playerData?.totalPlaytime ?? 0)}</TableCell>
<TableCell>{convertTimeMsToTimeElapsedString(importData.playerData?.totalPlaytime ?? 0)}</TableCell>
<TableCell>
{importData.playerData?.totalPlaytime !== currentData.playerData?.totalPlaytime && (
<ComparisonIcon
isBetter={
(importData.playerData?.totalPlaytime ?? 0) > (currentData.playerData?.totalPlaytime ?? 0)
}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Saved On</TableCell>
<TableCell>
{(currentData.playerData?.lastSave ?? 0) > 0 ?
new Date(currentData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'}
</TableCell>
<TableCell>
{(importData.playerData?.lastSave ?? 0) > 0 ?
new Date(importData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'}
</TableCell>
<TableCell>
{importData.playerData?.lastSave !== currentData.playerData?.lastSave && (
<ComparisonIcon
isBetter={(importData.playerData?.lastSave ?? 0) > (currentData.playerData?.lastSave ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Money</TableCell>
<TableCell>{numeralWrapper.formatMoney(currentData.playerData?.money ?? 0)}</TableCell>
<TableCell>{numeralWrapper.formatMoney(importData.playerData?.money ?? 0)}</TableCell>
<TableCell>
{importData.playerData?.money !== currentData.playerData?.money && (
<ComparisonIcon
isBetter={(importData.playerData?.money ?? 0) > (currentData.playerData?.money ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Hacking</TableCell>
<TableCell>{numeralWrapper.formatSkill(currentData.playerData?.hacking ?? 0)}</TableCell>
<TableCell>{numeralWrapper.formatSkill(importData.playerData?.hacking ?? 0)}</TableCell>
<TableCell>
{importData.playerData?.hacking !== currentData.playerData?.hacking && (
<ComparisonIcon
isBetter={(importData.playerData?.hacking ?? 0) > (currentData.playerData?.hacking ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Augmentations</TableCell>
<TableCell>{currentData.playerData?.augmentations}</TableCell>
<TableCell>{importData.playerData?.augmentations}</TableCell>
<TableCell>
{importData.playerData?.augmentations !== currentData.playerData?.augmentations && (
<ComparisonIcon
isBetter={
(importData.playerData?.augmentations ?? 0) > (currentData.playerData?.augmentations ?? 0)
}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Factions</TableCell>
<TableCell>{currentData.playerData?.factions}</TableCell>
<TableCell>{importData.playerData?.factions}</TableCell>
<TableCell>
{importData.playerData?.factions !== currentData.playerData?.factions && (
<ComparisonIcon
isBetter={(importData.playerData?.factions ?? 0) > (currentData.playerData?.factions ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Achievements</TableCell>
<TableCell>{currentData.playerData?.achievements}</TableCell>
<TableCell>{importData.playerData?.achievements}</TableCell>
<TableCell>
{importData.playerData?.achievements !== currentData.playerData?.achievements && (
<ComparisonIcon
isBetter={(importData.playerData?.achievements ?? 0) > (currentData.playerData?.achievements ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Source Files</TableCell>
<TableCell>{currentData.playerData?.sourceFiles}</TableCell>
<TableCell>{importData.playerData?.sourceFiles}</TableCell>
<TableCell>
{importData.playerData?.sourceFiles !== currentData.playerData?.sourceFiles && (
<ComparisonIcon
isBetter={(importData.playerData?.sourceFiles ?? 0) > (currentData.playerData?.sourceFiles ?? 0)}
/>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>BitNode</TableCell>
<TableCell>
{currentData.playerData?.bitNode}-{currentData.playerData?.bitNodeLevel}
</TableCell>
<TableCell>
{importData.playerData?.bitNode}-{importData.playerData?.bitNodeLevel}
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonGroup>
<Button onClick={handleGoBack} sx={{ my: 2 }} startIcon={<ArrowBackIcon />} color="secondary">
Take me back!
</Button>
<Button
onClick={() => setImportModalOpen(true)}
sx={{ my: 2 }}
startIcon={<DirectionsRunIcon />}
color="warning"
>
Proceed with import
</Button>
</ButtonGroup>
<ConfirmationModal
open={importModalOpen}
onClose={() => setImportModalOpen(false)}
onConfirm={handleImport}
confirmationText={
<>
Importing new save game data will <strong>completely wipe</strong> the current game data!
<br />
</>
}
/>
</Box>
</Box>
);
}
+5 -1
View File
@@ -14,6 +14,10 @@ const useStyles = makeStyles(() => ({
snackbar: {
// Log popup z-index increments, so let's add a padding to be well above them.
zIndex: `${logBoxBaseZIndex + 1000} !important` as any,
"& .MuiAlert-icon": {
alignSelf: 'center',
},
}
}));
@@ -27,7 +31,7 @@ export function SnackbarProvider(props: IProps): React.ReactElement {
);
}
export const SnackbarEvents = new EventEmitter<[string, "success" | "warning" | "error" | "info", number]>();
export const SnackbarEvents = new EventEmitter<[string | React.ReactNode, "success" | "warning" | "error" | "info", number]>();
export function Snackbar(): React.ReactElement {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
-165
View File
@@ -1,165 +0,0 @@
import React, { useEffect, useState } from "react";
import { Modal } from "./Modal";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import ReplyIcon from "@mui/icons-material/Reply";
import SaveIcon from "@mui/icons-material/Save";
import { ThemeEvents } from "./Theme";
import { Settings } from "../../Settings/Settings";
import { defaultStyles } from "../../Settings/Styles";
import { Tooltip } from "@mui/material";
import { IStyleSettings } from "../../ScriptEditor/NetscriptDefinitions";
interface IProps {
open: boolean;
onClose: () => void;
}
interface FontFamilyProps {
value: React.CSSProperties["fontFamily"];
onChange: (newValue: React.CSSProperties["fontFamily"], error?: string) => void;
refreshId: number;
}
function FontFamilyField({ value, onChange, refreshId }: FontFamilyProps): React.ReactElement {
const [errorText, setErrorText] = useState<string | undefined>();
const [fontFamily, setFontFamily] = useState<React.CSSProperties["fontFamily"]>(value);
function update(newValue: React.CSSProperties["fontFamily"]): void {
setFontFamily(newValue);
if (!newValue) {
setErrorText("Must have a value");
} else {
setErrorText("");
}
}
function onTextChange(event: React.ChangeEvent<HTMLInputElement>): void {
update(event.target.value);
}
useEffect(() => onChange(fontFamily, errorText), [fontFamily]);
useEffect(() => update(value), [refreshId]);
return (
<TextField
sx={{ my: 1 }}
label={"Font-Family"}
error={!!errorText}
value={fontFamily}
helperText={errorText}
onChange={onTextChange}
fullWidth
/>
);
}
interface LineHeightProps {
value: React.CSSProperties["lineHeight"];
onChange: (newValue: React.CSSProperties["lineHeight"], error?: string) => void;
refreshId: number;
}
function LineHeightField({ value, onChange, refreshId }: LineHeightProps): React.ReactElement {
const [errorText, setErrorText] = useState<string | undefined>();
const [lineHeight, setLineHeight] = useState<React.CSSProperties["lineHeight"]>(value);
function update(newValue: React.CSSProperties["lineHeight"]): void {
setLineHeight(newValue);
if (!newValue) {
setErrorText("Must have a value");
} else if (isNaN(Number(newValue))) {
setErrorText("Must be a number");
} else {
setErrorText("");
}
}
function onTextChange(event: React.ChangeEvent<HTMLInputElement>): void {
update(event.target.value);
}
useEffect(() => onChange(lineHeight, errorText), [lineHeight]);
useEffect(() => update(value), [refreshId]);
return (
<TextField
sx={{ my: 1 }}
label={"Line Height"}
error={!!errorText}
value={lineHeight}
helperText={errorText}
onChange={onTextChange}
/>
);
}
export function StyleEditorModal(props: IProps): React.ReactElement {
const [refreshId, setRefreshId] = useState<number>(0);
const [error, setError] = useState<string | undefined>();
const [customStyle, setCustomStyle] = useState<IStyleSettings>({
...Settings.styles,
});
function persistToSettings(styles: IStyleSettings): void {
Object.assign(Settings.styles, styles);
ThemeEvents.emit();
}
function saveStyles(): void {
persistToSettings(customStyle);
}
function setDefaults(): void {
const styles = { ...defaultStyles };
setCustomStyle(styles);
persistToSettings(styles);
setRefreshId(refreshId + 1);
}
function update(styles: IStyleSettings, errorMessage?: string): void {
setError(errorMessage);
if (!errorMessage) {
setCustomStyle(styles);
}
}
return (
<Modal open={props.open} onClose={props.onClose}>
<Typography variant="h6">Styles Editor</Typography>
<Typography>
WARNING: Changing styles <strong>may mess up</strong> the interface. Drastic changes are{" "}
<strong>NOT recommended</strong>.
</Typography>
<Paper sx={{ p: 2, my: 2 }}>
<FontFamilyField
value={customStyle.fontFamily}
refreshId={refreshId}
onChange={(value, error) => update({ ...customStyle, fontFamily: value as any }, error)}
/>
<br />
<LineHeightField
value={customStyle.lineHeight}
refreshId={refreshId}
onChange={(value, error) => update({ ...customStyle, lineHeight: value as any }, error)}
/>
<br />
<ButtonGroup sx={{ my: 1 }}>
<Button onClick={setDefaults} startIcon={<ReplyIcon />} color="secondary" variant="outlined">
Revert to Defaults
</Button>
<Tooltip title={"Save styles to settings"}>
<Button onClick={saveStyles} endIcon={<SaveIcon />} color={error ? "error" : "primary"} disabled={!!error}>
Save Modifications
</Button>
</Tooltip>
</ButtonGroup>
</Paper>
</Modal>
);
}
-377
View File
@@ -1,377 +0,0 @@
import React from "react";
import { createTheme, ThemeProvider, Theme, StyledEngineProvider } from "@mui/material/styles";
import { EventEmitter } from "../../utils/EventEmitter";
import { Settings } from "../../Settings/Settings";
export const ThemeEvents = new EventEmitter<[]>();
declare module "@mui/material/styles" {
interface Theme {
colors: {
hp: React.CSSProperties["color"];
money: React.CSSProperties["color"];
hack: React.CSSProperties["color"];
combat: React.CSSProperties["color"];
cha: React.CSSProperties["color"];
int: React.CSSProperties["color"];
rep: React.CSSProperties["color"];
backgroundprimary: React.CSSProperties["color"];
backgroundsecondary: React.CSSProperties["color"];
button: React.CSSProperties["color"];
successlight: React.CSSProperties["color"];
success: React.CSSProperties["color"];
successdark: React.CSSProperties["color"];
white: React.CSSProperties["color"];
black: React.CSSProperties["color"];
};
}
interface ThemeOptions {
colors: {
hp: React.CSSProperties["color"];
money: React.CSSProperties["color"];
hack: React.CSSProperties["color"];
combat: React.CSSProperties["color"];
cha: React.CSSProperties["color"];
int: React.CSSProperties["color"];
rep: React.CSSProperties["color"];
backgroundprimary: React.CSSProperties["color"];
backgroundsecondary: React.CSSProperties["color"];
button: React.CSSProperties["color"];
successlight: React.CSSProperties["color"];
success: React.CSSProperties["color"];
successdark: React.CSSProperties["color"];
white: React.CSSProperties["color"];
black: React.CSSProperties["color"];
};
}
}
let theme: Theme;
export function refreshTheme(): void {
theme = createTheme({
colors: {
hp: Settings.theme.hp,
money: Settings.theme.money,
hack: Settings.theme.hack,
combat: Settings.theme.combat,
cha: Settings.theme.cha,
int: Settings.theme.int,
rep: Settings.theme.rep,
backgroundprimary: Settings.theme.backgroundprimary,
backgroundsecondary: Settings.theme.backgroundsecondary,
button: Settings.theme.button,
successlight: Settings.theme.successlight,
success: Settings.theme.success,
successdark: Settings.theme.successdark,
white: Settings.theme.white,
black: Settings.theme.black,
},
palette: {
primary: {
light: Settings.theme.primarylight,
main: Settings.theme.primary,
dark: Settings.theme.primarydark,
},
secondary: {
light: Settings.theme.secondarylight,
main: Settings.theme.secondary,
dark: Settings.theme.secondarydark,
},
error: {
light: Settings.theme.errorlight,
main: Settings.theme.error,
dark: Settings.theme.errordark,
},
info: {
light: Settings.theme.infolight,
main: Settings.theme.info,
dark: Settings.theme.infodark,
},
warning: {
light: Settings.theme.warninglight,
main: Settings.theme.warning,
dark: Settings.theme.warningdark,
},
success: {
light: Settings.theme.successlight,
main: Settings.theme.success,
dark: Settings.theme.successdark,
},
background: {
default: Settings.theme.backgroundprimary,
paper: Settings.theme.well,
},
action: {
disabled: Settings.theme.disabled,
},
},
typography: {
fontFamily: Settings.styles.fontFamily,
button: {
textTransform: "none",
},
},
components: {
MuiInputBase: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.well,
color: Settings.theme.primary,
},
input: {
"&::placeholder": {
userSelect: "none",
color: Settings.theme.primarydark,
},
},
},
},
MuiInput: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.well,
borderBottomColor: "#fff",
},
underline: {
"&:hover": {
borderBottomColor: Settings.theme.primarydark,
},
"&:before": {
borderBottomColor: Settings.theme.primary,
},
"&:after": {
borderBottomColor: Settings.theme.primarylight,
},
},
},
},
MuiInputLabel: {
styleOverrides: {
root: {
color: Settings.theme.primarydark, // why is this switched?
userSelect: "none",
"&:before": {
color: Settings.theme.primarylight,
},
},
},
},
MuiButtonGroup: {
styleOverrides: {
root: {
'& .MuiButton-root:not(:last-of-type)': {
marginRight: '1px',
}
}
}
},
MuiButton: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.button,
border: "1px solid " + Settings.theme.well,
// color: Settings.theme.primary,
"&:hover": {
backgroundColor: Settings.theme.backgroundsecondary,
},
borderRadius: 0,
},
},
},
MuiSelect: {
styleOverrides: {
icon: {
color: Settings.theme.primary,
},
},
defaultProps: {
variant: "standard",
},
},
MuiTextField: {
defaultProps: {
variant: "standard",
},
},
MuiTypography: {
defaultProps: {
color: "primary",
},
styleOverrides: {
root: {
lineHeight: Settings.styles.lineHeight,
}
}
},
MuiMenu: {
styleOverrides: {
list: {
backgroundColor: Settings.theme.well,
},
},
},
MuiMenuItem: {
styleOverrides: {
root: {
color: Settings.theme.primary,
},
},
},
MuiAccordionSummary: {
styleOverrides: {
root: {
backgroundColor: "#111",
},
},
},
MuiAccordionDetails: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.backgroundsecondary,
},
},
},
MuiIconButton: {
styleOverrides: {
root: {
color: Settings.theme.primary,
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
fontSize: "1em",
color: Settings.theme.primary,
backgroundColor: Settings.theme.well,
borderRadius: 0,
border: "2px solid white",
maxWidth: "100vh",
},
},
defaultProps: {
disableInteractive: true,
},
},
MuiSlider: {
styleOverrides: {
valueLabel: {
color: Settings.theme.primary,
backgroundColor: Settings.theme.well,
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
"&::-webkit-scrollbar": {
// webkit
display: "none",
},
scrollbarWidth: "none", // firefox
backgroundColor: Settings.theme.backgroundsecondary,
},
paperAnchorDockedLeft: {
borderRight: "1px solid " + Settings.theme.welllight,
},
},
},
MuiDivider: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.welllight,
},
},
},
MuiFormControlLabel: {
styleOverrides: {
root: {
color: Settings.theme.primary,
},
},
},
MuiSwitch: {
styleOverrides: {
switchBase: {
color: Settings.theme.primarydark,
},
track: {
backgroundColor: Settings.theme.welllight,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 0,
backgroundColor: Settings.theme.backgroundsecondary,
border: "1px solid " + Settings.theme.welllight,
},
},
},
MuiTablePagination: {
styleOverrides: {
select: {
color: Settings.theme.primary,
},
selectLabel: {
color: Settings.theme.primary,
},
displayedRows: {
color: Settings.theme.primary,
},
},
},
MuiTab: {
styleOverrides: {
textColorPrimary: {
color: Settings.theme.secondary,
"&.Mui-selected": {
color: Settings.theme.primary,
},
},
},
},
MuiAlert: {
styleOverrides: {
root: {
backgroundColor: Settings.theme.black,
borderRadius: 0,
border: "1px solid " + Settings.theme.well,
},
standardSuccess: {
color: Settings.theme.successlight,
},
standardError: {
color: Settings.theme.errorlight,
},
standardWarning: {
color: Settings.theme.warninglight,
},
standardInfo: {
color: Settings.theme.infolight,
},
},
},
},
});
document.body.style.backgroundColor = theme.colors.backgroundprimary?.toString() ?? "black";
}
refreshTheme();
interface IProps {
children: JSX.Element[] | JSX.Element;
}
export const TTheme = ({ children }: IProps): React.ReactElement => (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</StyledEngineProvider>
);
-381
View File
@@ -1,381 +0,0 @@
import React, { useState } from "react";
import { Modal } from "./Modal";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton";
import ReplyIcon from "@mui/icons-material/Reply";
import PaletteSharpIcon from "@mui/icons-material/PaletteSharp";
import { Color, ColorPicker } from "material-ui-color";
import { ThemeEvents } from "./Theme";
import { Settings, defaultSettings } from "../../Settings/Settings";
import { getPredefinedThemes } from "../../Settings/Themes";
import { UserInterfaceTheme } from "../../ScriptEditor/NetscriptDefinitions";
interface IProps {
open: boolean;
onClose: () => void;
}
interface IColorEditorProps {
name: string;
color: string | undefined;
onColorChange: (name: string, value: string) => void;
defaultColor: string;
}
function ColorEditor({ name, onColorChange, color, defaultColor }: IColorEditorProps): React.ReactElement {
if (color === undefined) {
console.error(`color ${name} was undefined, reverting to default`);
color = defaultColor;
}
return (
<>
<TextField
sx={{ mx: 1 }}
label={name}
value={color}
InputProps={{
startAdornment: (
<>
<ColorPicker
hideTextfield
deferred
value={color}
onChange={(newColor: Color) => onColorChange(name, "#" + newColor.hex)}
disableAlpha
/>
</>
),
endAdornment: (
<>
<IconButton onClick={() => onColorChange(name, defaultColor)}>
<ReplyIcon color="primary" />
</IconButton>
</>
),
}}
/>
</>
);
}
export function ThemeEditorModal(props: IProps): React.ReactElement {
const [customTheme, setCustomTheme] = useState<{ [key: string]: string | undefined }>({
...Settings.theme,
});
const predefinedThemes = getPredefinedThemes();
const themes = predefinedThemes && Object.entries(predefinedThemes)
.map(([key, templateTheme]) => {
const name = templateTheme.name || key;
let inner = <Typography>{name}</Typography>;
let toolTipTitle;
if (templateTheme.credit) {
toolTipTitle = <Typography>{templateTheme.description || name} <em>by {templateTheme.credit}</em></Typography>;
} else if (templateTheme.description) {
toolTipTitle = <Typography>{templateTheme.description}</Typography>;
}
if (toolTipTitle) {
inner = <Tooltip title={toolTipTitle}>{inner}</Tooltip>
}
return (
<Button onClick={() => setTemplateTheme(templateTheme.colors)}
startIcon={<PaletteSharpIcon />} key={key} sx={{ mr: 1, mb: 1 }}>
{inner}
</Button>
);
}) || <></>;
function setTheme(theme: UserInterfaceTheme): void {
setCustomTheme(theme);
Object.assign(Settings.theme, theme);
ThemeEvents.emit();
}
function onThemeChange(event: React.ChangeEvent<HTMLInputElement>): void {
try {
const importedTheme = JSON.parse(event.target.value);
if (typeof importedTheme !== "object") return;
setCustomTheme(importedTheme);
for (const key of Object.keys(importedTheme)) {
Settings.theme[key] = importedTheme[key];
}
ThemeEvents.emit();
} catch (err) {
// ignore
}
}
function onColorChange(name: string, value: string): void {
setCustomTheme((old: any) => {
old[name] = value;
return old;
});
Settings.theme[name] = value;
ThemeEvents.emit();
}
function setTemplateTheme(theme: UserInterfaceTheme): void {
setTheme(theme);
}
return (
<Modal open={props.open} onClose={props.onClose}>
<Paper sx={{ px: 1, py: 1, my: 1 }}>
<Tooltip open={true} placement={"top"} title={<Typography>Example tooltip</Typography>}>
<Button color="primary" size="small">primary button</Button>
</Tooltip>
<Button color="secondary" size="small">secondary button</Button>
<Button color="warning" size="small">warning button</Button>
<Button color="info" size="small">info button</Button>
<Button color="error" size="small">error button</Button>
<Button disabled size="small">disabled button</Button>
<br />
<Typography color="primary" variant="caption">text with primary color</Typography>&nbsp;
<Typography color="secondary" variant="caption">text with secondary color</Typography>&nbsp;
<Typography color="error" variant="caption">text with error color</Typography>
<br />
<TextField value={"Text field"} size="small" />
</Paper>
<Paper sx={{ py: 1, my: 1 }}>
<ColorEditor
name="primarylight"
onColorChange={onColorChange}
color={customTheme["primarylight"]}
defaultColor={defaultSettings.theme["primarylight"]}
/>
<ColorEditor
name="primary"
onColorChange={onColorChange}
color={customTheme["primary"]}
defaultColor={defaultSettings.theme["primary"]}
/>
<ColorEditor
name="primarydark"
onColorChange={onColorChange}
color={customTheme["primarydark"]}
defaultColor={defaultSettings.theme["primarydark"]}
/>
<br />
<ColorEditor
name="successlight"
onColorChange={onColorChange}
color={customTheme["successlight"]}
defaultColor={defaultSettings.theme["successlight"]}
/>
<ColorEditor
name="success"
onColorChange={onColorChange}
color={customTheme["success"]}
defaultColor={defaultSettings.theme["success"]}
/>
<ColorEditor
name="successdark"
onColorChange={onColorChange}
color={customTheme["successdark"]}
defaultColor={defaultSettings.theme["successdark"]}
/>
<br />
<ColorEditor
name="errorlight"
onColorChange={onColorChange}
color={customTheme["errorlight"]}
defaultColor={defaultSettings.theme["errorlight"]}
/>
<ColorEditor
name="error"
onColorChange={onColorChange}
color={customTheme["error"]}
defaultColor={defaultSettings.theme["error"]}
/>
<ColorEditor
name="errordark"
onColorChange={onColorChange}
color={customTheme["errordark"]}
defaultColor={defaultSettings.theme["errordark"]}
/>
<br />
<ColorEditor
name="secondarylight"
onColorChange={onColorChange}
color={customTheme["secondarylight"]}
defaultColor={defaultSettings.theme["secondarylight"]}
/>
<ColorEditor
name="secondary"
onColorChange={onColorChange}
color={customTheme["secondary"]}
defaultColor={defaultSettings.theme["secondary"]}
/>
<ColorEditor
name="secondarydark"
onColorChange={onColorChange}
color={customTheme["secondarydark"]}
defaultColor={defaultSettings.theme["secondarydark"]}
/>
<br />
<ColorEditor
name="warninglight"
onColorChange={onColorChange}
color={customTheme["warninglight"]}
defaultColor={defaultSettings.theme["warninglight"]}
/>
<ColorEditor
name="warning"
onColorChange={onColorChange}
color={customTheme["warning"]}
defaultColor={defaultSettings.theme["warning"]}
/>
<ColorEditor
name="warningdark"
onColorChange={onColorChange}
color={customTheme["warningdark"]}
defaultColor={defaultSettings.theme["warningdark"]}
/>
<br />
<ColorEditor
name="infolight"
onColorChange={onColorChange}
color={customTheme["infolight"]}
defaultColor={defaultSettings.theme["infolight"]}
/>
<ColorEditor
name="info"
onColorChange={onColorChange}
color={customTheme["info"]}
defaultColor={defaultSettings.theme["info"]}
/>
<ColorEditor
name="infodark"
onColorChange={onColorChange}
color={customTheme["infodark"]}
defaultColor={defaultSettings.theme["infodark"]}
/>
<br />
<ColorEditor
name="welllight"
onColorChange={onColorChange}
color={customTheme["welllight"]}
defaultColor={defaultSettings.theme["welllight"]}
/>
<ColorEditor
name="well"
onColorChange={onColorChange}
color={customTheme["well"]}
defaultColor={defaultSettings.theme["well"]}
/>
<ColorEditor
name="white"
onColorChange={onColorChange}
color={customTheme["white"]}
defaultColor={defaultSettings.theme["white"]}
/>
<ColorEditor
name="black"
onColorChange={onColorChange}
color={customTheme["black"]}
defaultColor={defaultSettings.theme["black"]}
/>
<ColorEditor
name="backgroundprimary"
onColorChange={onColorChange}
color={customTheme["backgroundprimary"]}
defaultColor={defaultSettings.theme["backgroundprimary"]}
/>
<ColorEditor
name="backgroundsecondary"
onColorChange={onColorChange}
color={customTheme["backgroundsecondary"]}
defaultColor={defaultSettings.theme["backgroundsecondary"]}
/>
<ColorEditor
name="button"
onColorChange={onColorChange}
color={customTheme["button"]}
defaultColor={defaultSettings.theme["button"]}
/>
<br />
<ColorEditor
name="hp"
onColorChange={onColorChange}
color={customTheme["hp"]}
defaultColor={defaultSettings.theme["hp"]}
/>
<ColorEditor
name="money"
onColorChange={onColorChange}
color={customTheme["money"]}
defaultColor={defaultSettings.theme["money"]}
/>
<ColorEditor
name="hack"
onColorChange={onColorChange}
color={customTheme["hack"]}
defaultColor={defaultSettings.theme["hack"]}
/>
<ColorEditor
name="combat"
onColorChange={onColorChange}
color={customTheme["combat"]}
defaultColor={defaultSettings.theme["combat"]}
/>
<ColorEditor
name="cha"
onColorChange={onColorChange}
color={customTheme["cha"]}
defaultColor={defaultSettings.theme["cha"]}
/>
<ColorEditor
name="int"
onColorChange={onColorChange}
color={customTheme["int"]}
defaultColor={defaultSettings.theme["int"]}
/>
<ColorEditor
name="rep"
onColorChange={onColorChange}
color={customTheme["rep"]}
defaultColor={defaultSettings.theme["rep"]}
/>
<ColorEditor
name="disabled"
onColorChange={onColorChange}
color={customTheme["disabled"]}
defaultColor={defaultSettings.theme["disabled"]}
/>
</Paper>
<Paper sx={{ px: 1, py: 1, my: 1 }}>
<TextField
sx={{ mb: 1 }}
multiline
fullWidth
maxRows={3}
label={"import / export theme"}
value={JSON.stringify(customTheme)}
onChange={onThemeChange}
/>
<>
<Typography sx={{ my: 1 }}>Backup your theme or share it with others by copying the string above.</Typography>
<Typography sx={{ my: 1 }}>Replace the current theme with a pre-built template using the buttons below.</Typography>
{themes}
</>
</Paper>
</Modal>
);
}