components

This commit is contained in:
Alex Holliday
2025-07-31 15:37:40 -07:00
parent 3dc332010e
commit b61a7ffc3c
6 changed files with 510 additions and 102 deletions

View File

@@ -0,0 +1,48 @@
import IconButton from "@mui/material/IconButton";
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
import ArrowLeft from "../../assets/icons/left-arrow.svg?react";
import { useTheme } from "@mui/material/styles";
import { useDispatch } from "react-redux";
import { toggleSidebar } from "../../Features/UI/uiSlice";
const CollapseButton = ({ collapsed, setOpen }) => {
const theme = useTheme();
const dispatch = useDispatch();
return (
<IconButton
sx={{
position: "absolute",
/* TODO 60 is a magic number. if logo chnges size this might break */
top: 60,
right: 0,
transform: `translate(50%, 0)`,
backgroundColor: theme.palette.tertiary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
p: theme.spacing(2.5),
"& svg": {
width: theme.spacing(8),
height: theme.spacing(8),
"& path": {
stroke: theme.palette.primary.contrastTextSecondary,
},
},
"&:focus": { outline: "none" },
"&:hover": {
backgroundColor: theme.palette.primary.lowContrast,
borderColor: theme.palette.primary.lowContrast,
},
}}
onClick={() => {
setOpen((prev) =>
Object.fromEntries(Object.keys(prev).map((key) => [key, false]))
);
dispatch(toggleSidebar());
}}
>
{collapsed ? <ArrowRight /> : <ArrowLeft />}
</IconButton>
);
};
export default CollapseButton;

View File

