feature/IO-3702-ESPD-UI-AND-FIXES - Stage 6 (Dark Mode)

This commit is contained in:
Dave
2026-05-27 13:55:09 -04:00
parent 527a5ef16d
commit f3523ec821
7 changed files with 268 additions and 137 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ override.tf.json
.terraformrc .terraformrc
terraform.rc terraform.rc
/.eslintcache

View File

@@ -10,6 +10,7 @@ const store = new Store({
enabled: false, enabled: false,
interval: 30000, interval: 30000,
}, },
darkMode: false,
esApiKey: "", esApiKey: "",
}, },
app: { app: {

View File

@@ -1,5 +1,5 @@
import { ConfigProvider, Layout } from "antd"; import { ConfigProvider, Layout, theme } from "antd";
import { FC, useEffect } from "react"; import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { HashRouter, Route, Routes, useNavigate } from "react-router"; import { HashRouter, Route, Routes, useNavigate } from "react-router";
@@ -9,8 +9,11 @@ import Settings from "./components/Settings/Settings";
import UpdateAvailable from "./components/UpdateAvailable/UpdateAvailable"; import UpdateAvailable from "./components/UpdateAvailable/UpdateAvailable";
import reduxStore from "./redux/redux-store"; import reduxStore from "./redux/redux-store";
import { NotificationProvider } from "./util/notificationContext"; import { NotificationProvider } from "./util/notificationContext";
import { ThemeModeContext } from "./util/themeModeContext";
import ipcTypes from "../../util/ipcTypes.json"; import ipcTypes from "../../util/ipcTypes.json";
const { darkAlgorithm, defaultAlgorithm } = theme;
const ScrubHistoryNavigationBridge: FC = () => { const ScrubHistoryNavigationBridge: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,53 +37,112 @@ const ScrubHistoryNavigationBridge: FC = () => {
return null; return null;
}; };
const App: FC = () => { const AppShell: FC = () => {
const { token } = theme.useToken();
return ( return (
<ConfigProvider <Provider store={reduxStore}>
theme={{ <HashRouter>
token: { <ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
borderRadius: 8, <NotificationProvider>
}, <ScrubHistoryNavigationBridge />
components: { <Layout
Card: { style={{
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)", height: "100vh",
}, minHeight: 0,
}, overflow: "hidden",
}} background: token.colorBgLayout,
> }}
<Provider store={reduxStore}> >
<HashRouter> <Layout.Content
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<NotificationProvider>
<ScrubHistoryNavigationBridge />
<Layout
style={{ style={{
height: "100vh", height: "100%",
minHeight: 0, minHeight: 0,
overflow: "hidden", overflow: "auto",
background: "#f0f2f5", padding: "24px",
}} }}
> >
<Layout.Content <UpdateAvailable />
style={{ <Routes>
height: "100%", <Route path="/" element={<Home />} />
minHeight: 0, <Route path="/settings" element={<Settings />} />
overflow: "auto", </Routes>
padding: "24px", </Layout.Content>
}} </Layout>
> </NotificationProvider>
<UpdateAvailable /> </ErrorBoundary>
<Routes> </HashRouter>
<Route path="/" element={<Home />} /> </Provider>
<Route path="/settings" element={<Settings />} /> );
</Routes> };
</Layout.Content>
</Layout> const App: FC = () => {
</NotificationProvider> const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
</ErrorBoundary>
</HashRouter> useEffect(() => {
</Provider> let cancelled = false;
</ConfigProvider>
window.electron.ipcRenderer
.invoke(ipcTypes.toMain.settings.get, "darkMode")
.then((value: unknown) => {
if (!cancelled) {
setIsDarkMode(value === true);
}
})
.catch((error) => {
console.error("Failed to load dark mode setting", error);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
document.documentElement.style.colorScheme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
const updateDarkMode = useCallback((enabled: boolean): void => {
setIsDarkMode(enabled);
window.electron.ipcRenderer
.invoke(ipcTypes.toMain.settings.set, "darkMode", enabled)
.catch((error) => {
console.error("Failed to save dark mode setting", error);
});
}, []);
const themeModeContextValue = useMemo(
() => ({
isDarkMode,
setDarkMode: updateDarkMode,
}),
[isDarkMode, updateDarkMode],
);
const appTheme = useMemo(
() => ({
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
token: {
borderRadius: 8,
},
components: {
Card: {
boxShadow: isDarkMode
? "0 2px 8px rgba(0, 0, 0, 0.45)"
: "0 2px 8px rgba(0, 0, 0, 0.1)",
},
},
}),
[isDarkMode],
);
return (
<ThemeModeContext.Provider value={themeModeContextValue}>
<ConfigProvider theme={appTheme}>
<AppShell />
</ConfigProvider>
</ThemeModeContext.Provider>
); );
}; };

View File

@@ -0,0 +1,33 @@
import { Card, Flex, Space, Switch, Typography } from "antd";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { useThemeMode } from "@renderer/util/themeModeContext";
const SettingsAppearance: FC = () => {
const { t } = useTranslation();
const { isDarkMode, setDarkMode } = useThemeMode();
return (
<Card title={t("settings.labels.appearance")}>
<Flex align="center" justify="space-between" gap="large" wrap>
<Space orientation="vertical" size={2}>
<Typography.Text strong>
{t("settings.labels.darkMode")}
</Typography.Text>
<Typography.Text type="secondary">
{t("settings.labels.darkModeDescription")}
</Typography.Text>
</Space>
<Switch
checked={isDarkMode}
checkedChildren={t("settings.labels.dark")}
unCheckedChildren={t("settings.labels.light")}
onChange={setDarkMode}
/>
</Flex>
</Card>
);
};
export default SettingsAppearance;

View File

@@ -3,6 +3,7 @@ import { Button, Space } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined } from "@ant-design/icons";
import { FC } from "react"; import { FC } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import SettingsAppearance from "./Settings.Appearance";
import SettingsConfig from "./Settings.Config"; import SettingsConfig from "./Settings.Config";
import SettingsWatcher from "./Settings.Watcher"; import SettingsWatcher from "./Settings.Watcher";
@@ -10,20 +11,30 @@ const Settings: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Space orientation="vertical" size="large" style={{ width: "100%" }}> <div
<Button style={{
type="text" maxWidth: "1400px",
icon={<ArrowLeftOutlined />} height: "100%",
onClick={() => navigate("/")} margin: "0 auto",
style={{ alignSelf: "flex-start" }} }}
> >
Back <Space orientation="vertical" size="large" style={{ width: "100%" }}>
</Button> <Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/")}
style={{ alignSelf: "flex-start" }}
>
Back
</Button>
<SettingsWatcher /> <SettingsAppearance />
<SettingsConfig /> <SettingsWatcher />
</Space>
<SettingsConfig />
</Space>
</div>
); );
}; };

