Initial copy of shop partner app.

This commit is contained in:
Patrick Fic
2025-12-01 05:43:59 -08:00
commit 267ef714a7
193 changed files with 32199 additions and 0 deletions

17
src/renderer/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Shop Partner</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!-- <meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,97 @@
import { test, expect } from "@playwright/test";
import { Page } from "@playwright/test";
// src/renderer/src/App.test.tsx
// Mock data
const mockUser = {
uid: "test123",
email: "test@example.com",
displayName: "Test User",
toJSON: () => ({
uid: "test123",
email: "test@example.com",
displayName: "Test User",
}),
};
test.describe("App Component", () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
// Mock Firebase Auth
await page.addInitScript(() => {
window.mockAuthState = null;
// Mock the firebase auth module
jest.mock("./util/firebase", () => ({
auth: {
onAuthStateChanged: (callback) => {
callback(window.mockAuthState);
// Return mock unsubscribe function
return () => {};
},
},
}));
// Mock electron IPC
window.electron = {
ipcRenderer: {
send: jest.fn(),
},
};
});
await page.goto("/");
});
test("should show SignInForm when user is not authenticated", async () => {
await page.evaluate(() => {
window.mockAuthState = null;
});
await page.reload();
// Check if SignInForm is visible
const signInForm = await page
.locator("form")
.filter({ hasText: "Sign In" });
await expect(signInForm).toBeVisible();
});
test("should show routes when user is authenticated", async () => {
await page.evaluate((user) => {
window.mockAuthState = user;
}, mockUser);
await page.reload();
// Check if AuthHome is visible
const authHome = await page.locator('div:text("AuthHome")');
await expect(authHome).toBeVisible();
// Check that electron IPC was called with auth state
await expect(
page.evaluate(() => {
return window.electron.ipcRenderer.send.mock.calls.length > 0;
}),
).resolves.toBe(true);
});
test("should navigate to settings page when authenticated", async () => {
await page.evaluate((user) => {
window.mockAuthState = user;
}, mockUser);
await page.reload();
// Navigate to settings
await page.click('a[href="/settings"]');
// Check if Settings page is visible
const settingsPage = await page.locator('div:text("Settings")');
await expect(settingsPage).toBeVisible();
});
});