@@ -0,0 +1,260 @@
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";
import ThemeSwitch from "../../ThemeSwitch";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Divider from "@mui/material/Divider";
import DotsVertical from "../../../assets/icons/dots-vertical.svg?react";
import LogoutSvg from "../../../assets/icons/logout.svg?react";
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";
import { useDispatch } from "react-redux";
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 [popup, setPopup] = useState();
const openPopup = (event, id) => {
setAnchorEl(event.currentTarget);
setPopup(id);
};
const closePopup = () => {
setAnchorEl(null);
};
const logout = async () => {
// Clear auth state
dispatch(clearAuthState());
navigate("/login");
};
const renderAccountMenuItems = (user, items) => {
let filteredAccountMenuItems = [...items];
// If the user is in demo mode, remove the "Password" option
if (user.role?.includes("demo")) {
filteredAccountMenuItems = filteredAccountMenuItems.filter(
(item) => item.name !== "Password"
);
}
// If the user is NOT a superadmin, remove the "Team" option
if (user.role && !user.role.includes("superadmin")) {
filteredAccountMenuItems = filteredAccountMenuItems.filter(
(item) => item.name !== "Team"
);
}
return filteredAccountMenuItems.map((item) => (
<MenuItem
key={item.name}
onClick={() => {
closePopup();
navigate(item.path);
}}
sx={{
gap: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(4),
}}
>
{item.icon}
{item.name}
</MenuItem>
));
};
return (
<Stack
direction="row"
height="50px"
alignItems="center"
py={theme.spacing(4)}
px={theme.spacing(8)}
gap={theme.spacing(2)}
borderRadius={theme.shape.borderRadius}
>
<>
<Avatar small={true} />
<Stack
direction="row"
alignItems="center"
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
maxWidth: collapsed ? 0 : 400,
transition: "max-width 300ms ease, opacity 300ms ease",
}}
>
<Box
ml={theme.spacing(2)}
sx={{ maxWidth: "50%", overflow: "hidden" }}
>
<Typography
component="span"
fontWeight={500}
sx={{
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{authState.user?.firstName} {authState.user?.lastName}
</Typography>
<Typography sx={{ textTransform: "capitalize" }}>
{authState.user?.role?.includes("superadmin")
? t("roles.superAdmin")
: authState.user?.role?.includes("admin")
? t("roles.admin")
: authState.user?.role?.includes("user")
? t("roles.teamMember")
: authState.user?.role?.includes("demo")
? t("roles.demoUser")
: authState.user?.role}
</Typography>
</Box>
<Stack
className="sidebar-delay-fade"
flexDirection={"row"}
marginLeft={"auto"}
columnGap={theme.spacing(2)}
>
<ThemeSwitch color={theme.palette.primary.contrastTextTertiary} />
<Tooltip
title={t("navControls")}
disableInteractive
>
<IconButton
sx={{
ml: "auto",
mr: "-8px",
"&:focus": { outline: "none" },
alignSelf: "center",
padding: "10px",
"& svg": {
width: "22px",
height: "22px",
},
"& svg path": {
/* Vertical three dots */
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
onClick={(event) => openPopup(event, "logout")}
>
<DotsVertical />
</IconButton>
</Tooltip>
</Stack>
</Stack>
</>
<Menu
className="sidebar-popup"
anchorEl={anchorEl}
open={Boolean(anchorEl) && popup === "logout"}
onClose={closePopup}
disableScrollLock
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
slotProps={{
paper: {
sx: {
marginTop: theme.spacing(-4),
marginLeft: collapsed ? theme.spacing(2) : 0,
},
},
}}
MenuListProps={{
sx: {
p: 2,
"& li": { m: 0 },
"& li:has(.MuiBox-root):hover": {
backgroundColor: "transparent",
},
},
}}
sx={{
ml: theme.spacing(4),
}}
>
{collapsed && (
<MenuItem sx={{ cursor: "default", minWidth: "50%" }}>
<Box
mb={theme.spacing(2)}
sx={{
minWidth: "50%",
maxWidth: "max-content",
overflow: "visible",
whiteSpace: "nowrap",
}}
>
<Typography
component="span"
fontWeight={500}
fontSize={13}
sx={{
display: "block",
whiteSpace: "nowrap",
overflow: "visible",
// wordBreak: "break-word",
textOverflow: "clip",
}}
>
{authState.user?.firstName} {authState.user?.lastName}
</Typography>
<Typography
sx={{
textTransform: "capitalize",
fontSize: 12,
whiteSpace: "nowrap",
overflow: "visible",
// wordBreak: "break-word",
}}
>
{authState.user?.role}
</Typography>
</Box>
</MenuItem>
)}
{/* TODO Do we need two dividers? */}
{collapsed && <Divider />}
{/* <Divider /> */}
{renderAccountMenuItems(authState.user, accountMenuItems)}
<MenuItem
onClick={logout}
sx={{
gap: theme.spacing(4),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(4),
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<LogoutSvg />
{t("menu.logOut", "Log out")}
</MenuItem>
</Menu>
</Stack>
);
};
export default AuthFooter;

View File

@@ -0,0 +1,89 @@
import Tooltip from "@mui/material/Tooltip";
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 { useNavigate } from "react-router";
const NavItem = ({ item, collapsed, selected, onClick }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<Tooltip
placement="right"
title={collapsed ? item.name : ""}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -16],
},
},
],
},
}}
disableInteractive
>
<ListItemButton
sx={{
backgroundColor: selected ? theme.palette.secondary.main : "transparent",
"&:hover": {
backgroundColor: selected
? theme.palette.secondary.main
: theme.palette.tertiary.main,
},
height: "37px",
gap: theme.spacing(4),
borderRadius: theme.shape.borderRadius,
px: theme.spacing(4),
pl: theme.spacing(5),
}}
onClick={onClick}
>
<ListItemIcon
sx={{
minWidth: 0,
"& svg": {
height: "20px",
width: "20px",
opacity: 0.81,
},
"& svg path": {
stroke: selected
? theme.palette.primary.contrastText
: theme.palette.primary.contrastTextTertiary,
},
}}
>
{item.icon}
</ListItemIcon>
<Box
sx={{
overflow: "hidden",
transition: "opacity 900ms ease",
opacity: collapsed ? 0 : 1,
whiteSpace: "nowrap",
}}
>
<Typography
variant="body1"
color={theme.palette.primary.contrastText}
sx={{
fontWeight: selected ? 600 : 400,
opacity: 0.9,
}}
>
{item.name}
</Typography>
</Box>
</ListItemButton>
</Tooltip>
);
};
export default NavItem;

