Initial copy of shop partner app.
This commit is contained in:
17
src/renderer/index.html
Normal file
17
src/renderer/index.html
Normal 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>
|
||||
97
src/renderer/src/App.test.tsx
Normal file
97
src/renderer/src/App.test.tsx
Normal 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
88
src/renderer/src/App.tsx
Normal 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;
|
||||
@@ -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;
|
||||
11
src/renderer/src/components/Home/Home.tsx
Normal file
11
src/renderer/src/components/Home/Home.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FC } from "react";
|
||||
|
||||
const Home: FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
157
src/renderer/src/components/Settings/Settings.Watcher.tsx
Normal file
157
src/renderer/src/components/Settings/Settings.Watcher.tsx
Normal 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;
|
||||
45
src/renderer/src/components/Settings/Settings.tsx
Normal file
45
src/renderer/src/components/Settings/Settings.tsx
Normal 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;
|
||||
142
src/renderer/src/components/SignInForm/SignInForm.tsx
Normal file
142
src/renderer/src/components/SignInForm/SignInForm.tsx
Normal 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;
|
||||
@@ -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]}`;
|
||||
};
|
||||
15
src/renderer/src/components/Versions.tsx
Normal file
15
src/renderer/src/components/Versions.tsx
Normal 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;
|
||||
49
src/renderer/src/components/Welcome/Welcome.tsx
Normal file
49
src/renderer/src/components/Welcome/Welcome.tsx
Normal 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
1
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
src/renderer/src/main.tsx
Normal file
26
src/renderer/src/main.tsx
Normal 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>,
|
||||
);
|
||||
136
src/renderer/src/redux/app.slice.ts
Normal file
136
src/renderer/src/redux/app.slice.ts
Normal 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;
|
||||
16
src/renderer/src/redux/redux-store.ts
Normal file
16
src/renderer/src/redux/redux-store.ts
Normal 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;
|
||||
10
src/renderer/src/redux/reduxHooks.ts
Normal file
10
src/renderer/src/redux/reduxHooks.ts
Normal 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;
|
||||
124
src/renderer/src/util/countdownHook.ts
Normal file
124
src/renderer/src/util/countdownHook.ts
Normal 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;
|
||||
14
src/renderer/src/util/firebase.ts
Normal file
14
src/renderer/src/util/firebase.ts
Normal 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;
|
||||
30
src/renderer/src/util/graphql.client.ts
Normal file
30
src/renderer/src/util/graphql.client.ts
Normal 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;
|
||||
24
src/renderer/src/util/i18n.ts
Normal file
24
src/renderer/src/util/i18n.ts
Normal 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;
|
||||
97
src/renderer/src/util/ipcRendererHandler.ts
Normal file
97
src/renderer/src/util/ipcRendererHandler.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
46
src/renderer/src/util/notificationContext.tsx
Normal file
46
src/renderer/src/util/notificationContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user