diff --git a/client/src/App.tsx b/client/src/App.tsx index ef3625cad..0a6b364f8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,7 +8,7 @@ import darkTheme from "./Utils/Theme/darkTheme"; import { CssBaseline, GlobalStyles } from "@mui/material"; import { logger } from "./Utils/Logger"; // Import the logger import { Routes } from "./Routes"; -import AppLayout from "@/Components/v1/Layouts/AppLayout"; +import AppLayout from "@/Components/v2/layout/AppLayout"; import type { RootState } from "@/Types/state"; function App() { diff --git a/client/src/Components/v1/ArrowLeft/index.jsx b/client/src/Components/v1/ArrowLeft/index.jsx deleted file mode 100644 index e18a7f2d5..000000000 --- a/client/src/Components/v1/ArrowLeft/index.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import Icon from "../Icon"; -import PropTypes from "prop-types"; - -const ArrowLeft = ({ type, color = "#667085", ...props }) => { - if (type === "double") { - return ( - - ); - } else if (type === "long") { - return ( - - ); - } else { - return ( - - ); - } -}; - -ArrowLeft.propTypes = { - color: PropTypes.string, - type: PropTypes.oneOf(["double", "long", "default"]), -}; -export default ArrowLeft; diff --git a/client/src/Components/v1/Avatar/index.css b/client/src/Components/v1/Avatar/index.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/Components/v1/Avatar/index.jsx b/client/src/Components/v1/Avatar/index.jsx deleted file mode 100644 index d089080ff..000000000 --- a/client/src/Components/v1/Avatar/index.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Avatar as MuiAvatar } from "@mui/material"; -import PropTypes from "prop-types"; -import { useSelector } from "react-redux"; -import { useEffect, useState } from "react"; -import { useTheme } from "@emotion/react"; -/** - * @component - * @param {Object} props - * @param {string} props.src - Path to image for avatar - * @param {boolean} props.small - Specifies if avatar should be large - * @param {Object} [props.sx] - Additional styles to apply to the button. - * @returns {JSX.Element} - * @example - * // Render a red label - * - */ - -const Avatar = ({ src, small, sx, onClick = () => {} }) => { - const { user } = useSelector((state) => state.auth); - const theme = useTheme(); - - const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 }; - const border = small ? 1 : 3; - - const [image, setImage] = useState(); - useEffect(() => { - if (user.avatarImage) { - setImage(`data:image/png;base64,${user.avatarImage}`); - } - }, [user?.avatarImage]); - - return ( - - {user.firstName?.charAt(0)} - {user.lastName?.charAt(0) || ""} - - ); -}; - -Avatar.propTypes = { - src: PropTypes.string, - small: PropTypes.bool, - sx: PropTypes.object, - onClick: PropTypes.func, -}; - -export default Avatar; diff --git a/client/src/Components/v1/Label/index.css b/client/src/Components/v1/Label/index.css deleted file mode 100644 index d3fe53da5..000000000 --- a/client/src/Components/v1/Label/index.css +++ /dev/null @@ -1,6 +0,0 @@ -.label { - display: inline-flex; - justify-content: center; - align-items: center; - line-height: normal; -} diff --git a/client/src/Components/v1/Label/index.jsx b/client/src/Components/v1/Label/index.jsx deleted file mode 100644 index 2ed9b93bf..000000000 --- a/client/src/Components/v1/Label/index.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import PropTypes from "prop-types"; -import { Box } from "@mui/material"; -import { useTheme } from "@mui/material"; -import "./index.css"; - -/** - * @typedef {Object} Styles - * @param {string} [color] - The text color - * @param {string} [backgroundColor] - The background color - * @param {string} [borderColor] - The border color - */ - -/** - * @component - * @param {Object} props - * @param {string} props.label - The label of the label - * @param {Styles} props.styles - CSS Styles passed from parent component - * @param {React.ReactNode} children - Children passed from parent component - * @returns {JSX.Element} - */ - -const BaseLabel = ({ label, styles, children }) => { - const theme = useTheme(); - // Grab the default borderRadius from the theme to match button style - const { borderRadius } = theme.shape; - // Calculate padding for the label to mimic button. Appears to scale correctly, not 100% sure though. - const padding = theme.spacing(3, 5); - - return ( - - {children} - {label} - - ); -}; - -BaseLabel.propTypes = { - label: PropTypes.string.isRequired, - styles: PropTypes.shape({ - color: PropTypes.string, - backgroundColor: PropTypes.string, - }), - children: PropTypes.node, -}; - -// Produces a lighter color based on a hex color and a percent -// lightenColor("#067647", 20) will produce a color 20% lighter than #067647 -const lightenColor = (color, percent) => { - let r = parseInt(color.substring(1, 3), 16); - let g = parseInt(color.substring(3, 5), 16); - let b = parseInt(color.substring(5, 7), 16); - - const amt = Math.round((255 * percent) / 100); - - r = r + amt <= 255 ? r + amt : 255; - g = g + amt <= 255 ? g + amt : 255; - b = b + amt <= 255 ? b + amt : 255; - - r = r.toString(16).padStart(2, "0"); - g = g.toString(16).padStart(2, "0"); - b = b.toString(16).padStart(2, "0"); - - return `#${r}${g}${b}`; -}; - -/** - * @component - * @param {Object} props - * @param {string} props.label - The label of the label - * @param {string} props.color - The color of the label, specified in #RRGGBB format - * @returns {JSX.Element} - * @example - * // Render a red label - * - */ - -const ColoredLabel = ({ label, color }) => { - const theme = useTheme(); - // If an invalid color is passed, default to the labelGray color - if (typeof color !== "string" || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) { - color = theme.palette.primary.lowContrast; - } - - // Calculate lighter shades for border and bg - const borderColor = lightenColor(color, 20); - const bgColor = lightenColor(color, 75); - - return ( - - ); -}; - -ColoredLabel.propTypes = { - label: PropTypes.string.isRequired, - color: PropTypes.string.isRequired, -}; - -/** - * @component - * @param {Object} props - * @param {'up' | 'down' | 'paused' | 'pending' | 'cannot resolve' | 'published' | 'unpublished'} props.status - The status for the label - * @param {string} props.text - The text of the label - * @returns {JSX.Element} - * @example - * // Render an active label - * - */ - -const statusToTheme = { - up: "success", - down: "error", - paused: "warning", - pending: "warning", - "cannot resolve": "error", - published: "success", - unpublished: "error", -}; - -const StatusLabel = ({ status, text, customStyles }) => { - const theme = useTheme(); - - const themeColor = statusToTheme[status]; - - return ( - - - - ); -}; - -StatusLabel.propTypes = { - status: PropTypes.oneOf([ - "up", - "down", - "paused", - "pending", - "cannot resolve", - "published", - "unpublished", - ]), - text: PropTypes.string, - customStyles: PropTypes.object, -}; - -export { BaseLabel, ColoredLabel, StatusLabel }; diff --git a/client/src/Components/v1/Layouts/AppLayout/index.jsx b/client/src/Components/v1/Layouts/AppLayout/index.jsx deleted file mode 100644 index 0dbfb1e7a..000000000 --- a/client/src/Components/v1/Layouts/AppLayout/index.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import Box from "@mui/material/Box"; -import PropTypes from "prop-types"; -import { useTheme } from "@emotion/react"; -import BackgroundSVG from "../../../../assets/Images/background.svg"; -import { useSelector } from "react-redux"; - -const AppLayout = ({ children }) => { - const theme = useTheme(); - const ui = useSelector((state) => state.ui); - return ( - - {children} - - ); -}; - -AppLayout.propTypes = { - children: PropTypes.node, -}; - -export default AppLayout; diff --git a/client/src/Components/v1/Layouts/HomeLayout/index.css b/client/src/Components/v1/Layouts/HomeLayout/index.css deleted file mode 100644 index 61808aa57..000000000 --- a/client/src/Components/v1/Layouts/HomeLayout/index.css +++ /dev/null @@ -1,29 +0,0 @@ -.home-layout { - position: relative; - min-height: 100vh; - margin: 0 auto; - padding: 0; - overflow-x: hidden; - width: 100%; -} - -/* TODO go for this approach for responsiveness. The aside needs to be taken care of */ -/* @media (max-width: 1000px) { - .home-layout { - flex-direction: column !important; - } -} */ - -.home-layout > .home-content-wrapper { - min-height: calc(100vh - var(--env-var-spacing-2) * 2); - flex: 1; -} - -.home-content-wrapper { - padding: var(--env-var-spacing-2); - max-width: 1400px; - margin: 0 auto; - flex: 1; - min-width: 0; - overflow-x: hidden; -} diff --git a/client/src/Components/v1/Layouts/HomeLayout/index.jsx b/client/src/Components/v1/Layouts/HomeLayout/index.jsx deleted file mode 100644 index b078fe048..000000000 --- a/client/src/Components/v1/Layouts/HomeLayout/index.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import Sidebar from "../../Sidebar/index.jsx"; -import { Outlet } from "react-router"; -import { Box, Stack } from "@mui/material"; -import { useSidebar } from "@/Hooks/useSidebar.js"; - -import "./index.css"; - -const HomeLayout = () => { - const { width, transition } = useSidebar(); - - return ( - - - {/* Spacer for fixed sidebar */} - - - - - - ); -}; - -export default HomeLayout; diff --git a/client/src/Components/v1/Sidebar/components/authFooter.jsx b/client/src/Components/v1/Sidebar/components/authFooter.jsx deleted file mode 100644 index 16b60d9f2..000000000 --- a/client/src/Components/v1/Sidebar/components/authFooter.jsx +++ /dev/null @@ -1,276 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import Avatar from "../../Avatar/index.jsx"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Divider from "@mui/material/Divider"; -import Icon from "../../Icon"; - -import { useTheme } from "@emotion/react"; -import { useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; -import { useState } from "react"; -import { useNavigate } from "react-router"; -import { clearAuthState } from "../../../../Features/Auth/authSlice.js"; -import { useDispatch } from "react-redux"; -import PropTypes from "prop-types"; - -const getFilteredAccountMenuItems = (user, items) => { - if (!user) return []; - - let filtered = [...items]; - - if (user.role?.includes("demo")) { - filtered = filtered.filter((item) => item.name !== "Password"); - } - - if (!user.role?.includes("superadmin")) { - filtered = filtered.filter((item) => item.name !== "Team"); - } - - return filtered; -}; - -const getRoleDisplayText = (user, t) => { - if (!user?.role) return ""; - - if (user.role.includes("superadmin")) return t("roles.superAdmin"); - if (user.role.includes("admin")) return t("roles.admin"); - if (user.role.includes("user")) return t("roles.teamMember"); - if (user.role.includes("demo")) return t("roles.demoUser"); - - return user.role; -}; - -const AuthFooter = ({ collapsed, accountMenuItems }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const authState = useSelector((state) => state.auth); - const navigate = useNavigate(); - const dispatch = useDispatch(); - - const [anchorEl, setAnchorEl] = useState(null); - - const openPopup = (event) => { - setAnchorEl(event.currentTarget); - }; - - const closePopup = () => { - setAnchorEl(null); - }; - - const logout = async () => { - dispatch(clearAuthState()); - navigate("/login"); - }; - const renderAccountMenuItems = (user, items) => { - const filteredItems = getFilteredAccountMenuItems(user, items); - - return filteredItems.map((item) => ( - { - closePopup(); - navigate(item.path); - }} - sx={{ - gap: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - color: theme.palette.primary.contrastTextTertiary, - "& svg": { - stroke: theme.palette.primary.contrastTextTertiary, - }, - }} - > - {item.icon} - {item.name} - - )); - }; - return ( - - collapsed && openPopup(e)} - sx={{ - cursor: collapsed ? "pointer" : "default", - }} - /> - - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {getRoleDisplayText(authState.user, t)} - - - - openPopup(event)} - > - - - - - - {collapsed && ( - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role} - - - - )} - {/* TODO Do we need two dividers? */} - {collapsed && } - {/* */} - {renderAccountMenuItems(authState.user, accountMenuItems)} - - - {t("menu.logOut", "Log out")} - - - - ); -}; - -AuthFooter.propTypes = { - collapsed: PropTypes.bool, - accountMenuItems: PropTypes.array, -}; - -export default AuthFooter; diff --git a/client/src/Components/v1/Sidebar/components/collapseButton.jsx b/client/src/Components/v1/Sidebar/components/collapseButton.jsx deleted file mode 100644 index e3a9c91f5..000000000 --- a/client/src/Components/v1/Sidebar/components/collapseButton.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import IconButton from "@mui/material/IconButton"; -import ArrowRight from "../../ArrowRight/index.jsx"; -import ArrowLeft from "../../ArrowLeft/index.jsx"; -import { useTheme } from "@mui/material/styles"; -import { useDispatch } from "react-redux"; -import { toggleSidebar } from "../../../../Features/UI/uiSlice.js"; -import PropTypes from "prop-types"; - -const CollapseButton = ({ collapsed }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const arrowIcon = collapsed ? ( - - ) : ( - - ); - return ( - { - dispatch(toggleSidebar()); - }} - > - {arrowIcon} - - ); -}; - -CollapseButton.propTypes = { - collapsed: PropTypes.bool.isRequired, -}; -export default CollapseButton; diff --git a/client/src/Components/v1/Sidebar/index.jsx b/client/src/Components/v1/Sidebar/index.jsx deleted file mode 100644 index 763a4123c..000000000 --- a/client/src/Components/v1/Sidebar/index.jsx +++ /dev/null @@ -1,152 +0,0 @@ -import Stack from "@mui/material/Stack"; - -import List from "@mui/material/List"; -import Logo from "./components/logo.jsx"; -import CollapseButton from "./components/collapseButton.jsx"; -import Divider from "@mui/material/Divider"; -import NavItem from "./components/navItem.jsx"; -import AuthFooter from "./components/authFooter.jsx"; - -import StarPrompt from "../StarPrompt/index.jsx"; -import Icon from "../Icon"; - -// Utils -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import { useSidebar } from "@/Hooks/useSidebar.js"; - -const URL_MAP = { - support: "https://discord.com/invite/NAb6H3UTjK", - discussions: "https://github.com/bluewave-labs/checkmate/discussions", - docs: "https://bluewavelabs.gitbook.io/checkmate", - changelog: "https://github.com/bluewave-labs/checkmate/releases", -}; - -const getMenu = (t) => [ - { name: t("menu.uptime"), path: "uptime", icon: }, - { name: t("menu.pagespeed"), path: "pagespeed", icon: }, - { name: t("menu.infrastructure"), path: "infrastructure", icon: }, - { - name: t("menu.notifications"), - path: "notifications", - icon: , - }, - { name: t("menu.checks"), path: "checks", icon: }, - { name: t("menu.incidents"), path: "incidents", icon: }, - { name: t("menu.statusPages"), path: "status", icon: }, - { name: t("menu.maintenance"), path: "maintenance", icon: }, - { name: t("menu.logs"), path: "logs", icon: }, - { - name: t("menu.settings"), - icon: , - path: "settings", - }, -]; - -const getOtherMenuItems = (t) => [ - { name: t("menu.support"), path: "support", icon: }, - { - name: t("menu.discussions"), - path: "discussions", - icon: , - }, - { name: t("menu.docs"), path: "docs", icon: }, - { name: t("menu.changelog"), path: "changelog", icon: }, -]; - -const getAccountMenuItems = (t) => [ - { name: t("menu.profile"), path: "account/profile", icon: }, - { name: t("menu.password"), path: "account/password", icon: }, - { name: t("menu.team"), path: "account/team", icon: }, -]; - -const Sidebar = () => { - const theme = useTheme(); - const { t } = useTranslation(); - const navigate = useNavigate(); - const { collapsed, width, transition } = useSidebar(); - - const menu = getMenu(t); - const otherMenuItems = getOtherMenuItems(t); - const accountMenuItems = getAccountMenuItems(t); - - return ( - - - - - {menu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - navigate(`/${item.path}`)} - /> - ); - })} - - {!collapsed && } - - {otherMenuItems.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - - return ( - { - const url = URL_MAP[item.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${item.path}`); - } - }} - /> - ); - })} - - - - - ); -}; - -export default Sidebar; diff --git a/client/src/Components/v2/design-elements/Avatar.tsx b/client/src/Components/v2/design-elements/Avatar.tsx new file mode 100644 index 000000000..74c06f8f3 --- /dev/null +++ b/client/src/Components/v2/design-elements/Avatar.tsx @@ -0,0 +1,47 @@ +import { Avatar as MuiAvatar } from "@mui/material"; +import { useSelector } from "react-redux"; +import { useEffect, useState } from "react"; +import { useTheme } from "@mui/material"; +import type { RootState } from "@/Types/state"; + +interface AvatarProps { + src?: string; + small?: boolean; + sx?: object; + onClick?: (event: React.MouseEvent) => void; +} + +export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => { + const { user } = useSelector((state: RootState) => state.auth); + const theme = useTheme(); + if (!user) return null; + + const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 }; + + const [image, setImage] = useState(); + useEffect(() => { + if (user.avatarImage) { + setImage(`data:image/png;base64,${user.avatarImage}`); + } + }, [user?.avatarImage]); + + return ( + + {user.firstName?.charAt(0)} + {user.lastName?.charAt(0) || ""} + + ); +}; diff --git a/client/src/Components/v2/design-elements/Icon.tsx b/client/src/Components/v2/design-elements/Icon.tsx index b32cd84b2..22f939750 100644 --- a/client/src/Components/v2/design-elements/Icon.tsx +++ b/client/src/Components/v2/design-elements/Icon.tsx @@ -4,6 +4,7 @@ interface IconProps { icon: LucideIcon; size?: number; strokeWidth?: number; + stroke?: string; } const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => { diff --git a/client/src/Components/v2/design-elements/OfflineBanner.tsx b/client/src/Components/v2/design-elements/OfflineBanner.tsx new file mode 100644 index 000000000..062b1ca0d --- /dev/null +++ b/client/src/Components/v2/design-elements/OfflineBanner.tsx @@ -0,0 +1,63 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { WifiOff } from "lucide-react"; +import { useState, useEffect } from "react"; + +interface OfflineBannerProps { + visible: boolean; +} + +export const OfflineBanner = ({ visible }: OfflineBannerProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [shouldRender, setShouldRender] = useState(visible); + const [isAnimating, setIsAnimating] = useState(false); + + useEffect(() => { + if (visible) { + setShouldRender(true); + requestAnimationFrame(() => setIsAnimating(true)); + } else { + setIsAnimating(false); + const timer = setTimeout(() => setShouldRender(false), 1000); + return () => clearTimeout(timer); + } + }, [visible]); + + if (!shouldRender) return null; + + return ( + + + + + {t("components.offlineBanner.serverUnreachable")} + + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index b7b1d484e..ed6a885f6 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -19,3 +19,5 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; +export * from "./OfflineBanner"; +export * from "./Avatar"; diff --git a/client/src/Components/v2/layout/AppLayout.tsx b/client/src/Components/v2/layout/AppLayout.tsx new file mode 100644 index 000000000..7c9c3c903 --- /dev/null +++ b/client/src/Components/v2/layout/AppLayout.tsx @@ -0,0 +1,69 @@ +import Box from "@mui/material/Box"; +import { useState, useEffect, useRef } from "react"; +import { ThemeProvider, useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; +import BackgroundSVG from "@/assets/Images/background.svg"; +import type { RootState } from "@/Types/state"; +import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; +import { OfflineBanner } from "@/Components/v2/design-elements"; +import { setServerUnreachableCallback, get } from "@/Utils/ApiClient"; + +interface AppLayoutProps { + children: React.ReactNode; +} + +const AppLayout = ({ children }: AppLayoutProps) => { + const theme = useTheme(); + const mode = useSelector((state: RootState) => state.ui.mode); + const v2theme = mode === "dark" ? darkTheme : lightTheme; + + const [serverUnreachable, setServerUnreachable] = useState(false); + const retryIntervalRef = useRef(null); + + useEffect(() => { + setServerUnreachableCallback(setServerUnreachable); + }, []); + + useEffect(() => { + if (serverUnreachable) { + retryIntervalRef.current = window.setInterval(async () => { + try { + await get("/health", { timeout: 5000 }); + } catch { + // NO_OP + } + }, 5000); + } else if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current); + retryIntervalRef.current = null; + } + + return () => { + if (retryIntervalRef.current) { + clearInterval(retryIntervalRef.current); + } + }; + }, [serverUnreachable]); + + return ( + + + + + {children} + + ); +}; + +export default AppLayout; diff --git a/client/src/Components/v2/layout/RootLayout.tsx b/client/src/Components/v2/layout/RootLayout.tsx new file mode 100644 index 000000000..0b6defb91 --- /dev/null +++ b/client/src/Components/v2/layout/RootLayout.tsx @@ -0,0 +1,51 @@ +import { Sidebar } from "@/Components/v2/sidebar"; +import { Outlet } from "react-router"; +import Stack from "@mui/material/Stack"; +import { useMediaQuery } from "@mui/material"; +import { useSidebar } from "@/Hooks/useSidebar"; + +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; +import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; +import { ThemeProvider, useTheme } from "@mui/material"; + +const RootLayout = () => { + const mode = useSelector((state: RootState) => state.ui.mode); + const v2theme = mode === "dark" ? darkTheme : lightTheme; + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { collapsedWidth } = useSidebar(); + + return ( + + + + + + + + + + + ); +}; + +export default RootLayout; diff --git a/client/src/Components/v2/sidebar/Authfooter.tsx b/client/src/Components/v2/sidebar/Authfooter.tsx new file mode 100644 index 000000000..62ea5610b --- /dev/null +++ b/client/src/Components/v2/sidebar/Authfooter.tsx @@ -0,0 +1,183 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material"; +import { useSelector, useDispatch } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { MoreVertical, LogOut } from "lucide-react"; +import { Avatar, Icon } from "@/Components/v2/design-elements"; +import { clearAuthState } from "@/Features/Auth/authSlice.js"; +import type { RootState } from "@/Types/state.js"; + +interface AuthFooterProps { + collapsed: boolean; + accountMenuItems: Array<{ + name: string; + path: string; + icon: React.ReactNode; + }>; +} + +export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const user = useSelector((state: RootState) => state.auth.user); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const [anchorEl, setAnchorEl] = useState(null); + + const menuOpen = Boolean(anchorEl); + const handleMenuOpen = (e: React.MouseEvent) => + setAnchorEl(e.currentTarget); + const handleMenuClose = () => setAnchorEl(null); + + const handleNavigate = (path: string) => { + handleMenuClose(); + navigate(path); + }; + + const handleLogout = () => { + dispatch(clearAuthState()); + navigate("/login"); + }; + + const getRoleText = () => { + const role = user?.role ?? ""; + if (role.includes("superadmin")) + return t("components.sidebar.authFooter.roles.superAdmin"); + if (role.includes("admin")) return t("components.sidebar.authFooter.roles.admin"); + if (role.includes("user")) return t("components.sidebar.authFooter.roles.user"); + if (role.includes("demo")) return t("components.sidebar.authFooter.roles.demoUser"); + return role; + }; + + const filteredMenuItems = accountMenuItems.filter((item) => { + if (!user) return false; + if (item.name === "Password" && user.role?.includes("demo")) return false; + if (item.name === "Team" && !user.role?.includes("superadmin")) return false; + return true; + }); + + const menuItemSx = { + gap: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + "& svg": { stroke: theme.palette.text.secondary }, + }; + + return ( + + + + {!collapsed && ( + + + + {user?.firstName} {user?.lastName} + + + {getRoleText()} + + + + + + + )} + + + {collapsed && ( + + + {user?.firstName} {user?.lastName} + + + {getRoleText()} + + + )} + {filteredMenuItems.map((item) => ( + handleNavigate(item.path)} + sx={menuItemSx} + > + {item.icon} + {item.name} + + ))} + + + {t("components.sidebar.authFooter.logOut")} + + + + ); +}; diff --git a/client/src/Components/v1/Sidebar/components/logo.jsx b/client/src/Components/v2/sidebar/Logo.tsx similarity index 60% rename from client/src/Components/v1/Sidebar/components/logo.jsx rename to client/src/Components/v2/sidebar/Logo.tsx index a0c0ff84e..d7ab2c751 100644 --- a/client/src/Components/v1/Sidebar/components/logo.jsx +++ b/client/src/Components/v2/sidebar/Logo.tsx @@ -1,37 +1,37 @@ -import Stack from "@mui/material/Stack"; +import Stack, { type StackProps } from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; -import { useNavigate } from "react-router"; +import { toggleSidebar } from "@/Features/UI/uiSlice.js"; +import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; -import PropTypes from "prop-types"; +import useSidebar from "@/Hooks/useSidebar"; -const Logo = ({ collapsed }) => { +export const Logo = (props: StackProps) => { const { t } = useTranslation(); const theme = useTheme(); - const navigate = useNavigate(); - + const dispatch = useDispatch(); + const { collapsed } = useSidebar(); return ( navigate("/")} + onClick={() => { + dispatch(toggleSidebar()); + }} sx={{ cursor: "pointer" }} + {...props} > C @@ -49,9 +49,8 @@ const Logo = ({ collapsed }) => { {t("common.appName")} @@ -59,9 +58,3 @@ const Logo = ({ collapsed }) => { ); }; - -Logo.propTypes = { - collapsed: PropTypes.bool, -}; - -export default Logo; diff --git a/client/src/Components/v2/sidebar/Menu.tsx b/client/src/Components/v2/sidebar/Menu.tsx new file mode 100644 index 000000000..918a82858 --- /dev/null +++ b/client/src/Components/v2/sidebar/Menu.tsx @@ -0,0 +1,120 @@ +import { Icon } from "@/Components/v2/design-elements"; + +import { + Globe, + Gauge, + Link, + Bell, + FileText, + AlertTriangle, + Wifi, + Wrench, + Database, + Settings, + HelpCircle, + MessageCircle, + Code, + User, + Lock, + Users, +} from "lucide-react"; + +export const getMenu = (t: Function) => { + return [ + { + name: t("components.sidebar.menu.uptime"), + path: "uptime", + icon: , + }, + { + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , + }, + { + name: t("components.sidebar.menu.infrastructure"), + path: "infrastructure", + icon: , + }, + { + name: t("components.sidebar.menu.notifications"), + path: "notifications", + icon: , + }, + { + name: t("components.sidebar.menu.checks"), + path: "checks", + icon: , + }, + { + name: t("components.sidebar.menu.incidents"), + path: "incidents", + icon: , + }, + { + name: t("components.sidebar.menu.statusPages"), + path: "status", + icon: , + }, + { + name: t("components.sidebar.menu.maintenance"), + path: "maintenance", + icon: , + }, + { + name: t("components.sidebar.menu.logs"), + path: "logs", + icon: , + }, + { + name: t("components.sidebar.menu.settings"), + icon: , + path: "settings", + }, + ]; +}; + +export const getBottomMenu = (t: Function) => { + return [ + { + name: t("components.sidebar.bottomMenu.support"), + path: "support", + icon: , + }, + { + name: t("components.sidebar.bottomMenu.discussions"), + path: "discussions", + icon: , + }, + { + name: t("components.sidebar.bottomMenu.docs"), + path: "docs", + icon: , + }, + { + name: t("components.sidebar.bottomMenu.changelog"), + path: "changelog", + icon: , + }, + ]; +}; + +export const getAccountMenu = (t: Function) => { + return [ + { + name: t("components.sidebar.accountMenu.profile"), + path: "account/profile", + icon: , + }, + { + name: t("components.sidebar.accountMenu.password"), + path: "account/password", + icon: , + }, + { + name: t("components.sidebar.accountMenu.team"), + path: "account/team", + icon: , + }, + ]; +}; diff --git a/client/src/Components/v1/Sidebar/components/navItem.jsx b/client/src/Components/v2/sidebar/NavItem.tsx similarity index 58% rename from client/src/Components/v1/Sidebar/components/navItem.jsx rename to client/src/Components/v2/sidebar/NavItem.tsx index 170d39bee..756b10d60 100644 --- a/client/src/Components/v1/Sidebar/components/navItem.jsx +++ b/client/src/Components/v2/sidebar/NavItem.tsx @@ -3,19 +3,31 @@ import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; +import { useTheme } from "@mui/material/styles"; +import useSidebar from "@/Hooks/useSidebar"; -const NavItem = ({ item, collapsed, selected, onClick }) => { +export interface NavData { + name: string; + icon: React.ReactElement; +} + +export const NavItem = ({ + item, + selected, + onClick, +}: { + item: NavData; + selected: boolean; + onClick: (event: React.MouseEvent) => void; +}) => { + const { collapsed } = useSidebar(); const theme = useTheme(); - const iconStroke = selected - ? theme.palette.primary.contrastText - : theme.palette.primary.contrastTextTertiary; + const iconStroke = selected ? theme.palette.primary.main : theme.palette.text.secondary; - const buttonBgColor = selected ? theme.palette.secondary.main : "transparent"; + const buttonBgColor = selected ? theme.palette.action.selected : "transparent"; const buttonBgHoverColor = selected - ? theme.palette.secondary.main - : theme.palette.tertiary.main; + ? theme.palette.action.selected + : theme.palette.action.hover; const fontWeight = selected ? 600 : 400; return ( { { {item.icon} @@ -73,7 +96,6 @@ const NavItem = ({ item, collapsed, selected, onClick }) => { > { ); }; - -NavItem.propTypes = { - item: PropTypes.object, - collapsed: PropTypes.bool, - selected: PropTypes.bool, - onClick: PropTypes.func, -}; -export default NavItem; diff --git a/client/src/Components/v1/StarPrompt/index.jsx b/client/src/Components/v2/sidebar/StarPrompt.tsx similarity index 64% rename from client/src/Components/v1/StarPrompt/index.jsx rename to client/src/Components/v2/sidebar/StarPrompt.tsx index f1dd5e136..5319f6bcc 100644 --- a/client/src/Components/v1/StarPrompt/index.jsx +++ b/client/src/Components/v2/sidebar/StarPrompt.tsx @@ -1,18 +1,25 @@ -import React from "react"; import { Typography, IconButton, Stack, Box } from "@mui/material"; -import Icon from "../Icon"; -import { useTheme } from "@emotion/react"; +import { Icon } from "@/Components/v2/design-elements"; +import { X } from "lucide-react"; + +import { useTheme } from "@mui/material"; import { useSelector, useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; -import { setStarPromptOpen } from "../../../Features/UI/uiSlice.js"; +import { setStarPromptOpen } from "@/Features/UI/uiSlice.js"; +import type { RootState } from "@/Types/state.js"; +import useSidebar from "@/Hooks/useSidebar.js"; -const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" }) => { +export const StarPrompt = ({ + repoUrl = "https://github.com/bluewave-labs/checkmate", +}: { + repoUrl?: string; +}) => { const theme = useTheme(); const dispatch = useDispatch(); const { t } = useTranslation(); - const isOpen = useSelector((state) => state.ui?.starPromptOpen ?? true); - const mode = useSelector((state) => state.ui.mode); - + const isOpen = useSelector((state: RootState) => state.ui?.starPromptOpen ?? true); + const mode = useSelector((state: RootState) => state.ui.mode); + const { collapsed } = useSidebar(); const handleClose = () => { dispatch(setStarPromptOpen(false)); }; @@ -21,6 +28,7 @@ const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" }) window.open(repoUrl, "_blank"); }; + if (collapsed) return null; if (!isOpen) return null; return ( @@ -29,8 +37,8 @@ const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" }) sx={{ width: "100%", padding: `${theme.spacing(6)} ${theme.spacing(6)}`, - borderTop: `1px solid ${theme.palette.primary.lowContrast}`, - borderBottom: `1px solid ${theme.palette.primary.lowContrast}`, + borderTop: `1px solid ${theme.palette.divider}`, + borderBottom: `1px solid ${theme.palette.divider}`, borderRadius: 0, gap: theme.spacing(1.5), }} @@ -44,15 +52,9 @@ const StarPrompt = ({ repoUrl = "https://github.com/bluewave-labs/checkmate" }) > - {t("starPromptTitle")} + {t("components.sidebar.starPrompt.title")} - {t("starPromptDescription")} + {t("components.sidebar.starPrompt.description")} ); }; - -export default StarPrompt; diff --git a/client/src/Components/v2/sidebar/index.tsx b/client/src/Components/v2/sidebar/index.tsx new file mode 100644 index 000000000..9bd36a853 --- /dev/null +++ b/client/src/Components/v2/sidebar/index.tsx @@ -0,0 +1,121 @@ +import { useEffect } from "react"; +import Stack from "@mui/material/Stack"; +import List from "@mui/material/List"; +import Divider from "@mui/material/Divider"; +import { useSidebar } from "@/Hooks/useSidebar.js"; +import { Logo } from "@/Components/v2/sidebar/Logo"; +import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/v2/sidebar/Menu"; +import { NavItem } from "@/Components/v2/sidebar/NavItem"; +import { StarPrompt } from "@/Components/v2/sidebar/StarPrompt"; +import { AuthFooter } from "@/Components/v2/sidebar/Authfooter"; + +import { useNavigate, useLocation } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme, useMediaQuery } from "@mui/material"; +import { useDispatch } from "react-redux"; +import { setCollapsed } from "@/Features/UI/uiSlice"; + +const URL_MAP: Record = { + support: "https://discord.com/invite/NAb6H3UTjK", + discussions: "https://github.com/bluewave-labs/checkmate/discussions", + docs: "https://bluewavelabs.gitbook.io/checkmate", + changelog: "https://github.com/bluewave-labs/checkmate/releases", +}; + +export const Sidebar = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation(); + const { width, transition, collapsed } = useSidebar(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const menu = getMenu(t); + const bottomMenu = getBottomMenu(t); + const accountMenu = getAccountMenu(t); + + useEffect(() => { + dispatch(setCollapsed({ collapsed: isSmall })); + }, [isSmall, dispatch]); + + const handleNavClick = (path: string) => { + const url = URL_MAP[path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${path}`); + } + if (isSmall) { + dispatch(setCollapsed({ collapsed: true })); + } + }; + + return ( + t.zIndex.drawer : "auto", + }} + > + + + {menu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + + + {bottomMenu.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + + + + + ); +}; diff --git a/client/src/Hooks/useSidebar.js b/client/src/Hooks/useSidebar.js deleted file mode 100644 index 97724b417..000000000 --- a/client/src/Hooks/useSidebar.js +++ /dev/null @@ -1,29 +0,0 @@ -import { useSelector } from "react-redux"; - -// CSS variable names for sidebar widths -const SIDEBAR_WIDTH_VAR = "var(--env-var-side-bar-width)"; -const SIDEBAR_COLLAPSED_WIDTH_VAR = "var(--env-var-side-bar-collapsed-width)"; - -// Transition timing for sidebar width changes -const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; - -/** - * Hook to get sidebar state and computed width - * Centralizes sidebar width logic to avoid duplication between Sidebar and HomeLayout - * - * @returns {Object} Sidebar state and styles - * @returns {boolean} collapsed - Whether the sidebar is collapsed - * @returns {string} width - CSS width value based on collapsed state - * @returns {string} transition - CSS transition for width changes - */ -export const useSidebar = () => { - const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false); - - return { - collapsed, - width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, - transition: SIDEBAR_TRANSITION, - }; -}; - -export default useSidebar; diff --git a/client/src/Hooks/useSidebar.ts b/client/src/Hooks/useSidebar.ts new file mode 100644 index 000000000..1cf705dd1 --- /dev/null +++ b/client/src/Hooks/useSidebar.ts @@ -0,0 +1,25 @@ +import type { RootState } from "@/Types/state"; +import { useSelector } from "react-redux"; + +// CSS variable names for sidebar widths +const SIDEBAR_WIDTH_VAR = 250; +const SIDEBAR_COLLAPSED_WIDTH_VAR = 64; + +// Transition timing for sidebar width changes +const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; + +export const useSidebar = () => { + const collapsed = useSelector( + (state: RootState) => state.ui.sidebar?.collapsed ?? false + ); + + return { + collapsed, + collapsedWidth: SIDEBAR_COLLAPSED_WIDTH_VAR, + expandedWidth: SIDEBAR_WIDTH_VAR, + width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, + transition: SIDEBAR_TRANSITION, + }; +}; + +export default useSidebar; diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx index b0fd6554e..4a57b539a 100644 --- a/client/src/Pages/Account/index.tsx +++ b/client/src/Pages/Account/index.tsx @@ -1,5 +1,5 @@ import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { TabProfile } from "./TabProfile"; import { TabPassword } from "./TabPassword"; @@ -19,6 +19,11 @@ const Account = ({ open = "profile" }: AccountProps) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + // Sync activeTab when open prop changes (e.g., navigating from sidebar) + useEffect(() => { + setActiveTab(TAB_MAP[open]); + }, [open]); + return ( { - const theme = useTheme(); - const navigate = useNavigate(); - const { t } = useTranslation(); - - // State for tracking connection check status - const [isCheckingConnection, setIsCheckingConnection] = useState(false); - - const handleRetry = React.useCallback(async () => { - setIsCheckingConnection(true); - try { - // Try to connect to the backend with a simple API call - // We'll use any lightweight endpoint that doesn't require authentication - await get("/health", { timeout: 5000 }); - - // If successful, show toast and navigate to login page - createToast({ - body: t("errorPages.serverUnreachable.toasts.reconnected"), - }); - navigate("/login"); - } catch (error) { - // If still unreachable, stay on this page and show toast - createToast({ - body: t("errorPages.serverUnreachable.toasts.stillUnreachable"), - }); - } finally { - setIsCheckingConnection(false); - } - }, [navigate, t]); - - return ( - - - - - - {/* Header with logo */} - - - - {t("common.appName")} - - - - - - - .MuiStack-root": { - border: 1, - borderRadius: theme.spacing(5), - borderColor: theme.palette.primary.lowContrast, - backgroundColor: theme.palette.primary.main, - padding: { - xs: theme.spacing(12), - sm: theme.spacing(20), - }, - }, - }} - > - - - - - - - {t("errorPages.serverUnreachable.description")} - - - - - - - - - ); -}; - -export default ServerUnreachable; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 07dc1793c..7d9163c43 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -4,7 +4,7 @@ import { lightTheme, darkTheme } from "@/Utils/Theme/v2Theme"; import { useSelector } from "react-redux"; import { Navigate, Route, Routes as LibRoutes } from "react-router"; -import HomeLayout from "@/Components/v1/Layouts/HomeLayout"; +import RootLayout from "@/Components/v2/layout/RootLayout"; import NotFound from "@/Pages/NotFound"; // Auth @@ -25,9 +25,6 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/"; import Infrastructure from "@/Pages/Infrastructure/Monitors"; import InfrastructureDetails from "@/Pages/Infrastructure/Details/index"; -// Server Status -import ServerUnreachable from "../Pages/ServerUnreachable.jsx"; - // Checks import Checks from "../Pages/Checks/index"; @@ -67,7 +64,7 @@ const Routes = () => { path="/" element={ - + } > @@ -432,10 +429,6 @@ const Routes = () => { } /> - } - /> void; +let serverUnreachableCallback: ServerUnreachableCallback | null = null; + +export const setServerUnreachableCallback = ( + callback: ServerUnreachableCallback +): void => { + serverUnreachableCallback = callback; +}; + export const initApiClient = (store: StoreType): void => { storeInstance = store; @@ -39,8 +48,18 @@ export const initApiClient = (store: StoreType): void => { } ); - const onSuccess = (response: AxiosResponse) => response; + const onSuccess = (response: AxiosResponse) => { + // Server is reachable, hide offline banner if shown + serverUnreachableCallback?.(false); + return response; + }; const onError = (error: AxiosError) => { + // Handle network errors (server unreachable) + if (error.code === "ERR_NETWORK") { + serverUnreachableCallback?.(true); + return Promise.reject(error); + } + if (error.response?.status === 401) { if (window.location.pathname !== "/login") { window.location.href = "/login"; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbd9ea7b7..d2a066c11 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -325,6 +325,55 @@ "week": "Showing statistics for past 7 days.", "month": "Showing statistics for past 30 days." } + }, + "offlineBanner": { + "serverUnreachable": "Unable to reach server", + "retry": "Retry", + "retrying": "Retrying...", + "reconnected": "Connection restored" + }, + "sidebar": { + "menu": { + "uptime": "Uptime", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastructure", + "notifications": "Notifications", + "checks": "Checks", + "incidents": "Incidents", + "statusPages": "Status pages", + "maintenance": "Maintenance", + "logs": "Logs", + "settings": "Settings" + }, + "bottomMenu": { + "support": "Support", + "discussions": "Discussions", + "docs": "Docs", + "changelog": "Changelog" + }, + "accountMenu": { + "profile": "Profile", + "password": "Password", + "team": "Team" + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "See the latest releases and help grow the community on GitHub" + }, + "authFooter": { + "navControls": "Controls", + "logOut": "Log out", + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "user": "User", + "demoUser": "Demo user" + } + } + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "See the latest releases and help grow the community on GitHub" } }, "configure": "Configure", @@ -533,7 +582,7 @@ "notFoundButton": "Go to the main dashboard", "notifications": { "fallback": { - "actionButton": "Let's create your first notification channel!", + "actionButton": "Create notification channel!", "checks": [ "Alert teams about downtime or performance issues", "Let engineers know when incidents happen", @@ -970,7 +1019,7 @@ } }, "fallback": { - "actionButton": "Let's create your first infrastructure monitor!", + "actionButton": "Create a monitor!", "checks": [ "Track the performance of your servers", "Identify bottlenecks and optimize usage", @@ -1038,7 +1087,7 @@ }, "maintenanceWindow": { "fallback": { - "actionButton": "Let's create your first maintenance window!", + "actionButton": "Create a maintenance window!", "checks": [ "Mark your maintenance periods", "Eliminate any misunderstandings", @@ -1179,7 +1228,7 @@ } }, "fallback": { - "actionButton": "Let's create your first PageSpeed monitor!", + "actionButton": "Create a monitor!", "checks": [ "Report on the user experience of a page", "Help analyze webpage speed", @@ -1198,7 +1247,7 @@ "Build trust with transparent service monitoring", "Reduce support requests during incidents" ], - "actionButton": "Let's create your first status page!" + "actionButton": "Create a status page!" }, "monitorsList": { "chartTypeHeatmap": "Heatmap",