View File

@@ -1,21 +1,16 @@
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import ArrowRight from "../../assets/icons/right-arrow.svg?react";
import ArrowLeft from "../../assets/icons/left-arrow.svg?react";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Tooltip from "@mui/material/Tooltip";
import Logo from "./logo";
import ThemeSwitch from "../ThemeSwitch";
import Avatar from "../Avatar";
import List from "@mui/material/List";
import Logo from "./logo";
import CollapseButton from "./collapseButton";
import Divider from "@mui/material/Divider";
import NavItem from "./components/navItem";
import AuthFooter from "./components/authFooter";
import StarPrompt from "../StarPrompt";
import LockSvg from "../../assets/icons/lock.svg?react";
import UserSvg from "../../assets/icons/user.svg?react";
import TeamSvg from "../../assets/icons/user-two.svg?react";
import LogoutSvg from "../../assets/icons/logout.svg?react";
import Support from "../../assets/icons/support.svg?react";
import Maintenance from "../../assets/icons/maintenance.svg?react";
import Monitors from "../../assets/icons/monitors.svg?react";
@@ -25,7 +20,6 @@ import PageSpeed from "../../assets/icons/page-speed.svg?react";
import Settings from "../../assets/icons/settings.svg?react";
import ArrowDown from "../../assets/icons/down-arrow.svg?react";
import ArrowUp from "../../assets/icons/up-arrow.svg?react";
import DotsVertical from "../../assets/icons/dots-vertical.svg?react";
import ChangeLog from "../../assets/icons/changeLog.svg?react";
import Docs from "../../assets/icons/docs.svg?react";
import StatusPages from "../../assets/icons/status-pages.svg?react";
@@ -35,12 +29,18 @@ import Logs from "../../assets/icons/logs.svg?react";
// Utils
import { useTheme } from "@mui/material/styles";
import { useDispatch, useSelector } from "react-redux";
import { toggleSidebar } from "../../Features/UI/uiSlice";
import { useSelector } from "react-redux";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
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: <Monitors /> },
{ name: t("menu.pagespeed"), path: "pagespeed", icon: <PageSpeed /> },
@@ -64,9 +64,25 @@ const getMenu = (t) => [
},
];
const getOtherMenuItems = (t) => [
{ name: t("menu.support"), path: "support", icon: <Support /> },
{
name: t("menu.discussions"),
path: "discussions",
icon: <Discussions />,
},
{ name: t("menu.docs"), path: "docs", icon: <Docs /> },
{ name: t("menu.changelog"), path: "changelog", icon: <ChangeLog /> },
];
const getAccountMenuItems = (t) => [
{ name: t("menu.profile"), path: "account/profile", icon: <UserSvg /> },
{ name: t("menu.password"), path: "account/password", icon: <LockSvg /> },
{ name: t("menu.team"), path: "account/team", icon: <TeamSvg /> },
];
const Sidebar = () => {
const theme = useTheme();
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
// Redux state
@@ -76,100 +92,82 @@ const Sidebar = () => {
const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false });
const menu = getMenu(t);
console.log(collapsed);
const otherMenuItems = getOtherMenuItems(t);
const accountMenuItems = getAccountMenuItems(t);
return (
<Stack
width={collapsed ? "64px" : "var(--env-var-side-bar-width)"}
component="aside"
position="relative"
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
sx={{
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
}}
>
<IconButton
sx={{
position: "absolute",
/* TODO 60 is a magic number. if logo chnges size this might break */
top: 60,
right: 0,
transform: `translate(50%, 0)`,
backgroundColor: theme.palette.tertiary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
p: theme.spacing(2.5),
"& svg": {
width: theme.spacing(8),
height: theme.spacing(8),
"& path": {
stroke: theme.palette.primary.contrastTextSecondary,
},
},
"&:focus": { outline: "none" },
"&:hover": {
backgroundColor: theme.palette.primary.lowContrast,
borderColor: theme.palette.primary.lowContrast,
},
}}
onClick={() => {
setOpen((prev) =>
Object.fromEntries(Object.keys(prev).map((key) => [key, false]))
);
dispatch(toggleSidebar());
}}
>
{collapsed ? <ArrowRight /> : <ArrowLeft />}
</IconButton>
<Logo />
<CollapseButton
collapsed={collapsed}
setOpen={setOpen}
/>
<Logo collapsed={collapsed} />
<List
component="nav"
aria-labelledby="nested-menu-subheader"
disablePadding
sx={{
mt: theme.spacing(12),
px: theme.spacing(6),
height: "100%",
/* overflow: "hidden", */
}}
>
{menu.map((item) => {
return item.path ? (
/* If item has a path */
<Tooltip
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
placement="right"
title={collapsed ? item.name : ""}
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -16],
},
},
],
},
}}
disableInteractive
>
<ListItemButton
className={
location.pathname.startsWith(`/${item.path}`) ? "selected-path" : ""
}
onClick={() => navigate(`/${item.path}`)}
sx={{
height: "37px",
gap: theme.spacing(4),
borderRadius: theme.shape.borderRadius,
px: theme.spacing(4),
pl: theme.spacing(5),
}}
>
<ListItemIcon sx={{ minWidth: 0 }}>{item.icon}</ListItemIcon>
{!collapsed && <ListItemText>{item.name}</ListItemText>}
</ListItemButton>
</Tooltip>
) : null;
item={item}
collapsed={collapsed}
selected={selected}
onClick={() => navigate(`/${item.path}`)}
/>
);
})}
</List>
{!collapsed && <StarPrompt />}
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(6) }}
>
{otherMenuItems.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
collapsed={collapsed}
selected={selected}
onClick={() => {
const url = URL_MAP[item.path];
if (url) {
window.open(url, "_blank", "noreferrer");
} else {
navigate(`/${item.path}`);
}
}}
/>
);
})}
</List>
<Divider sx={{ mt: "auto", borderColor: theme.palette.primary.lowContrast }} />
<AuthFooter
collapsed={collapsed}
accountMenuItems={accountMenuItems}
/>
</Stack>
);
};