88
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,88 @@
import "@ant-design/v5-patch-for-react-19";
import { Layout, Skeleton, ConfigProvider, Badge } from "antd";
import { User } from "firebase/auth";
import { useEffect, useState, FC } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Provider } from "react-redux";
import { HashRouter, Route, Routes } from "react-router";
import ipcTypes from "../../util/ipcTypes.json";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback/ErrorBoundaryFallback";
import Settings from "./components/Settings/Settings";
import SignInForm from "./components/SignInForm/SignInForm";
import UpdateAvailable from "./components/UpdateAvailable/UpdateAvailable";
import reduxStore from "./redux/redux-store";
import { auth } from "./util/firebase";
import { NotificationProvider } from "./util/notificationContext";
const App: FC = () => {
const [user, setUser] = useState<User | boolean | null>(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 (
<ConfigProvider
theme={{
token: {},
components: {
Card: {
borderRadius: 8,
colorBgBase: "#ffaacc",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
},
},
}}
>
<Provider store={reduxStore}>
<HashRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<NotificationProvider>
<Skeleton loading={user === false} active>
<Layout style={{ minHeight: "100vh" }}>
{!user ? (
<SignInForm />
) : (
<Badge.Ribbon
text={isTest && "Connected to Test"}
color={isTest ? "red" : undefined}
>
<Layout.Content style={{ padding: "0 24px" }}>
<UpdateAvailable />
<Routes>
<Route path="/" element={<Settings />} />
</Routes>
</Layout.Content>
</Badge.Ribbon>
)}
</Layout>
</Skeleton>
</NotificationProvider>
</ErrorBoundary>
</HashRouter>
</Provider>
</ConfigProvider>
);
};
export default App;

View File

@@ -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<FallbackProps> = ({
error,
resetErrorBoundary,
}) => {
const { t } = useTranslation();
return (
<Result
status={"500"}
title={t("errors.errorboundary")}
subTitle={error?.message}
extra={[
<Button key="try-again" onClick={resetErrorBoundary}>
Try again
</Button>,
]}
/>
);
};
export default ErrorBoundaryFallback;

View File

@@ -0,0 +1,11 @@
import { FC } from "react";
const Home: FC = () => {
return (
<div>
<h1>Home</h1>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,164 @@
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<
PaintScaleConfig[]
>([]);
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 defaultPolling = configType === "input" ? 1440 : 60;
const updatedConfigs = configs.map((config) => ({
...config,
pollingInterval: config.pollingInterval || defaultPolling, // Default to 1440 for input, 60 for output
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<boolean> => {
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 defaultPolling = configType === "input" ? 1440 : 60;
const newConfig: PaintScaleConfig = {
id: Date.now().toString(),
type,
pollingInterval: defaultPolling, // Default to 1440 for input, 60 for output
};
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,
};
};

View File

@@ -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<string | null>(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 (
<Card title={t("settings.labels.emsOutFilePath")}>
<Space wrap>
<Input
value={emsFilePath || ""}
placeholder={t("settings.labels.emsOutFilePath")}
disabled
/>
<Button onClick={handlePathChange} icon={<FolderOpenFilled />} />
</Space>
</Card>
);
};
export default SettingsEmsOutFilePath;

View File

@@ -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("output");
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedType, setSelectedType] = useState<PaintScaleType | null>(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<Record<PaintScaleType, string>> = {
[PaintScaleType.PPG]: "blue",
// Add other types and colors as needed
};
return <Tag color={colorMap[type] || "default"}>{label}</Tag>;
},
},
{
title: t("settings.labels.paintScalePath"),
dataIndex: "path",
key: "path",
render: (path: string | null, record: PaintScaleConfig) => {
const isValid = path && path.trim() !== "";
return (
<Space>
<Input
value={path || ""}
placeholder={t("settings.labels.paintScalePath")}
disabled
style={{
borderColor: isValid ? token.colorSuccess : token.colorError, // Use semantic tokens
}}
suffix={
<Tooltip
title={
isValid
? t("settings.labels.validPath")
: t("settings.labels.invalidPath")
}
>
{isValid ? (
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
<WarningFilled style={{ color: token.colorError }} />
)}
</Tooltip>
}
/>
<Button
onClick={() => handlePathChange(record.id)}
icon={<FolderOpenFilled />}
/>
</Space>
);
},
},
{
title: t("settings.labels.pollingInterval"),
dataIndex: "pollingInterval",
key: "pollingInterval",
render: (pollingInterval: number, record: PaintScaleConfig) => (
<Input
type="number"
value={pollingInterval}
onChange={(e) =>
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) => (
<Button danger onClick={() => handleRemoveConfig(record.id)}>
{t("settings.labels.remove")}
</Button>
),
},
];
return (
<>
<Card
title={t("settings.labels.paintScaleSettingsInput")}
extra={
<Button onClick={showAddPathModal} icon={<FileAddFilled />}>
{t("settings.actions.addpath")}
</Button>
}
>
<Table
dataSource={paintScaleConfigs}
columns={columns}
rowKey="id"
pagination={false}
/>
</Card>
<Modal
title={t("settings.labels.selectPaintScaleType")}
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
okButtonProps={{ disabled: !selectedType }}
>
<Select
value={selectedType}
options={paintScaleTypeOptions}
onChange={(value) => setSelectedType(value)}
style={{ width: "100%" }}
placeholder={t("settings.labels.selectPaintScaleType")}
/>
</Modal>
</>
);
};
export default SettingsPaintScaleInputPaths;

View File

@@ -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("input");
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedType, setSelectedType] = useState<PaintScaleType | null>(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<Record<PaintScaleType, string>> = {
[PaintScaleType.PPG]: "blue",
// Add other types and colors as needed
};
return <Tag color={colorMap[type] || "default"}>{label}</Tag>;
},
},
{
title: t("settings.labels.paintScalePath"),
dataIndex: "path",
key: "path",
render: (path: string | null, record: PaintScaleConfig) => {
const isValid = path && path.trim() !== "";
return (
<Space>
<Input
value={path || ""}
placeholder={t("settings.labels.paintScalePath")}
disabled
style={{
borderColor: isValid ? token.colorSuccess : token.colorError,
}}
suffix={
isValid ? (
<CheckCircleFilled style={{ color: token.colorSuccess }} />
) : (
<WarningFilled style={{ color: token.colorError }} />
)
}
/>
<Button
onClick={() => handlePathChange(record.id)}
icon={<FolderOpenFilled />}
/>
</Space>
);
},
},
{
title: t("settings.labels.pollingInterval"),
dataIndex: "pollingInterval",
key: "pollingInterval",
render: (pollingInterval: number, record: PaintScaleConfig) => (
<Input
type="number"
value={pollingInterval}
onChange={(e) =>
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) => (
<Button danger onClick={() => handleRemoveConfig(record.id)}>
{t("settings.labels.remove")}
</Button>
),
},
];
return (
<>
<Card
title={t("settings.labels.paintScaleSettingsOutput")}
extra={
<Button onClick={showAddPathModal} icon={<FileAddFilled />}>
{t("settings.actions.addpath")}
</Button>
}
>
<Table
dataSource={paintScaleConfigs}
columns={columns}
rowKey="id"
pagination={false}
/>
</Card>
<Modal
title={t("settings.labels.selectPaintScaleType")}
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
okButtonProps={{ disabled: !selectedType }}
>
<Select
value={selectedType}
options={paintScaleTypeOptions}
onChange={(value) => setSelectedType(value)}
style={{ width: "100%" }}
placeholder={t("settings.labels.selectPaintScaleType")}
/>
</Modal>
</>
);
};
export default SettingsPaintScaleOutputPaths;