View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
export type ThemeModeContextValue = {
isDarkMode: boolean;
setDarkMode: (enabled: boolean) => void;
};
export const ThemeModeContext = createContext<ThemeModeContextValue | null>(
null,
);
export function useThemeMode(): ThemeModeContextValue {
const context = useContext(ThemeModeContext);
if (!context) {
throw new Error("useThemeMode must be used within ThemeModeContext.");
}
return context;
}

View File

@@ -1,83 +1,88 @@
{ {
"translation": { "translation": {
"auth": { "auth": {
"labels": { "labels": {
"welcome": "Hi {{name}}" "welcome": "Hi {{name}}"
}, },
"login": { "login": {
"error": "The username and password combination provided is not valid.", "error": "The username and password combination provided is not valid.",
"login": "Log In", "login": "Log In",
"resetpassword": "Reset Password" "resetpassword": "Reset Password"
} }
}, },
"dashboard": { "dashboard": {
"actions": { "actions": {
"clear_all": "Clear All" "clear_all": "Clear All"
}, },
"labels": { "labels": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"estimate_scrub_history": "Scrub History", "estimate_scrub_history": "Scrub History",
"last_processed": "Last Processed at", "last_processed": "Last Processed at",
"scrub_results": "Scrub Results", "scrub_results": "Scrub Results",
"total_estimates": "Total Estimates" "total_estimates": "Total Estimates"
} }
}, },
"errors": { "errors": {
"errorboundary": "Uh oh - we've hit an error.", "errorboundary": "Uh oh - we've hit an error.",
"notificationtitle": "Error Encountered" "notificationtitle": "Error Encountered"
}, },
"navigation": { "navigation": {
"home": "Home", "home": "Home",
"settings": "Settings", "settings": "Settings",
"signout": "Sign Out" "signout": "Sign Out"
}, },
"settings": { "settings": {
"actions": { "actions": {
"addpath": "Add path", "addpath": "Add path",
"startwatcher": "Start Watcher", "startwatcher": "Start Watcher",
"stopwatcher": "Stop Watcher\n" "stopwatcher": "Stop Watcher\n"
}, },
"errors": { "errors": {
"duplicatePath": "The selected directory is already used in another configuration." "duplicatePath": "The selected directory is already used in another configuration."
}, },
"labels": { "labels": {
"actions": "Actions", "actions": "Actions",
"addPaintScalePath": "Add Paint Scale Path", "addPaintScalePath": "Add Paint Scale Path",
"config": "Configuration", "appearance": "Appearance",
"emsOutFilePath": "EMS Out File Path (Parts Order, etc.)", "config": "Configuration",
"esApiKey": "Estimate Scrubber API Key", "dark": "Dark",
"invalidPath": "Path not set or invalid", "darkMode": "Dark Mode",
"paintScalePath": "Paint Scale Path", "darkModeDescription": "Use the dark application theme.",
"paintScaleSettingsInput": "BSMS To Paint Scale", "emsOutFilePath": "EMS Out File Path (Parts Order, etc.)",
"paintScaleSettingsOutput": "Paint Scale To BSMS", "esApiKey": "Estimate Scrubber API Key",
"paintScaleType": "Paint Scale Type", "invalidPath": "Path not set or invalid",
"pollingInterval": "Polling Interval (m)", "light": "Light",
"pollinginterval": "Polling Interval (ms)", "paintScalePath": "Paint Scale Path",
"ppcfilepath": "Parts Price Change File Path", "paintScaleSettingsInput": "BSMS To Paint Scale",
"remove": "Remove", "paintScaleSettingsOutput": "Paint Scale To BSMS",
"selectPaintScaleType": "Select Paint Scale Type", "paintScaleType": "Paint Scale Type",
"started": "Started", "pollingInterval": "Polling Interval (m)",
"stopped": "Stopped", "pollinginterval": "Polling Interval (ms)",
"validPath": "Valid path", "ppcfilepath": "Parts Price Change File Path",
"watchedpaths": "Watched Paths", "remove": "Remove",
"watchermodepolling": "Polling", "selectPaintScaleType": "Select Paint Scale Type",
"watchermoderealtime": "Real Time", "started": "Started",
"watcherstatus": "Watcher Status" "stopped": "Stopped",
}, "validPath": "Valid path",
"validation": { "watchedpaths": "Watched Paths",
"esApiKeyRequired": "Estimate Scrubber API Key is required." "watchermodepolling": "Polling",
} "watchermoderealtime": "Real Time",
}, "watcherstatus": "Watcher Status"
"title": { },
"imex": "ImEX Online", "validation": {
"rome": "Rome Online" "esApiKeyRequired": "Estimate Scrubber API Key is required."
}, }
"updates": { },
"apply": "Apply Update", "title": {
"applying": "Applying update", "imex": "ImEX Online",
"available": "An update is available.", "rome": "Rome Online"
"download": "Download Update", },
"downloading": "An update is downloading." "updates": {
} "apply": "Apply Update",
} "applying": "Applying update",
"available": "An update is available.",
"download": "Download Update",
"downloading": "An update is downloading."
}
}
} }