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
terraform.rc
/.eslintcache

View File

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

View File

@@ -1,5 +1,5 @@
import { ConfigProvider, Layout } from "antd";
import { FC, useEffect } from "react";
import { ConfigProvider, Layout, theme } from "antd";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Provider } from "react-redux";
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 reduxStore from "./redux/redux-store";
import { NotificationProvider } from "./util/notificationContext";
import { ThemeModeContext } from "./util/themeModeContext";
import ipcTypes from "../../util/ipcTypes.json";
const { darkAlgorithm, defaultAlgorithm } = theme;
const ScrubHistoryNavigationBridge: FC = () => {
const navigate = useNavigate();
@@ -34,53 +37,112 @@ const ScrubHistoryNavigationBridge: FC = () => {
return null;
};
const App: FC = () => {
const AppShell: FC = () => {
const { token } = theme.useToken();
return (
<ConfigProvider
theme={{
token: {
borderRadius: 8,
},
components: {
Card: {
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
},
},
}}
>
<Provider store={reduxStore}>
<HashRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<NotificationProvider>
<ScrubHistoryNavigationBridge />
<Layout
<Provider store={reduxStore}>
<HashRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<NotificationProvider>
<ScrubHistoryNavigationBridge />
<Layout
style={{
height: "100vh",
minHeight: 0,
overflow: "hidden",
background: token.colorBgLayout,
}}
>
<Layout.Content
style={{
height: "100vh",
height: "100%",
minHeight: 0,
overflow: "hidden",
background: "#f0f2f5",
overflow: "auto",
padding: "24px",
}}
>
<Layout.Content
style={{
height: "100%",
minHeight: 0,
overflow: "auto",
padding: "24px",
}}
>
<UpdateAvailable />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout.Content>
</Layout>
</NotificationProvider>
</ErrorBoundary>
</HashRouter>
</Provider>
</ConfigProvider>
<UpdateAvailable />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout.Content>
</Layout>
</NotificationProvider>
</ErrorBoundary>
</HashRouter>
</Provider>
);
};
const App: FC = () => {
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
useEffect(() => {
let cancelled = false;
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 { FC } from "react";
import { useNavigate } from "react-router";
import SettingsAppearance from "./Settings.Appearance";
import SettingsConfig from "./Settings.Config";
import SettingsWatcher from "./Settings.Watcher";
@@ -10,20 +11,30 @@ const Settings: FC = () => {
const navigate = useNavigate();
return (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/")}
style={{ alignSelf: "flex-start" }}
>
Back
</Button>
<div
style={{
maxWidth: "1400px",
height: "100%",
margin: "0 auto",
}}
>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/")}
style={{ alignSelf: "flex-start" }}
>
Back
</Button>
<SettingsWatcher />
<SettingsAppearance />
<SettingsConfig />
</Space>
<SettingsWatcher />
<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": {
"auth": {
"labels": {
"welcome": "Hi {{name}}"
},
"login": {
"error": "The username and password combination provided is not valid.",
"login": "Log In",
"resetpassword": "Reset Password"
}
},
"dashboard": {
"actions": {
"clear_all": "Clear All"
},
"labels": {
"dashboard": "Dashboard",
"estimate_scrub_history": "Scrub History",
"last_processed": "Last Processed at",
"scrub_results": "Scrub Results",
"total_estimates": "Total Estimates"
}
},
"errors": {
"errorboundary": "Uh oh - we've hit an error.",
"notificationtitle": "Error Encountered"
},
"navigation": {
"home": "Home",
"settings": "Settings",
"signout": "Sign Out"
},
"settings": {
"actions": {
"addpath": "Add path",
"startwatcher": "Start Watcher",
"stopwatcher": "Stop Watcher\n"
},
"errors": {
"duplicatePath": "The selected directory is already used in another configuration."
},
"labels": {
"actions": "Actions",
"addPaintScalePath": "Add Paint Scale Path",
"config": "Configuration",
"emsOutFilePath": "EMS Out File Path (Parts Order, etc.)",
"esApiKey": "Estimate Scrubber API Key",
"invalidPath": "Path not set or invalid",
"paintScalePath": "Paint Scale Path",
"paintScaleSettingsInput": "BSMS To Paint Scale",
"paintScaleSettingsOutput": "Paint Scale To BSMS",
"paintScaleType": "Paint Scale Type",
"pollingInterval": "Polling Interval (m)",
"pollinginterval": "Polling Interval (ms)",
"ppcfilepath": "Parts Price Change File Path",
"remove": "Remove",
"selectPaintScaleType": "Select Paint Scale Type",
"started": "Started",
"stopped": "Stopped",
"validPath": "Valid path",
"watchedpaths": "Watched Paths",
"watchermodepolling": "Polling",
"watchermoderealtime": "Real Time",
"watcherstatus": "Watcher Status"
},
"validation": {
"esApiKeyRequired": "Estimate Scrubber API Key is required."
}
},
"title": {
"imex": "ImEX Online",
"rome": "Rome Online"
},
"updates": {
"apply": "Apply Update",
"applying": "Applying update",
"available": "An update is available.",
"download": "Download Update",
"downloading": "An update is downloading."
}
}
"translation": {
"auth": {
"labels": {
"welcome": "Hi {{name}}"
},
"login": {
"error": "The username and password combination provided is not valid.",
"login": "Log In",
"resetpassword": "Reset Password"
}
},
"dashboard": {
"actions": {
"clear_all": "Clear All"
},
"labels": {
"dashboard": "Dashboard",
"estimate_scrub_history": "Scrub History",
"last_processed": "Last Processed at",
"scrub_results": "Scrub Results",
"total_estimates": "Total Estimates"
}
},
"errors": {
"errorboundary": "Uh oh - we've hit an error.",
"notificationtitle": "Error Encountered"
},
"navigation": {
"home": "Home",
"settings": "Settings",
"signout": "Sign Out"
},
"settings": {
"actions": {
"addpath": "Add path",
"startwatcher": "Start Watcher",
"stopwatcher": "Stop Watcher\n"
},
"errors": {
"duplicatePath": "The selected directory is already used in another configuration."
},
"labels": {
"actions": "Actions",
"addPaintScalePath": "Add Paint Scale Path",
"appearance": "Appearance",
"config": "Configuration",
"dark": "Dark",
"darkMode": "Dark Mode",
"darkModeDescription": "Use the dark application theme.",
"emsOutFilePath": "EMS Out File Path (Parts Order, etc.)",
"esApiKey": "Estimate Scrubber API Key",
"invalidPath": "Path not set or invalid",
"light": "Light",
"paintScalePath": "Paint Scale Path",
"paintScaleSettingsInput": "BSMS To Paint Scale",
"paintScaleSettingsOutput": "Paint Scale To BSMS",
"paintScaleType": "Paint Scale Type",
"pollingInterval": "Polling Interval (m)",
"pollinginterval": "Polling Interval (ms)",
"ppcfilepath": "Parts Price Change File Path",
"remove": "Remove",
"selectPaintScaleType": "Select Paint Scale Type",
"started": "Started",
"stopped": "Stopped",
"validPath": "Valid path",
"watchedpaths": "Watched Paths",
"watchermodepolling": "Polling",
"watchermoderealtime": "Real Time",
"watcherstatus": "Watcher Status"
},
"validation": {
"esApiKeyRequired": "Estimate Scrubber API Key is required."
}
},
"title": {
"imex": "ImEX Online",
"rome": "Rome Online"
},
"updates": {
"apply": "Apply Update",
"applying": "Applying update",
"available": "An update is available.",
"download": "Download Update",
"downloading": "An update is downloading."
}
}
}