Add theme browser page accessible from game options

Removed the themes buttons that were in the ThemeEditorModal and only
left a "Revert to Default" button along with a link to the ThemeBrowser
page.

Split off the buttons into reusable components since they are now used
in two pages.

Display the themes in big cards with a zoomable screenshot. Applying the
theme now shows a toast with an option to undo the action.

The snackbar now allows ReactNode instead of only strings.

- Add link with details on how to create a new theme in the game.
- Add link to the theme-sharing discord channel.
- Add icons to the theme & style buttons in GameOptions
- Add "Theme Editor" button to ThemeBrowser
- Add "Style Editor" button to ThemeBrowser
- Move Styles related files into Themes folder
- Includes a modal that shows a bigger version of the screenshot.
- Change Snackbar to allow for ReactNode as the message
This commit is contained in:
Martin Fournier 2022-01-19 09:49:08 -05:00
parent 61d6e43b37
commit a26b9c8dcf
15 changed files with 294 additions and 41 deletions

@ -6,7 +6,7 @@ import { GameInfo, IStyleSettings, UserInterface as IUserInterface, UserInterfac
import { Settings } from "../Settings/Settings"; import { Settings } from "../Settings/Settings";
import { ThemeEvents } from "../Themes/ui/Theme"; import { ThemeEvents } from "../Themes/ui/Theme";
import { defaultTheme } from "../Themes/Themes"; import { defaultTheme } from "../Themes/Themes";
import { defaultStyles } from "../Settings/Styles"; import { defaultStyles } from "../Themes/Styles";
import { CONSTANTS } from "../Constants"; import { CONSTANTS } from "../Constants";
import { hash } from "../hash/hash"; import { hash } from "../hash/hash";

@ -1,7 +1,7 @@
import { ISelfInitializer, ISelfLoading } from "../types"; import { ISelfInitializer, ISelfLoading } from "../types";
import { OwnedAugmentationsOrderSetting, PurchaseAugmentationsOrderSetting } from "./SettingEnums"; import { OwnedAugmentationsOrderSetting, PurchaseAugmentationsOrderSetting } from "./SettingEnums";
import { defaultTheme, ITheme } from "../Themes/Themes"; import { defaultTheme, ITheme } from "../Themes/Themes";
import { defaultStyles } from "./Styles"; import { defaultStyles } from "../Themes/Styles";
import { WordWrapOptions } from "../ScriptEditor/ui/Options"; import { WordWrapOptions } from "../ScriptEditor/ui/Options";
import { OverviewSettings } from "../ui/React/Overview"; import { OverviewSettings } from "../ui/React/Overview";
import { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions"; import { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions";

@ -8,7 +8,8 @@ See [CONTRIBUTING.md](/CONTRIBUTING.md) for details.
1. Duplicate one of the folders in `/src/Themes/data` and give it a new name (keep the hyphenated format) 1. Duplicate one of the folders in `/src/Themes/data` and give it a new name (keep the hyphenated format)
2. Modify the data in the new `/src/Themes/data/new-folder/index.ts` file 2. Modify the data in the new `/src/Themes/data/new-folder/index.ts` file
3. Add the import/export into the `/src/Themes/data/index.ts` file 3. Replace the screenshot.png with one of your theme
4. Add the import/export into the `/src/Themes/data/index.ts` file
The themes are ordered according to the export order in `index.ts` The themes are ordered according to the export order in `index.ts`

@ -0,0 +1,19 @@
import React, { useState } from "react";
import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip";
import TextFormatIcon from "@mui/icons-material/TextFormat";
import { StyleEditorModal } from "./StyleEditorModal";
export function StyleEditorButton(): React.ReactElement {
const [styleEditorOpen, setStyleEditorOpen] = useState(false);
return (
<>
<Tooltip title="The style editor allows you to modify certain CSS rules used by the game.">
<Button startIcon={<TextFormatIcon />} onClick={() => setStyleEditorOpen(true)}>
Style Editor
</Button>
</Tooltip>
<StyleEditorModal open={styleEditorOpen} onClose={() => setStyleEditorOpen(false)} />
</>
);
}

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Modal } from "./Modal"; import { Modal } from "../../ui/React/Modal";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup"; import ButtonGroup from "@mui/material/ButtonGroup";
@ -9,9 +9,9 @@ import TextField from "@mui/material/TextField";
import ReplyIcon from "@mui/icons-material/Reply"; import ReplyIcon from "@mui/icons-material/Reply";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import { ThemeEvents } from "../../Themes/ui/Theme"; import { ThemeEvents } from "./Theme";
import { Settings } from "../../Settings/Settings"; import { Settings } from "../../Settings/Settings";
import { defaultStyles } from "../../Settings/Styles"; import { defaultStyles } from "../Styles";
import { Tooltip } from "@mui/material"; import { Tooltip } from "@mui/material";
import { IStyleSettings } from "../../ScriptEditor/NetscriptDefinitions"; import { IStyleSettings } from "../../ScriptEditor/NetscriptDefinitions";

@ -0,0 +1,93 @@
import React, { useEffect, useState } from "react";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import { ThemeEvents } from "./Theme";
import { Settings } from "../../Settings/Settings";
import { getPredefinedThemes, IPredefinedTheme } from "../Themes";
import { Box, ButtonGroup, Button } from "@mui/material";
import { IRouter } from "../../ui/Router";
import { ThemeEditorButton } from "./ThemeEditorButton";
import { StyleEditorButton } from "./StyleEditorButton";
import { ThemeEntry } from "./ThemeEntry";
import { ThemeCollaborate } from "./ThemeCollaborate";
import { Modal } from "../../ui/React/Modal";
import { SnackbarEvents } from "../../ui/React/Snackbar";
interface IProps {
router: IRouter;
}
// Everything dies when the theme gets reloaded, so we'll keep the current scroll to not jump around.
let previousScrollY = 0;
export function ThemeBrowser({ router }: IProps): React.ReactElement {
const [modalOpen, setModalOpen] = useState(false);
const [modalImageSrc, setModalImageSrc] = useState<string | undefined>();
const predefinedThemes = getPredefinedThemes();
const themes = (predefinedThemes &&
Object.entries(predefinedThemes).map(([key, templateTheme]) => (
<ThemeEntry
key={key}
theme={templateTheme}
onActivated={() => setTheme(templateTheme)}
onImageClick={handleZoom}
/>
))) || <></>;
function setTheme(theme: IPredefinedTheme): void {
previousScrollY = window.scrollY;
const previousColors = { ...Settings.theme };
Object.assign(Settings.theme, theme.colors);
ThemeEvents.emit();
SnackbarEvents.emit(
<>
Updated theme to "<strong>{theme.name}</strong>"
<Button
sx={{ ml: 1 }}
color="secondary"
size="small"
onClick={() => {
Object.assign(Settings.theme, previousColors);
ThemeEvents.emit();
}}
>
UNDO
</Button>
</>,
"info",
30000,
);
}
function handleZoom(src: string): void {
previousScrollY = window.scrollY;
setModalImageSrc(src);
setModalOpen(true);
}
function handleCloseZoom(): void {
previousScrollY = window.scrollY;
setModalOpen(false);
}
useEffect(() => {
requestAnimationFrame(() => window.scrollTo(0, previousScrollY));
});
return (
<Box sx={{ mx: 2 }}>
<Typography variant="h4">Theme Browser</Typography>
<Paper sx={{ px: 2, py: 1, my: 1 }}>
<ThemeCollaborate />
<ButtonGroup sx={{ mb: 2, display: "block" }}>
<ThemeEditorButton router={router} />
<StyleEditorButton />
</ButtonGroup>
<Box sx={{ display: "flex", flexWrap: "wrap" }}>{themes}</Box>
<Modal open={modalOpen} onClose={handleCloseZoom}>
<img src={modalImageSrc} style={{ width: "100%" }} />
</Modal>
</Paper>
</Box>
);
}

@ -0,0 +1,24 @@
import React from "react";
import Typography from "@mui/material/Typography";
import { Link } from "@mui/material";
export function ThemeCollaborate(): React.ReactElement {
return (
<>
<Typography sx={{ my: 1 }}>
If you've created a theme that you believe should be added in game's theme browser, feel free to{" "}
<Link href="https://github.com/danielyxie/bitburner/tree/dev/src/Themes/README.md" target="_blank">
create a pull request
</Link>
.
</Typography>
<Typography sx={{ my: 1 }}>
Head over to the{" "}
<Link href="https://discord.com/channels/415207508303544321/921991895230611466" target="_blank">
theme-sharing
</Link>{" "}
discord channel for more.
</Typography>
</>
);
}

@ -0,0 +1,24 @@
import React, { useState } from "react";
import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip";
import { ThemeEditorModal } from "./ThemeEditorModal";
import { IRouter } from "../../ui/Router";
import ColorizeIcon from "@mui/icons-material/Colorize";
interface IProps {
router: IRouter;
}
export function ThemeEditorButton({ router }: IProps): React.ReactElement {
const [themeEditorOpen, setThemeEditorOpen] = useState(false);
return (
<>
<Tooltip title="The theme editor allows you to modify the colors the game uses.">
<Button startIcon={<ColorizeIcon />} onClick={() => setThemeEditorOpen(true)}>
Theme Editor
</Button>
</Tooltip>
<ThemeEditorModal open={themeEditorOpen} onClose={() => setThemeEditorOpen(false)} router={router} />
</>
);
}

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Modal } from "../../ui/React/Modal"; import { Modal } from "../../ui/React/Modal";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
@ -8,15 +9,19 @@ import TextField from "@mui/material/TextField";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import ReplyIcon from "@mui/icons-material/Reply"; import ReplyIcon from "@mui/icons-material/Reply";
import PaletteSharpIcon from "@mui/icons-material/PaletteSharp"; import PaletteSharpIcon from "@mui/icons-material/PaletteSharp";
import HistoryIcon from '@mui/icons-material/History';
import { Color, ColorPicker } from "material-ui-color"; import { Color, ColorPicker } from "material-ui-color";
import { ThemeEvents } from "./Theme"; import { ThemeEvents } from "./Theme";
import { Settings, defaultSettings } from "../../Settings/Settings"; import { Settings, defaultSettings } from "../../Settings/Settings";
import { getPredefinedThemes } from "../Themes"; import { defaultTheme } from "../Themes";
import { UserInterfaceTheme } from "../../ScriptEditor/NetscriptDefinitions"; import { UserInterfaceTheme } from "../../ScriptEditor/NetscriptDefinitions";
import { IRouter } from "../../ui/Router";
import { ThemeCollaborate } from "./ThemeCollaborate";
interface IProps { interface IProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
router: IRouter;
} }
interface IColorEditorProps { interface IColorEditorProps {
@ -68,28 +73,6 @@ export function ThemeEditorModal(props: IProps): React.ReactElement {
...Settings.theme, ...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 { function setTheme(theme: UserInterfaceTheme): void {
setCustomTheme(theme); setCustomTheme(theme);
Object.assign(Settings.theme, theme); Object.assign(Settings.theme, theme);
@ -372,8 +355,18 @@ export function ThemeEditorModal(props: IProps): React.ReactElement {
/> />
<> <>
<Typography sx={{ my: 1 }}>Backup your theme or share it with others by copying the string above.</Typography> <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> <ThemeCollaborate />
{themes} <ButtonGroup>
<Tooltip title="Reverts all modification back to the default theme. This is permanent.">
<Button onClick={() => setTemplateTheme(defaultTheme)}
startIcon={<HistoryIcon />}>
Revert to Default
</Button>
</Tooltip>
<Tooltip title="Move over to the theme browser's page to use one of our predefined themes.">
<Button startIcon={<PaletteSharpIcon />} onClick={() => props.router.toThemeBrowser()}>See more themes</Button>
</Tooltip>
</ButtonGroup>
</> </>
</Paper> </Paper>
</Modal> </Modal>

@ -0,0 +1,77 @@
import React from "react";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import PaletteSharpIcon from "@mui/icons-material/PaletteSharp";
import { Settings } from "../../Settings/Settings";
import { IPredefinedTheme } from "../Themes";
import { Link, Card, CardHeader, CardContent, CardMedia, Button } from "@mui/material";
interface IProps {
theme: IPredefinedTheme;
onActivated: () => void;
onImageClick: (src: string) => void;
}
export function ThemeEntry({ theme, onActivated, onImageClick }: IProps): React.ReactElement {
if (!theme) return <></>;
return (
<Card key={theme.screenshot} sx={{ width: 400, mr: 1, mb: 1 }}>
<CardHeader
action={
<Tooltip title="Use this theme">
<Button startIcon={<PaletteSharpIcon />} onClick={onActivated} variant="outlined">
Use
</Button>
</Tooltip>
}
title={theme.name}
subheader={
<>
by {theme.credit}{" "}
{theme.reference && (
<>
(
<Link href={theme.reference} target="_blank">
ref
</Link>
)
</>
)}
</>
}
sx={{
color: Settings.theme.primary,
"& .MuiCardHeader-subheader": {
color: Settings.theme.secondarydark,
},
"& .MuiButton-outlined": {
backgroundColor: "transparent",
},
}}
/>
<CardMedia
component="img"
width="400"
image={theme.screenshot}
alt={`Theme Screenshot of "${theme.name}"`}
sx={{
borderTop: `1px solid ${Settings.theme.welllight}`,
borderBottom: `1px solid ${Settings.theme.welllight}`,
cursor: "zoom-in",
}}
onClick={() => onImageClick(theme.screenshot)}
/>
<CardContent>
<Typography
variant="body2"
color="text.secondary"
sx={{
color: Settings.theme.primarydark,
}}
>
{theme.description}
</Typography>
</CardContent>
</Card>
);
}

@ -79,6 +79,7 @@ import { RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot";
import { AchievementsRoot } from "../Achievements/AchievementsRoot"; import { AchievementsRoot } from "../Achievements/AchievementsRoot";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { Settings } from "../Settings/Settings"; import { Settings } from "../Settings/Settings";
import { ThemeBrowser } from "../Themes/ui/ThemeBrowser";
const htmlLocation = location; const htmlLocation = location;
@ -194,6 +195,9 @@ export let Router: IRouter = {
toAchievements: () => { toAchievements: () => {
throw new Error("Router called before initialization"); throw new Error("Router called before initialization");
}, },
toThemeBrowser: () => {
throw new Error("Router called before initialization");
},
}; };
function determineStartPage(player: IPlayer): Page { function determineStartPage(player: IPlayer): Page {
@ -307,6 +311,9 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
toAchievements: () => { toAchievements: () => {
setPage(Page.Achievements); setPage(Page.Achievements);
}, },
toThemeBrowser: () => {
setPage(Page.ThemeBrowser);
}
}; };
useEffect(() => { useEffect(() => {
@ -471,6 +478,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
mainPage = ( mainPage = (
<GameOptionsRoot <GameOptionsRoot
player={player} player={player}
router={Router}
save={() => saveObject.saveGame()} save={() => saveObject.saveGame()}
export={() => { export={() => {
// Apply the export bonus before saving the game // Apply the export bonus before saving the game
@ -503,6 +511,10 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme
mainPage = <AchievementsRoot />; mainPage = <AchievementsRoot />;
break; break;
} }
case Page.ThemeBrowser: {
mainPage = <ThemeBrowser router={Router} />;
break;
}
} }
return ( return (

@ -22,12 +22,11 @@ import TextField from "@mui/material/TextField";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import UploadIcon from "@mui/icons-material/Upload"; import UploadIcon from "@mui/icons-material/Upload";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import PaletteIcon from '@mui/icons-material/Palette';
import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal"; import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal";
import { dialogBoxCreate } from "./DialogBox"; import { dialogBoxCreate } from "./DialogBox";
import { ConfirmationModal } from "./ConfirmationModal"; import { ConfirmationModal } from "./ConfirmationModal";
import { ThemeEditorModal } from "../../Themes/ui/ThemeEditorModal";
import { StyleEditorModal } from "./StyleEditorModal";
import { SnackbarEvents } from "./Snackbar"; import { SnackbarEvents } from "./Snackbar";
@ -37,6 +36,9 @@ import { formatTime } from "../../utils/helpers/formatTime";
import { OptionSwitch } from "./OptionSwitch"; import { OptionSwitch } from "./OptionSwitch";
import { DeleteGameButton } from "./DeleteGameButton"; import { DeleteGameButton } from "./DeleteGameButton";
import { SoftResetButton } from "./SoftResetButton"; import { SoftResetButton } from "./SoftResetButton";
import { IRouter } from "../Router";
import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton";
import { StyleEditorButton } from "../../Themes/ui/StyleEditorButton";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -50,6 +52,7 @@ const useStyles = makeStyles((theme: Theme) =>
interface IProps { interface IProps {
player: IPlayer; player: IPlayer;
router: IRouter;
save: () => void; save: () => void;
export: () => void; export: () => void;
forceKill: () => void; forceKill: () => void;
@ -74,8 +77,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat); const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat);
const [locale, setLocale] = useState(Settings.Locale); const [locale, setLocale] = useState(Settings.Locale);
const [diagnosticOpen, setDiagnosticOpen] = useState(false); const [diagnosticOpen, setDiagnosticOpen] = useState(false);
const [themeEditorOpen, setThemeEditorOpen] = useState(false);
const [styleEditorOpen, setStyleEditorOpen] = useState(false);
const [importSaveOpen, setImportSaveOpen] = useState(false); const [importSaveOpen, setImportSaveOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null); const [importData, setImportData] = useState<ImportData | null>(null);
@ -642,9 +643,14 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
<Button onClick={() => setDiagnosticOpen(true)}>Diagnose files</Button> <Button onClick={() => setDiagnosticOpen(true)}>Diagnose files</Button>
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr" }}>
<Button onClick={() => setThemeEditorOpen(true)}>Theme editor</Button> <Tooltip title="Head to the theme browser to see a collection of prebuilt themes.">
<Button onClick={() => setStyleEditorOpen(true)}>Style editor</Button> <Button startIcon={<PaletteIcon />} onClick={() => props.router.toThemeBrowser()}>
Theme Browser
</Button>
</Tooltip>
<ThemeEditorButton router={props.router} />
<StyleEditorButton />
</Box> </Box>
<Box> <Box>
<Link href="https://github.com/danielyxie/bitburner/issues/new" target="_blank"> <Link href="https://github.com/danielyxie/bitburner/issues/new" target="_blank">
@ -669,8 +675,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
</Box> </Box>
</Grid> </Grid>
<FileDiagnosticModal open={diagnosticOpen} onClose={() => setDiagnosticOpen(false)} /> <FileDiagnosticModal open={diagnosticOpen} onClose={() => setDiagnosticOpen(false)} />
<ThemeEditorModal open={themeEditorOpen} onClose={() => setThemeEditorOpen(false)} />
<StyleEditorModal open={styleEditorOpen} onClose={() => setStyleEditorOpen(false)} />
</div> </div>
); );
} }

@ -14,6 +14,10 @@ const useStyles = makeStyles(() => ({
snackbar: { snackbar: {
// Log popup z-index increments, so let's add a padding to be well above them. // Log popup z-index increments, so let's add a padding to be well above them.
zIndex: `${logBoxBaseZIndex + 1000} !important` as any, 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 { export function Snackbar(): React.ReactElement {
const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const { enqueueSnackbar, closeSnackbar } = useSnackbar();

@ -37,6 +37,7 @@ export enum Page {
StaneksGift, StaneksGift,
Recovery, Recovery,
Achievements, Achievements,
ThemeBrowser,
} }
export interface ScriptEditorRouteOptions { export interface ScriptEditorRouteOptions {
@ -82,4 +83,5 @@ export interface IRouter {
toLocation(location: Location): void; toLocation(location: Location): void;
toStaneksGift(): void; toStaneksGift(): void;
toAchievements(): void; toAchievements(): void;
toThemeBrowser(): void;
} }