(false);
+ useEffect(() => {
+ // Only set up the listener once when component mounts
+ if (auth.currentUser) {
+ setUser(auth.currentUser);
+ } else {
+ setUser(false);
+ }
+ const unsubscribe = auth.onAuthStateChanged((user: User | null) => {
+ setUser(user);
+ //Send back to the main process so that it knows we are authenticated.
+ if (user) {
+ window.electron.ipcRenderer.send(
+ ipcTypes.toMain.authStateChanged,
+ user.toJSON(),
+ );
+ window.electron.ipcRenderer.send(ipcTypes.toMain.watcher.start);
+ }
+ });
+
+ // Clean up the listener when component unmounts
+ return (): void => unsubscribe();
+ }, []);
+
+ const isTest = window.api.isTest();
return (
- <>
-
- Powered by electron-vite
-
- Build an Electron app with React
- and TypeScript
-
-
- Please try pressing F12 to open the devTool
-
-
-
- >
- )
-}
+
+
+
+
+
+
+
+ {!user ? (
+
+ ) : (
+
+
+
+
+ } />
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
-export default App
+export default App;
diff --git a/src/renderer/src/assets/base.css b/src/renderer/src/assets/base.css
deleted file mode 100644
index 5ed6406..0000000
--- a/src/renderer/src/assets/base.css
+++ /dev/null
@@ -1,67 +0,0 @@
-:root {
- --ev-c-white: #ffffff;
- --ev-c-white-soft: #f8f8f8;
- --ev-c-white-mute: #f2f2f2;
-
- --ev-c-black: #1b1b1f;
- --ev-c-black-soft: #222222;
- --ev-c-black-mute: #282828;
-
- --ev-c-gray-1: #515c67;
- --ev-c-gray-2: #414853;
- --ev-c-gray-3: #32363f;
-
- --ev-c-text-1: rgba(255, 255, 245, 0.86);
- --ev-c-text-2: rgba(235, 235, 245, 0.6);
- --ev-c-text-3: rgba(235, 235, 245, 0.38);
-
- --ev-button-alt-border: transparent;
- --ev-button-alt-text: var(--ev-c-text-1);
- --ev-button-alt-bg: var(--ev-c-gray-3);
- --ev-button-alt-hover-border: transparent;
- --ev-button-alt-hover-text: var(--ev-c-text-1);
- --ev-button-alt-hover-bg: var(--ev-c-gray-2);
-}
-
-:root {
- --color-background: var(--ev-c-black);
- --color-background-soft: var(--ev-c-black-soft);
- --color-background-mute: var(--ev-c-black-mute);
-
- --color-text: var(--ev-c-text-1);
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
- margin: 0;
- font-weight: normal;
-}
-
-ul {
- list-style: none;
-}
-
-body {
- min-height: 100vh;
- color: var(--color-text);
- background: var(--color-background);
- line-height: 1.6;
- font-family:
- Inter,
- -apple-system,
- BlinkMacSystemFont,
- 'Segoe UI',
- Roboto,
- Oxygen,
- Ubuntu,
- Cantarell,
- 'Fira Sans',
- 'Droid Sans',
- 'Helvetica Neue',
- sans-serif;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
diff --git a/src/renderer/src/assets/electron.svg b/src/renderer/src/assets/electron.svg
deleted file mode 100644
index 45ef09c..0000000
--- a/src/renderer/src/assets/electron.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
deleted file mode 100644
index 0179fc4..0000000
--- a/src/renderer/src/assets/main.css
+++ /dev/null
@@ -1,171 +0,0 @@
-@import './base.css';
-
-body {
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- background-image: url('./wavy-lines.svg');
- background-size: cover;
- user-select: none;
-}
-
-code {
- font-weight: 600;
- padding: 3px 5px;
- border-radius: 2px;
- background-color: var(--color-background-mute);
- font-family:
- ui-monospace,
- SFMono-Regular,
- SF Mono,
- Menlo,
- Consolas,
- Liberation Mono,
- monospace;
- font-size: 85%;
-}
-
-#root {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- margin-bottom: 80px;
-}
-
-.logo {
- margin-bottom: 20px;
- -webkit-user-drag: none;
- height: 128px;
- width: 128px;
- will-change: filter;
- transition: filter 300ms;
-}
-
-.logo:hover {
- filter: drop-shadow(0 0 1.2em #6988e6aa);
-}
-
-.creator {
- font-size: 14px;
- line-height: 16px;
- color: var(--ev-c-text-2);
- font-weight: 600;
- margin-bottom: 10px;
-}
-
-.text {
- font-size: 28px;
- color: var(--ev-c-text-1);
- font-weight: 700;
- line-height: 32px;
- text-align: center;
- margin: 0 10px;
- padding: 16px 0;
-}
-
-.tip {
- font-size: 16px;
- line-height: 24px;
- color: var(--ev-c-text-2);
- font-weight: 600;
-}
-
-.react {
- background: -webkit-linear-gradient(315deg, #087ea4 55%, #7c93ee);
- background-clip: text;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- font-weight: 700;
-}
-
-.ts {
- background: -webkit-linear-gradient(315deg, #3178c6 45%, #f0dc4e);
- background-clip: text;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- font-weight: 700;
-}
-
-.actions {
- display: flex;
- padding-top: 32px;
- margin: -6px;
- flex-wrap: wrap;
- justify-content: flex-start;
-}
-
-.action {
- flex-shrink: 0;
- padding: 6px;
-}
-
-.action a {
- cursor: pointer;
- text-decoration: none;
- display: inline-block;
- border: 1px solid transparent;
- text-align: center;
- font-weight: 600;
- white-space: nowrap;
- border-radius: 20px;
- padding: 0 20px;
- line-height: 38px;
- font-size: 14px;
- border-color: var(--ev-button-alt-border);
- color: var(--ev-button-alt-text);
- background-color: var(--ev-button-alt-bg);
-}
-
-.action a:hover {
- border-color: var(--ev-button-alt-hover-border);
- color: var(--ev-button-alt-hover-text);
- background-color: var(--ev-button-alt-hover-bg);
-}
-
-.versions {
- position: absolute;
- bottom: 30px;
- margin: 0 auto;
- padding: 15px 0;
- font-family: 'Menlo', 'Lucida Console', monospace;
- display: inline-flex;
- overflow: hidden;
- align-items: center;
- border-radius: 22px;
- background-color: #202127;
- backdrop-filter: blur(24px);
-}
-
-.versions li {
- display: block;
- float: left;
- border-right: 1px solid var(--ev-c-gray-1);
- padding: 0 20px;
- font-size: 14px;
- line-height: 14px;
- opacity: 0.8;
- &:last-child {
- border: none;
- }
-}
-
-@media (max-width: 720px) {
- .text {
- font-size: 20px;
- }
-}
-
-@media (max-width: 620px) {
- .versions {
- display: none;
- }
-}
-
-@media (max-width: 350px) {
- .tip,
- .actions {
- display: none;
- }
-}
diff --git a/src/renderer/src/assets/wavy-lines.svg b/src/renderer/src/assets/wavy-lines.svg
deleted file mode 100644
index d08c611..0000000
--- a/src/renderer/src/assets/wavy-lines.svg
+++ /dev/null
@@ -1,25 +0,0 @@
-
diff --git a/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx b/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx
new file mode 100644
index 0000000..010e452
--- /dev/null
+++ b/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx
@@ -0,0 +1,25 @@
+import { FC } from "react";
+import { Button, Result } from "antd";
+import { FallbackProps } from "react-error-boundary";
+import { useTranslation } from "react-i18next";
+
+const ErrorBoundaryFallback: FC = ({
+ error,
+ resetErrorBoundary,
+}) => {
+ const { t } = useTranslation();
+ return (
+
+ Try again
+ ,
+ ]}
+ />
+ );
+};
+
+export default ErrorBoundaryFallback;
diff --git a/src/renderer/src/components/Home/Home.tsx b/src/renderer/src/components/Home/Home.tsx
new file mode 100644
index 0000000..cb159a2
--- /dev/null
+++ b/src/renderer/src/components/Home/Home.tsx
@@ -0,0 +1,11 @@
+import { FC } from "react";
+
+const Home: FC = () => {
+ return (
+
+
Home
+
+ );
+};
+
+export default Home;
diff --git a/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts b/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts
new file mode 100644
index 0000000..f87be8f
--- /dev/null
+++ b/src/renderer/src/components/Settings/PaintScale/usePaintScaleConfig.ts
@@ -0,0 +1,131 @@
+import { useState, useEffect } from 'react';
+import ipcTypes from '../../../../../util/ipcTypes.json';
+import { PaintScaleConfig, PaintScaleType } from '../../../../../util/types/paintScale';
+import { message } from "antd";
+import {useTranslation} from "react-i18next";
+
+type ConfigType = 'input' | 'output';
+
+export const usePaintScaleConfig = (configType: ConfigType) => {
+ const [paintScaleConfigs, setPaintScaleConfigs] = useState([]);
+ const { t } = useTranslation();
+
+ // Get the appropriate IPC methods based on config type
+ const getConfigsMethod = configType === 'input'
+ ? ipcTypes.toMain.settings.paintScale.getInputConfigs
+ : ipcTypes.toMain.settings.paintScale.getOutputConfigs;
+
+ const setConfigsMethod = configType === 'input'
+ ? ipcTypes.toMain.settings.paintScale.setInputConfigs
+ : ipcTypes.toMain.settings.paintScale.setOutputConfigs;
+
+ const setPathMethod = configType === 'input'
+ ? ipcTypes.toMain.settings.paintScale.setInputPath
+ : ipcTypes.toMain.settings.paintScale.setOutputPath;
+
+ // Load paint scale configs on mount
+ useEffect(() => {
+ window.electron.ipcRenderer
+ .invoke(getConfigsMethod)
+ .then((configs: PaintScaleConfig[]) => {
+ // Ensure all configs have a pollingInterval and type (for backward compatibility)
+ const updatedConfigs = configs.map(config => ({
+ ...config,
+ pollingInterval: config.pollingInterval || 1440, // Default to 1440 seconds
+ type: config.type || PaintScaleType.PPG, // Default type if missing
+ }));
+ setPaintScaleConfigs(updatedConfigs || []);
+ })
+ .catch((error) => {
+ console.error(`Failed to load paint scale ${configType} configs:`, error);
+ });
+ }, [getConfigsMethod]);
+
+ // Save configs to store and notify main process of config changes
+ const saveConfigs = (configs: PaintScaleConfig[]) => {
+ window.electron.ipcRenderer
+ .invoke(setConfigsMethod, configs)
+ .then(() => {
+ // Notify main process to update cron job
+ if (configType === 'input') {
+ window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateInputCron, configs);
+ } else if (configType === 'output') {
+ window.electron.ipcRenderer.send(ipcTypes.toMain.settings.paintScale.updateOutputCron, configs);
+ }
+ })
+ .catch((error) => {
+ console.error(`Failed to save paint scale ${configType} configs:`, error);
+ });
+ };
+
+ // New helper to check if a path is unique across input and output configs
+ const checkPathUnique = async (newPath: string): Promise => {
+ try {
+ const inputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getInputConfigs);
+ const outputConfigs: PaintScaleConfig[] = await window.electron.ipcRenderer.invoke(ipcTypes.toMain.settings.paintScale.getOutputConfigs);
+ const allConfigs = [...inputConfigs, ...outputConfigs];
+ // Allow updating the current config even if its current value equals newPath.
+ return !allConfigs.some(config => config.path === newPath);
+ } catch (error) {
+ console.error("Failed to check unique path:", error);
+ return false;
+ }
+ };
+
+ // Handle adding a new paint scale config
+ const handleAddConfig = (type: PaintScaleType) => {
+ const newConfig: PaintScaleConfig = {
+ id: Date.now().toString(),
+ type,
+ pollingInterval: 1440, // Default to 1440 seconds
+ };
+ const updatedConfigs = [...paintScaleConfigs, newConfig];
+ setPaintScaleConfigs(updatedConfigs);
+ saveConfigs(updatedConfigs);
+ };
+
+ // Handle removing a config
+ const handleRemoveConfig = (id: string) => {
+ const updatedConfigs = paintScaleConfigs.filter((config) => config.id !== id);
+ setPaintScaleConfigs(updatedConfigs);
+ saveConfigs(updatedConfigs);
+ };
+
+ // Handle path selection (modified to check directory uniqueness)
+ const handlePathChange = async (id: string) => {
+ try {
+ const path: string | null = await window.electron.ipcRenderer.invoke(setPathMethod, id);
+ if (path) {
+ const isUnique = await checkPathUnique(path);
+ if (!isUnique) {
+ message.error(t("settings.errors.duplicatePath"));
+ return;
+ }
+ const updatedConfigs = paintScaleConfigs.map((config) =>
+ config.id === id ? { ...config, path } : config,
+ );
+ setPaintScaleConfigs(updatedConfigs);
+ saveConfigs(updatedConfigs);
+ }
+ } catch (error) {
+ console.error(`Failed to set paint scale ${configType} path:`, error);
+ }
+ };
+
+ // Handle polling interval change
+ const handlePollingIntervalChange = (id: string, pollingInterval: number) => {
+ const updatedConfigs = paintScaleConfigs.map((config) =>
+ config.id === id ? { ...config, pollingInterval } : config,
+ );
+ setPaintScaleConfigs(updatedConfigs);
+ saveConfigs(updatedConfigs);
+ };
+
+ return {
+ paintScaleConfigs,
+ handleAddConfig,
+ handleRemoveConfig,
+ handlePathChange,
+ handlePollingIntervalChange,
+ };
+};
diff --git a/src/renderer/src/components/Settings/Settings.EmsOutFilePath.tsx b/src/renderer/src/components/Settings/Settings.EmsOutFilePath.tsx
new file mode 100644
index 0000000..a84e0a5
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.EmsOutFilePath.tsx
@@ -0,0 +1,46 @@
+import { FolderOpenFilled } from "@ant-design/icons";
+import { Button, Card, Input, Space } from "antd";
+import { useEffect, useState, FC } from "react";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const SettingsEmsOutFilePath: FC = () => {
+ const { t } = useTranslation();
+
+ const [emsFilePath, setEmsFilePath] = useState(null);
+
+ const getPollingStateFromStore = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.getEmsOutFilePath)
+ .then((filePath: string | null) => {
+ setEmsFilePath(filePath);
+ });
+ };
+
+ //Get state first time it renders.
+ useEffect(() => {
+ getPollingStateFromStore();
+ }, []);
+
+ const handlePathChange = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.setEmsOutFilePath)
+ .then((filePath: string | null) => {
+ setEmsFilePath(filePath);
+ });
+ };
+
+ return (
+
+
+
+ } />
+
+
+ );
+};
+export default SettingsEmsOutFilePath;
diff --git a/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx b/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx
new file mode 100644
index 0000000..02118d9
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.PaintScaleInputPaths.tsx
@@ -0,0 +1,183 @@
+import {
+ CheckCircleFilled,
+ FileAddFilled,
+ FolderOpenFilled,
+ WarningFilled,
+} from "@ant-design/icons";
+import {
+ Button,
+ Card,
+ Input,
+ Modal,
+ Select,
+ Space,
+ Table,
+ Tag,
+ theme,
+ Tooltip,
+} from "antd";
+import { JSX, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ PaintScaleConfig,
+ PaintScaleType,
+ paintScaleTypeOptions,
+} from "../../../../util/types/paintScale";
+import { usePaintScaleConfig } from "./PaintScale/usePaintScaleConfig";
+
+const SettingsPaintScaleInputPaths = (): JSX.Element => {
+ const { t } = useTranslation();
+ const { token } = theme.useToken(); // Access theme tokens
+
+ const {
+ paintScaleConfigs,
+ handleAddConfig,
+ handleRemoveConfig,
+ handlePathChange,
+ handlePollingIntervalChange,
+ } = usePaintScaleConfig("input");
+
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [selectedType, setSelectedType] = useState(null);
+
+ // Show modal when adding a new path
+ const showAddPathModal = () => {
+ setSelectedType(null);
+ setIsModalVisible(true);
+ };
+
+ // Handle modal confirmation
+ const handleModalOk = () => {
+ if (selectedType) {
+ handleAddConfig(selectedType);
+ setIsModalVisible(false);
+ }
+ };
+
+ // Handle modal cancellation
+ const handleModalCancel = () => {
+ setIsModalVisible(false);
+ };
+
+ // Table columns for paint scale configs
+ const columns = [
+ {
+ title: t("settings.labels.paintScaleType"),
+ dataIndex: "type",
+ key: "type",
+ render: (type: PaintScaleType) => {
+ const typeOption = paintScaleTypeOptions.find(
+ (option) => option.value === type,
+ );
+ const label = typeOption ? typeOption.label : type;
+ const colorMap: Partial> = {
+ [PaintScaleType.PPG]: "blue",
+ // Add other types and colors as needed
+ };
+ return {label};
+ },
+ },
+ {
+ title: t("settings.labels.paintScalePath"),
+ dataIndex: "path",
+ key: "path",
+ render: (path: string | null, record: PaintScaleConfig) => {
+ const isValid = path && path.trim() !== "";
+ return (
+
+
+ {isValid ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ );
+ },
+ },
+ {
+ title: t("settings.labels.pollingInterval"),
+ dataIndex: "pollingInterval",
+ key: "pollingInterval",
+ render: (pollingInterval: number, record: PaintScaleConfig) => (
+
+ handlePollingIntervalChange(record.id, Number(e.target.value))
+ }
+ style={{ width: 100 }}
+ placeholder={t("settings.labels.pollingInterval")}
+ />
+ ),
+ },
+ {
+ title: t("settings.labels.actions"),
+ key: "actions",
+ render: (_: any, record: PaintScaleConfig) => (
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+ }>
+ {t("settings.actions.addpath")}
+
+ }
+ >
+
+
+
+
+
+ >
+ );
+};
+
+export default SettingsPaintScaleInputPaths;
diff --git a/src/renderer/src/components/Settings/Settings.PaintScaleOutputPaths.tsx b/src/renderer/src/components/Settings/Settings.PaintScaleOutputPaths.tsx
new file mode 100644
index 0000000..56436d2
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.PaintScaleOutputPaths.tsx
@@ -0,0 +1,173 @@
+import {
+ CheckCircleFilled,
+ FileAddFilled,
+ FolderOpenFilled,
+ WarningFilled,
+} from "@ant-design/icons";
+import {
+ Button,
+ Card,
+ Input,
+ Modal,
+ Select,
+ Space,
+ Table,
+ Tag,
+ theme,
+} from "antd";
+import { JSX, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ PaintScaleConfig,
+ PaintScaleType,
+ paintScaleTypeOptions,
+} from "../../../../util/types/paintScale";
+import { usePaintScaleConfig } from "./PaintScale/usePaintScaleConfig";
+
+const SettingsPaintScaleOutputPaths = (): JSX.Element => {
+ const { token } = theme.useToken();
+ const { t } = useTranslation();
+ const {
+ paintScaleConfigs,
+ handleAddConfig,
+ handleRemoveConfig,
+ handlePathChange,
+ handlePollingIntervalChange,
+ } = usePaintScaleConfig("output");
+
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [selectedType, setSelectedType] = useState(null);
+
+ // Show modal when adding a new path
+ const showAddPathModal = () => {
+ setSelectedType(null);
+ setIsModalVisible(true);
+ };
+
+ // Handle modal confirmation
+ const handleModalOk = () => {
+ if (selectedType) {
+ handleAddConfig(selectedType);
+ setIsModalVisible(false);
+ }
+ };
+
+ // Handle modal cancellation
+ const handleModalCancel = () => {
+ setIsModalVisible(false);
+ };
+
+ // Table columns for paint scale configs
+ const columns = [
+ {
+ title: t("settings.labels.paintScaleType"),
+ dataIndex: "type",
+ key: "type",
+ render: (type: PaintScaleType) => {
+ const typeOption = paintScaleTypeOptions.find(
+ (option) => option.value === type,
+ );
+ const label = typeOption ? typeOption.label : type;
+ const colorMap: Partial> = {
+ [PaintScaleType.PPG]: "blue",
+ // Add other types and colors as needed
+ };
+ return {label};
+ },
+ },
+ {
+ title: t("settings.labels.paintScalePath"),
+ dataIndex: "path",
+ key: "path",
+ render: (path: string | null, record: PaintScaleConfig) => {
+ const isValid = path && path.trim() !== "";
+ return (
+
+
+ ) : (
+
+ )
+ }
+ />
+
+ );
+ },
+ },
+ {
+ title: t("settings.labels.pollingInterval"),
+ dataIndex: "pollingInterval",
+ key: "pollingInterval",
+ render: (pollingInterval: number, record: PaintScaleConfig) => (
+
+ handlePollingIntervalChange(record.id, Number(e.target.value))
+ }
+ style={{ width: 100 }}
+ placeholder={t("settings.labels.pollingInterval")}
+ />
+ ),
+ },
+ {
+ title: t("settings.labels.actions"),
+ key: "actions",
+ render: (_: any, record: PaintScaleConfig) => (
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+ }>
+ {t("settings.actions.addpath")}
+
+ }
+ >
+
+
+
+
+
+ >
+ );
+};
+
+export default SettingsPaintScaleOutputPaths;
diff --git a/src/renderer/src/components/Settings/Settings.PpcFilePath.tsx b/src/renderer/src/components/Settings/Settings.PpcFilePath.tsx
new file mode 100644
index 0000000..82ff80c
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.PpcFilePath.tsx
@@ -0,0 +1,46 @@
+import { FolderOpenFilled } from "@ant-design/icons";
+import { Button, Card, Input, Space } from "antd";
+import { useEffect, useState, FC } from "react";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const SettingsPpcFilepath: FC = () => {
+ const { t } = useTranslation();
+
+ const [ppcFilePath, setPpcFilePath] = useState(null);
+
+ const getPollingStateFromStore = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.getPpcFilePath)
+ .then((filePath: string | null) => {
+ setPpcFilePath(filePath);
+ });
+ };
+
+ //Get state first time it renders.
+ useEffect(() => {
+ getPollingStateFromStore();
+ }, []);
+
+ const handlePathChange = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.setPpcFilePath)
+ .then((filePath: string | null) => {
+ setPpcFilePath(filePath);
+ });
+ };
+
+ return (
+
+
+
+ } />
+
+
+ );
+};
+export default SettingsPpcFilepath;
diff --git a/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx b/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx
new file mode 100644
index 0000000..81026f4
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx
@@ -0,0 +1,64 @@
+import { DeleteFilled, FileAddFilled } from "@ant-design/icons";
+import { Button, Card, Space, Timeline } from "antd";
+import { useEffect, useState, FC } from "react";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const SettingsWatchedPaths: FC = () => {
+ const [watchedPaths, setWatchedPaths] = useState([]);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.filepaths.get)
+ .then((paths: string[]) => {
+ setWatchedPaths(paths);
+ });
+ }, []);
+
+ const handleAddPath = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.filepaths.add)
+ .then((paths: string[]) => {
+ setWatchedPaths(paths);
+ });
+ };
+
+ const handleRemovePath = (path: string): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.filepaths.remove, path)
+ .then((paths: string[]) => {
+ setWatchedPaths(paths);
+ });
+ };
+
+ return (
+ }>
+ {t("settings.actions.addpath")}
+
+ }
+ >
+ ({
+ key: index,
+ children: (
+
+ {path}
+ }
+ onClick={() => handleRemovePath(path)}
+ />
+
+ ),
+ }))}
+ />
+
+ );
+};
+export default SettingsWatchedPaths;
diff --git a/src/renderer/src/components/Settings/Settings.Watcher.tsx b/src/renderer/src/components/Settings/Settings.Watcher.tsx
new file mode 100644
index 0000000..6215ec6
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.Watcher.tsx
@@ -0,0 +1,157 @@
+import { useAppSelector } from "@renderer/redux/reduxHooks";
+import {
+ CheckCircleFilled,
+ ExclamationCircleFilled,
+ PauseCircleOutlined,
+ PlayCircleOutlined,
+} from "@ant-design/icons";
+import {
+ selectWatcherError,
+ selectWatcherStatus,
+} from "@renderer/redux/app.slice";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Col,
+ InputNumber,
+ Row,
+ Space,
+ Switch,
+} from "antd";
+import { FC, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const colSpans = {
+ md: 12,
+ sm: 24,
+};
+
+const SettingsWatcher: FC = () => {
+ const { t } = useTranslation();
+ const isWatcherStarted = useAppSelector(selectWatcherStatus);
+ const watcherError = useAppSelector(selectWatcherError);
+
+ const [pollingState, setPollingState] = useState<{
+ enabled: boolean;
+ interval: number;
+ }>({
+ enabled: false,
+ interval: 0,
+ });
+
+ const getPollingStateFromStore = (): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.watcher.getpolling)
+ .then((storePollingState: { enabled: boolean; interval: number }) => {
+ setPollingState(storePollingState);
+ });
+ };
+
+ //Get state first time it renders.
+ useEffect(() => {
+ getPollingStateFromStore();
+ }, []);
+
+ const handleStart = (): void => {
+ window.electron.ipcRenderer.send(ipcTypes.toMain.watcher.start);
+ };
+
+ const handleStop = (): void => {
+ window.electron.ipcRenderer.send(ipcTypes.toMain.watcher.stop);
+ };
+
+ const toggleWatcherMode = (checked: boolean): void => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.watcher.setpolling, {
+ enabled: !checked,
+ interval: pollingState.interval,
+ })
+ .then((storePollingState: { enabled: boolean; interval: number }) => {
+ setPollingState(storePollingState);
+ });
+ };
+
+ const handlePollingIntervalChange = (value: number | null): void => {
+ if (value) {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.settings.watcher.setpolling, {
+ enabled: pollingState.enabled,
+ interval: value,
+ })
+ .then((storePollingState: { enabled: boolean; interval: number }) => {
+ setPollingState(storePollingState);
+ });
+ }
+ getPollingStateFromStore();
+ };
+
+ return (
+
+
+ {t("settings.labels.started")}
+
+ ) : (
+
+
+ {t("settings.labels.stopped")}
+
+ )
+ }
+ color={isWatcherStarted ? "green" : "red"}
+ >
+
+
+
+ {isWatcherStarted ? (
+ }
+ onClick={handleStop}
+ >
+ {t("settings.actions.stopwatcher")}
+
+ ) : (
+ }
+ onClick={handleStart}
+ >
+ {t("settings.actions.startwatcher")}
+
+ )}
+
+
+
+
+ {pollingState.enabled && (
+
+ {t("settings.labels.pollinginterval")}
+
+
+ )}
+ {watcherError && }
+
+
+
+
+
+ );
+};
+export default SettingsWatcher;
diff --git a/src/renderer/src/components/Settings/Settings.tsx b/src/renderer/src/components/Settings/Settings.tsx
new file mode 100644
index 0000000..5cdb13b
--- /dev/null
+++ b/src/renderer/src/components/Settings/Settings.tsx
@@ -0,0 +1,45 @@
+// renderer/Settings.tsx
+import { Col, Row } from "antd";
+import { FC } from "react";
+import SettingsWatchedPaths from "./Settings.WatchedPaths";
+import SettingsWatcher from "./Settings.Watcher";
+import Welcome from "../Welcome/Welcome";
+import SettingsPpcFilepath from "./Settings.PpcFilePath";
+import SettingsEmsOutFilePath from "./Settings.EmsOutFilePath";
+import SettingsPaintScaleInputPaths from "./Settings.PaintScaleInputPaths";
+import SettingsPaintScaleOutputPaths from "./Settings.PaintScaleOutputPaths";
+
+const colSpans = {
+ md: 12, // Two columns on medium screens and above
+ sm: 24, // One column on small screens
+};
+
+const Settings: FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Settings;
\ No newline at end of file
diff --git a/src/renderer/src/components/SignInForm/SignInForm.tsx b/src/renderer/src/components/SignInForm/SignInForm.tsx
new file mode 100644
index 0000000..360cc23
--- /dev/null
+++ b/src/renderer/src/components/SignInForm/SignInForm.tsx
@@ -0,0 +1,142 @@
+import { auth } from "@renderer/util/firebase";
+import type { FormProps } from "antd";
+import { Alert, Button, Card, Form, Input, Typography } from "antd";
+import log from "electron-log/renderer";
+import { signInWithEmailAndPassword } from "firebase/auth";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import errorTypeCheck from "../../../../util/errorTypeCheck";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const { Title } = Typography;
+
+type FieldType = {
+ username: string;
+ password: string;
+ remember?: string;
+};
+
+const SignInForm: React.FC = () => {
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const { t } = useTranslation();
+ const onFinish: FormProps["onFinish"] = async (values) => {
+ const { username, password } = values;
+ setLoading(true);
+ try {
+ const result = await signInWithEmailAndPassword(auth, username, password);
+ log.debug("Login result", result);
+ } catch (error) {
+ log.error("Login error", errorTypeCheck(error));
+ setError(t("auth.login.error"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const onFinishFailed: FormProps["onFinishFailed"] = (
+ errorInfo,
+ ) => {
+ log.log("Failed:", errorInfo);
+ };
+
+ return (
+
+
+
+ {import.meta.env.VITE_COMPANY === "IMEX"
+ ? t("title.imex")
+ : t("title.rome")}
+
+
+
+
+
+
+ )}
+
+
+ label="Username"
+ name="username"
+ rules={[
+ {
+ required: true,
+ message: t(
+ "auth.login.usernameRequired",
+ "Please enter your username",
+ ),
+ },
+ ]}
+ >
+
+
+
+
+ label="Password"
+ name="password"
+ rules={[
+ {
+ required: true,
+ message: t(
+ "auth.login.passwordRequired",
+ "Please enter your password",
+ ),
+ },
+ ]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SignInForm;
diff --git a/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx
new file mode 100644
index 0000000..a43c61b
--- /dev/null
+++ b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx
@@ -0,0 +1,95 @@
+import {
+ selectAppUpdateCompleted,
+ selectAppUpdateProgress,
+ selectAppUpdateSpeed,
+ selectUpdateAvailable,
+} from "@renderer/redux/app.slice";
+import { useAppSelector } from "@renderer/redux/reduxHooks";
+import { Affix, Button, Card, Progress, Space, Statistic } from "antd";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+import { useState, FC } from "react";
+
+const UpdateAvailable: FC = () => {
+ const { t } = useTranslation();
+
+ const isUpdateAvailable = useAppSelector(selectUpdateAvailable);
+ const updateSpeed = useAppSelector(selectAppUpdateSpeed);
+ const updateProgress = useAppSelector(selectAppUpdateProgress);
+ const isUpdateComplete = useAppSelector(selectAppUpdateCompleted);
+ const [applyingUpdate, setApplyingUpdate] = useState(false);
+
+ const handleDownload = (): void => {
+ window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download);
+ };
+
+ const handleApply = (): void => {
+ setApplyingUpdate(true);
+ window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply);
+ };
+
+ if (!isUpdateAvailable) {
+ return null;
+ }
+ return (
+
+
+
+ {updateProgress === 0 && (
+
+ )}
+
+ {!isUpdateComplete && formatSpeed(updateSpeed)}
+ {isUpdateComplete && (
+ <>
+
+ handleApply()}
+ />
+ >
+ )}
+
+
+
+ );
+};
+
+export default UpdateAvailable;
+
+/**
+ * Formats bytes into a human-readable string with appropriate units
+ * @param bytes Number of bytes
+ * @returns Formatted string with appropriate unit (B/KB/MB/GB)
+ */
+const formatSpeed = (bytes: number): string => {
+ if (bytes === 0) return "0 B/s";
+
+ const units = ["B/s", "KB/s", "MB/s", "GB/s"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ // Limit to available units and format with 2 decimal places (rounded)
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i] || units[units.length - 1]}`;
+};
diff --git a/src/renderer/src/components/Versions.tsx b/src/renderer/src/components/Versions.tsx
index dac185f..ddd7349 100644
--- a/src/renderer/src/components/Versions.tsx
+++ b/src/renderer/src/components/Versions.tsx
@@ -1,7 +1,7 @@
-import { useState } from 'react'
+import { JSX, useState } from "react";
function Versions(): JSX.Element {
- const [versions] = useState(window.electron.process.versions)
+ const [versions] = useState(window.electron.process.versions);
return (
@@ -9,7 +9,7 @@ function Versions(): JSX.Element {
- Chromium v{versions.chrome}
- Node v{versions.node}
- )
+ );
}
-export default Versions
+export default Versions;
diff --git a/src/renderer/src/components/Welcome/Welcome.tsx b/src/renderer/src/components/Welcome/Welcome.tsx
new file mode 100644
index 0000000..d3d3c87
--- /dev/null
+++ b/src/renderer/src/components/Welcome/Welcome.tsx
@@ -0,0 +1,49 @@
+import { LogoutOutlined } from "@ant-design/icons";
+import { auth } from "@renderer/util/firebase";
+import { Button, Space, Typography } from "antd";
+import { isEmpty } from "lodash";
+import { JSX, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import ipcTypes from "../../../../util/ipcTypes.json";
+
+const Welcome = (): JSX.Element => {
+ const { t } = useTranslation();
+ const [shopName, setShopName] = useState(null);
+ useEffect(() => {
+ window.electron.ipcRenderer
+ .invoke(ipcTypes.toMain.user.getActiveShop)
+ .then((shopName: string) => {
+ console.log("Active shop name:", shopName);
+ setShopName(shopName);
+ });
+ }, []);
+
+ return (
+ <>
+
+ {t("auth.labels.welcome", {
+ name: isEmpty(auth.currentUser?.displayName)
+ ? auth.currentUser?.email
+ : `${auth.currentUser?.displayName} (${auth.currentUser?.email})`.trim(),
+ })}
+
+
+ {shopName || ""}
+ }
+ onClick={(): void => {
+ auth.signOut().catch((error) => {
+ console.error("Sign out error:", error);
+ });
+ }}
+ >
+ {t("navigation.signout")}
+
+
+ >
+ );
+};
+
+export default Welcome;
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx
index f4d40c7..ef1b4a0 100644
--- a/src/renderer/src/main.tsx
+++ b/src/renderer/src/main.tsx
@@ -1,11 +1,26 @@
-import './assets/main.css'
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./util/i18n";
+import "./util/ipcRendererHandler";
+import * as Sentry from "@sentry/electron/renderer";
-import React from 'react'
-import ReactDOM from 'react-dom/client'
-import App from './App'
+// Extend the Window interface to include the api property
+declare global {
+ interface Window {
+ api: {
+ isTest: () => boolean;
+ };
+ }
+}
-ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+
+Sentry.init({
+ dsn: "https://ba41d22656999a8c1fd63bcb7df98650@o492140.ingest.us.sentry.io/4509074139447296",
+});
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-)
+ ,
+);
diff --git a/src/renderer/src/redux/app.slice.ts b/src/renderer/src/redux/app.slice.ts
new file mode 100644
index 0000000..806c8e8
--- /dev/null
+++ b/src/renderer/src/redux/app.slice.ts
@@ -0,0 +1,136 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import log from "electron-log/renderer";
+import type { RootState } from "./redux-store";
+
+interface AppState {
+ value: number;
+ watcher: {
+ started: boolean;
+ error: string | null;
+ polling: {
+ enabled: boolean;
+ interval: number;
+ };
+ };
+ updates: {
+ available: boolean;
+ checking: boolean;
+ progress: number;
+ speed: number;
+ completed: boolean;
+ };
+}
+
+// Define the initial state using that type
+const initialState: AppState = {
+ value: 0,
+ watcher: {
+ started: false,
+ error: null,
+ polling: {
+ enabled: false,
+ interval: 30000,
+ },
+ },
+ updates: {
+ available: false,
+ checking: false,
+ progress: 0,
+ speed: 0,
+ completed: false,
+ },
+};
+
+export const appSlice = createSlice({
+ name: "app",
+ // `createSlice` will infer the state type from the `initialState` argument
+ initialState,
+ reducers: {
+ watcherStarted: (state) => {
+ state.watcher.started = true;
+ },
+ watcherStopped: (state) => {
+ state.watcher.started = false;
+ },
+ watcherError: (state, action: PayloadAction) => {
+ state.watcher.error = action.payload;
+ state.watcher.started = false;
+ log.error("[Redux] AppSlice: Watcher Error", action.payload);
+ },
+
+ updateChecking: (state) => {
+ state.updates.checking = true;
+ },
+ updateAvailable: (state) => {
+ state.updates.available = true;
+ state.updates.checking = false;
+ },
+ updateProgress: (
+ state,
+ action: PayloadAction<{ progress: number; speed: number }>,
+ ) => {
+ state.updates.available = true;
+ state.updates.progress = action.payload.progress;
+ state.updates.speed = action.payload.speed;
+ },
+ updateDownloaded: (state) => {
+ state.updates.completed = true;
+ state.updates.progress = 100;
+ state.updates.speed = 0;
+ },
+ setWatcherPolling: (
+ state,
+ action: PayloadAction<{ enabled: boolean; interval: number }>,
+ ) => {
+ state.watcher.polling.enabled = action.payload.enabled;
+ state.watcher.polling.interval = action.payload.interval;
+ },
+ },
+});
+
+export const {
+ watcherError,
+ watcherStarted,
+ watcherStopped,
+ updateAvailable,
+ updateChecking,
+ updateDownloaded,
+ updateProgress,
+ setWatcherPolling,
+} = appSlice.actions;
+
+// Other code such as selectors can use the imported `RootState` type
+export const selectWatcherStatus = (state: RootState): boolean =>
+ state.app.watcher.started;
+
+export const selectWatcherError = (state: RootState): string | null =>
+ state.app.watcher.error;
+
+export const selectUpdateAvailable = (state: RootState): boolean =>
+ state.app.updates.available;
+
+export const selectAppUpdateProgress = (state: RootState): number =>
+ state.app.updates.progress;
+
+export const selectAppUpdateSpeed = (state: RootState): number =>
+ state.app.updates.speed;
+
+export const selectAppUpdateCompleted = (state: RootState): boolean =>
+ state.app.updates.completed;
+
+export const selectWatcherPolling = (
+ state: RootState,
+): {
+ enabled: boolean;
+ interval: number;
+} => state.app.watcher.polling;
+
+//Async Functions - Thunks
+// Define a thunk that dispatches those action creators
+// const fetchUsers = () => async (dispatch) => {
+// //dispatch(watcherStarted());
+// //Some sort of async action.
+// // dispatch(incrementByAmount(100));
+// };
+
+export default appSlice.reducer;
diff --git a/src/renderer/src/redux/redux-store.ts b/src/renderer/src/redux/redux-store.ts
new file mode 100644
index 0000000..a798de3
--- /dev/null
+++ b/src/renderer/src/redux/redux-store.ts
@@ -0,0 +1,16 @@
+import { configureStore } from "@reduxjs/toolkit";
+import logger from "redux-logger";
+import appReducer from "./app.slice";
+
+const store = configureStore({
+ reducer: { app: appReducer },
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
+});
+
+// Infer the `RootState` and `AppDispatch` types from the store itself
+export type RootState = ReturnType;
+
+// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
+export type AppDispatch = typeof store.dispatch;
+export type AppStore = typeof store;
+export default store;
diff --git a/src/renderer/src/redux/reduxHooks.ts b/src/renderer/src/redux/reduxHooks.ts
new file mode 100644
index 0000000..6834500
--- /dev/null
+++ b/src/renderer/src/redux/reduxHooks.ts
@@ -0,0 +1,10 @@
+import type { TypedUseSelectorHook } from "react-redux";
+import { useDispatch, useSelector, useStore } from "react-redux";
+import type { AppDispatch, AppStore, RootState } from "./redux-store";
+import store from "./redux-store";
+
+//Use these custom hooks to access the Redux store from your component with type safety.
+export type AppDispatch = typeof store.dispatch;
+export const useAppDispatch = useDispatch.withTypes(); // Ex
+export const useAppSelector: TypedUseSelectorHook = useSelector;
+export const useAppStore: () => AppStore = useStore;
diff --git a/src/renderer/src/util/countdownHook.ts b/src/renderer/src/util/countdownHook.ts
new file mode 100644
index 0000000..61b2d28
--- /dev/null
+++ b/src/renderer/src/util/countdownHook.ts
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+type Timer = {
+ started: number | null;
+ lastInterval: number | null;
+ timeLeft: number;
+ timeToCount: number;
+ requestId: number;
+};
+
+const useCountDown = (
+ timeToCount = 60 * 1000,
+ interval = 1000,
+): [
+ number,
+ {
+ start: (ttc?: number) => void;
+ pause: () => void;
+ resume: () => void;
+ reset: () => void;
+ },
+] => {
+ const [timeLeft, setTimeLeft] = useState(0);
+ const timer = useRef({
+ started: null,
+ lastInterval: null,
+ timeLeft: 0,
+ timeToCount: 0,
+ requestId: 0,
+ });
+
+ const run = (ts: number) => {
+ if (!timer.current.started) {
+ timer.current.started = ts;
+ timer.current.lastInterval = ts;
+ }
+
+ const localInterval = Math.min(
+ interval,
+ timer.current.timeLeft || Infinity,
+ );
+ if (timer.current.lastInterval && ts - timer.current.lastInterval >= localInterval) {
+ timer.current.lastInterval += localInterval;
+ setTimeLeft((timeLeft) => {
+ timer.current.timeLeft = timeLeft - localInterval;
+ return timer.current.timeLeft;
+ });
+ }
+
+ if (ts - timer.current.started < timer.current.timeToCount) {
+ timer.current.requestId = window.requestAnimationFrame(run);
+ } else {
+ timer.current = {
+ started: null,
+ lastInterval: null,
+ timeLeft: 0,
+ timeToCount: 0,
+ requestId: 0,
+ };
+ setTimeLeft(0);
+ }
+ };
+
+ const start = useCallback(
+ (ttc) => {
+ window.cancelAnimationFrame(timer.current.requestId);
+
+ const newTimeToCount = ttc !== undefined ? ttc : timeToCount;
+ timer.current.started = null;
+ timer.current.lastInterval = null;
+ timer.current.timeToCount = newTimeToCount;
+ timer.current.requestId = window.requestAnimationFrame(run);
+
+ setTimeLeft(newTimeToCount);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ const pause = useCallback(() => {
+ window.cancelAnimationFrame(timer.current.requestId);
+ timer.current.started = null;
+ timer.current.lastInterval = null;
+ timer.current.timeToCount = timer.current.timeLeft;
+ }, []);
+
+ const resume = useCallback(
+ () => {
+ if (!timer.current.started && timer.current.timeLeft > 0) {
+ window.cancelAnimationFrame(timer.current.requestId);
+ timer.current.requestId = window.requestAnimationFrame(run);
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ const reset = useCallback(() => {
+ if (timer.current.timeLeft) {
+ window.cancelAnimationFrame(timer.current.requestId);
+ timer.current = {
+ started: null,
+ lastInterval: null,
+ timeLeft: 0,
+ timeToCount: 0,
+ requestId: 0,
+ };
+ setTimeLeft(0);
+ }
+ }, []);
+
+ const actions = useMemo(
+ () => ({ start, pause, resume, reset }), // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ useEffect(() => {
+ return () => window.cancelAnimationFrame(timer.current.requestId);
+ }, []);
+
+ return [timeLeft, actions];
+};
+
+export default useCountDown;
diff --git a/src/renderer/src/util/firebase.ts b/src/renderer/src/util/firebase.ts
new file mode 100644
index 0000000..6d85c3b
--- /dev/null
+++ b/src/renderer/src/util/firebase.ts
@@ -0,0 +1,14 @@
+import { initializeApp } from "firebase/app";
+import { getAuth } from "firebase/auth";
+
+// TODO: Replace the following with your app's Firebase project configuration
+const firebaseConfig = JSON.parse(
+ window.api.isTest()
+ ? import.meta.env.VITE_FIREBASE_CONFIG_TEST
+ : import.meta.env.VITE_FIREBASE_CONFIG,
+);
+
+const app = initializeApp(firebaseConfig);
+export const auth = getAuth();
+
+export default app;
diff --git a/src/renderer/src/util/graphql.client.ts b/src/renderer/src/util/graphql.client.ts
new file mode 100644
index 0000000..939baa0
--- /dev/null
+++ b/src/renderer/src/util/graphql.client.ts
@@ -0,0 +1,30 @@
+import {
+ ApolloClient,
+ ApolloLink,
+ // HttpLink,
+ InMemoryCache,
+} from "@apollo/client";
+
+// const httpLink: HttpLink = new HttpLink({
+// uri: import.meta.env.VITE_GRAPHQL_URL,
+// });
+
+const middlewares = [];
+
+const client: ApolloClient = new ApolloClient({
+ link: ApolloLink.from(middlewares),
+ cache: new InMemoryCache(),
+ defaultOptions: {
+ watchQuery: {
+ fetchPolicy: "network-only",
+ },
+ query: {
+ fetchPolicy: "network-only",
+ },
+ mutate: {
+ errorPolicy: "none",
+ },
+ },
+});
+
+export default client;
diff --git a/src/renderer/src/util/i18n.ts b/src/renderer/src/util/i18n.ts
new file mode 100644
index 0000000..0df882e
--- /dev/null
+++ b/src/renderer/src/util/i18n.ts
@@ -0,0 +1,24 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import enTranslations from "../../../util/translations/en-US/renderer.json";
+
+const resources = {
+ en: enTranslations,
+};
+
+i18n
+ .use(initReactI18next)
+ .init({
+ resources,
+ debug: import.meta.env.DEV,
+ lng: "en",
+ interpolation: {
+ escapeValue: false,
+ },
+ })
+ .catch((err) => {
+ console.error("i18n initialization error:", err);
+ throw err;
+ });
+
+export default i18n;
diff --git a/src/renderer/src/util/ipcRendererHandler.ts b/src/renderer/src/util/ipcRendererHandler.ts
new file mode 100644
index 0000000..8182169
--- /dev/null
+++ b/src/renderer/src/util/ipcRendererHandler.ts
@@ -0,0 +1,97 @@
+//Set up all the IPC handlers.
+import {
+ setWatcherPolling,
+ updateAvailable,
+ updateChecking,
+ updateDownloaded,
+ updateProgress,
+ watcherError,
+ watcherStarted,
+ watcherStopped,
+} from "@renderer/redux/app.slice";
+import store from "@renderer/redux/redux-store";
+import ipcTypes from "../../../util/ipcTypes.json";
+import { auth } from "./firebase";
+import { notification } from "antd";
+import i18n from "./i18n";
+
+const ipcRenderer = window.electron.ipcRenderer;
+const dispatch = store.dispatch;
+
+ipcRenderer.on(
+ ipcTypes.toRenderer.test,
+ (_event: Electron.IpcRendererEvent, arg) => {
+ console.log("Received test message from main process");
+ console.log(arg);
+ },
+);
+
+ipcRenderer.on(ipcTypes.toRenderer.user.getToken, async () => {
+ const token = await auth.currentUser?.getIdToken();
+ ipcRenderer.send(ipcTypes.toMain.user.getTokenResponse, token);
+});
+
+ipcRenderer.on(ipcTypes.toRenderer.watcher.started, () => {
+ console.log("Watcher has started");
+ dispatch(watcherStarted());
+});
+
+ipcRenderer.on(ipcTypes.toRenderer.watcher.stopped, () => {
+ console.log("Watcher has stopped");
+ dispatch(watcherStopped());
+});
+
+ipcRenderer.on(
+ ipcTypes.toRenderer.watcher.error,
+ (_event: Electron.IpcRendererEvent, error: string) => {
+ console.log("Watcher has encountered an error");
+ console.log(error);
+ dispatch(watcherError(error));
+ },
+);
+
+//Update Handlers
+ipcRenderer.on(ipcTypes.toRenderer.updates.checking, () => {
+ console.log("Checking for updates...");
+ dispatch(updateChecking());
+});
+
+ipcRenderer.on(ipcTypes.toRenderer.updates.available, () => {
+ dispatch(updateAvailable());
+});
+
+ipcRenderer.on(
+ ipcTypes.toRenderer.updates.downloading,
+ (_event: Electron.IpcRendererEvent, arg) => {
+ console.log("*** ARg", arg);
+ dispatch(
+ updateProgress({
+ progress: Math.round(arg.percent),
+ speed: arg.bytesPerSecond,
+ }),
+ );
+ },
+);
+
+ipcRenderer.on(ipcTypes.toRenderer.updates.downloaded, () => {
+ dispatch(updateDownloaded());
+});
+
+ipcRenderer.on(
+ ipcTypes.toRenderer.watcher.polling,
+ (_event: Electron.IpcRendererEvent, arg) => {
+ dispatch(
+ setWatcherPolling({ enabled: arg.enabled, interval: arg.interval }),
+ );
+ },
+);
+
+ipcRenderer.on(
+ ipcTypes.toRenderer.general.showErrorMessage,
+ (_event: Electron.IpcRendererEvent, error) => {
+ notification.error({
+ message: i18n.t("errors.notificationtitle"),
+ description: error,
+ });
+ },
+);
diff --git a/src/renderer/src/util/notificationContext.tsx b/src/renderer/src/util/notificationContext.tsx
new file mode 100644
index 0000000..efec48a
--- /dev/null
+++ b/src/renderer/src/util/notificationContext.tsx
@@ -0,0 +1,46 @@
+import {createContext, FC, ReactNode, useContext} from "react";
+import { notification } from "antd";
+
+/**
+ * Create our NotificationContext to store the `api` object
+ * returned by notification.useNotification().
+ */
+const NotificationContext = createContext(null);
+
+/**
+ * A custom hook to make usage easier in child components.
+ */
+// eslint-disable-next-line react-refresh/only-export-components, @typescript-eslint/explicit-function-return-type
+export const useNotification = () => {
+ return useContext(NotificationContext);
+};
+
+/**
+ * The Provider itself:
+ * - Call notification.useNotification() to get [api, contextHolder].
+ * - Render contextHolder somewhere high-level in your app (so the notifications mount properly).
+ * - Provide `api` via the NotificationContext.
+ */
+interface NotificationProviderProps {
+ children?: ReactNode | ReactNode[];
+}
+
+export const NotificationProvider: FC = ({
+ // eslint-disable-next-line react/prop-types
+ children, //TODO: Unable to resolve this. Adding an eslint disable.
+}) => {
+ const [api, contextHolder] = notification.useNotification({
+ placement: "bottomRight",
+ bottom: 70,
+ showProgress: true,
+ });
+
+ return (
+ // @ts-ignore
+
+ {/* contextHolder must be rendered in the DOM so notifications can appear */}
+ {contextHolder}
+ {children}
+
+ );
+};
diff --git a/src/util/deepLowercaseKeys.ts b/src/util/deepLowercaseKeys.ts
new file mode 100644
index 0000000..052ee8c
--- /dev/null
+++ b/src/util/deepLowercaseKeys.ts
@@ -0,0 +1,37 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * Deep renames all keys in an object to lowercase
+ * @param obj - The object to transform
+ * @returns A new object with all keys converted to lowercase
+ */
+function deepLowerCaseKeys(obj: any): T {
+ if (!obj || typeof obj !== "object") {
+ return obj;
+ }
+
+ // Handle arrays
+ if (Array.isArray(obj)) {
+ return obj.map((item) => deepLowerCaseKeys(item)) as unknown as T;
+ }
+
+ // Handle objects
+ return Object.keys(obj).reduce(
+ (result, key) => {
+ const value = obj[key];
+ const lowercaseKey = key.toLowerCase();
+
+ result[lowercaseKey] =
+ typeof value === "object" &&
+ value !== null &&
+ Object.keys(value).length > 0
+ ? deepLowerCaseKeys(value)
+ : value;
+
+ return result;
+ },
+ {} as Record,
+ ) as T;
+}
+
+export default deepLowerCaseKeys;
diff --git a/src/util/errorTypeCheck.ts b/src/util/errorTypeCheck.ts
new file mode 100644
index 0000000..149b348
--- /dev/null
+++ b/src/util/errorTypeCheck.ts
@@ -0,0 +1,21 @@
+//Type checking here allows us to skip the boilerplate in every catch block.
+function errorTypeCheck(passedError: Error | unknown): ParsedError {
+ const errorMessage =
+ passedError instanceof Error ? passedError.message : String(passedError);
+ const errorStack =
+ passedError instanceof Error
+ ? (passedError.stack ?? "")
+ : String(passedError);
+
+ return {
+ message: errorMessage,
+ stack: errorStack,
+ };
+}
+
+export default errorTypeCheck;
+
+export interface ParsedError {
+ message: string;
+ stack: string;
+}
diff --git a/src/util/ipcTypes.json b/src/util/ipcTypes.json
new file mode 100644
index 0000000..a53cd32
--- /dev/null
+++ b/src/util/ipcTypes.json
@@ -0,0 +1,71 @@
+{
+ "toMain": {
+ "test": "toMain_test",
+ "authStateChanged": "toMain_authStateChanged",
+ "debug": {
+ "decodeEstimate": "toMain_debug_decodeEstimate"
+ },
+ "updates": {
+ "checkForUpdates": "toMain_updates_checkForUpdates",
+ "download": "toMain_updates_download",
+ "apply": "toMain_updates_apply"
+ },
+ "watcher": {
+ "start": "toMain_watcher_start",
+ "stop": "toMain_watcher_stop"
+ },
+ "settings": {
+ "filepaths": {
+ "get": "toMain_settings_filepaths_get",
+ "add": "toMain_settings_filepaths_add",
+ "remove": "toMain_settings_filepaths_remove"
+ },
+ "getEmsOutFilePath": "toMain_settings_filepaths_getEmsOutFilePath",
+ "setEmsOutFilePath": "toMain_settings_filepaths_setEmsOutFilePath",
+ "getPpcFilePath": "toMain_settings_filepaths_getPpcFilePath",
+ "setPpcFilePath": "toMain_settings_filepaths_setPpcFilePath",
+ "watcher": {
+ "getpolling": "toMain_settings_watcher_getpolling",
+ "setpolling": "toMain_settings_watcher_setpolling"
+ },
+ "paintScale": {
+ "getInputConfigs": "toMain_settings_paintScale_getInputConfigs",
+ "setInputConfigs": "toMain_settings_paintScale_setInputConfigs",
+ "setInputPath": "toMain_settings_paintScale_setInputPath",
+ "getOutputConfigs": "toMain_settings_paintScale_getOutputConfigs",
+ "setOutputConfigs": "toMain_settings_paintScale_setOutputConfigs",
+ "setOutputPath": "toMain_settings_paintScale_setOutputPath",
+ "updateInputCron": "toMain_settings_paintScale_updateInputCron",
+ "updateOutputCron": "toMain_settings_paintScale_updateOutputCron"
+ }
+ },
+ "user": {
+ "getTokenResponse": "toMain_user_getTokenResponse",
+ "getActiveShop": "toMain_user_getActiveShopify",
+ "resetPassword": "toMain_user_resetPassword"
+ }
+ },
+ "toRenderer": {
+ "test": "toRenderer_test",
+ "watcher": {
+ "started": "toRenderer_watcher_started",
+ "stopped": "toRenderer_watcher_stopped",
+ "error": "toRenderer_watcher_error",
+ "polling": "toRenderer_watcher_polling"
+ },
+ "updates": {
+ "checking": "toRenderer_updates_checking",
+ "available": "toRenderer_updates_available",
+ "notAvailable": "toRenderer_updates_notAvailable",
+ "error": "toRenderer_updates_error",
+ "downloading": "toRenderer_updates_downloading",
+ "downloaded": "toRenderer_updates_downloaded"
+ },
+ "user": {
+ "getToken": "toRenderer_user_getToken"
+ },
+ "general": {
+ "showErrorMessage": "toRenderer_general_showErrorMessage"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/util/translations/en-US/main.json b/src/util/translations/en-US/main.json
new file mode 100644
index 0000000..8dba742
--- /dev/null
+++ b/src/util/translations/en-US/main.json
@@ -0,0 +1,5 @@
+{
+ "toolbar": {
+ "help": "Help"
+ }
+}
diff --git a/src/util/translations/en-US/renderer.json b/src/util/translations/en-US/renderer.json
new file mode 100644
index 0000000..80c7f64
--- /dev/null
+++ b/src/util/translations/en-US/renderer.json
@@ -0,0 +1,65 @@
+{
+ "translation": {
+ "auth": {
+ "labels": {
+ "welcome": "Hi {{name}}"
+ },
+ "login": {
+ "error": "The username and password combination provided is not valid.",
+ "login": "Log In",
+ "resetpassword": "Reset Password"
+ }
+ },
+ "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": {
+ "emsOutFilePath": "EMS Out File Path (Parts Order, etc.)",
+ "pollinginterval": "Polling Interval (ms)",
+ "ppcfilepath": "Parts Price Change File Path",
+ "started": "Started",
+ "stopped": "Stopped",
+ "watchedpaths": "Watched Paths",
+ "watchermodepolling": "Polling",
+ "watchermoderealtime": "Real Time",
+ "watcherstatus": "Watcher Status",
+ "paintScaleSettingsInput": "BSMS To Paint Scale",
+ "paintScaleSettingsOutput": "Paint Scale To BSMS",
+ "paintScalePath": "Paint Scale Path",
+ "paintScaleType": "Paint Scale Type",
+ "addPaintScalePath": "Add Paint Scale Path",
+ "remove": "Remove",
+ "actions": "Actions",
+ "pollingInterval": "Polling Interval (m)",
+ "validPath": "Valid path",
+ "invalidPath": "Path not set or invalid",
+ "selectPaintScaleType": "Select Paint Scale Type"
+ }
+ },
+ "title": {
+ "imex": "ImEX Online",
+ "rome": "Rome Online"
+ },
+ "updates": {
+ "apply": "Apply Update",
+ "available": "An update is available.",
+ "download": "Download Update",
+ "downloading": "An update is downloading."
+ }
+ }
+}
diff --git a/src/util/typeCaster.ts b/src/util/typeCaster.ts
new file mode 100644
index 0000000..2af4ca2
--- /dev/null
+++ b/src/util/typeCaster.ts
@@ -0,0 +1,63 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * Casts specified properties of an object to the desired types by specifying keys and their corresponding desired type.
+ *
+ * @param obj The object whose properties need to be cast
+ * @param typeMappings An object where keys are property names from the source object
+ * and values are the type to cast to ('string', 'number', 'boolean', etc.)
+ * @returns A new object with the specified properties cast to their desired types
+ */
+function typeCaster(
+ obj: T,
+ typeMappings: Partial<
+ Record
+ >,
+): T {
+ const result = { ...obj };
+
+ for (const key in typeMappings) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const targetType = typeMappings[key];
+ const value = obj[key];
+
+ switch (targetType) {
+ case "string":
+ (result as any)[key] = String(value);
+ break;
+ case "number":
+ (result as any)[key] = Number(value);
+ break;
+ case "boolean":
+ (result as any)[key] = Boolean(value);
+ break;
+ case "object":
+ if (value && typeof value !== "object") {
+ try {
+ (result as any)[key] = JSON.parse(String(value));
+ } catch {
+ (result as any)[key] = {};
+ }
+ }
+ break;
+ case "array":
+ if (Array.isArray(value)) {
+ (result as any)[key] = value;
+ } else if (value && typeof value === "string") {
+ try {
+ const parsed = JSON.parse(value);
+ (result as any)[key] = Array.isArray(parsed) ? parsed : [parsed];
+ } catch {
+ (result as any)[key] = [value];
+ }
+ } else {
+ (result as any)[key] = value ? [value] : [];
+ }
+ break;
+ }
+ }
+ }
+
+ return result;
+}
+
+export default typeCaster;
diff --git a/src/util/types/paintScale.ts b/src/util/types/paintScale.ts
new file mode 100644
index 0000000..a3fd053
--- /dev/null
+++ b/src/util/types/paintScale.ts
@@ -0,0 +1,17 @@
+export enum PaintScaleType {
+ PPG = "PPG",
+}
+
+export interface PaintScaleConfig {
+ id: string;
+ path?: string;
+ type: PaintScaleType;
+ pollingInterval: number;
+}
+
+export const paintScaleTypeOptions = Object.values(PaintScaleType).map(
+ (type) => ({
+ value: type,
+ label: type,
+ }),
+);
diff --git a/src/util/ynBoolConverter.ts b/src/util/ynBoolConverter.ts
new file mode 100644
index 0000000..2251f6d
--- /dev/null
+++ b/src/util/ynBoolConverter.ts
@@ -0,0 +1,12 @@
+const YNBoolConverter = (original: T): T => {
+ Object.keys(original).forEach((key) => {
+ if (original[key] === "Y") {
+ original[key] = true;
+ } else if (original[key] === "N") {
+ original[key] = false;
+ }
+ });
+ return original;
+};
+
+export default YNBoolConverter;
diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts
new file mode 100644
index 0000000..379e072
--- /dev/null
+++ b/tests-examples/demo-todo-app.spec.ts
@@ -0,0 +1,489 @@
+import { test, expect, type Page } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("https://demo.playwright.dev/todomvc");
+});
+
+const TODO_ITEMS = [
+ "buy some cheese",
+ "feed the cat",
+ "book a doctors appointment",
+] as const;
+
+test.describe("New Todo", () => {
+ test("should allow me to add todo items", async ({ page }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ // Create 1st todo.
+ await newTodo.fill(TODO_ITEMS[0]);
+ await newTodo.press("Enter");
+
+ // Make sure the list only has one todo item.
+ await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]);
+
+ // Create 2nd todo.
+ await newTodo.fill(TODO_ITEMS[1]);
+ await newTodo.press("Enter");
+
+ // Make sure the list now has two todo items.
+ await expect(page.getByTestId("todo-title")).toHaveText([
+ TODO_ITEMS[0],
+ TODO_ITEMS[1],
+ ]);
+
+ await checkNumberOfTodosInLocalStorage(page, 2);
+ });
+
+ test("should clear text input field when an item is added", async ({
+ page,
+ }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ // Create one todo item.
+ await newTodo.fill(TODO_ITEMS[0]);
+ await newTodo.press("Enter");
+
+ // Check that input is empty.
+ await expect(newTodo).toBeEmpty();
+ await checkNumberOfTodosInLocalStorage(page, 1);
+ });
+
+ test("should append new items to the bottom of the list", async ({
+ page,
+ }) => {
+ // Create 3 items.
+ await createDefaultTodos(page);
+
+ // create a todo count locator
+ const todoCount = page.getByTestId("todo-count");
+
+ // Check test using different methods.
+ await expect(page.getByText("3 items left")).toBeVisible();
+ await expect(todoCount).toHaveText("3 items left");
+ await expect(todoCount).toContainText("3");
+ await expect(todoCount).toHaveText(/3/);
+
+ // Check all items in one call.
+ await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS);
+ await checkNumberOfTodosInLocalStorage(page, 3);
+ });
+});
+
+test.describe("Mark all as completed", () => {
+ test.beforeEach(async ({ page }) => {
+ await createDefaultTodos(page);
+ await checkNumberOfTodosInLocalStorage(page, 3);
+ });
+
+ test.afterEach(async ({ page }) => {
+ await checkNumberOfTodosInLocalStorage(page, 3);
+ });
+
+ test("should allow me to mark all items as completed", async ({ page }) => {
+ // Complete all todos.
+ await page.getByLabel("Mark all as complete").check();
+
+ // Ensure all todos have 'completed' class.
+ await expect(page.getByTestId("todo-item")).toHaveClass([
+ "completed",
+ "completed",
+ "completed",
+ ]);
+ await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+ });
+
+ test("should allow me to clear the complete state of all items", async ({
+ page,
+ }) => {
+ const toggleAll = page.getByLabel("Mark all as complete");
+ // Check and then immediately uncheck.
+ await toggleAll.check();
+ await toggleAll.uncheck();
+
+ // Should be no completed classes.
+ await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]);
+ });
+
+ test("complete all checkbox should update state when items are completed / cleared", async ({
+ page,
+ }) => {
+ const toggleAll = page.getByLabel("Mark all as complete");
+ await toggleAll.check();
+ await expect(toggleAll).toBeChecked();
+ await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+ // Uncheck first todo.
+ const firstTodo = page.getByTestId("todo-item").nth(0);
+ await firstTodo.getByRole("checkbox").uncheck();
+
+ // Reuse toggleAll locator and make sure its not checked.
+ await expect(toggleAll).not.toBeChecked();
+
+ await firstTodo.getByRole("checkbox").check();
+ await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+ // Assert the toggle all is checked again.
+ await expect(toggleAll).toBeChecked();
+ });
+});
+
+test.describe("Item", () => {
+ test("should allow me to mark items as complete", async ({ page }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ // Create two items.
+ for (const item of TODO_ITEMS.slice(0, 2)) {
+ await newTodo.fill(item);
+ await newTodo.press("Enter");
+ }
+
+ // Check first item.
+ const firstTodo = page.getByTestId("todo-item").nth(0);
+ await firstTodo.getByRole("checkbox").check();
+ await expect(firstTodo).toHaveClass("completed");
+
+ // Check second item.
+ const secondTodo = page.getByTestId("todo-item").nth(1);
+ await expect(secondTodo).not.toHaveClass("completed");
+ await secondTodo.getByRole("checkbox").check();
+
+ // Assert completed class.
+ await expect(firstTodo).toHaveClass("completed");
+ await expect(secondTodo).toHaveClass("completed");
+ });
+
+ test("should allow me to un-mark items as complete", async ({ page }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ // Create two items.
+ for (const item of TODO_ITEMS.slice(0, 2)) {
+ await newTodo.fill(item);
+ await newTodo.press("Enter");
+ }
+
+ const firstTodo = page.getByTestId("todo-item").nth(0);
+ const secondTodo = page.getByTestId("todo-item").nth(1);
+ const firstTodoCheckbox = firstTodo.getByRole("checkbox");
+
+ await firstTodoCheckbox.check();
+ await expect(firstTodo).toHaveClass("completed");
+ await expect(secondTodo).not.toHaveClass("completed");
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+ await firstTodoCheckbox.uncheck();
+ await expect(firstTodo).not.toHaveClass("completed");
+ await expect(secondTodo).not.toHaveClass("completed");
+ await checkNumberOfCompletedTodosInLocalStorage(page, 0);
+ });
+
+ test("should allow me to edit an item", async ({ page }) => {
+ await createDefaultTodos(page);
+
+ const todoItems = page.getByTestId("todo-item");
+ const secondTodo = todoItems.nth(1);
+ await secondTodo.dblclick();
+ await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue(
+ TODO_ITEMS[1],
+ );
+ await secondTodo
+ .getByRole("textbox", { name: "Edit" })
+ .fill("buy some sausages");
+ await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter");
+
+ // Explicitly assert the new text value.
+ await expect(todoItems).toHaveText([
+ TODO_ITEMS[0],
+ "buy some sausages",
+ TODO_ITEMS[2],
+ ]);
+ await checkTodosInLocalStorage(page, "buy some sausages");
+ });
+});
+
+test.describe("Editing", () => {
+ test.beforeEach(async ({ page }) => {
+ await createDefaultTodos(page);
+ await checkNumberOfTodosInLocalStorage(page, 3);
+ });
+
+ test("should hide other controls when editing", async ({ page }) => {
+ const todoItem = page.getByTestId("todo-item").nth(1);
+ await todoItem.dblclick();
+ await expect(todoItem.getByRole("checkbox")).not.toBeVisible();
+ await expect(
+ todoItem.locator("label", {
+ hasText: TODO_ITEMS[1],
+ }),
+ ).not.toBeVisible();
+ await checkNumberOfTodosInLocalStorage(page, 3);
+ });
+
+ test("should save edits on blur", async ({ page }) => {
+ const todoItems = page.getByTestId("todo-item");
+ await todoItems.nth(1).dblclick();
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .fill("buy some sausages");
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .dispatchEvent("blur");
+
+ await expect(todoItems).toHaveText([
+ TODO_ITEMS[0],
+ "buy some sausages",
+ TODO_ITEMS[2],
+ ]);
+ await checkTodosInLocalStorage(page, "buy some sausages");
+ });
+
+ test("should trim entered text", async ({ page }) => {
+ const todoItems = page.getByTestId("todo-item");
+ await todoItems.nth(1).dblclick();
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .fill(" buy some sausages ");
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .press("Enter");
+
+ await expect(todoItems).toHaveText([
+ TODO_ITEMS[0],
+ "buy some sausages",
+ TODO_ITEMS[2],
+ ]);
+ await checkTodosInLocalStorage(page, "buy some sausages");
+ });
+
+ test("should remove the item if an empty text string was entered", async ({
+ page,
+ }) => {
+ const todoItems = page.getByTestId("todo-item");
+ await todoItems.nth(1).dblclick();
+ await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("");
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .press("Enter");
+
+ await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+ });
+
+ test("should cancel edits on escape", async ({ page }) => {
+ const todoItems = page.getByTestId("todo-item");
+ await todoItems.nth(1).dblclick();
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .fill("buy some sausages");
+ await todoItems
+ .nth(1)
+ .getByRole("textbox", { name: "Edit" })
+ .press("Escape");
+ await expect(todoItems).toHaveText(TODO_ITEMS);
+ });
+});
+
+test.describe("Counter", () => {
+ test("should display the current number of todo items", async ({ page }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ // create a todo count locator
+ const todoCount = page.getByTestId("todo-count");
+
+ await newTodo.fill(TODO_ITEMS[0]);
+ await newTodo.press("Enter");
+
+ await expect(todoCount).toContainText("1");
+
+ await newTodo.fill(TODO_ITEMS[1]);
+ await newTodo.press("Enter");
+ await expect(todoCount).toContainText("2");
+
+ await checkNumberOfTodosInLocalStorage(page, 2);
+ });
+});
+
+test.describe("Clear completed button", () => {
+ test.beforeEach(async ({ page }) => {
+ await createDefaultTodos(page);
+ });
+
+ test("should display the correct text", async ({ page }) => {
+ await page.locator(".todo-list li .toggle").first().check();
+ await expect(
+ page.getByRole("button", { name: "Clear completed" }),
+ ).toBeVisible();
+ });
+
+ test("should remove completed items when clicked", async ({ page }) => {
+ const todoItems = page.getByTestId("todo-item");
+ await todoItems.nth(1).getByRole("checkbox").check();
+ await page.getByRole("button", { name: "Clear completed" }).click();
+ await expect(todoItems).toHaveCount(2);
+ await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+ });
+
+ test("should be hidden when there are no items that are completed", async ({
+ page,
+ }) => {
+ await page.locator(".todo-list li .toggle").first().check();
+ await page.getByRole("button", { name: "Clear completed" }).click();
+ await expect(
+ page.getByRole("button", { name: "Clear completed" }),
+ ).toBeHidden();
+ });
+});
+
+test.describe("Persistence", () => {
+ test("should persist its data", async ({ page }) => {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ for (const item of TODO_ITEMS.slice(0, 2)) {
+ await newTodo.fill(item);
+ await newTodo.press("Enter");
+ }
+
+ const todoItems = page.getByTestId("todo-item");
+ const firstTodoCheck = todoItems.nth(0).getByRole("checkbox");
+ await firstTodoCheck.check();
+ await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+ await expect(firstTodoCheck).toBeChecked();
+ await expect(todoItems).toHaveClass(["completed", ""]);
+
+ // Ensure there is 1 completed item.
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+ // Now reload.
+ await page.reload();
+ await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+ await expect(firstTodoCheck).toBeChecked();
+ await expect(todoItems).toHaveClass(["completed", ""]);
+ });
+});
+
+test.describe("Routing", () => {
+ test.beforeEach(async ({ page }) => {
+ await createDefaultTodos(page);
+ // make sure the app had a chance to save updated todos in storage
+ // before navigating to a new view, otherwise the items can get lost :(
+ // in some frameworks like Durandal
+ await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
+ });
+
+ test("should allow me to display active items", async ({ page }) => {
+ const todoItem = page.getByTestId("todo-item");
+ await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
+
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+ await page.getByRole("link", { name: "Active" }).click();
+ await expect(todoItem).toHaveCount(2);
+ await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+ });
+
+ test("should respect the back button", async ({ page }) => {
+ const todoItem = page.getByTestId("todo-item");
+ await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
+
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+ await test.step("Showing all items", async () => {
+ await page.getByRole("link", { name: "All" }).click();
+ await expect(todoItem).toHaveCount(3);
+ });
+
+ await test.step("Showing active items", async () => {
+ await page.getByRole("link", { name: "Active" }).click();
+ });
+
+ await test.step("Showing completed items", async () => {
+ await page.getByRole("link", { name: "Completed" }).click();
+ });
+
+ await expect(todoItem).toHaveCount(1);
+ await page.goBack();
+ await expect(todoItem).toHaveCount(2);
+ await page.goBack();
+ await expect(todoItem).toHaveCount(3);
+ });
+
+ test("should allow me to display completed items", async ({ page }) => {
+ await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+ await page.getByRole("link", { name: "Completed" }).click();
+ await expect(page.getByTestId("todo-item")).toHaveCount(1);
+ });
+
+ test("should allow me to display all items", async ({ page }) => {
+ await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
+ await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+ await page.getByRole("link", { name: "Active" }).click();
+ await page.getByRole("link", { name: "Completed" }).click();
+ await page.getByRole("link", { name: "All" }).click();
+ await expect(page.getByTestId("todo-item")).toHaveCount(3);
+ });
+
+ test("should highlight the currently applied filter", async ({ page }) => {
+ await expect(page.getByRole("link", { name: "All" })).toHaveClass(
+ "selected",
+ );
+
+ //create locators for active and completed links
+ const activeLink = page.getByRole("link", { name: "Active" });
+ const completedLink = page.getByRole("link", { name: "Completed" });
+ await activeLink.click();
+
+ // Page change - active items.
+ await expect(activeLink).toHaveClass("selected");
+ await completedLink.click();
+
+ // Page change - completed items.
+ await expect(completedLink).toHaveClass("selected");
+ });
+});
+
+async function createDefaultTodos(page: Page) {
+ // create a new todo locator
+ const newTodo = page.getByPlaceholder("What needs to be done?");
+
+ for (const item of TODO_ITEMS) {
+ await newTodo.fill(item);
+ await newTodo.press("Enter");
+ }
+}
+
+async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
+ return await page.waitForFunction((e) => {
+ return JSON.parse(localStorage["react-todos"]).length === e;
+ }, expected);
+}
+
+async function checkNumberOfCompletedTodosInLocalStorage(
+ page: Page,
+ expected: number,
+) {
+ return await page.waitForFunction((e) => {
+ return (
+ JSON.parse(localStorage["react-todos"]).filter(
+ (todo: any) => todo.completed,
+ ).length === e
+ );
+ }, expected);
+}
+
+async function checkTodosInLocalStorage(page: Page, title: string) {
+ return await page.waitForFunction((t) => {
+ return JSON.parse(localStorage["react-todos"])
+ .map((todo: any) => todo.title)
+ .includes(t);
+ }, title);
+}
diff --git a/tests/example.spec.ts b/tests/example.spec.ts
new file mode 100644
index 0000000..b60fe7c
--- /dev/null
+++ b/tests/example.spec.ts
@@ -0,0 +1,20 @@
+import { test, expect } from "@playwright/test";
+
+test("has title", async ({ page }) => {
+ await page.goto("https://playwright.dev/");
+
+ // Expect a title "to contain" a substring.
+ await expect(page).toHaveTitle(/Playwright/);
+});
+
+test("get started link", async ({ page }) => {
+ await page.goto("https://playwright.dev/");
+
+ // Click the get started link.
+ await page.getByRole("link", { name: "Get started" }).click();
+
+ // Expects page to have a heading with the name of Installation.
+ await expect(
+ page.getByRole("heading", { name: "Installation" }),
+ ).toBeVisible();
+});
diff --git a/translations.babel b/translations.babel
new file mode 100644
index 0000000..741c65d
--- /dev/null
+++ b/translations.babel
@@ -0,0 +1,482 @@
+
+
+
+ i18next
+ translations.babel
+
+
+
+
+
+ main
+
+
+ toolbar
+
+
+ help
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+
+
+
+ renderer
+
+
+ translation
+
+
+ auth
+
+
+ labels
+
+
+ welcome
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+ login
+
+
+ error
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ login
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ resetpassword
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+
+
+ errors
+
+
+ errorboundary
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ notificationtitle
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+ navigation
+
+
+ home
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ settings
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ signout
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+ settings
+
+
+ actions
+
+
+ addpath
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ startwatcher
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ stopwatcher
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+ labels
+
+
+ emsOutFilePath
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ pollinginterval
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ ppcfilepath
+ false
+
+
+
+
+
+ en-US
+ true
+
+
+
+
+ started
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ stopped
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ watchedpaths
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ watchermodepolling
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ watchermoderealtime
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ watcherstatus
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+
+
+ title
+
+
+ imex
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ rome
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+ updates
+
+
+ apply
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ available
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ download
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+ downloading
+ false
+
+
+
+
+
+ en-US
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+ en-US
+
+ src/util/translations/en-US
+
+
+
+
+ src/util/translations/en-US
+
+
+
+ true
+
+ '%1'
+ { this.props.t('%1') }
+ { t('%1') }
+
+
+ en-US
+
+ tab
+ namespaced-json
+
+
diff --git a/tsconfig.json b/tsconfig.json
index 31bac6e..155ebaa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,7 @@
{
"files": [],
- "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.web.json" }
+ ]
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index db23a68..d7105fc 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -1,8 +1,17 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
- "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
+ "include": [
+ "electron.vite.config.*",
+ "src/main/**/*",
+ "src/preload/**/*",
+ "src/util/**/*",
+ "src/interfaces/**/*",
+ "tests/index.spec.ts",
+ "/resources/**/*"
+ ],
"compilerOptions": {
+ "resolveJsonModule": true,
"composite": true,
- "types": ["electron-vite/node"]
+ "types": ["electron-vite/node", "vite/client"]
}
}
diff --git a/tsconfig.web.json b/tsconfig.web.json
index 9c16b66..e46e2d2 100644
--- a/tsconfig.web.json
+++ b/tsconfig.web.json
@@ -4,16 +4,17 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
- "src/preload/*.d.ts"
+ "src/preload/*.d.ts",
+ "src/util/**/*"
],
"compilerOptions": {
+ "resolveJsonModule": true,
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
+ "types": ["vite/client"],
"paths": {
- "@renderer/*": [
- "src/renderer/src/*"
- ]
+ "@renderer/*": ["src/renderer/src/*"]
}
}
}