View File

@@ -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<string | null>(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 (
<Card title={t("settings.labels.ppcfilepath")}>
<Space wrap>
<Input
value={ppcFilePath || ""}
placeholder={t("settings.labels.ppcfilepath")}
disabled
/>
<Button onClick={handlePathChange} icon={<FolderOpenFilled />} />
</Space>
</Card>
);
};
export default SettingsPpcFilepath;

View File

@@ -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<string[]>([]);
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 (
<Card
title={t("settings.labels.watchedpaths")}
extra={
<Button onClick={handleAddPath} icon={<FileAddFilled />}>
{t("settings.actions.addpath")}
</Button>
}
>
<Timeline
items={watchedPaths.map((path, index) => ({
key: index,
children: (
<Space align="baseline">
{path}
<Button
size="small"
danger
type="text"
icon={<DeleteFilled />}
onClick={() => handleRemovePath(path)}
/>
</Space>
),
}))}
/>
</Card>
);
};
export default SettingsWatchedPaths;

View File

@@ -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 (
<Badge.Ribbon
text={
isWatcherStarted ? (
<Space>
<CheckCircleFilled />
{t("settings.labels.started")}
</Space>
) : (
<Space>
<ExclamationCircleFilled />
{t("settings.labels.stopped")}
</Space>
)
}
color={isWatcherStarted ? "green" : "red"}
>
<Card title={t("settings.labels.watcherstatus")}>
<Row gutter={[16, 16]}>
<Col {...colSpans}>
{isWatcherStarted ? (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={handleStop}
>
{t("settings.actions.stopwatcher")}
</Button>
) : (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleStart}
>
{t("settings.actions.startwatcher")}
</Button>
)}
</Col>
<Col {...colSpans}>
<Space direction="vertical" wrap>
<Switch
checked={!pollingState.enabled}
onChange={toggleWatcherMode}
checkedChildren={t("settings.labels.watchermoderealtime")}
unCheckedChildren={t("settings.labels.watchermodepolling")}
/>
{pollingState.enabled && (
<Space size="small" direction="vertical" wrap>
<span>{t("settings.labels.pollinginterval")}</span>
<InputNumber
title={t("settings.labels.pollinginterval")}
disabled={!pollingState.enabled}
min={1000}
value={pollingState.interval}
onChange={handlePollingIntervalChange}
/>
</Space>
)}
{watcherError && <Alert message={watcherError} />}
</Space>
</Col>
</Row>
</Card>
</Badge.Ribbon>
);
};
export default SettingsWatcher;

View File

@@ -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 (
<Row gutter={[16, 16]}>
<Col span={24}>
<Welcome />
</Col>
<Col {...colSpans}>
<SettingsWatchedPaths />
</Col>
<Col {...colSpans}>
<SettingsWatcher />
</Col>
<Col {...colSpans}>
<SettingsPpcFilepath />
</Col>
<Col {...colSpans}>
<SettingsEmsOutFilePath />
</Col>
<Col {...colSpans}>
<SettingsPaintScaleInputPaths />
</Col>
<Col {...colSpans}>
<SettingsPaintScaleOutputPaths />
</Col>
</Row>
);
};
export default Settings;

View File

