diff --git a/new-log-viewer/src/App.tsx b/new-log-viewer/src/App.tsx index afaaa4b3..3e00df4f 100644 --- a/new-log-viewer/src/App.tsx +++ b/new-log-viewer/src/App.tsx @@ -1,4 +1,5 @@ import Layout from "./components/Layout"; +import NotificationContextProvider from "./contexts/NotificationContextProvider"; import StateContextProvider from "./contexts/StateContextProvider"; import UrlContextProvider from "./contexts/UrlContextProvider"; @@ -10,11 +11,13 @@ import UrlContextProvider from "./contexts/UrlContextProvider"; */ const App = () => { return ( - - - - - + + + + + + + ); }; diff --git a/new-log-viewer/src/components/Layout.tsx b/new-log-viewer/src/components/Layout.tsx index 66765ffa..9e6335ee 100644 --- a/new-log-viewer/src/components/Layout.tsx +++ b/new-log-viewer/src/components/Layout.tsx @@ -1,9 +1,10 @@ -import {CssVarsProvider} from "@mui/joy/styles"; +import {CssVarsProvider} from "@mui/joy"; import {CONFIG_KEY} from "../typings/config"; import {CONFIG_DEFAULT} from "../utils/config"; import CentralContainer from "./CentralContainer"; import MenuBar from "./MenuBar"; +import PopUps from "./PopUps"; import StatusBar from "./StatusBar"; import APP_THEME from "./theme"; @@ -23,6 +24,7 @@ const Layout = () => { + ); }; diff --git a/new-log-viewer/src/components/PopUps/PopUpMessageBox.tsx b/new-log-viewer/src/components/PopUps/PopUpMessageBox.tsx new file mode 100644 index 00000000..7ae9f20b --- /dev/null +++ b/new-log-viewer/src/components/PopUps/PopUpMessageBox.tsx @@ -0,0 +1,121 @@ +import { + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { + Alert, + Box, + CircularProgress, + IconButton, + Typography, +} from "@mui/joy"; + +import CloseIcon from "@mui/icons-material/Close"; + +import { + NotificationContext, + PopUpMessage, +} from "../../contexts/NotificationContextProvider"; +import {WithId} from "../../typings/common"; +import {LOG_LEVEL} from "../../typings/logs"; +import {DO_NOT_TIMEOUT_VALUE} from "../../typings/notifications"; + + +const AUTO_DISMISS_PERCENT_UPDATE_INTERVAL_MILLIS = 50; + +interface PopUpMessageProps { + message: WithId, +} + +/** + * Display a pop-up message in an alert box. + * + * @param props + * @param props.message + * @return + */ +const PopUpMessageBox = ({message}: PopUpMessageProps) => { + const {id, level, message: messageStr, title, timeoutMillis} = message; + + const {handlePopUpMessageClose} = useContext(NotificationContext); + const [percentRemaining, setPercentRemaining] = useState(100); + const intervalCountRef = useRef(0); + + const handleCloseButtonClick = () => { + handlePopUpMessageClose(id); + }; + + useEffect(() => { + if (DO_NOT_TIMEOUT_VALUE === timeoutMillis) { + return () => {}; + } + + const totalIntervals = Math.ceil( + timeoutMillis / AUTO_DISMISS_PERCENT_UPDATE_INTERVAL_MILLIS + ); + const intervalId = setInterval(() => { + intervalCountRef.current++; + const newPercentRemaining = 100 - (100 * (intervalCountRef.current / totalIntervals)); + if (0 >= newPercentRemaining) { + handlePopUpMessageClose(id); + } + setPercentRemaining(newPercentRemaining); + }, AUTO_DISMISS_PERCENT_UPDATE_INTERVAL_MILLIS); + + return () => { + clearInterval(intervalId); + }; + }, [ + timeoutMillis, + handlePopUpMessageClose, + id, + ]); + + const color = level >= LOG_LEVEL.ERROR ? + "danger" : + "primary"; + + return ( + +
+ + + {title} + + + + + + + + + {messageStr} + +
+
+ ); +}; + +export default PopUpMessageBox; diff --git a/new-log-viewer/src/components/PopUps/index.css b/new-log-viewer/src/components/PopUps/index.css new file mode 100644 index 00000000..be9612fd --- /dev/null +++ b/new-log-viewer/src/components/PopUps/index.css @@ -0,0 +1,47 @@ +.pop-up-messages-container-snackbar { + /* Disable pointer events on the transparent container to allow components underneath to be + accessed. */ + pointer-events: none; + + right: 14px !important; + bottom: var(--ylv-status-bar-height) !important; + + padding: 0 !important; + + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +.pop-up-messages-container-stack { + scrollbar-width: none; + overflow-y: auto; + height: calc(100vh - var(--ylv-status-bar-height) - var(--ylv-menu-bar-height)); +} + +.pop-up-message-box-alert { + /* Restore pointer events on the pop-up messages. See above `pointer-events: none` in + `.pop-up-messages-container-snackbar`. */ + pointer-events: initial; + padding-inline: 18px !important; +} + +.pop-up-message-box-alert-layout { + width: 300px; +} + +.pop-up-message-box-title-container { + display: flex; + align-items: center; +} + +.pop-up-message-box-title-text { + flex-grow: 1; +} + +.pop-up-message-box-close-button { + /* stylelint-disable-next-line custom-property-pattern */ + --IconButton-size: 18px !important; + + border-radius: 18px !important; +} diff --git a/new-log-viewer/src/components/PopUps/index.tsx b/new-log-viewer/src/components/PopUps/index.tsx new file mode 100644 index 00000000..c146a20f --- /dev/null +++ b/new-log-viewer/src/components/PopUps/index.tsx @@ -0,0 +1,42 @@ +import {useContext} from "react"; + +import { + Snackbar, + Stack, +} from "@mui/joy"; + +import {NotificationContext} from "../../contexts/NotificationContextProvider"; +import PopUpMessageBox from "./PopUpMessageBox"; + +import "./index.css"; + + +/** + * Displays pop-ups in a transparent container positioned on the right side of the viewport. + * + * @return + */ +const PopUps = () => { + const {popUpMessages} = useContext(NotificationContext); + + return ( + + + {popUpMessages.map((message) => ( + + ))} + + + ); +}; + +export default PopUps; diff --git a/new-log-viewer/src/components/StatusBar/index.css b/new-log-viewer/src/components/StatusBar/index.css index d732d42e..744db600 100644 --- a/new-log-viewer/src/components/StatusBar/index.css +++ b/new-log-viewer/src/components/StatusBar/index.css @@ -15,5 +15,4 @@ .status-message { flex-grow: 1; - padding-left: 8px; } diff --git a/new-log-viewer/src/components/StatusBar/index.tsx b/new-log-viewer/src/components/StatusBar/index.tsx index 085e7892..b23bbee2 100644 --- a/new-log-viewer/src/components/StatusBar/index.tsx +++ b/new-log-viewer/src/components/StatusBar/index.tsx @@ -32,11 +32,8 @@ const StatusBar = () => { return ( - - Status message + + {/* This is left blank intentionally until status messages are implemented. */} - - - + {CONFIG_FORM_FIELDS.map((field, index) => ( diff --git a/new-log-viewer/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx b/new-log-viewer/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx new file mode 100644 index 00000000..fbc90c45 --- /dev/null +++ b/new-log-viewer/src/components/modals/SettingsModal/ThemeSwitchToggle.tsx @@ -0,0 +1,53 @@ +import { + Button, + ToggleButtonGroup, + useColorScheme, +} from "@mui/joy"; +import type {Mode} from "@mui/system/cssVars/useCurrentColorScheme"; + +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LightModeIcon from "@mui/icons-material/LightMode"; +import SettingsBrightnessIcon from "@mui/icons-material/SettingsBrightness"; + +import {THEME_NAME} from "../../../typings/config"; + + +/** + * Renders a toggle button group for theme selection. + * + * @return + */ +const ThemeSwitchToggle = () => { + const {setMode, mode} = useColorScheme(); + + return ( + { + setMode(newValue as Mode); + }} + > + + + + + ); +}; + +export default ThemeSwitchToggle; diff --git a/new-log-viewer/src/contexts/NotificationContextProvider.tsx b/new-log-viewer/src/contexts/NotificationContextProvider.tsx new file mode 100644 index 00000000..01ed7bdf --- /dev/null +++ b/new-log-viewer/src/contexts/NotificationContextProvider.tsx @@ -0,0 +1,84 @@ +import React, { + createContext, + useCallback, + useRef, + useState, +} from "react"; + +import {WithId} from "../typings/common"; +import {PopUpMessage} from "../typings/notifications"; + + +interface NotificationContextType { + popUpMessages: WithId[], + + handlePopUpMessageClose: (messageId: number) => void; + postPopUp: (message: PopUpMessage) => void, +} + +const NotificationContext = createContext({} as NotificationContextType); + +/** + * Default values of the Notification context value object. + */ +const NOTIFICATION_DEFAULT: Readonly = Object.freeze({ + popUpMessages: [], + + handlePopUpMessageClose: () => {}, + postPopUp: () => {}, +}); + + +interface NotificationContextProviderProps { + children: React.ReactNode, +} + +/** + * Provides notification management for the application. This provider must be at the outermost + * layer of subscriber / publisher components to ensure they can receive / publish notifications. + * + * @param props + * @param props.children + * @return + */ +const NotificationContextProvider = ({children}: NotificationContextProviderProps) => { + const [popUpMessages, setPopUpMessages] = useState[]>( + NOTIFICATION_DEFAULT.popUpMessages + ); + const nextPopUpMessageIdRef = useRef(0); + + const postPopUp = useCallback((message:PopUpMessage) => { + const newMessage = { + id: nextPopUpMessageIdRef.current, + ...message, + }; + + nextPopUpMessageIdRef.current++; + + setPopUpMessages((v) => ([ + newMessage, + ...v, + ])); + }, []); + + const handlePopUpMessageClose = useCallback((messageId: number) => { + // Keep everything but except input message. + setPopUpMessages((v) => v.filter((m) => m.id !== messageId)); + }, []); + + return ( + + {children} + + ); +}; + +export {NotificationContext}; +export type {PopUpMessage}; +export default NotificationContextProvider; diff --git a/new-log-viewer/src/contexts/StateContextProvider.tsx b/new-log-viewer/src/contexts/StateContextProvider.tsx index dec854c2..9d745470 100644 --- a/new-log-viewer/src/contexts/StateContextProvider.tsx +++ b/new-log-viewer/src/contexts/StateContextProvider.tsx @@ -12,6 +12,7 @@ import LogExportManager, {EXPORT_LOG_PROGRESS_VALUE_MIN} from "../services/LogEx import {Nullable} from "../typings/common"; import {CONFIG_KEY} from "../typings/config"; import {LogLevelFilter} from "../typings/logs"; +import {DEFAULT_AUTO_DISMISS_TIMEOUT_MILLIS} from "../typings/notifications"; import {SEARCH_PARAM_NAMES} from "../typings/url"; import { BeginLineNumToLogEventNumMap, @@ -37,6 +38,7 @@ import { isWithinBounds, } from "../utils/data"; import {clamp} from "../utils/math"; +import {NotificationContext} from "./NotificationContextProvider"; import { updateWindowUrlHashParams, updateWindowUrlSearchParams, @@ -223,6 +225,7 @@ const updateUrlIfEventOnPage = ( */ // eslint-disable-next-line max-lines-per-function, max-statements const StateContextProvider = ({children}: StateContextProviderProps) => { + const {postPopUp} = useContext(NotificationContext); const {filePath, logEventNum} = useContext(UrlContext); // States @@ -261,10 +264,12 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { setOnDiskFileSizeInBytes(args.onDiskFileSizeInBytes); break; case WORKER_RESP_CODE.NOTIFICATION: - // eslint-disable-next-line no-warning-comments - // TODO: notifications should be shown in the UI when the NotificationProvider - // is added - console.error(args.logLevel, args.message); + postPopUp({ + level: args.logLevel, + message: args.message, + timeoutMillis: DEFAULT_AUTO_DISMISS_TIMEOUT_MILLIS, + title: "Action failed", + }); break; case WORKER_RESP_CODE.PAGE_DATA: { setLogData(args.logs); @@ -280,7 +285,7 @@ const StateContextProvider = ({children}: StateContextProviderProps) => { console.error(`Unexpected ev.data: ${JSON.stringify(ev.data)}`); break; } - }, []); + }, [postPopUp]); const exportLogs = useCallback(() => { if (null === mainWorkerRef.current) { diff --git a/new-log-viewer/src/typings/common.ts b/new-log-viewer/src/typings/common.ts index 01000ce1..cd6acf17 100644 --- a/new-log-viewer/src/typings/common.ts +++ b/new-log-viewer/src/typings/common.ts @@ -4,7 +4,10 @@ type NullableProperties = { [P in keyof T]: Nullable; }; +type WithId = T & { id: number }; + export type { Nullable, NullableProperties, + WithId, }; diff --git a/new-log-viewer/src/typings/notifications.ts b/new-log-viewer/src/typings/notifications.ts new file mode 100644 index 00000000..f67fc892 --- /dev/null +++ b/new-log-viewer/src/typings/notifications.ts @@ -0,0 +1,29 @@ +import {LOG_LEVEL} from "./logs"; + + +/** + * Contents of pop-up messages and its associated auto dismiss timeout. + */ +interface PopUpMessage { + level: LOG_LEVEL, + message: string, + timeoutMillis: number, + title: string, +} + +/** + * A value that indicates that a pop-up message should not be automatically dismissed. + */ +const DO_NOT_TIMEOUT_VALUE = 0; + +/** + * The default duration in milliseconds after which an automatic dismissal will occur. + */ +const DEFAULT_AUTO_DISMISS_TIMEOUT_MILLIS = 10_000; + + +export type {PopUpMessage}; +export { + DEFAULT_AUTO_DISMISS_TIMEOUT_MILLIS, + DO_NOT_TIMEOUT_VALUE, +};