From 122c0cebebf76c431398beb05a71b2103f0c53fd Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 26 May 2026 11:03:22 -0400 Subject: [PATCH] feature/IO-3702-ESPD-UI-AND-FIXES - Stage 1 --- serverless/src/handlers/emsupload.ts | 2 +- serverless/src/handlers/scrub.ts | 4 +- src/main/decoder/decode-stl.ts | 2 +- src/main/index.ts | 15 +- src/main/ipc/ipcMainConfig.ts | 8 +- src/main/watcher/watcher.ts | 51 +- src/renderer/src/App.tsx | 18 +- .../ErrorBoundaryFallback.tsx | 1 + src/renderer/src/components/Home/Home.tsx | 872 ++++++++++++------ .../Settings/Settings.WatchedPaths.tsx | 4 +- .../UpdateAvailable/UpdateAvailable.tsx | 62 +- src/renderer/src/index.css | 11 + src/renderer/src/main.tsx | 1 + src/renderer/src/util/ipcRendererHandler.ts | 9 + src/util/ipcTypes.json | 3 +- src/util/translations/en-US/renderer.json | 3 + 16 files changed, 758 insertions(+), 308 deletions(-) create mode 100644 src/renderer/src/index.css diff --git a/serverless/src/handlers/emsupload.ts b/serverless/src/handlers/emsupload.ts index cdc6b0f..b34504f 100644 --- a/serverless/src/handlers/emsupload.ts +++ b/serverless/src/handlers/emsupload.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const s3Client = new S3Client({ region: process.env.AWS_REGION || 'ca-central-1' }); diff --git a/serverless/src/handlers/scrub.ts b/serverless/src/handlers/scrub.ts index e8f639d..8d3697d 100644 --- a/serverless/src/handlers/scrub.ts +++ b/serverless/src/handlers/scrub.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import axios, { AxiosError } from 'axios'; +import axios from 'axios'; import FormData from 'form-data'; -import { GraphQLClient, gql } from 'graphql-request'; +import { gql, GraphQLClient } from 'graphql-request'; import { ESJobObject, RawJobDataObject } from '../../../shared/types'; import { transformJobForEstimateScrubber } from '../lib/transformEstimate'; import { getVehicleType } from '../lib/vehicleTypes/vehicleType'; diff --git a/src/main/decoder/decode-stl.ts b/src/main/decoder/decode-stl.ts index 0706099..8b35be2 100644 --- a/src/main/decoder/decode-stl.ts +++ b/src/main/decoder/decode-stl.ts @@ -77,7 +77,7 @@ const DecodeStl = async ( return singleLineData; }); - //Apply business logic transfomrations. + //Apply business logic transformations. //We don't have an inspection date, we instead have `date_estimated` return { cieca_stl: { data: rawStlData } }; diff --git a/src/main/index.ts b/src/main/index.ts index ba66be5..548f0c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -591,7 +591,20 @@ app.whenReady().then(async () => { isKeepAliveLaunch = true; } - StartWatcher(); + const runWatcherOnStartup = store.get("settings.runWatcherOnStartup") as + | boolean + | undefined; + const configuredWatcherPaths = + (store.get("settings.filepaths") as string[] | undefined) || []; + + if (runWatcherOnStartup !== false && configuredWatcherPaths.length > 0) { + StartWatcher({ notifyOnNoPaths: false }).catch((error) => { + log.error( + "Error starting watcher on app startup:", + errorTypeCheck(error), + ); + }); + } //The update itself will run when the bodyshop record is queried to know what release channel to use. openMainWindow(); diff --git a/src/main/ipc/ipcMainConfig.ts b/src/main/ipc/ipcMainConfig.ts index 9363d9d..62a618a 100644 --- a/src/main/ipc/ipcMainConfig.ts +++ b/src/main/ipc/ipcMainConfig.ts @@ -2,7 +2,11 @@ import { ipcMain } from "electron"; import log from "electron-log/main"; import { autoUpdater } from "electron-updater"; import ipcTypes from "../../util/ipcTypes.json"; -import { StartWatcher, StopWatcher } from "../watcher/watcher"; +import { + IsWatcherStarted, + StartWatcher, + StopWatcher, +} from "../watcher/watcher"; import { ScrubHistoryClearAll, ScrubHistoryDeleteJob, @@ -89,6 +93,8 @@ ipcMain.on(ipcTypes.toMain.watcher.stop, () => { }); }); +ipcMain.handle(ipcTypes.toMain.watcher.status, () => IsWatcherStarted()); + ipcMain.on(ipcTypes.toMain.updates.download, () => { log.info("Download update requested from renderer."); autoUpdater.downloadUpdate().catch((error) => { diff --git a/src/main/watcher/watcher.ts b/src/main/watcher/watcher.ts index 71e1057..6f2e1e0 100644 --- a/src/main/watcher/watcher.ts +++ b/src/main/watcher/watcher.ts @@ -10,17 +10,41 @@ import store from "../store/store"; import getMainWindow from "../../util/getMainWindow"; import { setWatcherTrayStatus } from "../util/trayStatus"; let watcher: FSWatcher | null; +let watcherReady = false; -async function StartWatcher(): Promise { - const filePaths: string[] = store.get("settings.filepaths") || []; +type StartWatcherOptions = { + notifyOnNoPaths?: boolean; +}; + +function getValidWatcherPaths(filePaths: string[]): string[] { + return filePaths.filter((filePath) => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isDirectory(); + } catch (error) { + log.warn(`Unable to validate watcher path ${filePath}:`, error); + return false; + } + }); +} + +async function StartWatcher( + options: StartWatcherOptions = {}, +): Promise { + const { notifyOnNoPaths = true } = options; + const configuredFilePaths: string[] = store.get("settings.filepaths") || []; + const filePaths = getValidWatcherPaths(configuredFilePaths); if (filePaths.length === 0) { - new Notification({ - //TODO: Add Translations - title: "Watcher cannot start", - body: "Please set the appropriate file paths and try again.", - }).show(); - log.warn("Cannot start watcher. No file paths set."); + if (notifyOnNoPaths) { + new Notification({ + //TODO: Add Translations + title: "Watcher cannot start", + body: "Please set the appropriate file paths and try again.", + }).show(); + } + log.warn("Cannot start watcher. No valid file paths set.", { + configuredFilePaths, + }); return false; } @@ -28,6 +52,8 @@ async function StartWatcher(): Promise { try { log.info("Trying to close watcher - it already existed."); await watcher.close(); + watcher = null; + watcherReady = false; log.info("Watcher closed successfully!"); } catch (error) { @@ -79,6 +105,7 @@ async function StartWatcher(): Promise { // }) .on("error", function (error) { log.error("Error in Watcher", errorTypeCheck(error)); + watcherReady = false; setWatcherTrayStatus(false); // mainWindow.webContents.send( // ipcTypes.toRenderer.watcher.error, @@ -111,6 +138,7 @@ function addWatcherPath(path: string | string[]): void { function onWatcherReady(): void { if (watcher) { const mainWindow = getMainWindow(); + watcherReady = true; new Notification({ title: "Watcher Started", body: "Newly exported estimates will be automatically uploaded.", @@ -126,6 +154,8 @@ async function StopWatcher(): Promise { if (watcher) { await watcher.close(); + watcher = null; + watcherReady = false; log.info("Watcher stopped."); setWatcherTrayStatus(false); mainWindow?.webContents.send(ipcTypes.toRenderer.watcher.stopped); @@ -139,6 +169,10 @@ async function StopWatcher(): Promise { return false; } +function IsWatcherStarted(): boolean { + return watcherReady; +} + async function HandleNewFile(path): Promise { log.log("Received a new file", path); await ImportJob(path); @@ -192,6 +226,7 @@ export { addWatcherPath, GetAllEnvFiles, GetLatestEnvFile, + IsWatcherStarted, removeWatcherPath, StartWatcher, StopWatcher, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d1009fd..0d17424 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,8 +28,22 @@ const App: FC = () => { - - + + } /> diff --git a/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx b/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx index 010e452..5882682 100644 --- a/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx +++ b/src/renderer/src/components/ErrorBoundaryFallback/ErrorBoundaryFallback.tsx @@ -12,6 +12,7 @@ const ErrorBoundaryFallback: FC = ({ diff --git a/src/renderer/src/components/Home/Home.tsx b/src/renderer/src/components/Home/Home.tsx index 6379c03..6949115 100644 --- a/src/renderer/src/components/Home/Home.tsx +++ b/src/renderer/src/components/Home/Home.tsx @@ -5,7 +5,10 @@ import { DatabaseOutlined, DeleteOutlined, DeleteRowOutlined, + FilePdfOutlined, FileTextOutlined, + LinkOutlined, + ReloadOutlined, SettingOutlined, } from "@ant-design/icons"; import { @@ -13,17 +16,20 @@ import { Badge, Card, Col, + Empty, Flex, + List, Popconfirm, Row, Space, + Spin, Statistic, Table, - Tag, + Tooltip, Typography, theme, } from "antd"; -import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { FC, UIEvent, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { selectWatcherStatus } from "@renderer/redux/app.slice"; @@ -32,6 +38,7 @@ import ipcTypes from "../../../../util/ipcTypes.json"; import dayjs from "dayjs"; const { Title, Text } = Typography; +const SCRUB_HISTORY_PAGE_SIZE = 20; const categoryConfig = { "Administrative Items": { color: "blue", priority: 1, icon: "📎" }, @@ -91,224 +98,283 @@ const Home: FC = () => { const [totalJobs, setTotalJobs] = useState(0); const [totalResults, setTotalResults] = useState(0); const [lastProcessed, setLastProcessed] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [loadedPage, setLoadedPage] = useState(1); + const [selectedJobId, setSelectedJobId] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); - const refresh = useCallback( - async (page: number, size: number) => { - setLoading(true); - try { - const response = (await ipcRenderer.invoke( - ipcTypes.toMain.scrubHistory.getAll, - { page, pageSize: size }, - )) as unknown; - - if (isScrubHistoryPage(response)) { - setHistory(response.items); - setTotalJobs(response.totalJobs); - setTotalResults(response.totalResults); - setLastProcessed(response.lastProcessed); - - if ( - response.items.length === 0 && - response.totalJobs > 0 && - page > 1 - ) { - setCurrentPage(page - 1); - } - } else if (Array.isArray(response)) { - setHistory(response as ScrubHistoryItem[]); - setTotalJobs((response as ScrubHistoryItem[]).length); - setTotalResults( - (response as ScrubHistoryItem[]).reduce( - (acc, job) => acc + (job.results?.length ?? 0), - 0, - ), - ); - setLastProcessed( - (response as ScrubHistoryItem[])[0]?.createdAt ?? null, - ); - } else { - setHistory([]); - setTotalJobs(0); - setTotalResults(0); - setLastProcessed(null); - } - } finally { - setLoading(false); - } - }, + const loadScrubHistoryPage = useCallback( + async (page: number, size: number): Promise => + ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.getAll, { + page, + pageSize: size, + }), [ipcRenderer], ); + const applyScrubHistoryResponse = useCallback( + (response: unknown, page: number, append = false) => { + if (isScrubHistoryPage(response)) { + setHistory((previous) => { + if (!append) return response.items; + + const existingIds = new Set(previous.map((item) => item.id)); + const nextItems = response.items.filter( + (item) => !existingIds.has(item.id), + ); + return [...previous, ...nextItems]; + }); + setTotalJobs(response.totalJobs); + setTotalResults(response.totalResults); + setLastProcessed(response.lastProcessed); + setLoadedPage(page); + + if (response.items.length === 0 && response.totalJobs > 0 && page > 1) { + setLoadedPage(page - 1); + } + } else if (Array.isArray(response)) { + setHistory(response as ScrubHistoryItem[]); + setTotalJobs((response as ScrubHistoryItem[]).length); + setTotalResults( + (response as ScrubHistoryItem[]).reduce( + (acc, job) => acc + (job.results?.length ?? 0), + 0, + ), + ); + setLastProcessed( + (response as ScrubHistoryItem[])[0]?.createdAt ?? null, + ); + setLoadedPage(1); + } else { + setHistory([]); + setTotalJobs(0); + setTotalResults(0); + setLastProcessed(null); + setLoadedPage(1); + } + }, + [], + ); + + const refresh = useCallback( + async (page: number, append = false) => { + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + + try { + const response = await loadScrubHistoryPage( + page, + SCRUB_HISTORY_PAGE_SIZE, + ); + applyScrubHistoryResponse(response, page, append); + } finally { + if (append) { + setLoadingMore(false); + } else { + setLoading(false); + } + } + }, + [applyScrubHistoryResponse, loadScrubHistoryPage], + ); + useEffect(() => { - refresh(currentPage, pageSize).catch(() => { - setLoading(false); - }); + let cancelled = false; + + const load = async () => { + try { + const response = await loadScrubHistoryPage(1, SCRUB_HISTORY_PAGE_SIZE); + if (!cancelled) { + applyScrubHistoryResponse(response, 1); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); const handler = () => { - refresh(currentPage, pageSize).catch(() => undefined); + refresh(1).catch(() => undefined); }; - ipcRenderer.on(ipcTypes.toRenderer.scrub.historyUpdated, handler); + const removeHistoryUpdatedListener = ipcRenderer.on( + ipcTypes.toRenderer.scrub.historyUpdated, + handler, + ); + return () => { - ipcRenderer.removeListener( - ipcTypes.toRenderer.scrub.historyUpdated, - handler, - ); + cancelled = true; + removeHistoryUpdatedListener(); }; - }, [ipcRenderer, refresh, currentPage, pageSize]); + }, [applyScrubHistoryResponse, ipcRenderer, loadScrubHistoryPage, refresh]); const totalItemsScrubbed = totalResults; + const hasMoreHistory = history.length < totalJobs; + + const loadMoreHistory = useCallback(async () => { + if (loading || loadingMore || !hasMoreHistory) return; + await refresh(loadedPage + 1, true); + }, [hasMoreHistory, loadedPage, loading, loadingMore, refresh]); + + const handleHistoryScroll = useCallback( + (event: UIEvent) => { + const target = event.currentTarget; + const distanceFromBottom = + target.scrollHeight - target.scrollTop - target.clientHeight; + + if (distanceFromBottom < 96) { + loadMoreHistory().catch(() => undefined); + } + }, + [loadMoreHistory], + ); const deleteJob = useCallback( async (jobId: string) => { await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.deleteJob, jobId); - await refresh(currentPage, pageSize); + if (selectedJobId === jobId) { + setSelectedJobId(null); + } + await refresh(1); }, - [ipcRenderer, refresh, currentPage, pageSize], + [ipcRenderer, refresh, selectedJobId], ); const clearAll = useCallback(async () => { await ipcRenderer.invoke(ipcTypes.toMain.scrubHistory.clearAll); - setCurrentPage(1); - await refresh(1, pageSize); - }, [ipcRenderer, refresh, pageSize]); + setLoadedPage(1); + setSelectedJobId(null); + await refresh(1); + }, [ipcRenderer, refresh]); - const jobColumns = useMemo( - () => [ - { - title: "Claim #", - dataIndex: "claimNumber", - key: "claimNumber", - render: (text: string) => {text}, - }, - { - title: "Owner", - dataIndex: "ownrName", - key: "ownrName", - }, - { - title: "Vehicle", - dataIndex: "vehicle", - key: "vehicle", - responsive: ["md" as const], - }, - { - title: "Scrubbed", - dataIndex: "createdAt", - key: "createdAt", - responsive: ["sm" as const], - render: (value: number) => { - const date = new Date(value); - return ( - - {date.toLocaleDateString()} - - {date.toLocaleTimeString()} - - - ); - }, - }, - { - title: "PDF", - key: "pdf", - width: 90, - render: (_: unknown, record: ScrubHistoryItem) => ( - - ), - }, - { - title: "Items", - key: "items", - width: 90, - align: "right" as const, - render: (_: unknown, record: ScrubHistoryItem) => ( - {record.results?.length ?? 0} - ), - }, - // { - // title: "Status", - // key: "status", - // width: 120, - // render: () => ( - // } - // style={{ margin: 0 }} - // > - // Done - // - // ), - // }, - { - title: "Actions", - key: "actions", - width: 110, - render: (_: unknown, record: ScrubHistoryItem) => ( - deleteJob(record.id)} + {displayText} + + ); + } + + return ( + - - ), - }, - ], - [deleteJob], + {displayText} + + + ); + }, + [openScrubReport], + ); + + const selectedJob = useMemo( + () => history.find((job) => job.id === selectedJobId) ?? null, + [history, selectedJobId], ); const resultColumns = useMemo( () => [ { - title: "Subcategory", - dataIndex: "subcategory", - key: "subcategory", - width: "20%", - }, - { title: "Item", dataIndex: "left", key: "left", width: "20%" }, - { title: "Description", dataIndex: "right", key: "right", width: "50%" }, - { - title: "Link", - dataIndex: "linktext", - key: "linktext", + title: "Line", + dataIndex: "left", + key: "left", + width: "28%", render: (text: string | null, record: ScrubHistoryResultItem) => - record.linktext ? ( - - ) : null, - width: "10%", + renderScrubReportText(text, record, true), + }, + { + title: "Extended Line", + dataIndex: "right", + key: "right", + width: "72%", + render: (text: string | null, record: ScrubHistoryResultItem) => + renderScrubReportText(text, record), }, ], - [], + [renderScrubReportText], ); + const selectedGroups = useMemo(() => { + if (!selectedJob) return []; + + const grouped = (selectedJob.results ?? []).reduce( + (acc, item) => { + const key = item.category ?? "Uncategorized"; + (acc[key] ??= []).push(item); + return acc; + }, + {} as Record, + ); + + return Object.entries(grouped) + .map(([category, items]) => ({ category, items })) + .sort((a, b) => { + const aCfg = categoryConfig[a.category as keyof typeof categoryConfig]; + const bCfg = categoryConfig[b.category as keyof typeof categoryConfig]; + const aPriority = aCfg?.priority ?? Number.POSITIVE_INFINITY; + const bPriority = bCfg?.priority ?? Number.POSITIVE_INFINITY; + + if (aPriority !== bPriority) return aPriority - bPriority; + return a.category.localeCompare(b.category); + }); + }, [selectedJob]); + return ( -
- + {/* Header */} @@ -336,7 +402,7 @@ const Home: FC = () => { {/* Stats Cards */} - + { header: { borderBottom: `2px solid ${token.colorBorderSecondary}`, }, + body: { + flex: 1, + minHeight: 0, + overflow: "hidden", + }, + }} + style={{ + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", }} > - {/* Table view for larger screens */} -
- { - setCurrentPage(nextPage); - setPageSize(nextSize); - }, + + { - const grouped = (record.results ?? []).reduce( - (acc, item) => { - const key = item.category ?? "Uncategorized"; - (acc[key] ??= []).push(item); - return acc; - }, - {} as Record, - ); + > +
+ + dataSource={history} + loading={loading} + rowKey="id" + split={false} + locale={{ + emptyText: , + }} + renderItem={(record) => { + const isSelected = record.id === selectedJobId; - const groups = Object.entries(grouped) - .map(([category, items]) => ({ category, items })) - .sort((a, b) => { - const aCfg = - categoryConfig[ - a.category as keyof typeof categoryConfig - ]; - const bCfg = - categoryConfig[ - b.category as keyof typeof categoryConfig - ]; - - const aPriority = - aCfg?.priority ?? Number.POSITIVE_INFINITY; - const bPriority = - bCfg?.priority ?? Number.POSITIVE_INFINITY; - - if (aPriority !== bPriority) return aPriority - bPriority; - return a.category.localeCompare(b.category); - }); - - return ( - - {groups.map(({ category, items }) => { - const cfg = - categoryConfig[ - category as keyof typeof categoryConfig - ]; - - return ( -
+ return ( + +
setSelectedJobId(record.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedJobId(record.id); + } + }} + style={{ + width: "100%", + minHeight: 112, + cursor: "pointer", + textAlign: "left", + border: `1px solid ${ + isSelected + ? token.colorPrimary + : token.colorBorderSecondary + }`, + borderRadius: token.borderRadius, + background: isSelected + ? token.colorPrimaryBg + : token.colorBgContainer, + boxShadow: isSelected + ? `0 0 0 2px ${token.colorPrimaryBorder}` + : "none", + padding: 12, + transition: "all 0.2s", + }} + > + - - - {cfg?.icon ? `${cfg.icon} ` : ""} - {category} + + + {record.claimNumber} - - {items.length} - + + {record.ownrName} + + + {record.vehicle} + + + + + + event.stopPropagation()} + onKeyDown={(event) => + event.stopPropagation() + } + > +
- `${category}-${row.createdAt}-${row.id}` - } - pagination={false} - size="small" - scroll={{ x: 1200 }} - /> - - ); - })} - - ); - }, - rowExpandable: (record: ScrubHistoryItem) => - (record.results?.length ?? 0) > 0, + + + {dayjs(record.createdAt).format( + "MMM D / h:mm A", + )} + + + + + + + + ); + }} + /> + + {history.length > 0 && ( + + {loadingMore ? ( + + ) : hasMoreHistory ? ( + + ) : ( + + {history.length} of {totalJobs} estimates loaded + + )} + + )} + + + + + +
-
+ > + {!selectedJob ? ( + + + + ) : ( + + + + + {selectedJob.claimNumber} + + {selectedJob.ownrName} + {selectedJob.vehicle} + + Scrubbed{" "} + {dayjs(selectedJob.createdAt).format( + "MMM D, YYYY @ h:mm A", + )} + + + + + + deleteJob(selectedJob.id)} + > +
+ `${category}-${row.createdAt}-${row.id}` + } + pagination={false} + size="small" + scroll={{ x: 900 }} + /> + + ); + }) + )} + + )} + + - + ); }; diff --git a/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx b/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx index 3b21763..3d7689d 100644 --- a/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx +++ b/src/renderer/src/components/Settings/Settings.WatchedPaths.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, FolderAddOutlined } from "@ant-design/icons"; -import { Button, Card, Empty, List, Space, Typography } from "antd"; -import { useEffect, useState, FC } from "react"; +import { Button, Empty, List, Typography } from "antd"; +import { FC, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import ipcTypes from "../../../../util/ipcTypes.json"; diff --git a/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx index 7bbccd5..82036c3 100644 --- a/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx +++ b/src/renderer/src/components/UpdateAvailable/UpdateAvailable.tsx @@ -8,7 +8,9 @@ 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"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; + +const AUTO_APPLY_DELAY_MS = 10 * 1000; const UpdateAvailable: FC = () => { const { t } = useTranslation(); @@ -23,10 +25,11 @@ const UpdateAvailable: FC = () => { window.electron.ipcRenderer.send(ipcTypes.toMain.updates.download); }; - const handleApply = (): void => { + const handleApply = useCallback((): void => { + if (applyingUpdate) return; setApplyingUpdate(true); window.electron.ipcRenderer.send(ipcTypes.toMain.updates.apply); - }; + }, [applyingUpdate]); if (!isUpdateAvailable) { return null; @@ -62,13 +65,7 @@ const UpdateAvailable: FC = () => { > {applyingUpdate ? t("updates.applying") : t("updates.apply")} - handleApply()} - /> + )} @@ -79,6 +76,51 @@ const UpdateAvailable: FC = () => { export default UpdateAvailable; +const AutoApplyCountdown: FC<{ onFinish: () => void }> = ({ onFinish }) => { + const [remainingMs, setRemainingMs] = useState(AUTO_APPLY_DELAY_MS); + const onFinishRef = useRef(onFinish); + + useEffect(() => { + onFinishRef.current = onFinish; + }, [onFinish]); + + useEffect(() => { + const startedAt = Date.now(); + + const intervalId = window.setInterval(() => { + const nextRemainingMs = Math.max( + AUTO_APPLY_DELAY_MS - (Date.now() - startedAt), + 0, + ); + + setRemainingMs(nextRemainingMs); + + if (nextRemainingMs === 0) { + window.clearInterval(intervalId); + onFinishRef.current(); + } + }, 250); + + return () => { + window.clearInterval(intervalId); + }; + }, []); + + const remainingSeconds = Math.ceil(remainingMs / 1000); + const minutes = Math.floor(remainingSeconds / 60) + .toString() + .padStart(2, "0"); + const seconds = (remainingSeconds % 60).toString().padStart(2, "0"); + + return ( + + ); +}; + /** * Formats bytes into a human-readable string with appropriate units * @param bytes Number of bytes diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css new file mode 100644 index 0000000..3322f43 --- /dev/null +++ b/src/renderer/src/index.css @@ -0,0 +1,11 @@ +html, +body, +#root { + height: 100%; + margin: 0; + overflow: hidden; +} + +* { + box-sizing: border-box; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 0ddb2db..7cc6b7f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import "./index.css"; import "./util/i18n"; import "./util/ipcRendererHandler"; import * as Sentry from "@sentry/electron/renderer"; diff --git a/src/renderer/src/util/ipcRendererHandler.ts b/src/renderer/src/util/ipcRendererHandler.ts index 014aa32..4de7663 100644 --- a/src/renderer/src/util/ipcRendererHandler.ts +++ b/src/renderer/src/util/ipcRendererHandler.ts @@ -36,6 +36,15 @@ ipcRenderer.on(ipcTypes.toRenderer.watcher.started, () => { dispatch(watcherStarted()); }); +ipcRenderer + .invoke(ipcTypes.toMain.watcher.status) + .then((started: boolean) => { + dispatch(started ? watcherStarted() : watcherStopped()); + }) + .catch((error) => { + console.error("Failed to get watcher status", error); + }); + ipcRenderer.on(ipcTypes.toRenderer.watcher.stopped, () => { console.log("Watcher has stopped"); dispatch(watcherStopped()); diff --git a/src/util/ipcTypes.json b/src/util/ipcTypes.json index 4e952e1..109a368 100644 --- a/src/util/ipcTypes.json +++ b/src/util/ipcTypes.json @@ -18,7 +18,8 @@ }, "watcher": { "start": "toMain_watcher_start", - "stop": "toMain_watcher_stop" + "stop": "toMain_watcher_stop", + "status": "toMain_watcher_status" }, "settings": { "filepaths": { diff --git a/src/util/translations/en-US/renderer.json b/src/util/translations/en-US/renderer.json index 1288474..5701936 100644 --- a/src/util/translations/en-US/renderer.json +++ b/src/util/translations/en-US/renderer.json @@ -63,6 +63,9 @@ "watchermodepolling": "Polling", "watchermoderealtime": "Real Time", "watcherstatus": "Watcher Status" + }, + "validation": { + "esApiKeyRequired": "Estimate Scrubber API Key is required." } }, "title": {