@@ -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<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const { t } = useTranslation();
const onFinish: FormProps<FieldType>["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<FieldType>["onFinishFailed"] = (
errorInfo,
) => {
log.log("Failed:", errorInfo);
};
return (
<Card
style={{
maxWidth: 600,
margin: "auto auto",
borderRadius: 8,
paddingLeft: 48,
paddingRight: 48,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<Title level={2}>
{import.meta.env.VITE_COMPANY === "IMEX"
? t("title.imex")
: t("title.rome")}
</Title>
</div>
<Form
name="desktop-sign-in"
layout="vertical"
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
requiredMark={false}
>
{error && (
<Form.Item>
<Alert
message={error}
type="error"
showIcon
style={{ marginBottom: 16 }}
/>
</Form.Item>
)}
<Form.Item<FieldType>
label="Username"
name="username"
rules={[
{
required: true,
message: t(
"auth.login.usernameRequired",
"Please enter your username",
),
},
]}
>
<Input size="large" />
</Form.Item>
<Form.Item<FieldType>
label="Password"
name="password"
rules={[
{
required: true,
message: t(
"auth.login.passwordRequired",
"Please enter your password",
),
},
]}
>
<Input.Password size="large" />
</Form.Item>
<Form.Item>
<Button
type="primary"
loading={loading}
htmlType="submit"
size="large"
block
>
{t("auth.login.login")}
</Button>
</Form.Item>
<Form.Item style={{ marginBottom: 0, textAlign: "center" }}>
<Button
type="link"
onClick={(): void => {
window.electron.ipcRenderer.send(
ipcTypes.toMain.user.resetPassword,
);
}}
>
{t("auth.login.resetpassword")}
</Button>
</Form.Item>
</Form>
</Card>
);
};
export default SignInForm;

View File

@@ -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<boolean>(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 (
<Affix offsetTop={40} style={{ position: "absolute", right: 20 }}>
<Card
title={t("updates.available")}
style={{
maxWidth: 600,
margin: "auto auto",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
>
<Space direction="vertical" style={{ width: "100%" }}>
{updateProgress === 0 && (
<Button onClick={handleDownload}>{t("updates.download")}</Button>
)}
<Progress
percent={updateProgress}
percentPosition={{ align: "center", type: "outer" }}
/>
{!isUpdateComplete && formatSpeed(updateSpeed)}
{isUpdateComplete && (
<>
<Button
type="primary"
loading={applyingUpdate}
style={{ width: "100%" }}
onClick={handleApply}
>
{applyingUpdate ? t("updates.applying") : t("updates.apply")}
</Button>
<Statistic.Countdown
title="Auto update in:"
format="mm:ss"
style={{ width: "100%", textAlign: "center" }}
value={Date.now() + 10 * 1000}
onFinish={(): void => handleApply()}
/>
</>
)}
</Space>
</Card>
</Affix>
);
};
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]}`;
};

View File

@@ -0,0 +1,15 @@
import { JSX, useState } from "react";
function Versions(): JSX.Element {
const [versions] = useState(window.electron.process.versions);
return (
<ul className="versions">
<li className="electron-version">Electron v{versions.electron}</li>
<li className="chrome-version">Chromium v{versions.chrome}</li>
<li className="node-version">Node v{versions.node}</li>
</ul>
);
}
export default Versions;

View File

@@ -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<string | null>(null);
useEffect(() => {
window.electron.ipcRenderer
.invoke(ipcTypes.toMain.user.getActiveShop)
.then((shopName: string) => {
console.log("Active shop name:", shopName);
setShopName(shopName);
});
}, []);
return (
<>
<Typography.Title level={4}>
{t("auth.labels.welcome", {
name: isEmpty(auth.currentUser?.displayName)
? auth.currentUser?.email
: `${auth.currentUser?.displayName} (${auth.currentUser?.email})`.trim(),
})}
</Typography.Title>
<Space align="baseline">
<Typography.Paragraph>{shopName || ""}</Typography.Paragraph>
<Button
size="small"
danger
icon={<LogoutOutlined />}
onClick={(): void => {
auth.signOut().catch((error) => {
console.error("Sign out error:", error);
});
}}
>
{t("navigation.signout")}
</Button>
</Space>
</>
);
};
export default Welcome;

1
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,26 @@
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";
// Extend the Window interface to include the api property
declare global {
interface Window {
api: {
isTest: () => boolean;
};
}
}
Sentry.init({
dsn: "https://ba41d22656999a8c1fd63bcb7df98650@o492140.ingest.us.sentry.io/4509074139447296",
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -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<string>) => {
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;

View File

@@ -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<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
export type AppStore = typeof store;
export default store;

View File

@@ -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<AppDispatch>(); // Ex
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppStore: () => AppStore = useStore;

View File

@@ -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<Timer>({
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;

View File

@@ -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;

View File

@@ -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<any> = new ApolloClient({
link: ApolloLink.from(middlewares),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: "network-only",
},
query: {
fetchPolicy: "network-only",
},
mutate: {
errorPolicy: "none",
},
},
});
export default client;

View File

@@ -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;

View File

@@ -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,
});
},
);

View File

@@ -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<NotificationProviderProps> = ({
// 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
<NotificationContext.Provider value={api}>
{/* contextHolder must be rendered in the DOM so notifications can appear */}
{contextHolder}
{children}
</NotificationContext.Provider>
);
};