View File

@@ -1,10 +1,11 @@
import Stack 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 { useTranslation } from "react-i18next";
const Logo = () => {
const Logo = ({ collapsed }) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
@@ -35,20 +36,31 @@ const Logo = () => {
sx={{
position: "relative",
backgroundColor: theme.palette.accent.main,
color: theme.palette.accent.contrastText,
borderRadius: theme.shape.borderRadius,
userSelect: "none",
}}
>
C
</Stack>
<Typography
component="span"
mt={theme.spacing(2)}
sx={{ opacity: 0.8, fontWeight: 500 }}
<Box
sx={{
overflow: "hidden",
transition: "opacity 900ms ease",
opacity: collapsed ? 0 : 1,
whiteSpace: "nowrap",
}}
>
{t("common.appName")}
</Typography>
{" "}
<Typography
component="span"
mt={theme.spacing(2)}
color={theme.palette.accent.contrastText}
fontSize={"var(--env-var-font-size-medium-plus)"}
sx={{ opacity: 0.8, fontWeight: 500 }}
>
{t("common.appName")}
</Typography>
</Box>
</Stack>
</Stack>
);

View File

@@ -233,16 +233,16 @@ const baseTheme = (palette) => ({
},
MuiList: {
styleOverrides: {
root: {
root: ({ theme }) => ({
padding: 0,
},
}),
},
},
MuiListItemButton: {
styleOverrides: {
root: {
transition: "none",
},
root: ({ theme }) => ({
transition: "background-color .3s",
}),
},
},
MuiListItemText: {
@@ -279,6 +279,7 @@ const baseTheme = (palette) => ({
}),
},
},
MuiTableHead: {
styleOverrides: {
root: ({ theme }) => ({