diff --git a/src/components/Sidebar/SidebarTab.tsx b/src/components/Sidebar/SidebarTab.tsx
index 9c3dc476..2c674fdb 100644
--- a/src/components/Sidebar/SidebarTab.tsx
+++ b/src/components/Sidebar/SidebarTab.tsx
@@ -17,7 +17,7 @@ const SidebarTab = ({
return (
{title}
diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx
index 80e62708..7f5a5a0b 100644
--- a/src/components/Table/TableBody.tsx
+++ b/src/components/Table/TableBody.tsx
@@ -24,7 +24,7 @@ const TableBody = ({ title, data, columns }: ITableBodyProps) => {
-
+
{title}
{/* ({ title, data, columns }: ITableBodyProps) => {
{/* Header element */}
-
+
{table.getHeaderGroups().map((headerGroup) => (
// Row to contain header data
{headerGroup.headers.map((header) => (
// Mapping each header into the row
@@ -64,7 +65,7 @@ const TableBody = ({ title, data, columns }: ITableBodyProps) => {
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
diff --git a/src/components/Table/TableLayout.tsx b/src/components/Table/TableLayout.tsx
index 45fd7f8a..6c69b4d2 100644
--- a/src/components/Table/TableLayout.tsx
+++ b/src/components/Table/TableLayout.tsx
@@ -19,8 +19,8 @@ const TableLayout = ({
backtrace,
}: ITableLayoutProps) => {
return (
-
-
+
+
diff --git a/src/components/Waypoints/WaypointMenu.tsx b/src/components/Waypoints/WaypointMenu.tsx
index cdc89bf6..d4d11595 100644
--- a/src/components/Waypoints/WaypointMenu.tsx
+++ b/src/components/Waypoints/WaypointMenu.tsx
@@ -65,24 +65,27 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
activeWaypoint;
return (
-
+
{!!icon &&
{String.fromCodePoint(icon)}
}
-
+
{getWaypointTitle(activeWaypoint)}
-
+
{t("map.panes.waypointInfo.description")}
@@ -92,12 +95,12 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
-
+
{t("map.panes.waypointInfo.details")}
-
+
{lockedTo ? (
@@ -111,7 +114,7 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
? t("map.panes.waypointInfo.onlyNodeEdit", {
nodeName: usersMap[lockedTo]?.shortName || lockedTo,
})
- : t("map.panes.waypointInfo.anyoneEdit")}
+ : t("map.panes.waypointInfo.locked.anyoneEdit")}
@@ -133,7 +136,7 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
-
+
@@ -159,7 +162,7 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
-
+
{!expire ? (
@@ -198,7 +201,7 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
type="button"
onClick={handleEditWaypoint}
disabled={!!lockedTo && lockedTo !== device?.myNodeInfo.myNodeNum}
- className=" text-gray-500 hover:text-gray-600 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
+ className=" text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:text-gray-300 dark:disabled:text-gray-600 disabled:cursor-not-allowed transition-colors"
>
{t("map.panes.waypointInfo.editWaypoint")}
@@ -206,7 +209,7 @@ const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => {
diff --git a/src/components/config/ConfigInput.tsx b/src/components/config/ConfigInput.tsx
index c1f24ed4..4fa0fa21 100644
--- a/src/components/config/ConfigInput.tsx
+++ b/src/components/config/ConfigInput.tsx
@@ -24,7 +24,7 @@ const ConfigInput = forwardRef<
{error &&
{error}
}
diff --git a/src/components/config/ConfigLayout.tsx b/src/components/config/ConfigLayout.tsx
index c7b634cf..f437a225 100644
--- a/src/components/config/ConfigLayout.tsx
+++ b/src/components/config/ConfigLayout.tsx
@@ -24,15 +24,15 @@ const ConfigLayout = ({
children,
}: IConfigLayoutProps) => {
return (
-
+
-
+
-
+
{title}
@@ -42,7 +42,9 @@ const ConfigLayout = ({
className="cursor-pointer"
onClick={() => onTitleIconClick()}
>
- {renderTitleIcon("w-6 h-6 text-gray-400 my-auto")}
+ {renderTitleIcon(
+ "w-6 h-6 text-gray-400 dark:text-gray-400 my-auto"
+ )}
diff --git a/src/components/config/ConfigOption.tsx b/src/components/config/ConfigOption.tsx
index 10fc910b..ac518ff4 100644
--- a/src/components/config/ConfigOption.tsx
+++ b/src/components/config/ConfigOption.tsx
@@ -18,10 +18,12 @@ const ConfigOption = ({
@@ -31,10 +33,14 @@ const ConfigOption = ({
);
};
diff --git a/src/components/config/ConfigSelect.tsx b/src/components/config/ConfigSelect.tsx
new file mode 100644
index 00000000..af0c4caa
--- /dev/null
+++ b/src/components/config/ConfigSelect.tsx
@@ -0,0 +1,35 @@
+import React, { forwardRef } from "react";
+import type { DetailedHTMLProps, SelectHTMLAttributes } from "react";
+import type { UseFormRegister } from "react-hook-form";
+
+import ConfigLabel from "@components/config/ConfigLabel";
+
+export interface IConfigSelectProps
+ extends DetailedHTMLProps<
+ SelectHTMLAttributes
,
+ HTMLSelectElement
+ > {
+ text: string;
+ error?: string;
+}
+
+// eslint-disable-next-line react/display-name
+const ConfigSelect = forwardRef<
+ HTMLSelectElement,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ IConfigSelectProps & ReturnType>
+>(({ text, error, ...rest }, ref) => {
+ return (
+
+
+
+ );
+});
+
+export default ConfigSelect;
diff --git a/src/components/config/ConfigTitlebar.tsx b/src/components/config/ConfigTitlebar.tsx
index a50f0fd7..468cb63c 100644
--- a/src/components/config/ConfigTitlebar.tsx
+++ b/src/components/config/ConfigTitlebar.tsx
@@ -26,16 +26,20 @@ const ConfigTitle = ({
children,
}: IConfigTitleProps) => {
return (
-
-
+
+
-
{title}
-
{subtitle}
+
+ {title}
+
+
+ {subtitle}
+
diff --git a/src/components/config/application/GeneralConfigPage.tsx b/src/components/config/application/GeneralConfigPage.tsx
new file mode 100644
index 00000000..cb6dadd7
--- /dev/null
+++ b/src/components/config/application/GeneralConfigPage.tsx
@@ -0,0 +1,79 @@
+import React, { useMemo } from "react";
+import type { FormEventHandler } from "react";
+import { useTranslation } from "react-i18next";
+import { useDispatch, useSelector } from "react-redux";
+import { Save } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { v4 } from "uuid";
+
+import ConfigTitlebar from "@components/config/ConfigTitlebar";
+// import ConfigInput from "@components/config/ConfigInput";
+import ConfigSelect from "@components/config/ConfigSelect";
+
+import { requestPersistGeneralConfig } from "@features/appConfig/appConfigActions";
+import { selectGeneralConfigState } from "@features/appConfig/appConfigSelectors";
+import type { ColorMode } from "@features/appConfig/appConfigSlice";
+
+export interface IGeneralConfigPageProps {
+ className?: string;
+}
+
+type GeneralConfigFormInput = {
+ colorMode: ColorMode;
+};
+
+const GeneralConfigPage = ({ className = "" }: IGeneralConfigPageProps) => {
+ const { t } = useTranslation();
+
+ const dispatch = useDispatch();
+ const { colorMode } = useSelector(selectGeneralConfigState());
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm
({ defaultValues: { colorMode } });
+
+ const handleSubmitSuccess = (data: GeneralConfigFormInput) => {
+ dispatch(requestPersistGeneralConfig({ colorMode: data.colorMode }));
+ };
+
+ const handleFormSubmit: FormEventHandler = (e) => {
+ e.preventDefault();
+ void handleSubmit(handleSubmitSuccess, console.warn)(e);
+ };
+
+ const formId = useMemo(() => v4(), []);
+
+ return (
+
+ }
+ buttonTooltipText={t("applicationSettings.saveChanges")}
+ buttonProps={{ type: "submit", form: formId }}
+ >
+
+
+
+ );
+};
+
+export default GeneralConfigPage;
diff --git a/src/components/config/application/MapConfigPage.tsx b/src/components/config/application/MapConfigPage.tsx
index 669734fa..2d971480 100644
--- a/src/components/config/application/MapConfigPage.tsx
+++ b/src/components/config/application/MapConfigPage.tsx
@@ -8,8 +8,9 @@ import { v4 } from "uuid";
import ConfigTitlebar from "@components/config/ConfigTitlebar";
import ConfigInput from "@components/config/ConfigInput";
-import { selectMapState } from "@features/map/mapSelectors";
-import { mapSliceActions } from "@features/map/mapSlice";
+
+import { requestPersistMapConfig } from "@features/appConfig/appConfigActions";
+import { selectMapConfigState } from "@features/appConfig/appConfigSelectors";
export interface IMapConfigPageProps {
className?: string;
@@ -23,16 +24,16 @@ const MapConfigPage = ({ className = "" }: IMapConfigPageProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
- const { config } = useSelector(selectMapState());
+ const { style } = useSelector(selectMapConfigState());
const {
register,
handleSubmit,
formState: { errors },
- } = useForm({ defaultValues: { style: config.style } });
+ } = useForm({ defaultValues: { style } });
const handleSubmitSuccess = (data: MapConfigFormInput) => {
- dispatch(mapSliceActions.updateConfig(data));
+ dispatch(requestPersistMapConfig({ style: data.style }));
};
const handleFormSubmit: FormEventHandler = (e) => {
diff --git a/src/components/config/channel/ChannelConfigDetail.tsx b/src/components/config/channel/ChannelConfigDetail.tsx
index 2f0f34f8..c6ea971e 100644
--- a/src/components/config/channel/ChannelConfigDetail.tsx
+++ b/src/components/config/channel/ChannelConfigDetail.tsx
@@ -7,8 +7,8 @@ import { RotateCcw } from "lucide-react";
import debounce from "lodash.debounce";
import ConfigTitlebar from "@components/config/ConfigTitlebar";
-import ConfigLabel from "@components/config/ConfigLabel";
import ConfigInput from "@components/config/ConfigInput";
+import ConfigSelect from "@components/config/ConfigSelect";
import {
ChannelConfigInput,
@@ -135,22 +135,20 @@ const ChannelConfigDetail = ({
onIconClick={handleFormReset}
>
-
-
-
+
+
+
+
{
{...register("enabled")}
/>
-
-
-
+
+
+
+
{
onIconClick={handleFormReset}
>
-
-
-
+
+
+
+
+
+
+
+
{
{/* TODO BUTTON GPIO */}
{/* TODO BUZZER GPIO */}
-
-
-
+
+
+
+
{
{...register("compassNorthTop")}
/>
-
-
-
+
+
+
+
+
{
{...register("flipScreen")}
/>
-
-
-
+
+
+
+
+
+
+
{
{...register("headingBold")}
/>
-
-
-
+
+
+
+
+
{
{...register("screenOnSecs")}
/>
-
-
-
+
+
+
{
onIconClick={handleFormReset}
>
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
{...register("usePreset")}
/>
-
-
-
+
+
+
+
+
+
+
+
{
{...register("ethEnabled")}
/>
-
-
-
+
+
+
{
{...register("pttPin")}
/>
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
return (
{label}
diff --git a/src/components/connection/ConnectionInput.tsx b/src/components/connection/ConnectionInput.tsx
index ac0cd110..b1df4875 100644
--- a/src/components/connection/ConnectionInput.tsx
+++ b/src/components/connection/ConnectionInput.tsx
@@ -13,7 +13,7 @@ const ConnectionInput = forwardRef(
return (
{
return (
-
+
);
};
diff --git a/src/components/connection/SerialConnectPane.tsx b/src/components/connection/SerialConnectPane.tsx
index 5eb0b38d..b933baba 100644
--- a/src/components/connection/SerialConnectPane.tsx
+++ b/src/components/connection/SerialConnectPane.tsx
@@ -58,7 +58,7 @@ const SerialConnectPane = ({
/>
))
) : (
-
+
{t("connectPage.tabs.serial.empty")}
)}
@@ -70,18 +70,18 @@ const SerialConnectPane = ({
className="flex flex-row justify-center align-middle mx-auto gap-4 mt-5"
onClick={() => refreshPorts()}
>
-
-
+
+
{t("connectPage.tabs.serial.refresh")}
@@ -97,14 +97,14 @@ const SerialConnectPane = ({
/>
-
+
{t("connectPage.tabs.serial.dtrTitle")}
-
+
{t("connectPage.tabs.serial.rtsTitle")}
diff --git a/src/components/connection/SerialPortOption.tsx b/src/components/connection/SerialPortOption.tsx
index 6b380b08..60459d6b 100644
--- a/src/components/connection/SerialPortOption.tsx
+++ b/src/components/connection/SerialPortOption.tsx
@@ -21,33 +21,12 @@ const SerialPortOption = ({
onClick,
}: ISerialPortOptions) => {
switch (connectionState.status) {
- case "IDLE":
- return (
-
- );
-
case "PENDING":
return (
-
-
-
+
+
+
-
-
-
+
+
+
-
+
-
-
+
+
-
+
{connectionState.message}
);
- default:
+ default: // "IDLE"
return (
-
+
);
}
};
diff --git a/src/components/connection/TcpConnectPane.tsx b/src/components/connection/TcpConnectPane.tsx
index d3cefd57..a2b41af1 100644
--- a/src/components/connection/TcpConnectPane.tsx
+++ b/src/components/connection/TcpConnectPane.tsx
@@ -47,28 +47,30 @@ const TcpConnectPane = ({
/>
{activeSocketState.status === "FAILED" && (
-
+
{activeSocketState.message}
diff --git a/src/components/pages/ApplicationStatePage.tsx b/src/components/pages/ApplicationStatePage.tsx
index 710ee3de..10c7af7b 100644
--- a/src/components/pages/ApplicationStatePage.tsx
+++ b/src/components/pages/ApplicationStatePage.tsx
@@ -5,21 +5,29 @@ import ReactJson from "react-json-view";
import NavigationBacktrace from "@components/NavigationBacktrace";
import { selectRootState } from "@features/device/deviceSelectors";
+import { useIsDarkMode } from "@utils/hooks";
const ApplicationStatePage = () => {
const { t } = useTranslation();
+ const { isDarkMode } = useIsDarkMode();
+
const rootState = useSelector(selectRootState());
const backtrace = [t("applicationState.title")];
return (
-
-
+
);
diff --git a/src/components/pages/ConnectPage.tsx b/src/components/pages/ConnectPage.tsx
index ed8a316d..e0d9d19c 100644
--- a/src/components/pages/ConnectPage.tsx
+++ b/src/components/pages/ConnectPage.tsx
@@ -4,8 +4,9 @@ import { useDispatch, useSelector } from "react-redux";
import { open } from "@tauri-apps/api/shell";
import * as Tabs from "@radix-ui/react-tabs";
+import MeshLogoLight from "@app/assets/Mesh_Logo_Light.svg";
+import MeshLogoDark from "@app/assets/Mesh_Logo_Dark.svg";
import Hero_Image from "@app/assets/onboard_hero_image.jpg";
-import Meshtastic_Logo from "@app/assets/Mesh_Logo_Black.png";
import ConnectTab from "@components/connection/ConnectTab";
import TcpConnectPane from "@components/connection/TcpConnectPane";
@@ -31,6 +32,7 @@ import {
import { requestSliceActions } from "@features/requests/requestReducer";
import { ConnectionType } from "@utils/connections";
+import { useIsDarkMode } from "@utils/hooks";
import "@components/SplashScreen/SplashScreen.css";
@@ -44,6 +46,8 @@ export interface IOnboardPageProps {
const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => {
const { t } = useTranslation();
+ const { isDarkMode } = useIsDarkMode();
+
const dispatch = useDispatch();
const availableSerialPorts = useSelector(selectAvailablePorts());
const autoConnectPort = useSelector(selectAutoConnectPort());
@@ -176,27 +180,27 @@ const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => {
return (
-
+
-
+
{t("connectPage.title")}
-
+
{
onClick={() =>
void open("https://meshtastic.org/docs/introduction")
}
- className="hover:cursor-pointer hover:text-gray-600 underline"
+ className="hover:cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 underline transition-colors"
/>
),
}}
diff --git a/src/components/pages/MessagingPage.tsx b/src/components/pages/MessagingPage.tsx
index 382f8935..cc62bc53 100644
--- a/src/components/pages/MessagingPage.tsx
+++ b/src/components/pages/MessagingPage.tsx
@@ -45,8 +45,8 @@ const MessagingPage = () => {
{activeChannelIdx != null && !!channels[activeChannelIdx] ? (
) : (
-
-
+
+
{t("messaging.noChannelsSelected")}
diff --git a/src/components/pages/config/ApplicationSettingsPage.tsx b/src/components/pages/config/ApplicationSettingsPage.tsx
index 3b4f8763..c93f271b 100644
--- a/src/components/pages/config/ApplicationSettingsPage.tsx
+++ b/src/components/pages/config/ApplicationSettingsPage.tsx
@@ -1,21 +1,31 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
-import { Construction } from "lucide-react";
import i18next from "@app/i18n";
import ConfigLayout from "@components/config/ConfigLayout";
import ConfigOption from "@components/config/ConfigOption";
+
+import GeneralConfigPage from "@components/config/application/GeneralConfigPage";
import MapConfigPage from "@components/config/application/MapConfigPage";
+import type { IAppConfigState } from "@features/appConfig/appConfigSlice";
+
export const ApplicationSettingsOptions = {
+ general: i18next.t("applicationSettings.options.general"),
map: i18next.t("applicationSettings.options.map"),
};
-const _ActiveOption = ({ activeOption }: { activeOption: string }) => {
+const _ActiveOption = ({
+ activeOption,
+}: {
+ activeOption: keyof IAppConfigState;
+}) => {
const { t } = useTranslation();
switch (activeOption) {
+ case "general":
+ return
;
case "map":
return
;
default:
@@ -33,18 +43,18 @@ const ApplicationSettingsPage = () => {
const { t } = useTranslation();
const [activeOption, setActiveOption] =
- useState
("map");
+ useState("general");
return (
(
-
- )}
- titleIconTooltip={t("general.wip")}
- onTitleIconClick={() => console.warn(t("general.wip"))}
+ // Hides the title icon as the user doesn't need to take secondary action
+ // to persist application settings after committing within a pane
+ renderTitleIcon={() => <>>}
+ titleIconTooltip={""}
+ onTitleIconClick={() => null}
renderOptions={() =>
Object.entries(ApplicationSettingsOptions).map(([k, displayName]) => (
(
"@appConfig/persist-last-tcp-connection-meta"
);
+
+export const requestPersistGeneralConfig = createAction(
+ "@appConfig/persist-general-config"
+);
+
+export const requestPersistMapConfig = createAction(
+ "@appConfig/persist-map-config"
+);
diff --git a/src/features/appConfig/appConfigSagas.ts b/src/features/appConfig/appConfigSagas.ts
index ae2bef14..cf1e53ba 100644
--- a/src/features/appConfig/appConfigSagas.ts
+++ b/src/features/appConfig/appConfigSagas.ts
@@ -1,11 +1,17 @@
-import { all, put, takeEvery } from "redux-saga/effects";
+import { all, call, put, takeEvery } from "redux-saga/effects";
import { Store } from "tauri-plugin-store-api";
import {
requestFetchLastTcpConnectionMeta,
requestPersistLastTcpConnectionMeta,
+ requestPersistGeneralConfig,
+ requestPersistMapConfig,
} from "@features/appConfig/appConfigActions";
-import { appConfigActions } from "@features/appConfig/appConfigSlice";
+import {
+ ColorMode,
+ appConfigSliceActions,
+} from "@features/appConfig/appConfigSlice";
+import { requestInitializeApplication } from "@features/device/deviceActions";
import { requestSliceActions } from "@features/requests/requestReducer";
import type { CommandError } from "@utils/errors";
@@ -30,7 +36,9 @@ function* fetchLastTcpConnectionMetaWorker(
PersistedStateKeys.LastTcpConnection
)) as IPersistedState[PersistedStateKeys.LastTcpConnection];
- yield put(appConfigActions.setLastTcpConnection(persistedValue ?? null));
+ yield put(
+ appConfigSliceActions.setLastTcpConnection(persistedValue ?? null)
+ );
yield put(requestSliceActions.setRequestSuccessful({ name: action.type }));
} catch (error) {
@@ -76,6 +84,142 @@ function* persistLastTcpConnectionMetaWorker(
}
}
+/**
+ * Check if dark mode was manually selected or if system color mode is dark
+ * Add or remove the "dark" class from the document based on the selected mode
+ * https://tailwindcss.com/docs/dark-mode#supporting-system-preference-and-manual-selection
+ * @throws {Error} If the browser doesn't support the `matchMedia` API
+ * @param colorMode Color mode to apply a CSS class for
+ */
+function setColorModeClassWorker(colorMode: ColorMode) {
+ if (
+ colorMode === "dark" || // Manual dark mode
+ (colorMode === "system" &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches) // System dark mode
+ ) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+}
+
+function* persistGeneralConfigWorker(
+ action: ReturnType
+) {
+ try {
+ yield put(requestSliceActions.setRequestPending({ name: action.type }));
+
+ yield setValueInPersistedStore(
+ defaultStore,
+ PersistedStateKeys.GeneralConfig,
+ action.payload
+ );
+
+ // Fetch value from store to ensure it was set correctly
+ const persistedValue = (yield getValueFromPersistedStore(
+ defaultStore,
+ PersistedStateKeys.GeneralConfig
+ )) as IPersistedState[PersistedStateKeys.GeneralConfig];
+
+ if (JSON.stringify(persistedValue) !== JSON.stringify(action.payload)) {
+ throw new Error("Failed to persist general application config");
+ }
+
+ yield call(setColorModeClassWorker, action.payload.colorMode);
+
+ // Update redux cache value
+ yield put(appConfigSliceActions.updateGeneralConfig(action.payload));
+
+ yield put(requestSliceActions.setRequestSuccessful({ name: action.type }));
+ } catch (error) {
+ yield put(
+ requestSliceActions.setRequestFailed({
+ name: action.type,
+ message: (error as CommandError).message,
+ })
+ );
+ }
+}
+
+function* persistMapConfigWorker(
+ action: ReturnType
+) {
+ try {
+ yield put(requestSliceActions.setRequestPending({ name: action.type }));
+
+ yield setValueInPersistedStore(
+ defaultStore,
+ PersistedStateKeys.MapConfig,
+ action.payload
+ );
+
+ // Fetch value from store to ensure it was set correctly
+ const persistedValue = (yield getValueFromPersistedStore(
+ defaultStore,
+ PersistedStateKeys.MapConfig
+ )) as IPersistedState[PersistedStateKeys.MapConfig];
+
+ if (JSON.stringify(persistedValue) !== JSON.stringify(action.payload)) {
+ throw new Error("Failed to persist map config");
+ }
+
+ // Update redux cache value
+ yield put(appConfigSliceActions.updateMapConfig(action.payload));
+
+ yield put(requestSliceActions.setRequestSuccessful({ name: action.type }));
+ } catch (error) {
+ yield put(
+ requestSliceActions.setRequestFailed({
+ name: action.type,
+ message: (error as CommandError).message,
+ })
+ );
+ }
+}
+
+function* initializeAppConfigWorker(
+ action: ReturnType
+) {
+ try {
+ yield put(requestSliceActions.setRequestPending({ name: action.type }));
+
+ const persistedGeneralConfig = (yield getValueFromPersistedStore(
+ defaultStore,
+ PersistedStateKeys.GeneralConfig
+ )) as IPersistedState[PersistedStateKeys.GeneralConfig];
+
+ if (persistedGeneralConfig) {
+ yield put(
+ appConfigSliceActions.updateGeneralConfig(persistedGeneralConfig)
+ );
+ }
+
+ const persistedMapConfig = (yield getValueFromPersistedStore(
+ defaultStore,
+ PersistedStateKeys.MapConfig
+ )) as IPersistedState[PersistedStateKeys.MapConfig];
+
+ if (persistedMapConfig) {
+ yield put(appConfigSliceActions.updateMapConfig(persistedMapConfig));
+ }
+
+ // Initialize color theme when data loaded
+ yield call(
+ setColorModeClassWorker,
+ persistedGeneralConfig?.colorMode ?? "system"
+ );
+
+ yield put(requestSliceActions.setRequestSuccessful({ name: action.type }));
+ } catch (error) {
+ yield put(
+ requestSliceActions.setRequestFailed({
+ name: action.type,
+ message: (error as CommandError).message,
+ })
+ );
+ }
+}
+
export function* appConfigSaga() {
yield all([
takeEvery(
@@ -86,5 +230,8 @@ export function* appConfigSaga() {
requestPersistLastTcpConnectionMeta.type,
persistLastTcpConnectionMetaWorker
),
+ takeEvery(requestPersistGeneralConfig.type, persistGeneralConfigWorker),
+ takeEvery(requestPersistMapConfig.type, persistMapConfigWorker),
+ takeEvery(requestInitializeApplication.type, initializeAppConfigWorker),
]);
}
diff --git a/src/features/appConfig/appConfigSelectors.ts b/src/features/appConfig/appConfigSelectors.ts
index acd1a64d..d0259556 100644
--- a/src/features/appConfig/appConfigSelectors.ts
+++ b/src/features/appConfig/appConfigSelectors.ts
@@ -1,7 +1,21 @@
import type { RootState } from "@app/store";
-import type { TcpConnectionMeta } from "@features/appConfig/appConfigSlice";
+import type {
+ IGeneralConfigState,
+ IMapConfigState,
+ TcpConnectionMeta,
+} from "@features/appConfig/appConfigSlice";
export const selectPersistedTCPConnectionMeta =
() =>
(state: RootState): TcpConnectionMeta | null =>
state.appConfig.lastTcpConnection;
+
+export const selectGeneralConfigState =
+ () =>
+ (state: RootState): IGeneralConfigState =>
+ state.appConfig.general;
+
+export const selectMapConfigState =
+ () =>
+ (state: RootState): IMapConfigState =>
+ state.appConfig.map;
diff --git a/src/features/appConfig/appConfigSlice.ts b/src/features/appConfig/appConfigSlice.ts
index 0f3b7c88..5a3ed4cc 100644
--- a/src/features/appConfig/appConfigSlice.ts
+++ b/src/features/appConfig/appConfigSlice.ts
@@ -5,12 +5,33 @@ export type TcpConnectionMeta = {
port: number;
};
+export interface IMapConfigState {
+ style: string;
+}
+
+export type ColorMode = "light" | "dark" | "system";
+
+export interface IGeneralConfigState {
+ colorMode: ColorMode;
+}
+
export interface IAppConfigState {
lastTcpConnection: TcpConnectionMeta | null;
+ map: IMapConfigState;
+ general: IGeneralConfigState;
}
export const initialAppConfigState: IAppConfigState = {
+ // This is not intended to be manually updated by the user
+ // Might be worth adding a new "hidden" object
lastTcpConnection: null,
+
+ map: {
+ style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
+ },
+ general: {
+ colorMode: "system",
+ },
};
export const appConfigSlice = createSlice({
@@ -23,8 +44,20 @@ export const appConfigSlice = createSlice({
) => {
state.lastTcpConnection = action.payload;
},
+ updateGeneralConfig: (
+ state,
+ action: PayloadAction>
+ ) => {
+ state.general = { ...state.general, ...action.payload };
+ },
+ updateMapConfig: (
+ state,
+ action: PayloadAction>
+ ) => {
+ state.map = { ...state.map, ...action.payload };
+ },
},
});
-export const { actions: appConfigActions, reducer: appConfigReducer } =
+export const { actions: appConfigSliceActions, reducer: appConfigReducer } =
appConfigSlice;
diff --git a/src/features/map/mapSlice.ts b/src/features/map/mapSlice.ts
index af2658fd..fa16847b 100644
--- a/src/features/map/mapSlice.ts
+++ b/src/features/map/mapSlice.ts
@@ -1,10 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { ViewState } from "react-map-gl";
-export interface IMapConfig {
- style: string;
-}
-
export interface IMapUIState {
searchDockExpanded: boolean;
}
@@ -13,7 +9,6 @@ export interface IMapState {
viewState: Partial;
nodesFeatureCollection: GeoJSON.FeatureCollection | null;
edgesFeatureCollection: GeoJSON.FeatureCollection | null;
- config: IMapConfig;
mapUIState: IMapUIState;
}
@@ -25,9 +20,6 @@ export const initialMapState: IMapState = {
},
nodesFeatureCollection: null,
edgesFeatureCollection: null,
- config: {
- style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
- },
mapUIState: {
searchDockExpanded: true,
},
@@ -62,9 +54,6 @@ export const mapSlice = createSlice({
) => {
state.edgesFeatureCollection = action.payload;
},
- updateConfig: (state, action: PayloadAction>) => {
- state.config = { ...state.config, ...action.payload };
- },
setMapUIState: (state, action: PayloadAction>) => {
state.mapUIState = { ...state.mapUIState, ...action.payload };
},
diff --git a/src/index.css b/src/index.css
index 8b0525fb..ccbcbda8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -34,6 +34,17 @@ body {
@apply border;
@apply border-gray-100;
@apply shadow-lg;
+
+ /* Note: nesting of attributes not supported */
+ @media (prefers-color-scheme: dark) {
+ /* @apply bg-gray-800; */
+ --tw-bg-opacity: 1;
+ background-color: rgb(31 41 55 / var(--tw-bg-opacity));
+
+ /* @apply border-gray-700; */
+ --tw-border-opacity: 1;
+ border-color: rgb(55 65 81 / var(--tw-border-opacity));
+ }
}
.default-overlay.no-shadow {
@@ -50,3 +61,8 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
+
+/* Overrides background color on react-json-view */
+.react-json-view {
+ background-color: transparent !important;
+}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index bd04d19a..186afbaf 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -474,8 +474,19 @@
"saveChanges": "Save Changes",
"unknownOption": "Unknown option selected",
"options": {
+ "general": "General Settings",
"map": "Map Settings"
},
+ "general": {
+ "title": "General Settings",
+ "description": "Edit general application settings",
+ "colorMode": {
+ "title": "Application Color Mode",
+ "light": "Light Mode",
+ "dark": "Dark Mode",
+ "system": "Follow System"
+ }
+ },
"map": {
"title": "Map Settings",
"description": "Edit application map settings",
diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts
index 409ad839..24e17a33 100644
--- a/src/utils/hooks.ts
+++ b/src/utils/hooks.ts
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { error } from "@utils/errors";
/**
* This hook is intended for use in components that need to rerender
@@ -16,3 +17,34 @@ export const useComponentReload = (interval: number) => {
return time;
};
+
+// https://usehooks-ts.com/react-hook/use-dark-mode
+const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
+
+export interface IUseDarkMode {
+ isDarkMode: boolean;
+}
+
+/**
+ * This hook is intended for use in components that need to know
+ * whether the user has dark mode enabled for conditional rendering.
+ * Developers should only use this when it is not possible to use
+ * CSS media queries.
+ * @returns Whether the user has dark mode enabled at the OS level
+ */
+export const useIsDarkMode = (): IUseDarkMode => {
+ // Browser needs to support matchMedia but can't make useState conditional
+ if (!window.matchMedia) {
+ error("Browser doesn't support window.matchMedia method");
+ }
+
+ const runQuery = () => window.matchMedia(COLOR_SCHEME_QUERY);
+
+ const [query, setQuery] = useState(runQuery);
+
+ query.onchange = () => {
+ setQuery(runQuery);
+ };
+
+ return { isDarkMode: query.matches };
+};
diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts
index 37b9ccb5..dc58e48e 100644
--- a/src/utils/nodes.ts
+++ b/src/utils/nodes.ts
@@ -61,35 +61,35 @@ export const getColorClassFromNodeState = (
switch (nodeState) {
case "selected":
return {
- text: "text-blue-500",
- fill: "fill-blue-500",
- background: "bg-blue-500",
- border: "border-blue-500",
+ text: "text-blue-500 dark:text-blue-300",
+ fill: "fill-blue-500 dark:text-blue-300",
+ background: "bg-blue-500 dark:bg-blue-300",
+ border: "border-blue-500 dark:border-blue-300",
};
case "warning":
return {
- text: "text-orange-500",
- fill: "fill-orange-500",
- background: "bg-orange-500",
- border: "border-orange-500",
+ text: "text-orange-500 dark:text-orange-300",
+ fill: "fill-orange-500 dark:text-orange-300",
+ background: "bg-orange-500 dark:bg-orange-300",
+ border: "border-orange-500 dark:border-orange-300",
};
case "error":
return {
- text: "text-red-500",
- fill: "fill-red-500",
- background: "bg-red-500",
- border: "border-red-500",
+ text: "text-red-500 dark:text-red-300",
+ fill: "fill-red-500 dark:text-red-300",
+ background: "bg-red-500 dark:bg-red-300",
+ border: "border-red-500 dark:border-red-300",
};
// Nominal
default:
return {
- text: "text-gray-500",
- fill: "fill-gray-500",
- background: "bg-gray-500",
- border: "border-gray-500",
+ text: "text-gray-500 dark:text-gray-400",
+ fill: "fill-gray-500 dark:text-gray-400",
+ background: "bg-gray-500 dark:bg-gray-400",
+ border: "border-gray-500 dark:border-gray-400",
};
}
};
diff --git a/src/utils/persistence.ts b/src/utils/persistence.ts
index 72e05409..bd492f3c 100644
--- a/src/utils/persistence.ts
+++ b/src/utils/persistence.ts
@@ -1,16 +1,22 @@
-import type { TcpConnectionMeta } from "@features/appConfig/appConfigSlice";
+import type {
+ IGeneralConfigState,
+ IMapConfigState,
+ TcpConnectionMeta,
+} from "@features/appConfig/appConfigSlice";
import type { Store } from "tauri-plugin-store-api";
export const DEFAULT_STORE_FILE_NAME = "config.bin";
export enum PersistedStateKeys {
LastTcpConnection = "lastTcpConnection",
- OtherKey = "otherKey",
+ GeneralConfig = "generalConfig",
+ MapConfig = "mapConfig",
}
export interface IPersistedState {
[PersistedStateKeys.LastTcpConnection]?: TcpConnectionMeta;
- [PersistedStateKeys.OtherKey]?: string;
+ [PersistedStateKeys.GeneralConfig]?: IGeneralConfigState;
+ [PersistedStateKeys.MapConfig]?: IMapConfigState;
}
export function* setValueInPersistedStore(
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index 6834dfab..8ef11183 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -1,14 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ darkMode: "class",
theme: {
extend: {
borderWidth: {
- '065': '0.65px',
- }
+ "065": "0.65px",
+ },
},
},
- plugins: [
- require("tailwindcss-radix")(),
- ],
+ plugins: [require("tailwindcss-radix")()],
};