Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f39be632f | ||
|
|
9559b40e67 | ||
|
|
914d94497d | ||
|
|
8eb9b70c9b | ||
|
|
bf55c21e79 | ||
|
|
9d44e11496 | ||
|
|
478e9671d4 | ||
|
|
c44fd22031 | ||
|
|
62dc6453f1 | ||
|
|
7663ad6c74 | ||
|
|
2f7de5d103 | ||
|
|
4e1f9bc431 | ||
|
|
6b1d574698 | ||
|
|
b196d0de78 | ||
|
|
c7107a5034 |
No files matched your search
Generated
+2
-2
@@ -26,7 +26,7 @@
|
|||||||
"joi": "17.13.3",
|
"joi": "17.13.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mui-color-input": "^6.0.0",
|
"mui-color-input": "^6.0.0",
|
||||||
"pretty-ms": "^9.3.0",
|
"pretty-ms": "9.3.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "7.63.0",
|
"react-hook-form": "7.63.0",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"react-toastify": "^10.0.5",
|
"react-toastify": "^10.0.5",
|
||||||
"recharts": "2.15.2",
|
"recharts": "2.15.2",
|
||||||
"redux-persist": "6.0.0",
|
"redux-persist": "6.0.0",
|
||||||
"swr": "^2.3.6",
|
"swr": "2.3.6",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"zod": "4.1.11"
|
"zod": "4.1.11"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@
|
|||||||
"joi": "17.13.3",
|
"joi": "17.13.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mui-color-input": "^6.0.0",
|
"mui-color-input": "^6.0.0",
|
||||||
"pretty-ms": "^9.3.0",
|
"pretty-ms": "9.3.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "7.63.0",
|
"react-hook-form": "7.63.0",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"react-toastify": "^10.0.5",
|
"react-toastify": "^10.0.5",
|
||||||
"recharts": "2.15.2",
|
"recharts": "2.15.2",
|
||||||
"redux-persist": "6.0.0",
|
"redux-persist": "6.0.0",
|
||||||
"swr": "^2.3.6",
|
"swr": "2.3.6",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"zod": "4.1.11"
|
"zod": "4.1.11"
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-21
@@ -10,6 +10,7 @@ import { logger } from "./Utils/Logger"; // Import the logger
|
|||||||
import { networkService } from "./main";
|
import { networkService } from "./main";
|
||||||
import { Routes } from "./Routes";
|
import { Routes } from "./Routes";
|
||||||
import AppLayout from "@/Components/v1/Layouts/AppLayout";
|
import AppLayout from "@/Components/v1/Layouts/AppLayout";
|
||||||
|
import { UserGuideSidebarProvider } from "@/Components/v1/UserGuide";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const mode = useSelector((state) => state.ui.mode);
|
const mode = useSelector((state) => state.ui.mode);
|
||||||
@@ -22,29 +23,15 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const theme = mode === "light" ? lightTheme : darkTheme;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<GlobalStyles
|
<UserGuideSidebarProvider>
|
||||||
styles={{
|
<AppLayout>
|
||||||
body: {
|
<Routes />
|
||||||
backgroundColor: theme.palette.background.main,
|
</AppLayout>
|
||||||
},
|
</UserGuideSidebarProvider>
|
||||||
}}
|
<ToastContainer />
|
||||||
/>
|
|
||||||
<AppLayout>
|
|
||||||
<Routes />
|
|
||||||
</AppLayout>
|
|
||||||
<ToastContainer
|
|
||||||
newestOnTop={true}
|
|
||||||
theme={mode}
|
|
||||||
style={{
|
|
||||||
"--toastify-color-progress-light": "#7C8BA1",
|
|
||||||
"--toastify-color-progress-dark": "#7C8BA1",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const ActionsMenu = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
let monitor = { id: actions.id };
|
let monitor = { id: actions.id };
|
||||||
|
console.log(monitor);
|
||||||
await deleteMonitor({ monitor });
|
await deleteMonitor({ monitor });
|
||||||
updateRowCallback();
|
updateRowCallback();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Stack, Box, Tooltip, Typography } from "@mui/material";
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
const PlaceholderCheck = ({ daysToShow }) => {
|
const PlaceholderCheck = ({ daysToShow }) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Box, Stack, Typography } from "@mui/material";
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||||
import {
|
import {
|
||||||
tooltipDateFormatLookup,
|
tooltipDateFormatLookup,
|
||||||
tickDateFormatLookup,
|
tickDateFormatLookup,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||||
import { useEffect, useState, forwardRef } from "react";
|
import { useEffect, useState, forwardRef } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
@@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
key={`check-${check?.id}`}
|
key={`check-${check?._id}`}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
popper: {
|
popper: {
|
||||||
className: "bar-tooltip",
|
className: "bar-tooltip",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { Text } from "recharts";
|
import { Text } from "recharts";
|
||||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||||
import { Box, Stack, Typography } from "@mui/material";
|
import { Box, Stack, Typography } from "@mui/material";
|
||||||
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js";
|
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,14 +56,7 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) =>
|
|||||||
fontWeight: 450,
|
fontWeight: 450,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{noHighlightText && (
|
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
sx={{ color: "inherit" }}
|
|
||||||
>
|
|
||||||
{noHighlightText}
|
|
||||||
</Typography>
|
|
||||||
)}{" "}
|
|
||||||
{text}
|
{text}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
CheckCircle2,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -83,7 +82,6 @@ const iconComponents = {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
CheckCircle2,
|
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,44 @@
|
|||||||
import Sidebar from "../../Sidebar/index.jsx";
|
import Sidebar from "../../Sidebar/index.jsx";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import { Box, Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { UserGuideSidebar, TAB_BAR_WIDTH } from "../../UserGuide";
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
const HomeLayout = () => {
|
const HomeLayout = () => {
|
||||||
const { width, transition } = useSidebar();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
className="home-layout"
|
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
|
gap={14}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
minHeight: "100vh",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: 0,
|
||||||
|
overflowX: "hidden",
|
||||||
|
width: "100%",
|
||||||
|
"& > div": {
|
||||||
|
minHeight: `calc(100vh - ${theme.spacing(4)} * 2)`,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{/* Spacer for fixed sidebar */}
|
<Stack
|
||||||
<Box
|
|
||||||
sx={{
|
sx={{
|
||||||
width,
|
padding: theme.spacing(4),
|
||||||
flexShrink: 0,
|
paddingRight: `${TAB_BAR_WIDTH + 16}px`,
|
||||||
transition,
|
maxWidth: 1400,
|
||||||
|
margin: "0 auto",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflowX: "hidden",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Stack className="home-content-wrapper">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<UserGuideSidebar />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const MonitorDetailsControlHeader = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{monitor?.isActive ? t("pause") : t("resume")}
|
{monitor?.isActive ? "Pause" : "Resume"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PulseDot from "../Animated/PulseDot.jsx";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Dot from "../Dot/index.jsx";
|
import Dot from "../Dot/index.jsx";
|
||||||
// Utils
|
// Utils
|
||||||
import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js";
|
import { formatDurationRounded } from "../../../Utils/timeUtils.js";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
|
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const NotificationConfig = ({
|
|||||||
setMonitor((prev) => {
|
setMonitor((prev) => {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
notifications: value.map((notification) => notification.id),
|
notifications: value.map((notification) => notification._id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -39,14 +39,14 @@ const NotificationConfig = ({
|
|||||||
// Handlers
|
// Handlers
|
||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
const updatedNotifications = selectedNotifications.filter(
|
const updatedNotifications = selectedNotifications.filter(
|
||||||
(notification) => notification.id !== id
|
(notification) => notification._id !== id
|
||||||
);
|
);
|
||||||
|
|
||||||
setSelectedNotifications(updatedNotifications);
|
setSelectedNotifications(updatedNotifications);
|
||||||
setMonitor((prev) => {
|
setMonitor((prev) => {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
notifications: updatedNotifications.map((notification) => notification.id),
|
notifications: updatedNotifications.map((notification) => notification._id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -57,7 +57,7 @@ const NotificationConfig = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setNotifications) {
|
if (setNotifications) {
|
||||||
const toSet = setNotifications.map((notification) => {
|
const toSet = setNotifications.map((notification) => {
|
||||||
return notifications.find((n) => n.id === notification);
|
return notifications.find((n) => n._id === notification);
|
||||||
});
|
});
|
||||||
setSelectedNotifications(toSet);
|
setSelectedNotifications(toSet);
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ const NotificationConfig = ({
|
|||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
key={notification.id}
|
key={notification._id}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -106,7 +106,7 @@ const NotificationConfig = ({
|
|||||||
name="Trash2"
|
name="Trash2"
|
||||||
size={20}
|
size={20}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDelete(notification.id);
|
handleDelete(notification._id);
|
||||||
}}
|
}}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import Icon from "../Icon";
|
|||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
|
||||||
|
|
||||||
const URL_MAP = {
|
const URL_MAP = {
|
||||||
support: "https://discord.com/invite/NAb6H3UTjK",
|
support: "https://discord.com/invite/NAb6H3UTjK",
|
||||||
@@ -65,7 +65,8 @@ const Sidebar = () => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { collapsed, width, transition } = useSidebar();
|
// Redux state
|
||||||
|
const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false);
|
||||||
|
|
||||||
const menu = getMenu(t);
|
const menu = getMenu(t);
|
||||||
const otherMenuItems = getOtherMenuItems(t);
|
const otherMenuItems = getOtherMenuItems(t);
|
||||||
@@ -74,18 +75,21 @@ const Sidebar = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
height="100vh"
|
height="100vh"
|
||||||
width={width}
|
width={
|
||||||
|
collapsed
|
||||||
|
? "var(--env-var-side-bar-collapsed-width)"
|
||||||
|
: "var(--env-var-side-bar-width)"
|
||||||
|
}
|
||||||
component="aside"
|
component="aside"
|
||||||
position="fixed"
|
position="sticky"
|
||||||
top={0}
|
top={0}
|
||||||
left={0}
|
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||||
paddingTop={theme.spacing(6)}
|
paddingTop={theme.spacing(6)}
|
||||||
paddingBottom={theme.spacing(6)}
|
paddingBottom={theme.spacing(6)}
|
||||||
gap={theme.spacing(6)}
|
gap={theme.spacing(6)}
|
||||||
sx={{
|
sx={{
|
||||||
transition,
|
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
|
||||||
backgroundColor: theme.palette.background.main,
|
backgroundColor: theme.palette.primaryBackground.main,
|
||||||
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
|
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/* ArticlePage styles - hover states and nested selectors (Dark theme) */
|
||||||
|
|
||||||
|
/* Breadcrumb hover */
|
||||||
|
.user-guide-breadcrumb-link {
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-breadcrumb-link:hover {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ToC link hover */
|
||||||
|
.user-guide-toc-link {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-toc-link:hover {
|
||||||
|
color: #1570ef;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation button hover */
|
||||||
|
.user-guide-nav-button .nav-text {
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-nav-button:hover .nav-text {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Article body typography */
|
||||||
|
.user-guide-article-body h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d0d5dd;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body ul,
|
||||||
|
.user-guide-article-body ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body li {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d0d5dd;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body a {
|
||||||
|
color: #1570ef;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body code {
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: "Geist Mono", "Fira Code", "Consolas", monospace;
|
||||||
|
color: #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body pre {
|
||||||
|
background-color: #1c2130;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body blockquote {
|
||||||
|
border-left: 3px solid #1570ef;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body blockquote p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body th,
|
||||||
|
.user-guide-article-body td {
|
||||||
|
border: 1px solid #344054;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body th {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body td {
|
||||||
|
color: #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #344054;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-body hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #344054;
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Link,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./ArticlePage.css";
|
||||||
|
|
||||||
|
// Map icon names to actual Lucide components
|
||||||
|
const iconMap = {
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Link,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArticlePage = ({
|
||||||
|
collection,
|
||||||
|
article,
|
||||||
|
onBack,
|
||||||
|
onBackToHome,
|
||||||
|
onNextArticle,
|
||||||
|
onPrevArticle,
|
||||||
|
nextArticle,
|
||||||
|
prevArticle,
|
||||||
|
tocItems,
|
||||||
|
children,
|
||||||
|
mode = "in-app",
|
||||||
|
}) => {
|
||||||
|
const { colors, typography, spacing, border, isDark } = useUserGuideTheme();
|
||||||
|
const IconComponent = iconMap[collection.icon] || Rocket;
|
||||||
|
const isInApp = mode === "in-app";
|
||||||
|
const [activeSection, setActiveSection] = useState("");
|
||||||
|
|
||||||
|
// Track active section on scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tocItems || tocItems.length === 0) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const sections = tocItems
|
||||||
|
.map((item) => {
|
||||||
|
const id = item.href.replace("#", "");
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
return { id: item.href, element };
|
||||||
|
})
|
||||||
|
.filter((s) => s.element);
|
||||||
|
|
||||||
|
if (sections.length === 0) return;
|
||||||
|
|
||||||
|
let currentSection = sections[0].id;
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.element) {
|
||||||
|
const rect = section.element.getBoundingClientRect();
|
||||||
|
if (rect.top <= 150) {
|
||||||
|
currentSection = section.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSection(currentSection);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
document.addEventListener("scroll", handleScroll, { passive: true, capture: true });
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
document.removeEventListener("scroll", handleScroll, { capture: true });
|
||||||
|
};
|
||||||
|
}, [tocItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
|
||||||
|
{/* Main content area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: isInApp ? "100%" : 1100,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: isInApp ? spacing.lg : spacing["3xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Article Content */}
|
||||||
|
<div style={{ maxWidth: isInApp ? "100%" : 680, minWidth: 0 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize["3xl"],
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
lineHeight: typography.lineHeight.snug,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Table of Contents - at top */}
|
||||||
|
{tocItems && tocItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
paddingBottom: isInApp ? 0 : spacing.lg,
|
||||||
|
borderBottom: isInApp ? "none" : border.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: isInApp
|
||||||
|
? isDark
|
||||||
|
? "rgba(255, 255, 255, 0.05)"
|
||||||
|
: "rgba(0, 0, 0, 0.02)"
|
||||||
|
: "transparent",
|
||||||
|
borderRadius: isInApp ? border.radius : 0,
|
||||||
|
padding: isInApp ? spacing.md : 0,
|
||||||
|
border: isInApp ? border.default : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In this article
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: isInApp ? "column" : "row",
|
||||||
|
flexWrap: isInApp ? "nowrap" : "wrap",
|
||||||
|
gap: isInApp ? 0 : spacing.xs,
|
||||||
|
columnGap: isInApp ? 0 : spacing.lg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tocItems.map((item) => {
|
||||||
|
const isActive = activeSection === item.href;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="user-guide-toc-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isInApp) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = item.href.replace("#", "");
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: isInApp ? 13 : 12,
|
||||||
|
color: isActive ? colors.brand.primary : colors.text.secondary,
|
||||||
|
fontWeight: isActive
|
||||||
|
? typography.fontWeight.medium
|
||||||
|
: typography.fontWeight.normal,
|
||||||
|
textDecoration: "none",
|
||||||
|
padding: isInApp ? "2px 0" : `${spacing.xs} 0`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article Body */}
|
||||||
|
<div className="user-guide-article-body">
|
||||||
|
{children || (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
padding: spacing.xl,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: typography.fontSize.sm, color: colors.text.muted }}
|
||||||
|
>
|
||||||
|
Article content coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
{(prevArticle || nextArticle) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: spacing["2xl"],
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
borderTop: border.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prevArticle ? (
|
||||||
|
<div
|
||||||
|
onClick={onPrevArticle}
|
||||||
|
className="user-guide-nav-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="nav-text"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.primary,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prevArticle.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{nextArticle && (
|
||||||
|
<div
|
||||||
|
onClick={onNextArticle}
|
||||||
|
className="user-guide-nav-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
textAlign: "right",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="nav-text"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.primary,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nextArticle.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArticlePage;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* CollectionPage styles - hover states */
|
||||||
|
|
||||||
|
/* Back link hover */
|
||||||
|
.user-guide-back-link {
|
||||||
|
transition: color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-back-link:hover {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Article row hover */
|
||||||
|
.user-guide-article-row {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-article-row:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-row .arrow-icon {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-row:hover .arrow-icon {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
FileText,
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Link,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./CollectionPage.css";
|
||||||
|
|
||||||
|
// Map icon names to actual Lucide components
|
||||||
|
const iconMap = {
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Link,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollectionPage = ({ collection, onBack, onArticleClick }) => {
|
||||||
|
const { colors, typography, spacing, border } = useUserGuideTheme();
|
||||||
|
const IconComponent = iconMap[collection.icon] || Rocket;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
borderBottom: border.default,
|
||||||
|
padding: `${spacing.lg} ${spacing.lg}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 900, margin: "0 auto" }}>
|
||||||
|
{/* Collection Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}>
|
||||||
|
<IconComponent
|
||||||
|
size={24}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize["2xl"],
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.title}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.muted,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.articleCount} articles in this collection
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles List */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 900,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: `${spacing.lg} ${spacing.lg}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.articles.map((article, index) => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
onClick={() => onArticleClick(article.id)}
|
||||||
|
className="user-guide-article-row"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderBottom:
|
||||||
|
index < collection.articles.length - 1 ? border.default : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}>
|
||||||
|
<FileText
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{ marginTop: 2, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{article.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight
|
||||||
|
className="arrow-icon"
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionPage;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/* ContentRenderer styles - hover states and list styling */
|
||||||
|
|
||||||
|
/* List styling - colors handled by inline styles for theme support */
|
||||||
|
.user-guide-content-list li {
|
||||||
|
/* Font styles handled inline for theme-aware colors */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table row hover */
|
||||||
|
.user-guide-table-row {
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-table-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-table-row:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Article links hover */
|
||||||
|
.user-guide-article-link-item {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-link-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-article-link-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-link-item:hover .link-title {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-link-item .arrow-icon {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-article-link-item:hover .arrow-icon {
|
||||||
|
transform: translateX(4px);
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import ImageLightbox from "./ImageLightbox";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Rocket,
|
||||||
|
Shield,
|
||||||
|
Brain,
|
||||||
|
AlertTriangle,
|
||||||
|
Server,
|
||||||
|
Database,
|
||||||
|
Terminal,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
|
Gauge,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
ArrowRight,
|
||||||
|
Globe,
|
||||||
|
Bell,
|
||||||
|
Link,
|
||||||
|
} from "lucide-react";
|
||||||
|
import "./ContentRenderer.css";
|
||||||
|
|
||||||
|
// Icon mapping
|
||||||
|
const iconMap = {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Rocket,
|
||||||
|
Shield,
|
||||||
|
Brain,
|
||||||
|
AlertTriangle,
|
||||||
|
Server,
|
||||||
|
Database,
|
||||||
|
Terminal,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
|
Gauge,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
Info,
|
||||||
|
Lightbulb,
|
||||||
|
Globe,
|
||||||
|
Bell,
|
||||||
|
Link,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (name) => iconMap[name] || Info;
|
||||||
|
|
||||||
|
const ContentRenderer = ({ content, onNavigate }) => {
|
||||||
|
const { colors, typography, spacing, border, chipStyles, imageStyles, isDark } =
|
||||||
|
useUserGuideTheme();
|
||||||
|
const [lightboxImage, setLightboxImage] = useState(null);
|
||||||
|
|
||||||
|
// Parse basic markdown in text (inside component to access onNavigate)
|
||||||
|
const parseMarkdown = (text) => {
|
||||||
|
const parts = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
// Supports: **bold**, `code`, [[link text]](collection/article)
|
||||||
|
const combinedRegex = /\*\*([^*]+)\*\*|`([^`]+)`|\[\[([^\]]+)\]\]\(([^)]+)\)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
const getChipStyle = (codeContent) => {
|
||||||
|
const isUrl = /^https?:\/\//.test(codeContent);
|
||||||
|
return isUrl ? chipStyles.url : chipStyles.code;
|
||||||
|
};
|
||||||
|
|
||||||
|
while ((match = combinedRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1]) {
|
||||||
|
// Bold: **text**
|
||||||
|
parts.push(<strong key={key++}>{match[1]}</strong>);
|
||||||
|
} else if (match[2]) {
|
||||||
|
// Code: `text`
|
||||||
|
const style = getChipStyle(match[2]);
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key={key++}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{match[2]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (match[3] && match[4]) {
|
||||||
|
// Article link: [[text]](collection/article)
|
||||||
|
const linkText = match[3];
|
||||||
|
const linkPath = match[4];
|
||||||
|
const [collectionId, articleId] = linkPath.split("/");
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key={key++}
|
||||||
|
onClick={() => onNavigate?.(collectionId, articleId)}
|
||||||
|
style={{
|
||||||
|
color: colors.brand.primary,
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
textDecorationColor: "transparent",
|
||||||
|
transition: "text-decoration-color 150ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.textDecorationColor = colors.brand.primary;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.textDecorationColor = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linkText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBlock = (block, index) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case "heading":
|
||||||
|
return renderHeading(block, index);
|
||||||
|
case "paragraph":
|
||||||
|
return renderParagraph(block, index);
|
||||||
|
case "callout":
|
||||||
|
return renderCallout(block, index);
|
||||||
|
case "bullet-list":
|
||||||
|
return renderBulletList(block, index);
|
||||||
|
case "ordered-list":
|
||||||
|
return renderOrderedList(block, index);
|
||||||
|
case "checklist":
|
||||||
|
return renderChecklist(block, index);
|
||||||
|
case "icon-cards":
|
||||||
|
return renderIconCards(block, index);
|
||||||
|
case "grid-cards":
|
||||||
|
return renderGridCards(block, index);
|
||||||
|
case "time-estimate":
|
||||||
|
return renderTimeEstimate(block, index);
|
||||||
|
case "info-box":
|
||||||
|
return renderInfoBox(block, index);
|
||||||
|
case "code":
|
||||||
|
return renderCode(block, index);
|
||||||
|
case "requirements":
|
||||||
|
return renderRequirements(block, index);
|
||||||
|
case "table":
|
||||||
|
return renderTable(block, index);
|
||||||
|
case "image":
|
||||||
|
return renderImage(block, index);
|
||||||
|
case "article-links":
|
||||||
|
return renderArticleLinks(block, index);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeading = (block, index) => {
|
||||||
|
const Tag = block.level === 2 ? "h2" : "h3";
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={index}
|
||||||
|
id={block.id}
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize:
|
||||||
|
block.level === 2 ? typography.fontSize["2xl"] : typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
marginTop: index > 0 ? spacing["2xl"] : 0,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.text}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderParagraph = (block, index) => (
|
||||||
|
<p
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.relaxed,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(block.text)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCallout = (block, index) => {
|
||||||
|
const calloutColors = {
|
||||||
|
info: colors.brand.primary,
|
||||||
|
tip: colors.brand.primary,
|
||||||
|
warning: colors.brand.warning,
|
||||||
|
success: colors.brand.success,
|
||||||
|
};
|
||||||
|
const accentColor = calloutColors[block.variant || "info"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
border: border.default,
|
||||||
|
borderLeftWidth: "3px",
|
||||||
|
borderLeftColor: accentColor,
|
||||||
|
backgroundColor: isDark ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.02)",
|
||||||
|
paddingLeft: spacing.lg,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
paddingRight: spacing.lg,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.title && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(block.text)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBulletList = (block, index) => (
|
||||||
|
<ul
|
||||||
|
key={index}
|
||||||
|
className="user-guide-content-list"
|
||||||
|
style={{
|
||||||
|
paddingLeft: spacing.xl,
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.bold && (
|
||||||
|
<strong style={{ color: colors.text.primary }}>{item.bold}</strong>
|
||||||
|
)}
|
||||||
|
{item.bold && <span style={{ color: colors.text.secondary }}> — </span>}
|
||||||
|
<span style={{ color: colors.text.secondary }}>
|
||||||
|
{item.bold ? parseMarkdown(item.text) : parseMarkdown(item.text)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderOrderedList = (block, index) => (
|
||||||
|
<ol
|
||||||
|
key={index}
|
||||||
|
className="user-guide-content-list"
|
||||||
|
style={{
|
||||||
|
paddingLeft: spacing.xl,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.bold && (
|
||||||
|
<strong style={{ color: colors.text.primary }}>{item.bold}</strong>
|
||||||
|
)}
|
||||||
|
{item.bold && " "}
|
||||||
|
<span style={{ color: colors.text.secondary }}>
|
||||||
|
{item.bold ? parseMarkdown(item.text) : parseMarkdown(item.text)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderChecklist = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{ marginBottom: spacing["2xl"], paddingLeft: spacing.sm }}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "10px",
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
style={{ flexShrink: 0, marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.tight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(item)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIconCards = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => {
|
||||||
|
const IconComponent = getIcon(item.icon);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.sm,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "20px",
|
||||||
|
height: "22px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: "22px",
|
||||||
|
marginBottom: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(item.description)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderGridCards = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(auto-fit, minmax(${block.columns === 2 ? "250px" : "200px"}, 1fr))`,
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((item, i) => {
|
||||||
|
const IconComponent = item.icon ? getIcon(item.icon) : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: "14px",
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IconComponent && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "16px",
|
||||||
|
height: "18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: "18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.snug,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(item.description)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTimeEstimate = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
padding: `${spacing.md} ${spacing.lg}`,
|
||||||
|
backgroundColor: isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.03)",
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
height: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: "20px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(block.text)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderInfoBox = (block, index) => {
|
||||||
|
const IconComponent = block.icon ? getIcon(block.icon) : FolderKanban;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontSize: typography.fontSize.md,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: "20px" }}>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parseMarkdown(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCode = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.code,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
padding: "14px 16px",
|
||||||
|
overflow: "auto",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontFamily: typography.fontFamily.mono,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.code}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRequirements = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: spacing.md,
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.items.map((req, i) => {
|
||||||
|
const IconComponent = getIcon(req.icon);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{req.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: "20px" }}>
|
||||||
|
{req.items.map((item, j) => (
|
||||||
|
<li
|
||||||
|
key={j}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTableCell = (value) => {
|
||||||
|
if (value === "✓") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "rgba(19, 113, 91, 0.2)",
|
||||||
|
color: colors.brand.primary,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value === "✗" || value === "—") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "rgba(102, 112, 133, 0.2)",
|
||||||
|
color: colors.text.muted,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseMarkdown(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTable = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: block.columns.map((col) => col.width || "1fr").join(" "),
|
||||||
|
backgroundColor: isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.03)",
|
||||||
|
borderBottom: border.default,
|
||||||
|
padding: `${spacing.md} ${spacing.lg}`,
|
||||||
|
gap: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.columns.map((col) => (
|
||||||
|
<span
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.tight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
{block.rows.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="user-guide-table-row"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: block.columns.map((col) => col.width || "1fr").join(" "),
|
||||||
|
borderBottom: i < block.rows.length - 1 ? border.default : "none",
|
||||||
|
backgroundColor:
|
||||||
|
i % 2 === 1
|
||||||
|
? isDark
|
||||||
|
? "rgba(255, 255, 255, 0.02)"
|
||||||
|
: "rgba(0, 0, 0, 0.02)"
|
||||||
|
: "transparent",
|
||||||
|
padding: `10px ${spacing.lg}`,
|
||||||
|
gap: spacing.md,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.columns.map((col, j) => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight:
|
||||||
|
j === 0 ? typography.fontWeight.medium : typography.fontWeight.normal,
|
||||||
|
color: j === 0 ? colors.text.primary : colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderTableCell(row[col.key])}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderImage = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
marginBottom: spacing["2xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={block.src}
|
||||||
|
alt={block.alt}
|
||||||
|
onClick={() =>
|
||||||
|
setLightboxImage({ src: block.src, alt: block.alt, caption: block.caption })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...imageStyles.image,
|
||||||
|
cursor: "zoom-in",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{block.caption && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
...imageStyles.caption,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderArticleLinks = (block, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{ marginBottom: spacing["2xl"] }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "1px",
|
||||||
|
backgroundColor: colors.border.default,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{block.title && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{block.items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => onNavigate?.(item.collectionId, item.articleId)}
|
||||||
|
className="user-guide-article-link-item"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: spacing.sm,
|
||||||
|
margin: `0 -${spacing.sm}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="link-title"
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<ArrowRight
|
||||||
|
className="arrow-icon"
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{ flexShrink: 0, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{content.blocks.map((block, index) => renderBlock(block, index))}</div>
|
||||||
|
|
||||||
|
{/* Image Lightbox */}
|
||||||
|
{lightboxImage && (
|
||||||
|
<ImageLightbox
|
||||||
|
src={lightboxImage.src}
|
||||||
|
alt={lightboxImage.alt}
|
||||||
|
caption={lightboxImage.caption}
|
||||||
|
onClose={() => setLightboxImage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentRenderer;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* HelpSection styles */
|
||||||
|
|
||||||
|
.user-guide-help-button {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-help-button:hover {
|
||||||
|
border-color: #1570ef;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-help-button:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./HelpSection.css";
|
||||||
|
|
||||||
|
const HelpSection = () => {
|
||||||
|
const { colors, typography, spacing, border } = useUserGuideTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: spacing.xl,
|
||||||
|
minHeight: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Contact us */}
|
||||||
|
<div style={{ marginBottom: spacing["2xl"] }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Contact us
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Can't find what you need? Our support team is here to help.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/NAb6H3UTjK"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="user-guide-help-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
width: "100%",
|
||||||
|
padding: `${spacing.md} ${spacing.lg}`,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join our Discord
|
||||||
|
<ExternalLink
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ask the community */}
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GitHub discussions
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Questions about features, how-tos, or use cases? Join the community discussion.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/bluewave-labs/checkmate/discussions"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="user-guide-help-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
width: "100%",
|
||||||
|
padding: `${spacing.md} ${spacing.lg}`,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GitHub discussions
|
||||||
|
<ExternalLink
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HelpSection;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { typography, spacing } from "./styles/theme";
|
||||||
|
|
||||||
|
const ImageLightbox = ({ src, alt, caption, onClose }) => {
|
||||||
|
// Close on escape key
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Close when clicking backdrop
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
padding: spacing.xl,
|
||||||
|
cursor: "zoom-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "background-color 150ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
|
||||||
|
}}
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={24}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Image container */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "90vw",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: caption ? "calc(90vh - 40px)" : "90vh",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: "4px",
|
||||||
|
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.3)",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginTop: spacing.md,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: "rgba(255, 255, 255, 0.8)",
|
||||||
|
textAlign: "center",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageLightbox;
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
# Checkmate User Guide Style Guide
|
||||||
|
|
||||||
|
This document defines the writing standards, formatting rules, and content patterns for all Checkmate user documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voice & Tone
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Second person, active voice** - Address the reader directly
|
||||||
|
|
||||||
|
- ✅ "You can configure monitors from the dashboard"
|
||||||
|
- ❌ "Monitors can be configured from the dashboard"
|
||||||
|
|
||||||
|
2. **Professional but approachable** - Clear and helpful, not stiff or overly casual
|
||||||
|
|
||||||
|
- ✅ "Select the notification channel you want to use"
|
||||||
|
- ❌ "Go ahead and pick whatever notification channel works for you!"
|
||||||
|
- ❌ "The user shall select the appropriate notification channel"
|
||||||
|
|
||||||
|
3. **Direct and instructional** - Tell users what to do, not what the system does
|
||||||
|
|
||||||
|
- ✅ "Click Save to apply your changes"
|
||||||
|
- ❌ "The Save button will apply your changes"
|
||||||
|
|
||||||
|
4. **User-focused** - Lead with what users can accomplish
|
||||||
|
- ✅ "You can track server performance with infrastructure monitors"
|
||||||
|
- ❌ "Infrastructure monitors use the Capture agent to collect metrics"
|
||||||
|
|
||||||
|
### Language Patterns
|
||||||
|
|
||||||
|
**Opening phrases:**
|
||||||
|
|
||||||
|
- "The [Feature] allows you to..."
|
||||||
|
- "[Feature] is..."
|
||||||
|
- "You can..."
|
||||||
|
- "To [action]:"
|
||||||
|
|
||||||
|
**Navigation instructions:**
|
||||||
|
|
||||||
|
- "Navigate to [location]"
|
||||||
|
- "Click [element]"
|
||||||
|
- "Select [option]"
|
||||||
|
- "From the [page], click..."
|
||||||
|
|
||||||
|
**Avoid:**
|
||||||
|
|
||||||
|
- Passive voice where active is clearer
|
||||||
|
- Jargon without explanation
|
||||||
|
- Assuming prior knowledge
|
||||||
|
- Marketing language ("powerful", "seamless", "robust")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Structure
|
||||||
|
|
||||||
|
### Standard Article Pattern
|
||||||
|
|
||||||
|
Every article should follow this flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. OVERVIEW (H2)
|
||||||
|
- 1-2 paragraphs explaining what and why
|
||||||
|
- Set context for the topic
|
||||||
|
|
||||||
|
2. VISUAL (Optional)
|
||||||
|
- Screenshot showing the feature
|
||||||
|
- Include after intro, not before
|
||||||
|
|
||||||
|
3. MAIN CONTENT (H2/H3 sections)
|
||||||
|
- Procedures with ordered lists
|
||||||
|
- Features with bullet lists
|
||||||
|
- Reference info in tables
|
||||||
|
|
||||||
|
4. TIPS & WARNINGS (Callouts)
|
||||||
|
- Best practices
|
||||||
|
- Common pitfalls
|
||||||
|
- Important restrictions
|
||||||
|
|
||||||
|
5. RELATED ARTICLES (Links)
|
||||||
|
- 2-4 related topics
|
||||||
|
- Logical next steps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heading Hierarchy
|
||||||
|
|
||||||
|
Use only two heading levels within articles:
|
||||||
|
|
||||||
|
- **H2** - Major sections (Overview, Configuration, Settings)
|
||||||
|
- **H3** - Subsections within H2s (Creating a monitor, Editing settings)
|
||||||
|
|
||||||
|
Never use H4 or deeper. If content requires more nesting, restructure into separate sections.
|
||||||
|
|
||||||
|
### Section Length
|
||||||
|
|
||||||
|
| Element | Target Length |
|
||||||
|
| ------------- | -------------- |
|
||||||
|
| Paragraphs | 2-4 sentences |
|
||||||
|
| H2 sections | 100-300 words |
|
||||||
|
| H3 sections | 50-150 words |
|
||||||
|
| Full articles | 800-1500 words |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formatting Rules
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
**Bullet lists** - Use for features, options, characteristics (unordered items)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'bullet-list',
|
||||||
|
items: [
|
||||||
|
{ text: 'HTTP monitors check website availability' },
|
||||||
|
{ text: 'Ping monitors verify server reachability' },
|
||||||
|
{ text: 'Port monitors test specific service ports' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ordered lists** - Use for step-by-step procedures (sequential actions)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'ordered-list',
|
||||||
|
items: [
|
||||||
|
{ text: 'Navigate to **Uptime** in the sidebar' },
|
||||||
|
{ text: 'Click **Create monitor**' },
|
||||||
|
{ text: 'Enter the URL you want to monitor' },
|
||||||
|
{ text: 'Click **Save** to create the monitor' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**List guidelines:**
|
||||||
|
|
||||||
|
- Keep procedures to 4-6 steps maximum
|
||||||
|
- If longer, break into multiple procedures
|
||||||
|
- Start each item with an action verb for procedures
|
||||||
|
- Use parallel structure (all items same grammatical form)
|
||||||
|
|
||||||
|
### Callouts
|
||||||
|
|
||||||
|
Use callouts sparingly for important information that shouldn't be missed.
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
|
||||||
|
| Variant | Use For | Example |
|
||||||
|
| --------- | --------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| `info` | Additional context, clarification | "Monitors check at the interval you specify, starting from creation time." |
|
||||||
|
| `tip` | Best practices, recommendations | "Use descriptive names to easily identify monitors in alerts." |
|
||||||
|
| `warning` | Restrictions, potential issues | "Deleting a monitor removes all associated check history." |
|
||||||
|
| `success` | Confirmations, positive outcomes | "Your monitor is now active and checking every 5 minutes." |
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
variant: 'tip',
|
||||||
|
title: 'Best practice',
|
||||||
|
text: 'Group related monitors together for easier management.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callout guidelines:**
|
||||||
|
|
||||||
|
- Maximum 2-3 callouts per article
|
||||||
|
- Keep callout text concise (1-2 sentences)
|
||||||
|
- Title is optional but helps scannability
|
||||||
|
- Don't use callouts for routine information
|
||||||
|
|
||||||
|
### Inline Formatting
|
||||||
|
|
||||||
|
**Bold** - Use for:
|
||||||
|
|
||||||
|
- UI element names: "Click **Save**"
|
||||||
|
- Key terms on first use: "**Uptime** measures availability over time"
|
||||||
|
- Emphasis in lists: "**Name** — The display name for your monitor"
|
||||||
|
|
||||||
|
**Code/monospace** - Use for:
|
||||||
|
|
||||||
|
- URLs: `https://example.com`
|
||||||
|
- File paths: `/api/v1/monitors`
|
||||||
|
- Technical values: `200`, `timeout`
|
||||||
|
- Commands: `npm run dev`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In paragraph text, use backticks in markdown:
|
||||||
|
{
|
||||||
|
text: "Enter the URL (e.g., `https://example.com`) in the field";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
Use tables for:
|
||||||
|
|
||||||
|
- Feature comparisons
|
||||||
|
- Configuration options
|
||||||
|
- Reference data with multiple attributes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
columns: [
|
||||||
|
{ key: 'type', label: 'Monitor Type', width: '1fr' },
|
||||||
|
{ key: 'use', label: 'Best For', width: '2fr' }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ type: 'HTTP', use: 'Websites and APIs' },
|
||||||
|
{ type: 'Ping', use: 'Server availability' },
|
||||||
|
{ type: 'Port', use: 'Specific services (databases, mail servers)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Content Types
|
||||||
|
|
||||||
|
### Block Types Reference
|
||||||
|
|
||||||
|
| Type | Purpose | When to Use |
|
||||||
|
| --------------- | ----------------- | ------------------------- |
|
||||||
|
| `heading` | Section titles | Start each major topic |
|
||||||
|
| `paragraph` | Body text | Explanations, context |
|
||||||
|
| `bullet-list` | Unordered items | Features, options |
|
||||||
|
| `ordered-list` | Sequential steps | Procedures |
|
||||||
|
| `checklist` | Checkable items | Requirements, checklists |
|
||||||
|
| `callout` | Important notices | Tips, warnings |
|
||||||
|
| `code` | Code blocks | Commands, config examples |
|
||||||
|
| `table` | Structured data | Comparisons, reference |
|
||||||
|
| `image` | Screenshots | UI demonstration |
|
||||||
|
| `icon-cards` | Feature grids | Capability overviews |
|
||||||
|
| `article-links` | Related content | End-of-article navigation |
|
||||||
|
|
||||||
|
### Icon Cards
|
||||||
|
|
||||||
|
Use for presenting 4-8 related features or concepts:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'icon-cards',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: 'Globe',
|
||||||
|
title: 'HTTP monitoring',
|
||||||
|
description: 'Check website availability and response times'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'Server',
|
||||||
|
title: 'Infrastructure',
|
||||||
|
description: 'Monitor CPU, memory, and disk usage'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Article Links
|
||||||
|
|
||||||
|
Always end articles with related content:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'article-links',
|
||||||
|
title: 'Related articles',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
collectionId: 'uptime-monitoring',
|
||||||
|
articleId: 'http-monitors',
|
||||||
|
title: 'Creating HTTP monitors',
|
||||||
|
description: 'Set up your first website monitor'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Images & Screenshots
|
||||||
|
|
||||||
|
### When to Include Images
|
||||||
|
|
||||||
|
- After introducing a feature (not before)
|
||||||
|
- For complex UI that benefits from visual reference
|
||||||
|
- For multi-step workflows
|
||||||
|
- Skip for simple, text-describable actions
|
||||||
|
|
||||||
|
### Image Requirements
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
src: '/images/user-guide/monitor-create.png',
|
||||||
|
alt: 'Monitor creation form showing URL field, name field, and interval selector',
|
||||||
|
caption: 'The monitor creation form with required fields highlighted'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alt text guidelines:**
|
||||||
|
|
||||||
|
- Describe what the image shows (15-30 words)
|
||||||
|
- Include key UI elements visible
|
||||||
|
- Make it useful for screen readers
|
||||||
|
|
||||||
|
**Caption guidelines:**
|
||||||
|
|
||||||
|
- Explain the context or what to notice
|
||||||
|
- Keep to one sentence
|
||||||
|
- Optional but recommended
|
||||||
|
|
||||||
|
### Screenshot Standards
|
||||||
|
|
||||||
|
- Capture at consistent browser width (1200px recommended)
|
||||||
|
- Use light theme for screenshots
|
||||||
|
- Highlight relevant areas if needed
|
||||||
|
- Avoid personal data in screenshots
|
||||||
|
- Crop to show relevant UI only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing Procedures
|
||||||
|
|
||||||
|
### Step-by-Step Instructions
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'ordered-list',
|
||||||
|
items: [
|
||||||
|
{ text: 'Navigate to **Uptime** in the sidebar' },
|
||||||
|
{ text: 'Click **Create monitor** in the top right' },
|
||||||
|
{ text: 'Enter the URL you want to monitor' },
|
||||||
|
{ text: 'Set the check interval (default: 5 minutes)' },
|
||||||
|
{ text: 'Click **Save** to create the monitor' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
|
||||||
|
- Start each step with an action verb (Click, Enter, Select, Navigate)
|
||||||
|
- One action per step
|
||||||
|
- Include the location of UI elements
|
||||||
|
- Bold all clickable elements
|
||||||
|
- Add context in parentheses when helpful
|
||||||
|
|
||||||
|
### Describing Features
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'bullet-list',
|
||||||
|
items: [
|
||||||
|
{ bold: 'Name', text: 'A descriptive label for your monitor' },
|
||||||
|
{ bold: 'URL', text: 'The address to check (must include protocol)' },
|
||||||
|
{ bold: 'Interval', text: 'How often to perform checks (1-60 minutes)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
### Consistent Terms
|
||||||
|
|
||||||
|
Use these terms consistently throughout documentation:
|
||||||
|
|
||||||
|
| Term | Use | Don't Use |
|
||||||
|
| ------------ | ------------------------- | --------------------- |
|
||||||
|
| Monitor | The configured check | Watcher, probe, check |
|
||||||
|
| Check | A single monitoring event | Ping, request, poll |
|
||||||
|
| Incident | A detected problem | Outage, alert, issue |
|
||||||
|
| Notification | Alert sent to user | Message, email, ping |
|
||||||
|
| Dashboard | Main overview page | Home, overview |
|
||||||
|
| Sidebar | Left navigation | Menu, nav |
|
||||||
|
|
||||||
|
### Feature Names
|
||||||
|
|
||||||
|
Always match UI exactly:
|
||||||
|
|
||||||
|
- **Uptime** (not "Uptime Monitoring")
|
||||||
|
- **PageSpeed** (not "Page Speed" or "Pagespeed")
|
||||||
|
- **Infrastructure** (not "Server Monitoring")
|
||||||
|
- **Status pages** (not "Status Pages" - lowercase 'p')
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Article Templates
|
||||||
|
|
||||||
|
### Getting Started Article
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level: 2,
|
||||||
|
text: "Overview",
|
||||||
|
id: "overview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "[1-2 sentences: What this feature/concept is]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "[1-2 sentences: Why it matters to users]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level: 2,
|
||||||
|
text: "[Main topic]",
|
||||||
|
id: "main-topic",
|
||||||
|
},
|
||||||
|
// ... content blocks
|
||||||
|
{
|
||||||
|
type: "article-links",
|
||||||
|
title: "Next steps",
|
||||||
|
items: [
|
||||||
|
// 2-3 logical next articles
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How-To Article
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level: 2,
|
||||||
|
text: "Overview",
|
||||||
|
id: "overview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "[Brief description of what user will accomplish]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
level: 2,
|
||||||
|
text: "[Action: Creating/Configuring/Setting up...]",
|
||||||
|
id: "action",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ordered-list",
|
||||||
|
items: [
|
||||||
|
// 4-6 steps
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "callout",
|
||||||
|
variant: "tip",
|
||||||
|
text: "[Best practice or helpful hint]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "article-links",
|
||||||
|
title: "Related articles",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reference Article
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
level: 2,
|
||||||
|
text: 'Overview',
|
||||||
|
id: 'overview'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: '[What this reference covers]'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
level: 2,
|
||||||
|
text: '[Category 1]',
|
||||||
|
id: 'category-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
columns: [...],
|
||||||
|
rows: [...]
|
||||||
|
},
|
||||||
|
// More categories as needed
|
||||||
|
{
|
||||||
|
type: 'article-links',
|
||||||
|
title: 'Related articles',
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
Before publishing any article, verify:
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
- [ ] Overview explains what AND why
|
||||||
|
- [ ] Procedures are tested and accurate
|
||||||
|
- [ ] All UI element names match exactly
|
||||||
|
- [ ] No assumptions about user knowledge
|
||||||
|
- [ ] Related articles are linked
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- [ ] Only H2 and H3 headings used
|
||||||
|
- [ ] All headings have unique IDs
|
||||||
|
- [ ] Lists use correct type (bullet vs ordered)
|
||||||
|
- [ ] Callouts are used sparingly (max 3)
|
||||||
|
- [ ] Bold used for UI elements
|
||||||
|
|
||||||
|
### Style
|
||||||
|
|
||||||
|
- [ ] Active voice throughout
|
||||||
|
- [ ] Second person ("you")
|
||||||
|
- [ ] Action verbs start procedure steps
|
||||||
|
- [ ] Consistent terminology
|
||||||
|
- [ ] No marketing language
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- [ ] All links work
|
||||||
|
- [ ] Images have alt text
|
||||||
|
- [ ] Code examples are correct
|
||||||
|
- [ ] No placeholder content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Icons
|
||||||
|
|
||||||
|
For icon-cards and other icon references, use these Lucide icon names:
|
||||||
|
|
||||||
|
**Navigation:**
|
||||||
|
|
||||||
|
- `Globe` - Uptime/HTTP
|
||||||
|
- `Gauge` - PageSpeed
|
||||||
|
- `Server` - Infrastructure
|
||||||
|
- `Bell` - Notifications
|
||||||
|
- `AlertTriangle` - Incidents
|
||||||
|
- `Wifi` - Status pages
|
||||||
|
- `Wrench` - Maintenance
|
||||||
|
- `Settings` - Settings
|
||||||
|
- `Users` - Team
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
- `CheckCircle` - Success/complete
|
||||||
|
- `Clock` - Time/scheduling
|
||||||
|
- `Info` - Information
|
||||||
|
- `Lightbulb` - Tips
|
||||||
|
- `ArrowRight` - Navigation
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- `Database` - Data/storage
|
||||||
|
- `Terminal` - Commands
|
||||||
|
- `FileText` - Documentation
|
||||||
|
- `Shield` - Security
|
||||||
|
- `Rocket` - Getting started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Articles are stored in `/content/index.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const articleContents = {
|
||||||
|
"collection-id": {
|
||||||
|
"article-id": {
|
||||||
|
blocks: [
|
||||||
|
// Content blocks
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Article metadata is in `/content/userGuideConfig.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 'article-id',
|
||||||
|
title: 'Article Title',
|
||||||
|
description: 'Brief description for search and previews',
|
||||||
|
keywords: ['search', 'terms', 'for', 'this', 'article']
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Good Paragraph
|
||||||
|
|
||||||
|
> "HTTP monitors check your websites and APIs at regular intervals. When a check fails—due to a timeout, error response, or connection issue—Checkmate creates an incident and sends notifications to your configured channels."
|
||||||
|
|
||||||
|
### Good Procedure
|
||||||
|
|
||||||
|
> 1. Navigate to **Uptime** in the sidebar
|
||||||
|
> 2. Click **Create monitor** in the top right
|
||||||
|
> 3. Enter the URL you want to monitor (include `https://`)
|
||||||
|
> 4. Set your preferred check interval
|
||||||
|
> 5. Click **Save** to create the monitor
|
||||||
|
|
||||||
|
### Good Callout
|
||||||
|
|
||||||
|
> **Tip:** Use descriptive monitor names like "Production API" or "Marketing Website" to quickly identify issues in notifications.
|
||||||
|
|
||||||
|
### Good Feature List
|
||||||
|
|
||||||
|
> - **Name** — A descriptive label for easy identification
|
||||||
|
> - **URL** — The address to monitor (must include protocol)
|
||||||
|
> - **Interval** — How often to check (1-60 minutes)
|
||||||
|
> - **Timeout** — How long to wait before marking as failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: January 2025_
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* SearchResults styles (Dark theme) */
|
||||||
|
|
||||||
|
.user-guide-search-result-item {
|
||||||
|
transition:
|
||||||
|
background-color 150ms ease,
|
||||||
|
border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-search-result-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
border-color: #4a5568 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { FileText, ArrowRight } from "lucide-react";
|
||||||
|
import { searchArticles } from "./content/userGuideConfig";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./SearchResults.css";
|
||||||
|
|
||||||
|
const SearchResults = ({ query, onNavigate, onClearSearch }) => {
|
||||||
|
const { colors, typography, spacing, border } = useUserGuideTheme();
|
||||||
|
const results = searchArticles(query);
|
||||||
|
|
||||||
|
const handleResultClick = (result) => {
|
||||||
|
onNavigate(result.collectionId, result.articleId);
|
||||||
|
onClearSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: spacing.lg }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.muted,
|
||||||
|
textAlign: "center",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Type at least 2 characters to search...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: spacing.xl }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No results found for "{query}"
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.muted,
|
||||||
|
textAlign: "center",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try different keywords or browse by topic
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: spacing.lg }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClearSearch}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.brand.primary,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: spacing.sm }}>
|
||||||
|
{results.map((result) => (
|
||||||
|
<div
|
||||||
|
key={`${result.collectionId}-${result.articleId}`}
|
||||||
|
onClick={() => handleResultClick(result)}
|
||||||
|
className="user-guide-search-result-item"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.articleTitle}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.articleDescription}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.muted,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
in {result.collectionTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{ flexShrink: 0, marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchResults;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* SidebarHeader styles - Dark theme */
|
||||||
|
|
||||||
|
.user-guide-header-icon-button {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-header-icon-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: #1570ef !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-header-icon-button.active {
|
||||||
|
background-color: #0d0d0d;
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-breadcrumb-dropdown-trigger {
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-breadcrumb-dropdown-trigger:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-breadcrumb-item {
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-breadcrumb-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1570ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-search-input::placeholder {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./SidebarHeader.css";
|
||||||
|
|
||||||
|
const SidebarHeader = ({
|
||||||
|
breadcrumbs,
|
||||||
|
onHomeClick,
|
||||||
|
onBack,
|
||||||
|
onForward,
|
||||||
|
canGoBack,
|
||||||
|
canGoForward,
|
||||||
|
onClose,
|
||||||
|
onOpenInNewTab,
|
||||||
|
isSearchOpen,
|
||||||
|
onSearchToggle,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
showOpenInNewTab = true,
|
||||||
|
showNavigation = true,
|
||||||
|
}) => {
|
||||||
|
const { colors, typography, spacing, border, isDark } = useUserGuideTheme();
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus search input when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSearchOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isSearchOpen]);
|
||||||
|
|
||||||
|
// Get current breadcrumb (last item)
|
||||||
|
const currentBreadcrumb = breadcrumbs[breadcrumbs.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
borderBottom: border.default,
|
||||||
|
padding: `${spacing.sm} ${spacing.md}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Main header row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side: Navigation + Breadcrumb dropdown */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Navigation buttons - only shown when showNavigation is true */}
|
||||||
|
{showNavigation && (
|
||||||
|
<>
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
className="user-guide-header-icon-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: canGoBack ? "pointer" : "default",
|
||||||
|
color: canGoBack ? colors.text.secondary : colors.border.default,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: canGoBack ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Forward button */}
|
||||||
|
<button
|
||||||
|
onClick={onForward}
|
||||||
|
disabled={!canGoForward}
|
||||||
|
className="user-guide-header-icon-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: canGoForward ? "pointer" : "default",
|
||||||
|
color: canGoForward ? colors.text.secondary : colors.border.default,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: canGoForward ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
title="Forward"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Home button */}
|
||||||
|
<button
|
||||||
|
onClick={breadcrumbs.length > 1 ? onHomeClick : undefined}
|
||||||
|
disabled={breadcrumbs.length <= 1}
|
||||||
|
className="user-guide-header-icon-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: breadcrumbs.length > 1 ? "pointer" : "default",
|
||||||
|
color:
|
||||||
|
breadcrumbs.length > 1
|
||||||
|
? colors.text.secondary
|
||||||
|
: colors.border.default,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: breadcrumbs.length > 1 ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
title="Home"
|
||||||
|
>
|
||||||
|
<Home
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breadcrumb dropdown */}
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={{ position: "relative", flex: 1, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => breadcrumbs.length > 1 && setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="user-guide-breadcrumb-dropdown-trigger"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "4px 8px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: breadcrumbs.length > 1 ? "pointer" : "default",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.medium,
|
||||||
|
color: colors.text.primary,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentBreadcrumb?.label || "User guide"}
|
||||||
|
</span>
|
||||||
|
{breadcrumbs.length > 1 && (
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
transform: isDropdownOpen ? "rotate(180deg)" : "rotate(0)",
|
||||||
|
transition: "transform 150ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{isDropdownOpen && breadcrumbs.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="user-guide-breadcrumb-dropdown"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
marginTop: 4,
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
boxShadow: isDark
|
||||||
|
? "0 4px 12px rgba(0, 0, 0, 0.3)"
|
||||||
|
: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||||
|
minWidth: 180,
|
||||||
|
maxWidth: 280,
|
||||||
|
zIndex: 100,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{breadcrumbs.slice(0, -1).map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="user-guide-breadcrumb-item"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 14px",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Open in new tab - only shown when showOpenInNewTab is true */}
|
||||||
|
{showOpenInNewTab && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenInNewTab}
|
||||||
|
className="user-guide-header-icon-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: colors.text.secondary,
|
||||||
|
}}
|
||||||
|
title="Open in new tab"
|
||||||
|
>
|
||||||
|
<ExternalLink
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search toggle */}
|
||||||
|
<button
|
||||||
|
onClick={onSearchToggle}
|
||||||
|
className={`user-guide-header-icon-button ${isSearchOpen ? "active" : ""}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: isSearchOpen ? colors.background.alt : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: isSearchOpen ? colors.brand.primary : colors.text.secondary,
|
||||||
|
}}
|
||||||
|
title="Search"
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="user-guide-header-icon-button"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: colors.text.secondary,
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input (collapsible) */}
|
||||||
|
{isSearchOpen && (
|
||||||
|
<div style={{ marginTop: spacing.sm }}>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.text.muted}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 10,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search articles..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="user-guide-search-input"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 32,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
backgroundColor: colors.background.alt,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
border: border.default,
|
||||||
|
paddingLeft: 32,
|
||||||
|
paddingRight: 12,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarHeader;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* SidebarWrapper styles (Dark theme) */
|
||||||
|
|
||||||
|
.user-guide-sidebar-container {
|
||||||
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Push main content when sidebar is open */
|
||||||
|
body.user-guide-sidebar-open .home-content-wrapper {
|
||||||
|
margin-right: var(--help-sidebar-padding, 440px);
|
||||||
|
transition: margin-right 300ms ease-in-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import TabBar from "./TabBar";
|
||||||
|
import SidebarHeader from "./SidebarHeader";
|
||||||
|
import UserGuideLanding from "./UserGuideLanding";
|
||||||
|
import CollectionPage from "./CollectionPage";
|
||||||
|
import ArticlePage from "./ArticlePage";
|
||||||
|
import ContentRenderer from "./ContentRenderer";
|
||||||
|
import HelpSection from "./HelpSection";
|
||||||
|
import SearchResults from "./SearchResults";
|
||||||
|
import { getCollection, getArticle } from "./content/userGuideConfig";
|
||||||
|
import { getArticleContent } from "./content";
|
||||||
|
import { extractToc } from "./content/contentTypes";
|
||||||
|
import {
|
||||||
|
useUserGuideSidebarContext,
|
||||||
|
DEFAULT_CONTENT_WIDTH,
|
||||||
|
} from "./UserGuideSidebarContext";
|
||||||
|
import "./SidebarWrapper.css";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "checkmate-sidebar-state";
|
||||||
|
const MIN_CONTENT_WIDTH = DEFAULT_CONTENT_WIDTH;
|
||||||
|
const MAX_CONTENT_WIDTH = DEFAULT_CONTENT_WIDTH * 2; // 100% wider
|
||||||
|
|
||||||
|
const SidebarWrapper = ({ isOpen, onClose, onOpen, initialPath, onOpenInNewTab }) => {
|
||||||
|
const { setContentWidth: setContextContentWidth } = useUserGuideSidebarContext();
|
||||||
|
const { colors, border } = useUserGuideTheme();
|
||||||
|
const [activeTab, setActiveTab] = useState("user-guide");
|
||||||
|
const [collectionId, setCollectionId] = useState(undefined);
|
||||||
|
const [articleId, setArticleId] = useState(undefined);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [contentWidth, setContentWidthLocal] = useState(DEFAULT_CONTENT_WIDTH);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [isHoveringHandle, setIsHoveringHandle] = useState(false);
|
||||||
|
const [mouseY, setMouseY] = useState(0);
|
||||||
|
const resizeRef = useRef(null);
|
||||||
|
const handleRef = useRef(null);
|
||||||
|
|
||||||
|
// Sync content width with context whenever it changes
|
||||||
|
const setContentWidth = useCallback(
|
||||||
|
(width) => {
|
||||||
|
setContentWidthLocal(width);
|
||||||
|
setContextContentWidth(width);
|
||||||
|
},
|
||||||
|
[setContextContentWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize context with current width on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setContextContentWidth(DEFAULT_CONTENT_WIDTH);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Only run once on mount
|
||||||
|
|
||||||
|
// Navigation history
|
||||||
|
const [history, setHistory] = useState([
|
||||||
|
{ collectionId: undefined, articleId: undefined },
|
||||||
|
]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
const isNavigatingRef = useRef(false);
|
||||||
|
|
||||||
|
// Parse initial path on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialPath) {
|
||||||
|
const parts = initialPath.split("/");
|
||||||
|
if (parts.length >= 1 && parts[0]) {
|
||||||
|
setCollectionId(parts[0]);
|
||||||
|
if (parts.length >= 2 && parts[1]) {
|
||||||
|
setArticleId(parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialPath]);
|
||||||
|
|
||||||
|
// Persist sidebar state to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const state = { activeTab, collectionId, articleId };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
}, [isOpen, activeTab, collectionId, articleId]);
|
||||||
|
|
||||||
|
// Restore state from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (savedState && !initialPath) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
activeTab: savedTab,
|
||||||
|
collectionId: savedCollection,
|
||||||
|
articleId: savedArticle,
|
||||||
|
} = JSON.parse(savedState);
|
||||||
|
if (savedTab) setActiveTab(savedTab);
|
||||||
|
if (savedCollection) setCollectionId(savedCollection);
|
||||||
|
if (savedArticle) setArticleId(savedArticle);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialPath]);
|
||||||
|
|
||||||
|
// Track navigation changes in history
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNavigatingRef.current) {
|
||||||
|
isNavigatingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only track if this is a new navigation (not from back/forward)
|
||||||
|
const currentEntry = history[historyIndex];
|
||||||
|
if (
|
||||||
|
currentEntry?.collectionId !== collectionId ||
|
||||||
|
currentEntry?.articleId !== articleId
|
||||||
|
) {
|
||||||
|
// Truncate forward history and add new entry
|
||||||
|
const newHistory = [
|
||||||
|
...history.slice(0, historyIndex + 1),
|
||||||
|
{ collectionId, articleId },
|
||||||
|
];
|
||||||
|
setHistory(newHistory);
|
||||||
|
setHistoryIndex(newHistory.length - 1);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [collectionId, articleId]);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleNavigate = useCallback((newCollectionId, newArticleId) => {
|
||||||
|
setCollectionId(newCollectionId);
|
||||||
|
setArticleId(newArticleId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBackToHome = useCallback(() => {
|
||||||
|
setCollectionId(undefined);
|
||||||
|
setArticleId(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Back/Forward navigation
|
||||||
|
const canGoBack = historyIndex > 0;
|
||||||
|
const canGoForward = historyIndex < history.length - 1;
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
isNavigatingRef.current = true;
|
||||||
|
const prevIndex = historyIndex - 1;
|
||||||
|
const prevEntry = history[prevIndex];
|
||||||
|
setHistoryIndex(prevIndex);
|
||||||
|
setCollectionId(prevEntry.collectionId);
|
||||||
|
setArticleId(prevEntry.articleId);
|
||||||
|
}
|
||||||
|
}, [historyIndex, history]);
|
||||||
|
|
||||||
|
const handleForward = useCallback(() => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
isNavigatingRef.current = true;
|
||||||
|
const nextIndex = historyIndex + 1;
|
||||||
|
const nextEntry = history[nextIndex];
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCollectionId(nextEntry.collectionId);
|
||||||
|
setArticleId(nextEntry.articleId);
|
||||||
|
}
|
||||||
|
}, [historyIndex, history]);
|
||||||
|
|
||||||
|
const handleBackToCollection = useCallback(() => {
|
||||||
|
setArticleId(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHomeClick = useCallback(() => {
|
||||||
|
setCollectionId(undefined);
|
||||||
|
setArticleId(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get current collection and article
|
||||||
|
const collection = collectionId ? getCollection(collectionId) : undefined;
|
||||||
|
const article =
|
||||||
|
collectionId && articleId ? getArticle(collectionId, articleId) : undefined;
|
||||||
|
|
||||||
|
// Get prev/next articles for navigation
|
||||||
|
const getAdjacentArticles = () => {
|
||||||
|
if (!collection || !articleId) return { prev: undefined, next: undefined };
|
||||||
|
const currentIndex = collection.articles.findIndex((a) => a.id === articleId);
|
||||||
|
return {
|
||||||
|
prev: currentIndex > 0 ? collection.articles[currentIndex - 1] : undefined,
|
||||||
|
next:
|
||||||
|
currentIndex < collection.articles.length - 1
|
||||||
|
? collection.articles[currentIndex + 1]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { prev: prevArticle, next: nextArticle } = getAdjacentArticles();
|
||||||
|
|
||||||
|
// Get article content
|
||||||
|
const articleContent =
|
||||||
|
collectionId && articleId ? getArticleContent(collectionId, articleId) : undefined;
|
||||||
|
|
||||||
|
// Auto-generate TOC from content headings
|
||||||
|
const tocItems = articleContent ? extractToc(articleContent.blocks) : undefined;
|
||||||
|
|
||||||
|
// Build breadcrumb items for dropdown
|
||||||
|
const buildBreadcrumbs = () => {
|
||||||
|
let items = [];
|
||||||
|
switch (activeTab) {
|
||||||
|
case "user-guide":
|
||||||
|
items = [{ label: "User guide", onClick: handleHomeClick }];
|
||||||
|
if (collection) {
|
||||||
|
items.push({ label: collection.title, onClick: () => setArticleId(undefined) });
|
||||||
|
}
|
||||||
|
if (article) {
|
||||||
|
items.push({ label: article.title, onClick: () => {} });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
default:
|
||||||
|
return [{ label: "Help", onClick: () => {} }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "Open in new tab"
|
||||||
|
const handleOpenInNewTab = () => {
|
||||||
|
let path = "https://bluewavelabs.gitbook.io/checkmate";
|
||||||
|
if (onOpenInNewTab) {
|
||||||
|
onOpenInNewTab();
|
||||||
|
} else {
|
||||||
|
window.open(path, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear search and close search bar
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render User Guide content
|
||||||
|
const renderUserGuideContent = () => {
|
||||||
|
// Show search results when search is active
|
||||||
|
if (isSearchOpen && searchQuery.trim().length > 0) {
|
||||||
|
return (
|
||||||
|
<SearchResults
|
||||||
|
query={searchQuery}
|
||||||
|
onNavigate={(colId, artId) => {
|
||||||
|
handleNavigate(colId, artId);
|
||||||
|
handleClearSearch();
|
||||||
|
}}
|
||||||
|
onClearSearch={handleClearSearch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection && article) {
|
||||||
|
return (
|
||||||
|
<ArticlePage
|
||||||
|
collection={collection}
|
||||||
|
article={article}
|
||||||
|
onBack={handleBackToCollection}
|
||||||
|
onBackToHome={handleBackToHome}
|
||||||
|
prevArticle={prevArticle}
|
||||||
|
nextArticle={nextArticle}
|
||||||
|
onPrevArticle={
|
||||||
|
prevArticle ? () => handleNavigate(collectionId, prevArticle.id) : undefined
|
||||||
|
}
|
||||||
|
onNextArticle={
|
||||||
|
nextArticle ? () => handleNavigate(collectionId, nextArticle.id) : undefined
|
||||||
|
}
|
||||||
|
tocItems={tocItems}
|
||||||
|
mode="in-app"
|
||||||
|
>
|
||||||
|
{articleContent && (
|
||||||
|
<ContentRenderer
|
||||||
|
content={articleContent}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ArticlePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection) {
|
||||||
|
return (
|
||||||
|
<CollectionPage
|
||||||
|
collection={collection}
|
||||||
|
onArticleClick={(id) => handleNavigate(collectionId, id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserGuideLanding
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
mode="in-app"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentArea = (tabValue) => {
|
||||||
|
switch (tabValue) {
|
||||||
|
case "user-guide":
|
||||||
|
return renderUserGuideContent();
|
||||||
|
default:
|
||||||
|
return <HelpSection />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle tab click - open sidebar if closed, close if clicking active tab, or switch tab
|
||||||
|
const handleTabClick = (tab) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
onOpen();
|
||||||
|
setActiveTab(tab);
|
||||||
|
} else if (activeTab === tab) {
|
||||||
|
// Clicking the already-active tab closes the sidebar
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Clicking a different tab switches to it
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize handlers
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
resizeRef.current = { startX: e.clientX, startWidth: contentWidth };
|
||||||
|
},
|
||||||
|
[contentWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeMove = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!isResizing || !resizeRef.current) return;
|
||||||
|
|
||||||
|
const deltaX = resizeRef.current.startX - e.clientX;
|
||||||
|
const newWidth = Math.min(
|
||||||
|
MAX_CONTENT_WIDTH,
|
||||||
|
Math.max(MIN_CONTENT_WIDTH, resizeRef.current.startWidth + deltaX)
|
||||||
|
);
|
||||||
|
setContentWidth(newWidth);
|
||||||
|
},
|
||||||
|
[isResizing, setContentWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeEnd = useCallback(() => {
|
||||||
|
setIsResizing(false);
|
||||||
|
setIsHoveringHandle(false);
|
||||||
|
resizeRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add/remove resize event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (isResizing) {
|
||||||
|
document.addEventListener("mousemove", handleResizeMove);
|
||||||
|
document.addEventListener("mouseup", handleResizeEnd);
|
||||||
|
document.body.style.cursor = "ew-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleResizeMove);
|
||||||
|
document.removeEventListener("mouseup", handleResizeEnd);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
}, [isResizing, handleResizeMove, handleResizeEnd]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="user-guide-sidebar-container"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
zIndex: 1100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Resize Handle - positioned absolutely on the left edge */}
|
||||||
|
<div
|
||||||
|
ref={handleRef}
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
onMouseEnter={() => setIsHoveringHandle(true)}
|
||||||
|
onMouseLeave={() => !isResizing && setIsHoveringHandle(false)}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
const rect = handleRef.current.getBoundingClientRect();
|
||||||
|
setMouseY(e.clientY - rect.top);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 8,
|
||||||
|
height: "100%",
|
||||||
|
cursor: "ew-resize",
|
||||||
|
zIndex: 20,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Visual indicator line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isResizing ? 2 : 1,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor:
|
||||||
|
isResizing || isHoveringHandle ? "#4CAF93" : colors.border.default,
|
||||||
|
transition: isResizing ? "none" : "all 150ms ease",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grab indicator follows mouse */}
|
||||||
|
{(isHoveringHandle || isResizing) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -1,
|
||||||
|
top: mouseY - 10,
|
||||||
|
width: 3,
|
||||||
|
height: 20,
|
||||||
|
backgroundColor: "#555",
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Bar - Always visible */}
|
||||||
|
<TabBar
|
||||||
|
activeTab={isOpen ? activeTab : undefined}
|
||||||
|
onTabChange={handleTabClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Sidebar Content - Slides in/out */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isOpen ? contentWidth : 0,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
borderLeft: isOpen ? border.default : "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: isResizing ? "none" : "width 300ms ease-in-out",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<SidebarHeader
|
||||||
|
showOpenInNewTab={activeTab === "user-guide"}
|
||||||
|
showNavigation={activeTab === "user-guide"}
|
||||||
|
breadcrumbs={buildBreadcrumbs()}
|
||||||
|
onHomeClick={handleHomeClick}
|
||||||
|
onBack={handleBack}
|
||||||
|
onForward={handleForward}
|
||||||
|
canGoBack={canGoBack}
|
||||||
|
canGoForward={canGoForward}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpenInNewTab={handleOpenInNewTab}
|
||||||
|
isSearchOpen={isSearchOpen}
|
||||||
|
onSearchToggle={() => setIsSearchOpen(!isSearchOpen)}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
backgroundColor: colors.background.alt,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contentArea(activeTab)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarWrapper;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* TabBar styles */
|
||||||
|
|
||||||
|
.user-guide-tab-item {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-tab-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-tab-item.active:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { BookOpen, HelpCircle } from "lucide-react";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./TabBar.css";
|
||||||
|
|
||||||
|
const TabItem = ({ label, icon, isActive, onClick, colors, typography }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`user-guide-tab-item ${isActive ? "active" : ""}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "16px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: isActive ? colors.background.white : "transparent",
|
||||||
|
borderLeft: isActive
|
||||||
|
? `2px solid ${colors.brand.primary}`
|
||||||
|
: "2px solid transparent",
|
||||||
|
borderBottom: `1px solid ${colors.border.default}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: isActive ? colors.brand.primary : colors.text.muted,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
writingMode: "vertical-rl",
|
||||||
|
textOrientation: "mixed",
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: typography.fontWeight.normal,
|
||||||
|
color: isActive ? colors.brand.primary : colors.text.muted,
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabBar = ({ activeTab, onTabChange }) => {
|
||||||
|
const { colors, typography } = useUserGuideTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: colors.background.sidebar,
|
||||||
|
borderLeft: `1px solid ${colors.border.default}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabItem
|
||||||
|
id="user-guide"
|
||||||
|
label="User guide"
|
||||||
|
icon={
|
||||||
|
<BookOpen
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isActive={activeTab === "user-guide"}
|
||||||
|
onClick={() => onTabChange("user-guide")}
|
||||||
|
colors={colors}
|
||||||
|
typography={typography}
|
||||||
|
/>
|
||||||
|
<TabItem
|
||||||
|
id="help"
|
||||||
|
label="Help"
|
||||||
|
icon={
|
||||||
|
<HelpCircle
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isActive={activeTab === "help"}
|
||||||
|
onClick={() => onTabChange("help")}
|
||||||
|
colors={colors}
|
||||||
|
typography={typography}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TabBar;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* UserGuideLanding styles - hover states */
|
||||||
|
|
||||||
|
/* Collection card hover */
|
||||||
|
.user-guide-collection-card {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-collection-card:hover {
|
||||||
|
border-color: rgba(21, 112, 239, 0.3) !important;
|
||||||
|
background-color: rgba(21, 112, 239, 0.02) !important;
|
||||||
|
box-shadow: 0px 2px 8px -2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-collection-card:hover {
|
||||||
|
border-color: rgba(21, 112, 239, 0.5) !important;
|
||||||
|
background-color: rgba(21, 112, 239, 0.05) !important;
|
||||||
|
box-shadow: 0px 4px 16px -4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fast find item hover */
|
||||||
|
.user-guide-fast-find-item {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-fast-find-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mui-color-scheme="dark"] .user-guide-fast-find-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-fast-find-item .arrow-icon {
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide-fast-find-item:hover .arrow-icon {
|
||||||
|
color: #1570ef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Server,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
Wifi,
|
||||||
|
Wrench,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { collections, fastFinds, getCollection } from "./content/userGuideConfig";
|
||||||
|
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
|
||||||
|
import "./UserGuideLanding.css";
|
||||||
|
|
||||||
|
// Map icon names to actual Lucide components
|
||||||
|
const iconMap = {
|
||||||
|
Rocket,
|
||||||
|
Globe,
|
||||||
|
Gauge,
|
||||||
|
Server,
|
||||||
|
Bell,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
|
Wifi,
|
||||||
|
Wrench,
|
||||||
|
Users,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserGuideLanding = ({
|
||||||
|
onNavigate,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
mode = "in-app",
|
||||||
|
}) => {
|
||||||
|
const { colors, typography, spacing, border } = useUserGuideTheme();
|
||||||
|
const isInApp = mode === "in-app";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
|
||||||
|
{/* Main Content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: isInApp ? "100%" : 1200,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: isInApp ? spacing.lg : `${spacing["3xl"]} ${spacing["3xl"]}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Collections Grid */}
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: isInApp ? typography.fontSize.lg : typography.fontSize.xl,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: isInApp ? spacing.md : spacing.xl,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Browse by topic
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isInApp
|
||||||
|
? "1fr"
|
||||||
|
: "repeat(auto-fill, minmax(340px, 1fr))",
|
||||||
|
gap: isInApp ? spacing.md : spacing.lg,
|
||||||
|
marginBottom: isInApp ? spacing.xl : spacing["4xl"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collections.map((collection) => {
|
||||||
|
const IconComponent = iconMap[collection.icon] || Rocket;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={collection.id}
|
||||||
|
onClick={() => onNavigate(collection.id)}
|
||||||
|
className="user-guide-collection-card"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
padding: spacing.lg,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
color={colors.brand.primary}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: typography.fontSize.xs,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
backgroundColor: colors.background.alt,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.articleCount} articles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* About our docs - Only shown in in-app mode */}
|
||||||
|
{isInApp && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
padding: spacing.lg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
About our docs
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
There are a few ways to explore our docs:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: spacing.md }}>
|
||||||
|
<h4
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
marginTop: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
In the product
|
||||||
|
</h4>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Look for help icons and tooltips throughout the interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserGuideLanding;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
const SIDEBAR_STATE_KEY = "checkmate-sidebar-open";
|
||||||
|
|
||||||
|
// Constants for sidebar dimensions
|
||||||
|
export const TAB_BAR_WIDTH = 41; // 40px width + 1px border
|
||||||
|
export const DEFAULT_CONTENT_WIDTH = 400;
|
||||||
|
// Gap when sidebar is closed (need more space since tab bar is narrow)
|
||||||
|
export const CLOSED_GAP = 24;
|
||||||
|
// Gap when sidebar is open (less needed since content edge is clear)
|
||||||
|
export const OPEN_GAP = 8;
|
||||||
|
|
||||||
|
const UserGuideSidebarContext = createContext(null);
|
||||||
|
|
||||||
|
export const UserGuideSidebarProvider = ({ children }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const saved = localStorage.getItem(SIDEBAR_STATE_KEY);
|
||||||
|
return saved === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentPath, setCurrentPath] = useState(undefined);
|
||||||
|
const [contentWidth, setContentWidth] = useState(DEFAULT_CONTENT_WIDTH);
|
||||||
|
|
||||||
|
// Calculate total sidebar width and required padding
|
||||||
|
const totalSidebarWidth = isOpen ? TAB_BAR_WIDTH + contentWidth : TAB_BAR_WIDTH;
|
||||||
|
const gap = isOpen ? OPEN_GAP : CLOSED_GAP;
|
||||||
|
const requiredPaddingRight = totalSidebarWidth + gap;
|
||||||
|
|
||||||
|
// Set CSS custom properties on document root for global access
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty("--help-sidebar-width", `${totalSidebarWidth}px`);
|
||||||
|
root.style.setProperty("--help-sidebar-padding", `${requiredPaddingRight}px`);
|
||||||
|
}, [totalSidebarWidth, requiredPaddingRight]);
|
||||||
|
|
||||||
|
// Update body class for content push effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.classList.add("user-guide-sidebar-open");
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove("user-guide-sidebar-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove("user-guide-sidebar-open");
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Persist state to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(SIDEBAR_STATE_KEY, String(isOpen));
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const open = useCallback((path) => {
|
||||||
|
setCurrentPath(path);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setIsOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserGuideSidebarContext.Provider
|
||||||
|
value={{
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
currentPath,
|
||||||
|
contentWidth,
|
||||||
|
setContentWidth,
|
||||||
|
totalSidebarWidth,
|
||||||
|
requiredPaddingRight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</UserGuideSidebarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserGuideSidebarContext = () => {
|
||||||
|
const context = useContext(UserGuideSidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useUserGuideSidebarContext must be used within a UserGuideSidebarProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserGuideSidebarContext;
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Content block types for the templatic article system
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {'heading' | 'paragraph' | 'callout' | 'bullet-list' | 'ordered-list' | 'checklist' | 'icon-cards' | 'grid-cards' | 'time-estimate' | 'info-box' | 'code' | 'requirements' | 'table' | 'image' | 'article-links'} ContentBlockType
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} HeadingBlock
|
||||||
|
* @property {'heading'} type
|
||||||
|
* @property {string} id - Used for TOC anchors
|
||||||
|
* @property {2 | 3} level - h2 or h3
|
||||||
|
* @property {string} text
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ParagraphBlock
|
||||||
|
* @property {'paragraph'} type
|
||||||
|
* @property {string} text - Supports basic markdown: **bold**, *italic*, `code`
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CalloutBlock
|
||||||
|
* @property {'callout'} type
|
||||||
|
* @property {'info' | 'tip' | 'warning' | 'success'} [variant]
|
||||||
|
* @property {string} [title]
|
||||||
|
* @property {string} text
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ListItem
|
||||||
|
* @property {string} text
|
||||||
|
* @property {string} [bold] - Optional bold prefix
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BulletListBlock
|
||||||
|
* @property {'bullet-list'} type
|
||||||
|
* @property {ListItem[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} OrderedListBlock
|
||||||
|
* @property {'ordered-list'} type
|
||||||
|
* @property {ListItem[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ChecklistBlock
|
||||||
|
* @property {'checklist'} type
|
||||||
|
* @property {string[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} IconCardItem
|
||||||
|
* @property {string} icon - Icon name from lucide-react
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} IconCardsBlock
|
||||||
|
* @property {'icon-cards'} type
|
||||||
|
* @property {IconCardItem[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GridCardItem
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} [icon]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GridCardsBlock
|
||||||
|
* @property {'grid-cards'} type
|
||||||
|
* @property {GridCardItem[]} items
|
||||||
|
* @property {number} [columns]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TimeEstimateBlock
|
||||||
|
* @property {'time-estimate'} type
|
||||||
|
* @property {string} text
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} InfoBoxBlock
|
||||||
|
* @property {'info-box'} type
|
||||||
|
* @property {string} [icon]
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CodeBlock
|
||||||
|
* @property {'code'} type
|
||||||
|
* @property {string} code
|
||||||
|
* @property {string} [language]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} RequirementItem
|
||||||
|
* @property {string} icon
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} RequirementsBlock
|
||||||
|
* @property {'requirements'} type
|
||||||
|
* @property {RequirementItem[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TableColumn
|
||||||
|
* @property {string} key
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} [width]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TableBlock
|
||||||
|
* @property {'table'} type
|
||||||
|
* @property {TableColumn[]} columns
|
||||||
|
* @property {Object[]} rows
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ImageBlock
|
||||||
|
* @property {'image'} type
|
||||||
|
* @property {string} src
|
||||||
|
* @property {string} alt
|
||||||
|
* @property {string} [caption]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ArticleLink
|
||||||
|
* @property {string} collectionId
|
||||||
|
* @property {string} articleId
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} [description]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ArticleLinksBlock
|
||||||
|
* @property {'article-links'} type
|
||||||
|
* @property {string} [title] - e.g., "Next steps", "Related articles"
|
||||||
|
* @property {ArticleLink[]} items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ArticleContent
|
||||||
|
* @property {ContentBlock[]} blocks
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TocItem
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} href
|
||||||
|
* @property {number} level
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to extract TOC from content blocks
|
||||||
|
* @param {ContentBlock[]} blocks
|
||||||
|
* @returns {TocItem[]}
|
||||||
|
*/
|
||||||
|
export const extractToc = (blocks) => {
|
||||||
|
return blocks
|
||||||
|
.filter((block) => block.type === "heading")
|
||||||
|
.map((block) => ({
|
||||||
|
label: block.text,
|
||||||
|
href: `#${block.id}`,
|
||||||
|
level: block.level,
|
||||||
|
}));
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,604 @@
|
|||||||
|
// Icon names as strings - resolved to actual components in UserGuideLanding.jsx
|
||||||
|
// Available icons: Rocket, Globe, Gauge, Server, Bell, AlertTriangle, Settings, Wifi, Wrench, Users
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Article
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string[]} keywords
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Collection
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} icon
|
||||||
|
* @property {number} articleCount
|
||||||
|
* @property {Article[]} articles
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FastFind
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} collectionId
|
||||||
|
* @property {string} articleId
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Collections configuration for Checkmate
|
||||||
|
export const collections = [
|
||||||
|
{
|
||||||
|
id: "getting-started",
|
||||||
|
title: "Getting started",
|
||||||
|
description: "Essential first steps for new users.",
|
||||||
|
icon: "Rocket",
|
||||||
|
articleCount: 4,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "what-is-checkmate",
|
||||||
|
title: "What is Checkmate",
|
||||||
|
description: "Overview of monitoring capabilities and core features.",
|
||||||
|
keywords: ["introduction", "overview", "platform", "monitoring", "uptime"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quick-start",
|
||||||
|
title: "Quick start guide",
|
||||||
|
description: "Create your first monitor in 5 minutes.",
|
||||||
|
keywords: ["quick", "start", "first", "monitor", "setup", "tutorial"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
title: "Understanding the dashboard",
|
||||||
|
description: "Navigation and key metrics explained.",
|
||||||
|
keywords: ["dashboard", "navigation", "interface", "home", "metrics"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "roles-permissions",
|
||||||
|
title: "User roles and permissions",
|
||||||
|
description: "User, Admin, and Superadmin roles explained.",
|
||||||
|
keywords: ["role", "permission", "admin", "superadmin", "user", "access"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime-monitoring",
|
||||||
|
title: "Uptime monitoring",
|
||||||
|
description: "Monitor website and service availability.",
|
||||||
|
icon: "Globe",
|
||||||
|
articleCount: 9,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "http-monitors",
|
||||||
|
title: "Creating HTTP monitors",
|
||||||
|
description: "URL monitoring with response validation.",
|
||||||
|
keywords: ["http", "https", "url", "website", "monitor", "create"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ping-monitors",
|
||||||
|
title: "Ping monitors",
|
||||||
|
description: "ICMP-based availability checks.",
|
||||||
|
keywords: ["ping", "icmp", "availability", "monitor"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "port-monitors",
|
||||||
|
title: "Port monitors",
|
||||||
|
description: "TCP port availability monitoring.",
|
||||||
|
keywords: ["port", "tcp", "availability", "monitor", "service"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "docker-monitors",
|
||||||
|
title: "Docker monitors",
|
||||||
|
description: "Monitor Docker container status and health.",
|
||||||
|
keywords: ["docker", "container", "monitoring", "health", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bulk-import",
|
||||||
|
title: "Bulk import monitors",
|
||||||
|
description: "Import multiple monitors from a CSV file.",
|
||||||
|
keywords: ["bulk", "import", "csv", "multiple", "batch", "upload"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "intervals-timing",
|
||||||
|
title: "Monitor intervals and timing",
|
||||||
|
description: "Configure how often to check your services.",
|
||||||
|
keywords: ["interval", "timing", "frequency", "check", "schedule"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "json-path-matching",
|
||||||
|
title: "JSON path matching",
|
||||||
|
description: "Validate API response content.",
|
||||||
|
keywords: ["json", "api", "response", "validation", "match", "path"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ssl-tls-monitoring",
|
||||||
|
title: "SSL/TLS monitoring",
|
||||||
|
description: "Certificate tracking and error handling.",
|
||||||
|
keywords: ["ssl", "tls", "certificate", "https", "security", "expiry"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime-metrics",
|
||||||
|
title: "Understanding uptime metrics",
|
||||||
|
description: "Response time, status codes, and uptime percentage.",
|
||||||
|
keywords: [
|
||||||
|
"metrics",
|
||||||
|
"response",
|
||||||
|
"time",
|
||||||
|
"status",
|
||||||
|
"code",
|
||||||
|
"uptime",
|
||||||
|
"percentage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pagespeed",
|
||||||
|
title: "PageSpeed monitoring",
|
||||||
|
description: "Track website performance and Core Web Vitals.",
|
||||||
|
icon: "Gauge",
|
||||||
|
articleCount: 5,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "creating-pagespeed",
|
||||||
|
title: "Creating PageSpeed monitors",
|
||||||
|
description: "Setup and configuration for performance monitoring.",
|
||||||
|
keywords: ["pagespeed", "create", "setup", "performance", "lighthouse"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "understanding-scores",
|
||||||
|
title: "Understanding scores",
|
||||||
|
description: "Performance, Accessibility, SEO, and Best Practices explained.",
|
||||||
|
keywords: ["score", "performance", "accessibility", "seo", "best", "practices"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "core-web-vitals",
|
||||||
|
title: "Core Web Vitals explained",
|
||||||
|
description: "LCP, FCP, CLS, TBT, and Speed Index.",
|
||||||
|
keywords: ["core", "web", "vitals", "lcp", "fcp", "cls", "tbt", "speed", "index"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pagespeed-api",
|
||||||
|
title: "PageSpeed API configuration",
|
||||||
|
description: "Setting up your Google PageSpeed API key.",
|
||||||
|
keywords: ["api", "key", "google", "pagespeed", "configure", "setup"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "performance-trends",
|
||||||
|
title: "Performance trends",
|
||||||
|
description: "Tracking improvements over time.",
|
||||||
|
keywords: ["trend", "history", "improvement", "tracking", "chart"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "infrastructure",
|
||||||
|
title: "Infrastructure monitoring",
|
||||||
|
description: "Monitor server hardware with Capture agent.",
|
||||||
|
icon: "Server",
|
||||||
|
articleCount: 6,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "installing-capture",
|
||||||
|
title: "Installing Capture agent",
|
||||||
|
description: "Setup the monitoring agent on your servers.",
|
||||||
|
keywords: ["install", "capture", "agent", "setup", "server"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpu-monitoring",
|
||||||
|
title: "CPU monitoring",
|
||||||
|
description: "Usage, temperature, and core metrics.",
|
||||||
|
keywords: ["cpu", "processor", "usage", "temperature", "cores"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory-monitoring",
|
||||||
|
title: "Memory monitoring",
|
||||||
|
description: "RAM usage and availability tracking.",
|
||||||
|
keywords: ["memory", "ram", "usage", "available", "free"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "disk-monitoring",
|
||||||
|
title: "Disk monitoring",
|
||||||
|
description: "Storage space and read/write speeds.",
|
||||||
|
keywords: ["disk", "storage", "space", "read", "write", "speed"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "network-monitoring",
|
||||||
|
title: "Network monitoring",
|
||||||
|
description: "Bandwidth and packet statistics.",
|
||||||
|
keywords: ["network", "bandwidth", "packet", "interface", "traffic"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hardware-thresholds",
|
||||||
|
title: "Setting hardware thresholds",
|
||||||
|
description: "Alert when resources become critical.",
|
||||||
|
keywords: ["threshold", "alert", "critical", "warning", "limit"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "incidents",
|
||||||
|
title: "Incidents & alerts",
|
||||||
|
description: "Respond to downtime and issues.",
|
||||||
|
icon: "AlertTriangle",
|
||||||
|
articleCount: 4,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "understanding-incidents",
|
||||||
|
title: "Understanding incidents",
|
||||||
|
description: "How incidents are detected and created.",
|
||||||
|
keywords: ["incident", "detection", "downtime", "outage", "alert"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "incident-timeline",
|
||||||
|
title: "Incident timeline",
|
||||||
|
description: "Tracking start, duration, and resolution.",
|
||||||
|
keywords: ["timeline", "duration", "start", "end", "resolution"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resolving-incidents",
|
||||||
|
title: "Resolving incidents",
|
||||||
|
description: "Manual vs automatic resolution.",
|
||||||
|
keywords: ["resolve", "manual", "automatic", "acknowledge", "fix"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "incident-history",
|
||||||
|
title: "Incident history and analysis",
|
||||||
|
description: "Learn from past outages.",
|
||||||
|
keywords: ["history", "analysis", "report", "past", "outage"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "checks",
|
||||||
|
title: "Checks",
|
||||||
|
description: "View monitoring check results and history.",
|
||||||
|
icon: "FileText",
|
||||||
|
articleCount: 3,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "understanding-checks",
|
||||||
|
title: "Understanding checks",
|
||||||
|
description: "What checks are and how they work.",
|
||||||
|
keywords: ["check", "result", "monitoring", "status", "response"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "check-details",
|
||||||
|
title: "Check details and timing",
|
||||||
|
description: "Response times, status codes, and timing breakdown.",
|
||||||
|
keywords: ["response", "time", "timing", "breakdown", "dns", "tls"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filtering-checks",
|
||||||
|
title: "Filtering and date ranges",
|
||||||
|
description: "Filter checks by date range and status.",
|
||||||
|
keywords: ["filter", "date", "range", "pagination", "sort"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "logs",
|
||||||
|
title: "Logs & diagnostics",
|
||||||
|
description: "System logs, queue monitoring, and diagnostics.",
|
||||||
|
icon: "Database",
|
||||||
|
articleCount: 3,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "application-logs",
|
||||||
|
title: "Application logs",
|
||||||
|
description: "View and filter system logs by level.",
|
||||||
|
keywords: ["log", "error", "warning", "info", "debug", "filter"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-monitoring",
|
||||||
|
title: "Queue monitoring",
|
||||||
|
description: "Monitor background jobs and queue status.",
|
||||||
|
keywords: ["queue", "job", "background", "bullmq", "worker"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-diagnostics",
|
||||||
|
title: "System diagnostics",
|
||||||
|
description: "CPU, memory, and heap usage metrics.",
|
||||||
|
keywords: ["diagnostics", "cpu", "memory", "heap", "performance"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
title: "Notifications",
|
||||||
|
description: "Get alerted when things go wrong.",
|
||||||
|
icon: "Bell",
|
||||||
|
articleCount: 7,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "email-notifications",
|
||||||
|
title: "Email notifications",
|
||||||
|
description: "SMTP setup and configuration.",
|
||||||
|
keywords: ["email", "smtp", "mail", "setup", "configure"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack-integration",
|
||||||
|
title: "Slack integration",
|
||||||
|
description: "Webhook setup for Slack channels.",
|
||||||
|
keywords: ["slack", "webhook", "integration", "channel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discord-integration",
|
||||||
|
title: "Discord integration",
|
||||||
|
description: "Channel notifications via webhooks.",
|
||||||
|
keywords: ["discord", "webhook", "integration", "channel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pagerduty-integration",
|
||||||
|
title: "PagerDuty integration",
|
||||||
|
description: "On-call management integration.",
|
||||||
|
keywords: ["pagerduty", "oncall", "integration", "escalation"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "webhook-notifications",
|
||||||
|
title: "Webhook notifications",
|
||||||
|
description: "Custom HTTP callbacks for alerts.",
|
||||||
|
keywords: ["webhook", "http", "callback", "custom", "api"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "matrix-integration",
|
||||||
|
title: "Matrix integration",
|
||||||
|
description: "Decentralized messaging notifications.",
|
||||||
|
keywords: ["matrix", "messaging", "integration", "homeserver"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "testing-notifications",
|
||||||
|
title: "Testing notifications",
|
||||||
|
description: "Verify your notification setup works.",
|
||||||
|
keywords: ["test", "verify", "notification", "check"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status-pages",
|
||||||
|
title: "Status pages",
|
||||||
|
description: "Share uptime with your users.",
|
||||||
|
icon: "Wifi",
|
||||||
|
articleCount: 5,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "creating-status-page",
|
||||||
|
title: "Creating a status page",
|
||||||
|
description: "Public-facing uptime dashboard setup.",
|
||||||
|
keywords: ["status", "page", "create", "public", "dashboard"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customizing-appearance",
|
||||||
|
title: "Customizing appearance",
|
||||||
|
description: "Logo, colors, and branding options.",
|
||||||
|
keywords: ["customize", "logo", "color", "brand", "style"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "adding-monitors",
|
||||||
|
title: "Adding monitors to status page",
|
||||||
|
description: "Main monitors and sub-components.",
|
||||||
|
keywords: ["add", "monitor", "component", "group"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "display-options",
|
||||||
|
title: "Display options",
|
||||||
|
description: "Charts, uptime percentage, and login links.",
|
||||||
|
keywords: ["display", "chart", "uptime", "percentage", "option"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sharing",
|
||||||
|
title: "Sharing your status page",
|
||||||
|
description: "Share your status page URL with users.",
|
||||||
|
keywords: ["share", "url", "public", "link", "access"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance",
|
||||||
|
title: "Maintenance windows",
|
||||||
|
description: "Schedule planned downtime.",
|
||||||
|
icon: "Wrench",
|
||||||
|
articleCount: 3,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "creating-maintenance",
|
||||||
|
title: "Creating maintenance windows",
|
||||||
|
description: "Suppress alerts during planned maintenance.",
|
||||||
|
keywords: ["maintenance", "create", "schedule", "suppress", "alert"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recurring-maintenance",
|
||||||
|
title: "One-time vs recurring",
|
||||||
|
description: "Different scheduling options.",
|
||||||
|
keywords: ["recurring", "one-time", "schedule", "repeat", "frequency"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "managing-maintenance",
|
||||||
|
title: "Managing active maintenance",
|
||||||
|
description: "Edit, extend, or cancel maintenance windows.",
|
||||||
|
keywords: ["manage", "edit", "cancel", "extend", "active"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team-management",
|
||||||
|
title: "Team management",
|
||||||
|
description: "Collaborate with your team.",
|
||||||
|
icon: "Users",
|
||||||
|
articleCount: 3,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "inviting-members",
|
||||||
|
title: "Inviting team members",
|
||||||
|
description: "Send email invitations to your team.",
|
||||||
|
keywords: ["invite", "team", "member", "email", "add"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user-roles",
|
||||||
|
title: "User roles",
|
||||||
|
description: "Admin vs User permissions explained.",
|
||||||
|
keywords: ["role", "admin", "user", "permission", "access"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "managing-access",
|
||||||
|
title: "Managing team access",
|
||||||
|
description: "Add and remove team members.",
|
||||||
|
keywords: ["manage", "access", "remove", "team", "member"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
title: "Settings & configuration",
|
||||||
|
description: "Customize your Checkmate instance.",
|
||||||
|
icon: "Settings",
|
||||||
|
articleCount: 6,
|
||||||
|
articles: [
|
||||||
|
{
|
||||||
|
id: "account-settings",
|
||||||
|
title: "Account settings",
|
||||||
|
description: "Manage your profile, password, and account.",
|
||||||
|
keywords: ["account", "profile", "password", "avatar", "photo", "delete"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email-configuration",
|
||||||
|
title: "Email configuration",
|
||||||
|
description: "SMTP server setup for notifications.",
|
||||||
|
keywords: ["email", "smtp", "configure", "server", "mail"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "global-thresholds",
|
||||||
|
title: "Global thresholds",
|
||||||
|
description: "Default alert levels for all monitors.",
|
||||||
|
keywords: ["threshold", "global", "default", "alert", "level"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "appearance",
|
||||||
|
title: "Appearance settings",
|
||||||
|
description: "Theme and display preferences.",
|
||||||
|
keywords: ["appearance", "theme", "dark", "light", "display", "mode"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timezone-settings",
|
||||||
|
title: "Timezone settings",
|
||||||
|
description: "Display preferences for dates and times.",
|
||||||
|
keywords: ["timezone", "time", "date", "display", "preference"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "export-monitors",
|
||||||
|
title: "Export monitors",
|
||||||
|
description: "Download your monitors as JSON.",
|
||||||
|
keywords: ["export", "download", "backup", "json", "monitors"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fast Finds - Popular/quick access articles
|
||||||
|
export const fastFinds = [
|
||||||
|
{
|
||||||
|
id: "ff-1",
|
||||||
|
title: "Creating your first monitor",
|
||||||
|
collectionId: "getting-started",
|
||||||
|
articleId: "quick-start",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ff-2",
|
||||||
|
title: "Setting up email notifications",
|
||||||
|
collectionId: "notifications",
|
||||||
|
articleId: "email-notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ff-3",
|
||||||
|
title: "Installing Capture agent",
|
||||||
|
collectionId: "infrastructure",
|
||||||
|
articleId: "installing-capture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ff-4",
|
||||||
|
title: "Creating a status page",
|
||||||
|
collectionId: "status-pages",
|
||||||
|
articleId: "creating-status-page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ff-5",
|
||||||
|
title: "Understanding Core Web Vitals",
|
||||||
|
collectionId: "pagespeed",
|
||||||
|
articleId: "core-web-vitals",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export const getCollection = (collectionId) => {
|
||||||
|
return collections.find((c) => c.id === collectionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArticle = (collectionId, articleId) => {
|
||||||
|
const collection = getCollection(collectionId);
|
||||||
|
return collection?.articles.find((a) => a.id === articleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTotalArticleCount = () => {
|
||||||
|
return collections.reduce((sum, collection) => sum + collection.articleCount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SearchResult
|
||||||
|
* @property {string} collectionId
|
||||||
|
* @property {string} collectionTitle
|
||||||
|
* @property {string} articleId
|
||||||
|
* @property {string} articleTitle
|
||||||
|
* @property {string} articleDescription
|
||||||
|
* @property {'title' | 'description' | 'keyword'} matchType
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search articles by query
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {SearchResult[]}
|
||||||
|
*/
|
||||||
|
export const searchArticles = (query) => {
|
||||||
|
if (!query || query.trim().length < 2) return [];
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase().trim();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
for (const article of collection.articles) {
|
||||||
|
let matchType = null;
|
||||||
|
|
||||||
|
// Check title match (highest priority)
|
||||||
|
if (article.title.toLowerCase().includes(normalizedQuery)) {
|
||||||
|
matchType = "title";
|
||||||
|
}
|
||||||
|
// Check description match
|
||||||
|
else if (article.description.toLowerCase().includes(normalizedQuery)) {
|
||||||
|
matchType = "description";
|
||||||
|
}
|
||||||
|
// Check keywords match
|
||||||
|
else if (
|
||||||
|
article.keywords.some((kw) => kw.toLowerCase().includes(normalizedQuery))
|
||||||
|
) {
|
||||||
|
matchType = "keyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchType) {
|
||||||
|
results.push({
|
||||||
|
collectionId: collection.id,
|
||||||
|
collectionTitle: collection.title,
|
||||||
|
articleId: article.id,
|
||||||
|
articleTitle: article.title,
|
||||||
|
articleDescription: article.description,
|
||||||
|
matchType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by match type priority: title > description > keyword
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const priority = { title: 0, description: 1, keyword: 2 };
|
||||||
|
return priority[a.matchType] - priority[b.matchType];
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import SidebarWrapper from "./SidebarWrapper";
|
||||||
|
import {
|
||||||
|
UserGuideSidebarProvider,
|
||||||
|
useUserGuideSidebarContext,
|
||||||
|
TAB_BAR_WIDTH,
|
||||||
|
DEFAULT_CONTENT_WIDTH,
|
||||||
|
} from "./UserGuideSidebarContext";
|
||||||
|
|
||||||
|
// Main component that wraps SidebarWrapper with context consumer
|
||||||
|
const UserGuideSidebar = () => {
|
||||||
|
const { isOpen, open, close } = useUserGuideSidebarContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarWrapper
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpen={open}
|
||||||
|
onClose={close}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
UserGuideSidebar,
|
||||||
|
UserGuideSidebarProvider,
|
||||||
|
useUserGuideSidebarContext,
|
||||||
|
TAB_BAR_WIDTH,
|
||||||
|
DEFAULT_CONTENT_WIDTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserGuideSidebar;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Design Tokens for UserGuide
|
||||||
|
* Supports both light and dark themes via MUI theme integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Typography (theme-independent)
|
||||||
|
export const typography = {
|
||||||
|
fontFamily: {
|
||||||
|
sans: "'Inter', system-ui, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif",
|
||||||
|
mono: "'Geist Mono', 'Fira Code', 'Consolas', monospace",
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: 12,
|
||||||
|
sm: 12,
|
||||||
|
base: 13,
|
||||||
|
md: 14,
|
||||||
|
lg: 15,
|
||||||
|
xl: 16,
|
||||||
|
"2xl": 20,
|
||||||
|
"3xl": 22,
|
||||||
|
"4xl": 26,
|
||||||
|
"5xl": 30,
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: 1.2,
|
||||||
|
snug: 1.3,
|
||||||
|
normal: 1.4,
|
||||||
|
relaxed: 1.5,
|
||||||
|
},
|
||||||
|
fontWeight: {
|
||||||
|
normal: 400,
|
||||||
|
medium: 500,
|
||||||
|
semibold: 600,
|
||||||
|
bold: 700,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spacing (theme-independent)
|
||||||
|
export const spacing = {
|
||||||
|
xs: "4px",
|
||||||
|
sm: "8px",
|
||||||
|
md: "12px",
|
||||||
|
lg: "16px",
|
||||||
|
xl: "24px",
|
||||||
|
"2xl": "32px",
|
||||||
|
"3xl": "40px",
|
||||||
|
"4xl": "48px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Brand colors (theme-independent) - Checkmate blue
|
||||||
|
export const brandColors = {
|
||||||
|
primary: "#1570EF",
|
||||||
|
primaryDark: "#1054B0",
|
||||||
|
primaryLight: "#5BA3F5",
|
||||||
|
success: "#17B26A",
|
||||||
|
warning: "#F79009",
|
||||||
|
error: "#F04438",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme-aware colors based on MUI theme mode
|
||||||
|
* @param {'light' | 'dark'} mode - The current theme mode
|
||||||
|
* @returns {Object} Theme-aware color tokens
|
||||||
|
*/
|
||||||
|
export const getColors = (mode) => {
|
||||||
|
const isDark = mode === "dark";
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
primary: isDark ? "#ffffff" : "#1C2130",
|
||||||
|
secondary: isDark ? "#d0d5dd" : "#475467",
|
||||||
|
muted: isDark ? "#98a2b3" : "#667085",
|
||||||
|
white: "#ffffff",
|
||||||
|
whiteMuted: "rgba(255, 255, 255, 0.8)",
|
||||||
|
},
|
||||||
|
brand: brandColors,
|
||||||
|
border: {
|
||||||
|
default: isDark ? "#344054" : "#d0d5dd",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
white: isDark ? "#1a1a1a" : "#ffffff",
|
||||||
|
alt: isDark ? "#0d0d0d" : "#f9fafb",
|
||||||
|
code: isDark ? "#1C2130" : "#f3f4f6",
|
||||||
|
sidebar: isDark ? "#101010" : "#ffffff",
|
||||||
|
hover: isDark ? "#2a2a2a" : "#f3f4f6",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme-aware border styles
|
||||||
|
* @param {'light' | 'dark'} mode - The current theme mode
|
||||||
|
* @returns {Object} Border styles
|
||||||
|
*/
|
||||||
|
export const getBorder = (mode) => {
|
||||||
|
const colors = getColors(mode);
|
||||||
|
return {
|
||||||
|
radius: "4px",
|
||||||
|
default: `1px solid ${colors.border.default}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme-aware image styles
|
||||||
|
* @param {'light' | 'dark'} mode - The current theme mode
|
||||||
|
* @returns {Object} Image styles
|
||||||
|
*/
|
||||||
|
export const getImageStyles = (mode) => {
|
||||||
|
const colors = getColors(mode);
|
||||||
|
const border = getBorder(mode);
|
||||||
|
const isDark = mode === "dark";
|
||||||
|
|
||||||
|
return {
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
border: border.default,
|
||||||
|
display: "block",
|
||||||
|
boxShadow: isDark ? "0 2px 8px rgba(0, 0, 0, 0.3)" : "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.text.muted,
|
||||||
|
textAlign: "center",
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme-aware chip styles
|
||||||
|
* @param {'light' | 'dark'} mode - The current theme mode
|
||||||
|
* @returns {Object} Chip styles
|
||||||
|
*/
|
||||||
|
export const getChipStyles = (mode) => {
|
||||||
|
const isDark = mode === "dark";
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: {
|
||||||
|
backgroundColor: isDark ? "rgba(21, 112, 239, 0.2)" : "rgba(21, 112, 239, 0.1)",
|
||||||
|
color: isDark ? "#5BA3F5" : "#1570EF",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontFamily: typography.fontFamily.mono,
|
||||||
|
fontSize: "0.9em",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
backgroundColor: isDark ? "#2d2d2d" : "#f3f4f6",
|
||||||
|
color: isDark ? "#d0d5dd" : "#1C2130",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontFamily: typography.fontFamily.mono,
|
||||||
|
fontSize: "0.9em",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get theme-aware component styles
|
||||||
|
* @param {'light' | 'dark'} mode - The current theme mode
|
||||||
|
* @returns {Object} Component styles
|
||||||
|
*/
|
||||||
|
export const getComponentStyles = (mode) => {
|
||||||
|
const colors = getColors(mode);
|
||||||
|
const border = getBorder(mode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
h2: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize["2xl"],
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.relaxed,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.snug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy exports for backwards compatibility (dark mode defaults)
|
||||||
|
// These will be replaced by useUserGuideTheme hook usage
|
||||||
|
export const colors = getColors("dark");
|
||||||
|
export const border = getBorder("dark");
|
||||||
|
export const imageStyles = getImageStyles("dark");
|
||||||
|
export const chipStyles = getChipStyles("dark");
|
||||||
|
export const componentStyles = getComponentStyles("dark");
|
||||||
|
|
||||||
|
// Export everything as a single theme object (dark mode default)
|
||||||
|
export const theme = {
|
||||||
|
colors,
|
||||||
|
typography,
|
||||||
|
spacing,
|
||||||
|
border,
|
||||||
|
imageStyles,
|
||||||
|
chipStyles,
|
||||||
|
componentStyles,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default theme;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Hook to get theme-aware styles for UserGuide components
|
||||||
|
* Uses MUI's theme system from Checkmate
|
||||||
|
*/
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typography, spacing, brandColors } from "./theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns theme-aware styles for UserGuide components
|
||||||
|
*/
|
||||||
|
export const useUserGuideTheme = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
text: {
|
||||||
|
primary: theme.palette.primary.contrastText,
|
||||||
|
secondary: theme.palette.primary.contrastTextSecondary,
|
||||||
|
muted: theme.palette.primary.contrastTextTertiary,
|
||||||
|
white: "#ffffff",
|
||||||
|
},
|
||||||
|
brand: brandColors,
|
||||||
|
border: {
|
||||||
|
default: theme.palette.primary.lowContrast,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
white: isDark ? "#1a1a1a" : "#ffffff",
|
||||||
|
alt: theme.palette.primaryBackground.main,
|
||||||
|
code: isDark ? "#1C2130" : "#f3f4f6",
|
||||||
|
sidebar: theme.palette.primaryBackground.main,
|
||||||
|
hover: isDark ? "#2a2a2a" : "#f3f4f6",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const border = {
|
||||||
|
radius: "4px",
|
||||||
|
default: `1px solid ${colors.border.default}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageStyles = {
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: border.radius,
|
||||||
|
border: border.default,
|
||||||
|
display: "block",
|
||||||
|
boxShadow: isDark ? "0 2px 8px rgba(0, 0, 0, 0.3)" : "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.text.muted,
|
||||||
|
textAlign: "center",
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const chipStyles = {
|
||||||
|
url: {
|
||||||
|
backgroundColor: isDark ? "rgba(21, 112, 239, 0.2)" : "rgba(21, 112, 239, 0.1)",
|
||||||
|
color: isDark ? "#5BA3F5" : "#1570EF",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontFamily: typography.fontFamily.mono,
|
||||||
|
fontSize: "0.9em",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
backgroundColor: isDark ? "#2d2d2d" : "#f3f4f6",
|
||||||
|
color: isDark ? "#d0d5dd" : "#1C2130",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontFamily: typography.fontFamily.mono,
|
||||||
|
fontSize: "0.9em",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentStyles = {
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.background.white,
|
||||||
|
border: border.default,
|
||||||
|
borderRadius: border.radius,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
h2: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize["2xl"],
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.lg,
|
||||||
|
fontWeight: typography.fontWeight.semibold,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.normal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
fontFamily: typography.fontFamily.sans,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.relaxed,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.text.primary,
|
||||||
|
lineHeight: typography.lineHeight.snug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
colors,
|
||||||
|
typography,
|
||||||
|
spacing,
|
||||||
|
border,
|
||||||
|
imageStyles,
|
||||||
|
chipStyles,
|
||||||
|
componentStyles,
|
||||||
|
isDark,
|
||||||
|
theme,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUserGuideTheme;
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import Menu from "@mui/material/Menu";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { Settings } from "lucide-react";
|
|
||||||
|
|
||||||
export type ActionMenuItem = {
|
|
||||||
id: number | string;
|
|
||||||
label: React.ReactNode;
|
|
||||||
action: Function;
|
|
||||||
closeMenu?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<any>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAnchorEl(e.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (e: React.MouseEvent<any>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<IconButton onClick={handleClick}>
|
|
||||||
<Settings
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={item.id}
|
|
||||||
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (item.closeMenu) handleClose(e);
|
|
||||||
item.action();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import { getResponseColor } from "@/Utils/DataUtils";
|
|
||||||
import { HeatmapResponseTimeTooltip } from "@/Components/v2/common/charts/HeatmapResponseTimeTooltip";
|
|
||||||
import type { SxProps } from "@mui/material/styles";
|
|
||||||
import type { ResponsiveStyleValue } from "@mui/system";
|
|
||||||
|
|
||||||
interface PlaceholderCheck {
|
|
||||||
status: "placeholder";
|
|
||||||
responseTime: 0;
|
|
||||||
createdAt: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeatmapResponseTimeProps {
|
|
||||||
checks: CheckSnapshot[];
|
|
||||||
gap?: ResponsiveStyleValue<number | string>;
|
|
||||||
availabilityCellSx?: SxProps;
|
|
||||||
responseCellSx?: SxProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeatmapResponseTime = ({
|
|
||||||
checks,
|
|
||||||
gap,
|
|
||||||
availabilityCellSx,
|
|
||||||
responseCellSx,
|
|
||||||
}: HeatmapResponseTimeProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
if (!gap) {
|
|
||||||
gap = theme.spacing(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checks || checks.length === 0) return null;
|
|
||||||
|
|
||||||
const latestChecks = checks.slice(-25).reverse();
|
|
||||||
|
|
||||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
|
||||||
if (latestChecks.length !== 25) {
|
|
||||||
const placeholders = Array(25 - latestChecks.length).fill({
|
|
||||||
status: "placeholder" as const,
|
|
||||||
});
|
|
||||||
data = [...latestChecks, ...placeholders];
|
|
||||||
} else {
|
|
||||||
data = latestChecks;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
|
||||||
gap: gap,
|
|
||||||
alignItems: "stretch",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.map((check, index) => {
|
|
||||||
const statusBg =
|
|
||||||
check.status === "placeholder"
|
|
||||||
? theme.palette.action.hover
|
|
||||||
: check.status === true
|
|
||||||
? theme.palette.success.light
|
|
||||||
: theme.palette.error.light;
|
|
||||||
const respBg =
|
|
||||||
check.status === "placeholder"
|
|
||||||
? theme.palette.action.hover
|
|
||||||
: getResponseColor(check.responseTime || 0, {
|
|
||||||
start: theme.palette.success.main,
|
|
||||||
mid: theme.palette.warning.main,
|
|
||||||
end: theme.palette.error.main,
|
|
||||||
});
|
|
||||||
const statusBorder =
|
|
||||||
check.status === "placeholder"
|
|
||||||
? theme.palette.divider
|
|
||||||
: check.status === true
|
|
||||||
? theme.palette.success.main
|
|
||||||
: theme.palette.error.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HeatmapResponseTimeTooltip
|
|
||||||
key={`${check}-${index}`}
|
|
||||||
check={check}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateRows: "auto auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "10",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
bgcolor: statusBg,
|
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
...availabilityCellSx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "1 / 1",
|
|
||||||
bgcolor: respBg,
|
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
...responseCellSx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</HeatmapResponseTimeTooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
type HeatmapCheck =
|
|
||||||
| CheckSnapshot
|
|
||||||
| { status: "placeholder"; responseTime: 0; createdAt: "" };
|
|
||||||
|
|
||||||
export const HeatmapResponseTimeTooltip = ({
|
|
||||||
children,
|
|
||||||
check,
|
|
||||||
}: {
|
|
||||||
children: React.ReactElement;
|
|
||||||
check: HeatmapCheck;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const getColor = (status: boolean) => {
|
|
||||||
if (status === true) return theme.palette.success.light;
|
|
||||||
if (status === false) return theme.palette.error.light;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (check.status === "placeholder") {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
slotProps={{
|
|
||||||
tooltip: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "inherit",
|
|
||||||
boxShadow: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.secondary.main,
|
|
||||||
border: 1,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
p: theme.spacing(4),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography>
|
|
||||||
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
|
||||||
</Typography>
|
|
||||||
{check?.responseTime && (
|
|
||||||
<Typography>
|
|
||||||
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Typography textTransform={"capitalize"}>
|
|
||||||
Status:{" "}
|
|
||||||
<span style={{ color: getColor(check?.status) }}>
|
|
||||||
{check?.status === true
|
|
||||||
? t("pages.common.monitors.status.up")
|
|
||||||
: t("pages.common.monitors.status.down")}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { ResponsiveStyleValue } from "@mui/system";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
|
||||||
checks: CheckSnapshot[];
|
|
||||||
height?: ResponsiveStyleValue<number | string>;
|
|
||||||
gap?: ResponsiveStyleValue<number | string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_HEIGHT = 50;
|
|
||||||
|
|
||||||
export const HistogramResponseTime = ({
|
|
||||||
checks,
|
|
||||||
height = DEFAULT_HEIGHT,
|
|
||||||
gap,
|
|
||||||
}: HistogramResponseTimeProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
|
||||||
|
|
||||||
let data = Array<any>();
|
|
||||||
|
|
||||||
if (!checks || checks.length === 0) {
|
|
||||||
data = [];
|
|
||||||
}
|
|
||||||
if (checks.length !== 25) {
|
|
||||||
const placeholders = Array(25 - checks.length).fill({
|
|
||||||
status: "placeholder",
|
|
||||||
});
|
|
||||||
data = [...checks, ...placeholders];
|
|
||||||
} else {
|
|
||||||
data = checks;
|
|
||||||
}
|
|
||||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
|
||||||
const gridGap = gap ?? theme.spacing(0.5);
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: "100%" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
|
||||||
gap: gridGap,
|
|
||||||
alignItems: "end",
|
|
||||||
height: chartHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.map((check, index) => {
|
|
||||||
const isPlaceholder = (check as any).status === "placeholder";
|
|
||||||
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
|
||||||
const barColor =
|
|
||||||
check.status === true
|
|
||||||
? theme.palette.success.light
|
|
||||||
: theme.palette.error.light;
|
|
||||||
const bar = (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: theme.spacing(1),
|
|
||||||
bgcolor: theme.palette.action.hover,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: isPlaceholder ? 0 : heightPct,
|
|
||||||
bgcolor: barColor,
|
|
||||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return isPlaceholder ? (
|
|
||||||
<Box
|
|
||||||
key={index}
|
|
||||||
sx={{ height: "100%" }}
|
|
||||||
>
|
|
||||||
{bar}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<HeatmapResponseTimeTooltip
|
|
||||||
key={index}
|
|
||||||
check={check}
|
|
||||||
>
|
|
||||||
{bar}
|
|
||||||
</HeatmapResponseTimeTooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Stack } from "@mui/material";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ButtonInput } from "../../inputs/Button";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface HeaderCreateProps {
|
|
||||||
label?: string;
|
|
||||||
path: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeaderCreate = ({
|
|
||||||
label,
|
|
||||||
path,
|
|
||||||
isLoading = false,
|
|
||||||
isAdmin = false,
|
|
||||||
}: HeaderCreateProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
navigate(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
alignItems="center"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<ButtonInput
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleClick}
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
{label || t("common.buttons.create")}
|
|
||||||
</ButtonInput>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { Icon, MonitorStatus } from "@/Components/v2/design-elements";
|
|
||||||
import { Button } from "@/Components/v2/inputs";
|
|
||||||
import { Settings, Pause, Play, Mail, Bug } from "lucide-react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
|
||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor.js";
|
|
||||||
|
|
||||||
interface HeaderMonitorControlsProps {
|
|
||||||
path: string;
|
|
||||||
monitor?: Monitor;
|
|
||||||
isAdmin: boolean;
|
|
||||||
refetch: Function;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeaderMonitorControls = ({
|
|
||||||
path,
|
|
||||||
monitor,
|
|
||||||
isAdmin,
|
|
||||||
refetch,
|
|
||||||
}: HeaderMonitorControlsProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const theme = useTheme();
|
|
||||||
const {
|
|
||||||
post,
|
|
||||||
loading: isPosting,
|
|
||||||
// error: postError,
|
|
||||||
} = usePost<any, Monitor>();
|
|
||||||
|
|
||||||
if (!monitor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
spacing={{ xs: theme.spacing(8), md: 0 }}
|
|
||||||
direction={{ xs: "column", md: "row" }}
|
|
||||||
alignItems={"center"}
|
|
||||||
justifyContent={"space-between"}
|
|
||||||
>
|
|
||||||
<MonitorStatus monitor={monitor} />
|
|
||||||
<Stack
|
|
||||||
width={{ xs: "100%", md: "auto" }}
|
|
||||||
direction={{ xs: "column", md: "row" }}
|
|
||||||
gap={theme.spacing(2)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
loading={isPosting}
|
|
||||||
startIcon={<Icon icon={Mail} />}
|
|
||||||
onClick={async () => {
|
|
||||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common.buttons.testNotifications")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
startIcon={<Icon icon={Bug} />}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/incidents/${monitor?.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common.buttons.incidents")}
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
loading={isPosting}
|
|
||||||
startIcon={monitor?.isActive ? <Icon icon={Pause} /> : <Icon icon={Play} />}
|
|
||||||
onClick={async () => {
|
|
||||||
await post(`/monitors/pause/${monitor.id}`, {});
|
|
||||||
await refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{monitor?.isActive ? t("pause") : t("resume")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
startIcon={<Icon icon={Settings} />}
|
|
||||||
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
|
|
||||||
>
|
|
||||||
Configure
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
interface MonitorTimeFrameHeaderProps {
|
|
||||||
isLoading?: boolean;
|
|
||||||
hasDateRange?: boolean;
|
|
||||||
dateRange: string;
|
|
||||||
setDateRange: (dateRange: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HeaderTimeRange = ({
|
|
||||||
isLoading = false,
|
|
||||||
hasDateRange = true,
|
|
||||||
dateRange,
|
|
||||||
setDateRange,
|
|
||||||
}: MonitorTimeFrameHeaderProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const handleChange = (
|
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
|
||||||
newValue: string | null
|
|
||||||
) => {
|
|
||||||
if (newValue !== null) {
|
|
||||||
setDateRange(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let timeFramePicker = null;
|
|
||||||
|
|
||||||
if (hasDateRange) {
|
|
||||||
timeFramePicker = (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={dateRange}
|
|
||||||
exclusive
|
|
||||||
onChange={handleChange}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton
|
|
||||||
disabled={isLoading}
|
|
||||||
value="recent"
|
|
||||||
>
|
|
||||||
Recent
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
disabled={isLoading}
|
|
||||||
value="day"
|
|
||||||
>
|
|
||||||
Day
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
disabled={isLoading}
|
|
||||||
value="week"
|
|
||||||
>
|
|
||||||
Week
|
|
||||||
</ToggleButton>
|
|
||||||
<ToggleButton
|
|
||||||
disabled={isLoading}
|
|
||||||
value="month"
|
|
||||||
>
|
|
||||||
Month
|
|
||||||
</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Showing statistics for past{" "}
|
|
||||||
{dateRange === "recent"
|
|
||||||
? "2 hours"
|
|
||||||
: dateRange === "day"
|
|
||||||
? "24 hours"
|
|
||||||
: dateRange === "week"
|
|
||||||
? "7 days"
|
|
||||||
: "30 days"}
|
|
||||||
.
|
|
||||||
</Typography>
|
|
||||||
{timeFramePicker}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "../common/charts/HistogramResponseTime";
|
|
||||||
export * from "../common/charts/HeatmapResponseTime";
|
|
||||||
export * from "../common/charts/HeatmapResponseTimeTooltip";
|
|
||||||
export * from "../common/controls/HeaderCreate";
|
|
||||||
export * from "../common/controls/HeaderMonitorControls";
|
|
||||||
export * from "../common/controls/HeaderTimeRange";
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Box, { type BoxProps } from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
{...rest}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
border: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { BaseBox } from "../design-elements";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import type { ResponsiveStyleValue } from "@mui/system";
|
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
type BaseChartProps = React.PropsWithChildren<{
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
width?: number | string;
|
|
||||||
maxWidth?: number | string;
|
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const BaseChart = ({
|
|
||||||
children,
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
width = "100%",
|
|
||||||
maxWidth = "100%",
|
|
||||||
padding,
|
|
||||||
}: BaseChartProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
padding: padding ?? theme.spacing(8),
|
|
||||||
display: "flex",
|
|
||||||
flex: 1,
|
|
||||||
width: width,
|
|
||||||
maxWidth: maxWidth,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
gap={theme.spacing(8)}
|
|
||||||
flex={1}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems={"center"}
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
{icon && (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
"& svg": {
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
"& path": {
|
|
||||||
stroke: theme.palette.text.secondary,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</BaseBox>
|
|
||||||
)}
|
|
||||||
<Typography variant="h2">{title}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Box flex={1}>{children}</Box>
|
|
||||||
</Stack>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Alert from "@mui/material/Alert";
|
|
||||||
import Link from "@mui/material/Link";
|
|
||||||
import {
|
|
||||||
ErrorFallback,
|
|
||||||
EmptyFallback,
|
|
||||||
EmptyMonitorFallback,
|
|
||||||
BaseFallback,
|
|
||||||
} from "@/Components/v2/design-elements/Fallback";
|
|
||||||
import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb";
|
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
|
||||||
|
|
||||||
import type { StackProps } from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
|
||||||
import { Typography } from "@mui/material";
|
|
||||||
|
|
||||||
export const PageSpeedKeyPriorityFallback = () => {
|
|
||||||
return (
|
|
||||||
<BaseFallback>
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography>
|
|
||||||
<Trans
|
|
||||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
|
||||||
components={{
|
|
||||||
settingsLink: (
|
|
||||||
<Link
|
|
||||||
component={RouterLink}
|
|
||||||
to="/settings"
|
|
||||||
color="inherit"
|
|
||||||
fontWeight="inherit"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
</Alert>
|
|
||||||
</BaseFallback>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BasePageProps extends StackProps {
|
|
||||||
loading?: boolean;
|
|
||||||
error?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
breadcrumbOverride?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BasePage = ({
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
children,
|
|
||||||
breadcrumbOverride,
|
|
||||||
...props
|
|
||||||
}: BasePageProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
sx={{ height: "100%" }}
|
|
||||||
>
|
|
||||||
<CircularProgress
|
|
||||||
color="primary"
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<ErrorFallback
|
|
||||||
title="Something went wrong..."
|
|
||||||
subtitle="Please try again later"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
spacing={theme.spacing(10)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Breadcrumb />
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BasePageWithStatesProps extends StackProps {
|
|
||||||
loading: boolean;
|
|
||||||
error: any;
|
|
||||||
items: any[];
|
|
||||||
bullets: string[] | unknown;
|
|
||||||
page: string;
|
|
||||||
actionButtonText: string;
|
|
||||||
actionLink: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BasePageWithStates = ({
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
items,
|
|
||||||
page,
|
|
||||||
bullets,
|
|
||||||
actionButtonText,
|
|
||||||
actionLink,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: BasePageWithStatesProps) => {
|
|
||||||
const showLoading = loading && (!items || items.length === 0);
|
|
||||||
|
|
||||||
if (isEmpty(items)) {
|
|
||||||
return (
|
|
||||||
<EmptyFallback
|
|
||||||
bullets={bullets}
|
|
||||||
title={page}
|
|
||||||
actionButtonText={actionButtonText}
|
|
||||||
actionLink={actionLink}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BasePage
|
|
||||||
loading={showLoading}
|
|
||||||
error={error}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BasePage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MonitorBasePageWithStatesProps extends StackProps {
|
|
||||||
loading: boolean;
|
|
||||||
error: any;
|
|
||||||
items: any[];
|
|
||||||
page: string;
|
|
||||||
actionLink?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
priorityFallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEmpty = (items: any[]) => {
|
|
||||||
if (!items) return true;
|
|
||||||
if (Array.isArray(items) && items.length === 0) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MonitorBasePageWithStates = ({
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
items,
|
|
||||||
page,
|
|
||||||
actionLink,
|
|
||||||
children,
|
|
||||||
priorityFallback,
|
|
||||||
...props
|
|
||||||
}: MonitorBasePageWithStatesProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const showLoading = loading && (!items || items.length === 0);
|
|
||||||
|
|
||||||
if (priorityFallback) {
|
|
||||||
return (
|
|
||||||
<BasePage
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{priorityFallback}
|
|
||||||
</BasePage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loading && isEmpty(items)) {
|
|
||||||
return (
|
|
||||||
<EmptyMonitorFallback
|
|
||||||
page={page}
|
|
||||||
title={t(`pages.${page}.fallback.title`)}
|
|
||||||
bullets={t(`pages.${page}.fallback.checks`, { returnObjects: true })}
|
|
||||||
actionButtonText={t(`pages.${page}.fallback.actionButton`)}
|
|
||||||
actionLink={actionLink || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BasePage
|
|
||||||
loading={showLoading}
|
|
||||||
error={error}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BasePage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import MuiBreadcrumbs from "@mui/material/Breadcrumbs";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { Link, useLocation } from "react-router-dom";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
const isId = (segment: string): boolean => {
|
|
||||||
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Breadcrumb = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const segments = location.pathname.split("/").filter((x) => x);
|
|
||||||
|
|
||||||
// Build simplified breadcrumb: "uptime" or "uptime / details"
|
|
||||||
const basePage = segments[0] || "home";
|
|
||||||
const hasDetailsPage = segments.length > 1 && isId(segments[1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MuiBreadcrumbs
|
|
||||||
separator={
|
|
||||||
<ChevronRight
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
fontSize: "14px",
|
|
||||||
marginBottom: theme.spacing(6),
|
|
||||||
"& .MuiBreadcrumbs-separator": {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasDetailsPage ? (
|
|
||||||
<Link
|
|
||||||
to={`/${basePage}`}
|
|
||||||
style={{ textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontSize: "14px",
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
"&:hover": {
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
|
||||||
</Typography>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{hasDetailsPage && (
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</MuiBreadcrumbs>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export const BulletPointCheck = ({
|
|
||||||
text,
|
|
||||||
variant = "info",
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
noHighlightText?: string;
|
|
||||||
variant?: "success" | "error" | "info";
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const colors: Record<string, string | undefined> = {
|
|
||||||
success: theme.palette.success.main,
|
|
||||||
error: theme.palette.error.main,
|
|
||||||
info: theme.palette.text.secondary,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(6)}
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
|
|
||||||
fontWeight={450}
|
|
||||||
sx={{
|
|
||||||
opacity: 0.9,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { CSSProperties } from "react";
|
|
||||||
|
|
||||||
interface DotProps {
|
|
||||||
color?: string;
|
|
||||||
size?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
content: '""',
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: color,
|
|
||||||
opacity: 0.8,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { BulletPointCheck, SkeletonCard } from "@/Components/v2/design-elements";
|
|
||||||
import { Button } from "@/Components/v2/inputs";
|
|
||||||
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { StackProps } from "@mui/material/Stack";
|
|
||||||
|
|
||||||
interface BaseFallbackProps extends StackProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
alignItems={"center"}
|
|
||||||
margin={isSmall ? "inherit" : "auto"}
|
|
||||||
marginTop={isSmall ? "33%" : "10%"}
|
|
||||||
width={{
|
|
||||||
sm: "90%",
|
|
||||||
md: "70%",
|
|
||||||
lg: "50%",
|
|
||||||
xl: "40%",
|
|
||||||
}}
|
|
||||||
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
|
|
||||||
bgcolor={theme.palette.background.paper}
|
|
||||||
border={1}
|
|
||||||
borderColor={theme.palette.divider}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
sx={{
|
|
||||||
borderStyle: "dashed",
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SkeletonCard showHalo={true} />
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ErrorFallback = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<BaseFallback>
|
|
||||||
<Typography
|
|
||||||
variant="h1"
|
|
||||||
marginY={theme.spacing(4)}
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography>{subtitle}</Typography>
|
|
||||||
</BaseFallback>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmptyFallback = ({
|
|
||||||
title,
|
|
||||||
bullets,
|
|
||||||
actionButtonText,
|
|
||||||
actionLink,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
bullets: any;
|
|
||||||
actionButtonText: string;
|
|
||||||
actionLink: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<BaseFallback>
|
|
||||||
<Stack
|
|
||||||
gap={theme.spacing(10)}
|
|
||||||
zIndex={1}
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
component="h1"
|
|
||||||
color={theme.palette.primary.contrastText}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
maxWidth: "1100px",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.isArray(bullets) &&
|
|
||||||
bullets?.map((bullet: string) => (
|
|
||||||
<BulletPointCheck
|
|
||||||
text={bullet}
|
|
||||||
key={`${bullet}-${Math.random()}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate(actionLink)}
|
|
||||||
>
|
|
||||||
{actionButtonText}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</BaseFallback>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmptyMonitorFallback = ({
|
|
||||||
page,
|
|
||||||
title,
|
|
||||||
bullets,
|
|
||||||
actionButtonText,
|
|
||||||
actionLink,
|
|
||||||
}: {
|
|
||||||
page: string;
|
|
||||||
title: string;
|
|
||||||
bullets: any;
|
|
||||||
actionButtonText: string;
|
|
||||||
actionLink: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<BaseFallback>
|
|
||||||
<Stack
|
|
||||||
gap={theme.spacing(10)}
|
|
||||||
zIndex={1}
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
component="h1"
|
|
||||||
color={theme.palette.primary.contrastText}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: theme.spacing(2),
|
|
||||||
maxWidth: "1100px",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.isArray(bullets) &&
|
|
||||||
bullets?.map((bullet: string, index: number) => (
|
|
||||||
<BulletPointCheck
|
|
||||||
text={bullet}
|
|
||||||
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate(actionLink)}
|
|
||||||
>
|
|
||||||
{actionButtonText}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</BaseFallback>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
interface IconProps {
|
|
||||||
icon: LucideIcon;
|
|
||||||
size?: number;
|
|
||||||
strokeWidth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
size={size}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Icon;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { PulseDot, Dot } from "@/Components/v2/design-elements";
|
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import prettyMilliseconds from "pretty-ms";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
|
|
||||||
if (!monitor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Typography
|
|
||||||
fontSize={typographyLevels.xl}
|
|
||||||
fontWeight={500}
|
|
||||||
color={theme.palette.primary.contrastText}
|
|
||||||
overflow={"hidden"}
|
|
||||||
textOverflow={"ellipsis"}
|
|
||||||
whiteSpace={"nowrap"}
|
|
||||||
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
|
|
||||||
>
|
|
||||||
{monitor.name}
|
|
||||||
</Typography>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems={"center"}
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
|
||||||
<Typography
|
|
||||||
fontSize={typographyLevels.l}
|
|
||||||
fontWeight={"bolder"}
|
|
||||||
fontFamily={"monospace"}
|
|
||||||
overflow={"hidden"}
|
|
||||||
textOverflow={"ellipsis"}
|
|
||||||
whiteSpace={"nowrap"}
|
|
||||||
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
|
|
||||||
>
|
|
||||||
{formatUrl(monitor?.url)}
|
|
||||||
</Typography>
|
|
||||||
{!isSmall && (
|
|
||||||
<>
|
|
||||||
<Dot />
|
|
||||||
<Typography>
|
|
||||||
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Box, Stack, useTheme } from "@mui/material";
|
|
||||||
|
|
||||||
interface PulseDotProps {
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PulseDot = ({ color }: PulseDotProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
width="26px"
|
|
||||||
height="24px"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
minWidth="18px"
|
|
||||||
minHeight="18px"
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
backgroundColor: color,
|
|
||||||
borderRadius: "50%",
|
|
||||||
"&::before": {
|
|
||||||
content: `""`,
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "inherit",
|
|
||||||
borderRadius: "50%",
|
|
||||||
animation: "ripple 1.8s ease-out infinite",
|
|
||||||
},
|
|
||||||
"&::after": {
|
|
||||||
content: `""`,
|
|
||||||
position: "absolute",
|
|
||||||
width: "7px",
|
|
||||||
height: "7px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { Box, Stack, useTheme } from "@mui/material";
|
|
||||||
|
|
||||||
interface SkeletonCardProps {
|
|
||||||
width?: number | string | { xs?: number | string; md?: number | string };
|
|
||||||
showHalo?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SkeletonCard = ({
|
|
||||||
width = { xs: 130, md: 216 },
|
|
||||||
showHalo = true,
|
|
||||||
}: SkeletonCardProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const pulseAnimation = {
|
|
||||||
"@keyframes pulse": {
|
|
||||||
"0%, 100%": { opacity: 1 },
|
|
||||||
"50%": { opacity: 0.72 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const floatAnimation = {
|
|
||||||
"@keyframes float": {
|
|
||||||
"0%, 100%": { transform: "rotate(5deg) translate(12px, -20px)" },
|
|
||||||
"50%": { transform: "rotate(5deg) translate(12px, -24px)" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardStyle = {
|
|
||||||
width: width,
|
|
||||||
height: 57,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
background:
|
|
||||||
theme.palette.mode === "dark"
|
|
||||||
? theme.palette.secondary.dark
|
|
||||||
: theme.palette.background.paper,
|
|
||||||
boxShadow:
|
|
||||||
theme.palette.mode === "dark"
|
|
||||||
? "0 8px 30px rgba(0,0,0,.3), 0 0 0 1px rgba(255,255,255,.05) inset"
|
|
||||||
: "0 8px 30px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05) inset",
|
|
||||||
display: "flex",
|
|
||||||
gap: theme.spacing(5),
|
|
||||||
alignItems: "center",
|
|
||||||
padding: theme.spacing(5),
|
|
||||||
};
|
|
||||||
|
|
||||||
const blockStyle = {
|
|
||||||
width: 55,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
background: theme.palette.secondary.main,
|
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
|
||||||
animation: "pulse 1.6s ease-in-out infinite",
|
|
||||||
...pulseAnimation,
|
|
||||||
};
|
|
||||||
|
|
||||||
const lineStyle = {
|
|
||||||
height: 7,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
background: theme.palette.secondary.main,
|
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
|
||||||
animation: "pulse 1.6s ease-in-out infinite",
|
|
||||||
...pulseAnimation,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
position={"relative"}
|
|
||||||
mt={20}
|
|
||||||
mb={20}
|
|
||||||
>
|
|
||||||
{showHalo && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
pointerEvents: "none",
|
|
||||||
"&::before": {
|
|
||||||
content: '""',
|
|
||||||
width: "260px",
|
|
||||||
height: "260px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
background:
|
|
||||||
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "#fff",
|
|
||||||
filter: "blur(60px)",
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card stack */}
|
|
||||||
<Box sx={{ position: "relative", zIndex: 1 }}>
|
|
||||||
{/* Front card (now at bottom) */}
|
|
||||||
<Box sx={cardStyle}>
|
|
||||||
<Box sx={blockStyle} />
|
|
||||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
|
||||||
{/* Empty space to maintain card height */}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Back card A */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
...cardStyle,
|
|
||||||
transform: "rotate(-4deg) translate(-12px, -12px)",
|
|
||||||
filter: "blur(.2px)",
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<Box sx={blockStyle} />
|
|
||||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
|
||||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
|
||||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
|
||||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Back card B */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
...cardStyle,
|
|
||||||
transform: "rotate(5deg) translate(12px, -20px)",
|
|
||||||
filter: "blur(.3px)",
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
animation: "float 3s ease-in-out 5",
|
|
||||||
...floatAnimation,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<Box sx={blockStyle} />
|
|
||||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
|
||||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
|
||||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
|
||||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { lighten } from "@mui/material/styles";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
|
|
||||||
import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements";
|
|
||||||
import type { SxProps } from "@mui/material";
|
|
||||||
|
|
||||||
type GradientBox = React.PropsWithChildren<{
|
|
||||||
palette?: PaletteKey;
|
|
||||||
sx?: SxProps;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
const isLight = theme.palette.mode === "light";
|
|
||||||
const paper = theme.palette.background.paper;
|
|
||||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
|
||||||
const paperEnd = paper;
|
|
||||||
const bg = palette
|
|
||||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
|
||||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
|
||||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
|
||||||
background: bg,
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatBoxProps = React.PropsWithChildren<{
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
palette?: PaletteKey;
|
|
||||||
sx?: SxProps;
|
|
||||||
tooltip?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const StatBox = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
palette,
|
|
||||||
children,
|
|
||||||
sx,
|
|
||||||
tooltip,
|
|
||||||
onClick,
|
|
||||||
}: StatBoxProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GradientBox
|
|
||||||
palette={palette}
|
|
||||||
sx={{
|
|
||||||
...(sx as object),
|
|
||||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack onClick={onClick}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
|
||||||
<Typography color={textColor}>{title}</Typography>
|
|
||||||
{tooltip && (
|
|
||||||
<TooltipWithInfo
|
|
||||||
title={tooltip}
|
|
||||||
iconColor={textColor as string}
|
|
||||||
iconSize={14}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography color={textColor}>{subtitle}</Typography>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
</GradientBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { BaseBox } from "@/Components/v2/design-elements";
|
|
||||||
import Background from "@/assets/Images/background-grid.svg?react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>;
|
|
||||||
|
|
||||||
export const BGBox = ({ children }: StatusBoxProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
flex: 1,
|
|
||||||
padding: theme.spacing(4),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="-10%"
|
|
||||||
left="5%"
|
|
||||||
>
|
|
||||||
<Background />
|
|
||||||
</Box>
|
|
||||||
{children}
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusBox = ({
|
|
||||||
label,
|
|
||||||
n,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
n: number;
|
|
||||||
color: string | undefined;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<BGBox>
|
|
||||||
<Stack spacing={theme.spacing(4)}>
|
|
||||||
<Typography
|
|
||||||
variant={"h2"}
|
|
||||||
textTransform="uppercase"
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h1"
|
|
||||||
color={color}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</BGBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UpStatusBox = ({ n }: { n: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<StatusBox
|
|
||||||
label={t("pages.common.monitors.status.up")}
|
|
||||||
n={n}
|
|
||||||
color={theme.palette.success.light}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownStatusBox = ({ n }: { n: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<StatusBox
|
|
||||||
label={t("pages.common.monitors.status.down")}
|
|
||||||
n={n}
|
|
||||||
color={theme.palette.error.light}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PausedStatusBox = ({ n }: { n: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<StatusBox
|
|
||||||
label={t("pages.common.monitors.status.paused")}
|
|
||||||
n={n}
|
|
||||||
color={theme.palette.warning.light}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InitializingStatusBox = ({ n }: { n: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<StatusBox
|
|
||||||
label={t("pages.common.monitors.status.initializing")}
|
|
||||||
n={n}
|
|
||||||
color={theme.palette.warning.light}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { BaseBox } from "@/Components/v2/design-elements";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
import type { MonitorStatus } from "@/Types/Monitor";
|
|
||||||
import type { SxProps } from "@mui/material/styles";
|
|
||||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
|
||||||
export type ValueType = (typeof ValueTypes)[number];
|
|
||||||
|
|
||||||
export const StatusLabel = ({
|
|
||||||
status,
|
|
||||||
isActive,
|
|
||||||
sx,
|
|
||||||
}: {
|
|
||||||
status: MonitorStatus;
|
|
||||||
isActive?: boolean;
|
|
||||||
sx?: SxProps;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const palette = getStatusPalette(status);
|
|
||||||
|
|
||||||
const determineStatus = (
|
|
||||||
isActive: boolean | undefined,
|
|
||||||
status: MonitorStatus
|
|
||||||
): string => {
|
|
||||||
if (isActive === false) {
|
|
||||||
return t("pages.common.monitors.status.paused");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === true) {
|
|
||||||
return t("pages.common.monitors.status.up");
|
|
||||||
}
|
|
||||||
if (status === false) {
|
|
||||||
return t("pages.common.monitors.status.down");
|
|
||||||
}
|
|
||||||
return t("pages.common.monitors.status.initializing");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
display: "inline-flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: theme.spacing(3, 5),
|
|
||||||
color: theme.palette[palette].main,
|
|
||||||
borderColor:
|
|
||||||
theme.palette.mode === "dark"
|
|
||||||
? "rgba(255, 255, 255, 0.08)"
|
|
||||||
: "rgba(0, 0, 0, 0.08)",
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={7}
|
|
||||||
height={7}
|
|
||||||
bgcolor={theme.palette[palette].light}
|
|
||||||
borderRadius="50%"
|
|
||||||
marginRight="5px"
|
|
||||||
/>
|
|
||||||
<Typography textTransform={"capitalize"}>
|
|
||||||
{determineStatus(isActive, status)}
|
|
||||||
</Typography>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const palette = getValuePalette(value);
|
|
||||||
const transformedText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
sx={{
|
|
||||||
display: "inline-flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: theme.spacing(3, 5),
|
|
||||||
color: theme.palette[palette].main,
|
|
||||||
borderColor:
|
|
||||||
theme.palette.mode === "dark"
|
|
||||||
? "rgba(255, 255, 255, 0.08)"
|
|
||||||
: "rgba(0, 0, 0, 0.08)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={7}
|
|
||||||
height={7}
|
|
||||||
bgcolor={theme.palette[palette].light}
|
|
||||||
borderRadius="50%"
|
|
||||||
marginRight="5px"
|
|
||||||
/>
|
|
||||||
{transformedText}
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Grid2 from "@mui/material/Grid2";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import Paper from "@mui/material/Paper";
|
|
||||||
import Table from "@mui/material/Table";
|
|
||||||
import TableBody from "@mui/material/TableBody";
|
|
||||||
import TableCell from "@mui/material/TableCell";
|
|
||||||
import TableContainer from "@mui/material/TableContainer";
|
|
||||||
import TableHead from "@mui/material/TableHead";
|
|
||||||
import TableRow from "@mui/material/TableRow";
|
|
||||||
import Collapse from "@mui/material/Collapse";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import {
|
|
||||||
ChevronsLeft,
|
|
||||||
ChevronsRight,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Coffee,
|
|
||||||
Ellipsis,
|
|
||||||
PartyPopper,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import TablePagination from "@mui/material/TablePagination";
|
|
||||||
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useState, Fragment } from "react";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
|
|
||||||
export type Header<T> = {
|
|
||||||
id: number | string;
|
|
||||||
content: React.ReactNode;
|
|
||||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
|
||||||
render: (row: T) => React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
|
|
||||||
headers: Header<T>[];
|
|
||||||
data: T[];
|
|
||||||
onRowClick?: (row: T) => void;
|
|
||||||
cardsOnSmallScreens?: boolean;
|
|
||||||
expandableRows?: boolean;
|
|
||||||
renderExpandedContent?: (row: T) => React.ReactNode;
|
|
||||||
emptyViewText?: string;
|
|
||||||
emptyViewPositive?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DataTable<
|
|
||||||
T extends {
|
|
||||||
id?: string | number;
|
|
||||||
_id?: string | number;
|
|
||||||
onRowClick?: (row: T) => void;
|
|
||||||
},
|
|
||||||
>({
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
onRowClick,
|
|
||||||
cardsOnSmallScreens = true,
|
|
||||||
expandableRows = false,
|
|
||||||
renderExpandedContent,
|
|
||||||
emptyViewText,
|
|
||||||
emptyViewPositive,
|
|
||||||
}: DataTableProps<T>) {
|
|
||||||
const theme = useTheme();
|
|
||||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
|
||||||
const handleExpand = (row: T) => {
|
|
||||||
const key = row.id || row._id || null;
|
|
||||||
setExpanded(expanded === key ? null : key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyView
|
|
||||||
text={emptyViewText}
|
|
||||||
positive={emptyViewPositive}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = [];
|
|
||||||
// Return stack of cards for small screens
|
|
||||||
if (isSmall && cardsOnSmallScreens) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(4)}>
|
|
||||||
{data.map((row) => {
|
|
||||||
const key = row.id || row._id || Math.random();
|
|
||||||
keys.push(key);
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
|
||||||
spacing={theme.spacing(4)}
|
|
||||||
sx={{
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
padding: theme.spacing(4),
|
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
|
||||||
}}
|
|
||||||
key={key}
|
|
||||||
>
|
|
||||||
{headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<Grid2
|
|
||||||
container
|
|
||||||
key={header.id}
|
|
||||||
>
|
|
||||||
<Grid2
|
|
||||||
size={5}
|
|
||||||
display={"flex"}
|
|
||||||
alignItems={"center"}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
component="div"
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
>
|
|
||||||
{header.content}
|
|
||||||
</Typography>
|
|
||||||
</Grid2>
|
|
||||||
<Grid2
|
|
||||||
size={7}
|
|
||||||
display="flex"
|
|
||||||
alignItems={"center"}
|
|
||||||
>
|
|
||||||
{header.render(row)}{" "}
|
|
||||||
</Grid2>
|
|
||||||
</Grid2>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
|
|
||||||
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableContainer
|
|
||||||
component={Paper}
|
|
||||||
elevation={0}
|
|
||||||
sx={{ boxShadow: "none", overflowX: "hidden" }}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
sx={{
|
|
||||||
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
|
|
||||||
paddingLeft: theme.spacing(8),
|
|
||||||
},
|
|
||||||
"& .MuiTableCell-root": {
|
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
||||||
},
|
|
||||||
"& .MuiTableHead-root .MuiTableRow-root": {
|
|
||||||
height: "28px",
|
|
||||||
},
|
|
||||||
"& :is(th)": {
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
|
|
||||||
fontSize: theme.typography.fontSize,
|
|
||||||
},
|
|
||||||
"& :is(td)": {
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
|
|
||||||
fontSize: theme.typography.fontSize,
|
|
||||||
overflowX: "hidden",
|
|
||||||
},
|
|
||||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
|
||||||
borderBottom: "none",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
{headers.map((header, idx) => {
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
align={idx === 0 ? "left" : "center"}
|
|
||||||
key={header.id}
|
|
||||||
>
|
|
||||||
{header.content}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{data.map((row) => {
|
|
||||||
const key = row.id || row._id || Math.random();
|
|
||||||
const isExpanded = expanded === key;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={key}>
|
|
||||||
<TableRow
|
|
||||||
sx={{
|
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (expandableRows) handleExpand(row);
|
|
||||||
else if (onRowClick) onRowClick(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{headers.map((header, index) => {
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
align={index === 0 ? "left" : "center"}
|
|
||||||
key={header.id}
|
|
||||||
onClick={
|
|
||||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{header.render(row)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
{expandableRows &&
|
|
||||||
renderExpandedContent &&
|
|
||||||
renderExpandedContent(row) && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={headers.length}
|
|
||||||
style={{
|
|
||||||
borderBottom: isExpanded ? undefined : "none",
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Collapse
|
|
||||||
in={isExpanded}
|
|
||||||
timeout="auto"
|
|
||||||
unmountOnExit
|
|
||||||
>
|
|
||||||
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
|
|
||||||
</Collapse>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TablePaginationActionsProps {
|
|
||||||
count: number;
|
|
||||||
page: number;
|
|
||||||
rowsPerPage: number;
|
|
||||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { count, page, rowsPerPage, onPageChange } = props;
|
|
||||||
|
|
||||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, page - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, page + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
|
||||||
className="table-pagination-actions"
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleFirstPageButtonClick}
|
|
||||||
disabled={page === 0}
|
|
||||||
aria-label="first page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronsRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronsLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleBackButtonClick}
|
|
||||||
disabled={page === 0}
|
|
||||||
aria-label="previous page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleNextButtonClick}
|
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
|
||||||
aria-label="next page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleLastPageButtonClick}
|
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
|
||||||
aria-label="last page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronsLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronsRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HasMoreTablePaginationActionsProps {
|
|
||||||
hasMore?: boolean;
|
|
||||||
page: number;
|
|
||||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
|
||||||
}
|
|
||||||
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { hasMore, page, onPageChange } = props;
|
|
||||||
|
|
||||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, page - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onPageChange(event, page + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
|
||||||
className="table-pagination-actions"
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleFirstPageButtonClick}
|
|
||||||
disabled={page === 0}
|
|
||||||
aria-label="first page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronsRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronsLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleBackButtonClick}
|
|
||||||
disabled={page === 0}
|
|
||||||
aria-label="previous page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleNextButtonClick}
|
|
||||||
disabled={hasMore === false}
|
|
||||||
aria-label="next page"
|
|
||||||
>
|
|
||||||
{theme.direction === "rtl" ? (
|
|
||||||
<ChevronLeft
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChevronRight
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
|
|
||||||
<IconButton
|
|
||||||
disabled={hasMore === false}
|
|
||||||
aria-label="next page"
|
|
||||||
>
|
|
||||||
<Ellipsis
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaginationProps extends TablePaginationOwnProps {
|
|
||||||
component?: React.ElementType;
|
|
||||||
hasMore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Pagination = ({ ...props }: PaginationProps) => {
|
|
||||||
const { hasMore, ...rest } = props;
|
|
||||||
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
|
|
||||||
const theme = useTheme();
|
|
||||||
const labelDisplayedRows = ({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
count,
|
|
||||||
}: {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
count: number;
|
|
||||||
}) => {
|
|
||||||
if (hasMore) {
|
|
||||||
return to === 0 ? "" : `${from}–${to}`;
|
|
||||||
}
|
|
||||||
return `${from}–${to} of ${count}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TablePagination
|
|
||||||
ActionsComponent={(props) =>
|
|
||||||
typeof hasMore === "boolean" ? (
|
|
||||||
<HasMoreTablePaginationActions
|
|
||||||
{...props}
|
|
||||||
hasMore={hasMore}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TablePaginationActions {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rowsPerPageOptions={[5, 10, 25]}
|
|
||||||
labelDisplayedRows={labelDisplayedRows}
|
|
||||||
{...rest}
|
|
||||||
sx={{
|
|
||||||
"& .MuiTablePagination-toolbar": isSmall
|
|
||||||
? {
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gridAutoRows: "auto",
|
|
||||||
rowGap: theme.spacing(1),
|
|
||||||
alignItems: "center",
|
|
||||||
justifyItems: "center",
|
|
||||||
paddingLeft: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
}
|
|
||||||
: { display: "flex", alignItems: "center" },
|
|
||||||
"& .MuiTablePagination-selectLabel": {
|
|
||||||
gridColumn: "1",
|
|
||||||
gridRow: "1",
|
|
||||||
justifySelf: "center",
|
|
||||||
},
|
|
||||||
"& .MuiTablePagination-select": {
|
|
||||||
gridColumn: "2",
|
|
||||||
gridRow: "1",
|
|
||||||
justifySelf: "center",
|
|
||||||
},
|
|
||||||
"& .MuiTablePagination-displayedRows": isSmall
|
|
||||||
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
|
|
||||||
: {},
|
|
||||||
"& .table-pagination-actions": isSmall
|
|
||||||
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
|
|
||||||
: {},
|
|
||||||
"& .MuiSelect-select": {
|
|
||||||
border: 1,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const Icon = positive ? PartyPopper : Coffee;
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
alignItems={"center"}
|
|
||||||
justifyContent={"center"}
|
|
||||||
sx={{
|
|
||||||
py: theme.spacing(12),
|
|
||||||
px: theme.spacing(4),
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
mb: theme.spacing(2),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={28}
|
|
||||||
strokeWidth={1}
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
sx={{ fontWeight: 500 }}
|
|
||||||
>
|
|
||||||
{text ?? t("common.table.empty")}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import MuiTooltip from "@mui/material/Tooltip";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Info } from "lucide-react";
|
|
||||||
|
|
||||||
import type { TooltipProps } from "@mui/material/Tooltip";
|
|
||||||
|
|
||||||
type StyledTooltipProps = TooltipProps;
|
|
||||||
|
|
||||||
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MuiTooltip
|
|
||||||
title={props.title}
|
|
||||||
placement={placement}
|
|
||||||
slotProps={{
|
|
||||||
tooltip: {
|
|
||||||
sx: {
|
|
||||||
background:
|
|
||||||
theme.palette.mode === "dark"
|
|
||||||
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
|
|
||||||
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontSize: "13px",
|
|
||||||
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
|
|
||||||
borderRadius: `${theme.shape.borderRadius}px`,
|
|
||||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</MuiTooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
|
|
||||||
iconSize?: number;
|
|
||||||
iconColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TooltipWithInfo = ({
|
|
||||||
iconSize = 14,
|
|
||||||
iconColor,
|
|
||||||
...props
|
|
||||||
}: TooltipWithInfoProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const defaultColor = theme.palette.text.secondary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip {...props}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: "help",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Info
|
|
||||||
size={iconSize}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{
|
|
||||||
opacity: 0.7,
|
|
||||||
color: iconColor || defaultColor,
|
|
||||||
transition: "opacity 0.2s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.opacity = "1";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.opacity = "0.7";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export * from "./Breadcrumb";
|
|
||||||
export * from "./BasePage";
|
|
||||||
export * from "./Fallback";
|
|
||||||
export * from "./SkeletonCard";
|
|
||||||
export * from "./BulletPointCheck";
|
|
||||||
export * from "./StatusBox";
|
|
||||||
export * from "./BaseBox";
|
|
||||||
export * from "./Table";
|
|
||||||
export { DataTable as Table } from "./Table";
|
|
||||||
export * from "./StatusLabel";
|
|
||||||
export * from "./PulseDot";
|
|
||||||
export * from "./Dot";
|
|
||||||
export * from "./MonitorStatus";
|
|
||||||
export { default as Icon } from "./Icon";
|
|
||||||
export * from "./Tooltip";
|
|
||||||
export * from "./StatBox";
|
|
||||||
export * from "./BaseChart";
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import Autocomplete from "@mui/material/Autocomplete";
|
|
||||||
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
|
||||||
import { TextField, Checkbox } from "@/Components/v2/inputs";
|
|
||||||
import ListItem from "@mui/material/ListItem";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { FieldLabel } from "./FieldLabel";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
type AutoCompleteInputProps = Omit<
|
|
||||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
|
||||||
"renderInput"
|
|
||||||
> & {
|
|
||||||
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
|
|
||||||
fieldLabel?: string;
|
|
||||||
required?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AutoCompleteInput = ({
|
|
||||||
fieldLabel,
|
|
||||||
required,
|
|
||||||
renderInput,
|
|
||||||
...props
|
|
||||||
}: AutoCompleteInputProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const multiple = props.multiple;
|
|
||||||
|
|
||||||
const defaultRenderInput = (params: any) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder="Type to search"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const autocomplete = (
|
|
||||||
<Autocomplete
|
|
||||||
{...props}
|
|
||||||
disableCloseOnSelect={!!multiple}
|
|
||||||
popupIcon={
|
|
||||||
<ChevronDown
|
|
||||||
size={18}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ marginRight: theme.spacing(3) }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
renderInput={renderInput || defaultRenderInput}
|
|
||||||
getOptionKey={(option) => option.id}
|
|
||||||
renderTags={() => null}
|
|
||||||
renderOption={(props, option, { selected }) => {
|
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
return (
|
|
||||||
<ListItem
|
|
||||||
key={key}
|
|
||||||
{...optionProps}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction={"row"}
|
|
||||||
alignItems={"center"}
|
|
||||||
gap={theme.spacing(2)}
|
|
||||||
>
|
|
||||||
{multiple && <Checkbox checked={selected} />}
|
|
||||||
{option.name}
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
|
|
||||||
padding: `0 ${theme.spacing(5)}`,
|
|
||||||
},
|
|
||||||
"& .MuiInputBase-root .MuiAutocomplete-endAdornment": {
|
|
||||||
right: theme.spacing(3),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fieldLabel) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(2)}>
|
|
||||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
|
||||||
{autocomplete}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return autocomplete;
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import Button from "@mui/material/Button";
|
|
||||||
import type { ButtonProps } from "@mui/material/Button";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const outlinedSx =
|
|
||||||
props.variant === "outlined"
|
|
||||||
? {
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
"&:hover": {
|
|
||||||
borderColor: theme.palette.text.secondary,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
sx={{
|
|
||||||
textTransform: "none",
|
|
||||||
height: 34,
|
|
||||||
fontWeight: 400,
|
|
||||||
borderRadius: 2,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "none",
|
|
||||||
"&:hover": {
|
|
||||||
boxShadow: "none",
|
|
||||||
},
|
|
||||||
...outlinedSx,
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
|
||||||
import type { CheckboxProps } from "@mui/material/Checkbox";
|
|
||||||
import { Square, SquareCheck } from "lucide-react";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
type CheckboxInputProps = CheckboxProps & {
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
export const CheckboxInput = forwardRef<HTMLInputElement, CheckboxInputProps>(
|
|
||||||
function CheckboxInput(
|
|
||||||
{ sx, ...props }: CheckboxInputProps,
|
|
||||||
ref: React.Ref<HTMLInputElement>
|
|
||||||
) {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Checkbox
|
|
||||||
{...props}
|
|
||||||
aria-label={props.label}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
ref: ref,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
<Square
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
checkedIcon={
|
|
||||||
<SquareCheck
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
"&.Mui-checked": {
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
"&:hover": { backgroundColor: "transparent" },
|
|
||||||
"& svg": {
|
|
||||||
stroke: "currentColor",
|
|
||||||
},
|
|
||||||
"& svg path, & svg line, & svg polyline, & svg rect": {
|
|
||||||
stroke: "currentColor",
|
|
||||||
fill: "none",
|
|
||||||
},
|
|
||||||
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import Dialog from "@mui/material/Dialog";
|
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
|
||||||
import DialogContentText from "@mui/material/DialogContentText";
|
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
|
||||||
import { Button } from "@/Components/v2/inputs";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const DialogInput = ({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
confirmColor = "primary",
|
|
||||||
cancelColor = "error",
|
|
||||||
loading = false,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
title?: string;
|
|
||||||
content?: string;
|
|
||||||
onConfirm?(item: any): any;
|
|
||||||
onCancel?(item: any): any;
|
|
||||||
confirmColor?: "error" | "primary";
|
|
||||||
cancelColor?: "error" | "primary";
|
|
||||||
loading?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Dialog open={open}>
|
|
||||||
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText>{content}</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
loading={loading}
|
|
||||||
variant="contained"
|
|
||||||
color={cancelColor}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{t("common.buttons.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
loading={loading}
|
|
||||||
variant="contained"
|
|
||||||
color={confirmColor}
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{t("common.buttons.confirm")}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
interface FieldLabelProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
required?: boolean;
|
|
||||||
htmlFor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
component="label"
|
|
||||||
htmlFor={htmlFor}
|
|
||||||
sx={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{required && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: theme.palette.error.main,
|
|
||||||
marginLeft: theme.spacing(2),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Select from "@mui/material/Select";
|
|
||||||
import React from "react";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { SelectProps } from "@mui/material/Select";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { FieldLabel } from "./FieldLabel";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
|
||||||
fieldLabel?: string;
|
|
||||||
required?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
placeholderColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SelectInput = <T,>({
|
|
||||||
fieldLabel,
|
|
||||||
required,
|
|
||||||
placeholder,
|
|
||||||
placeholderColor,
|
|
||||||
...props
|
|
||||||
}: SelectInputProps<T>) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
|
|
||||||
|
|
||||||
const renderValue = (selected: any) => {
|
|
||||||
const isMultiple = Boolean((props as any).multiple);
|
|
||||||
const isEmpty = isMultiple
|
|
||||||
? !Array.isArray(selected) || selected.length === 0
|
|
||||||
: selected === undefined || selected === null || selected === "";
|
|
||||||
|
|
||||||
if (isEmpty && placeholder) {
|
|
||||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMultiple) {
|
|
||||||
const items: string[] = Array.isArray(selected) ? selected : [];
|
|
||||||
const capitalized = items.map(
|
|
||||||
(item) => item.charAt(0).toUpperCase() + item.slice(1)
|
|
||||||
);
|
|
||||||
return (<Typography>{capitalized.join(" | ")}</Typography>) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = React.Children.toArray(props.children as React.ReactNode);
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!React.isValidElement(node)) continue;
|
|
||||||
const el = node as React.ReactElement<{
|
|
||||||
value?: unknown;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}>;
|
|
||||||
if (el.props?.value === selected) {
|
|
||||||
return (el.props.children ?? selected) as React.ReactNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return selected as React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const select = (
|
|
||||||
<Select<T>
|
|
||||||
{...props}
|
|
||||||
displayEmpty
|
|
||||||
renderValue={renderValue}
|
|
||||||
inputProps={{
|
|
||||||
...(props.inputProps || {}),
|
|
||||||
"aria-placeholder": placeholder,
|
|
||||||
}}
|
|
||||||
IconComponent={() => (
|
|
||||||
<ChevronDown
|
|
||||||
size={18}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ marginRight: theme.spacing(3) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sx={{
|
|
||||||
height: "34px",
|
|
||||||
"& .MuiSelect-icon": {
|
|
||||||
right: theme.spacing(3),
|
|
||||||
},
|
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
...props.sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fieldLabel) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(2)}>
|
|
||||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
|
||||||
{select}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return select;
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import type { TextFieldProps } from "@mui/material";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { FieldLabel } from "./FieldLabel";
|
|
||||||
|
|
||||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
|
||||||
fieldLabel?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
|
||||||
{ fieldLabel, required, ...props },
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const input = (
|
|
||||||
<TextField
|
|
||||||
{...props}
|
|
||||||
inputRef={ref}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
height: 34,
|
|
||||||
fontSize: typographyLevels.base,
|
|
||||||
},
|
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"& .MuiFormHelperText-root": {
|
|
||||||
marginLeft: 0,
|
|
||||||
marginRight: 0,
|
|
||||||
marginTop: theme.spacing(1),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fieldLabel) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(2)}>
|
|
||||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
|
||||||
{input}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
});
|
|
||||||
TextInput.displayName = "TextInput";
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import ToggleButton from "@mui/material/ToggleButton";
|
|
||||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
|
||||||
import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
|
||||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|
||||||
return (
|
|
||||||
<ToggleButton
|
|
||||||
{...props}
|
|
||||||
sx={{
|
|
||||||
px: 8,
|
|
||||||
textTransform: "none",
|
|
||||||
height: 34,
|
|
||||||
fontWeight: 400,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "none",
|
|
||||||
"&:hover": {
|
|
||||||
boxShadow: "none",
|
|
||||||
},
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<ToggleButtonGroup
|
|
||||||
{...props}
|
|
||||||
sx={{
|
|
||||||
"& .MuiToggleButtonGroup-grouped": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
|
|
||||||
borderRadius: 0,
|
|
||||||
"&:first-of-type": {
|
|
||||||
borderTopLeftRadius: 2,
|
|
||||||
borderBottomLeftRadius: 2,
|
|
||||||
},
|
|
||||||
"&:last-of-type": {
|
|
||||||
borderTopRightRadius: 2,
|
|
||||||
borderBottomRightRadius: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export { ButtonInput as Button } from "./Button";
|
|
||||||
export * from "./FieldLabel";
|
|
||||||
export { SelectInput as Select } from "./Select";
|
|
||||||
export { CheckboxInput as Checkbox } from "./Checkbox";
|
|
||||||
export { AutoCompleteInput as Autocomplete } from "./AutoComplete";
|
|
||||||
export { TextInput as TextField } from "./TextInput";
|
|
||||||
export {
|
|
||||||
ToggleButtonInput as ToggleButton,
|
|
||||||
ToggleButtonGroupInput as ToggleButtonGroup,
|
|
||||||
} from "./ToggleButton";
|
|
||||||
export { DialogInput as Dialog } from "./Dialog";
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { Select, Button } from "@/Components/v2/inputs";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
||||||
|
|
||||||
import type { MonitorType } from "@/Types/Monitor";
|
|
||||||
import { Typography, useTheme } from "@mui/material";
|
|
||||||
|
|
||||||
const types = ["http", "ping", "port", "docker"];
|
|
||||||
const statuses = ["up", "down"];
|
|
||||||
const states = ["active", "paused"];
|
|
||||||
|
|
||||||
export const ControlsFilter = ({
|
|
||||||
selectedTypes,
|
|
||||||
setSelectedTypes,
|
|
||||||
selectedStatus,
|
|
||||||
setSelectedStatus,
|
|
||||||
selectedState,
|
|
||||||
setSelectedState,
|
|
||||||
onClearFilters,
|
|
||||||
}: {
|
|
||||||
selectedTypes: MonitorType[];
|
|
||||||
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
|
|
||||||
selectedStatus: string;
|
|
||||||
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
selectedState: string;
|
|
||||||
setSelectedState: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
onClearFilters: () => void;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
const isFilterActive =
|
|
||||||
selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== "";
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
gap={theme.spacing(2)}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
multiple
|
|
||||||
placeholder="Type"
|
|
||||||
value={selectedTypes}
|
|
||||||
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
|
|
||||||
>
|
|
||||||
{types.map((type) => (
|
|
||||||
<MenuItem
|
|
||||||
key={type}
|
|
||||||
value={type}
|
|
||||||
>
|
|
||||||
<Typography textTransform={"capitalize"}>{type}</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
placeholder="Status"
|
|
||||||
value={selectedStatus}
|
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<MenuItem
|
|
||||||
key={status}
|
|
||||||
value={status}
|
|
||||||
>
|
|
||||||
<Typography textTransform={"capitalize"}>{status}</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
placeholder="State"
|
|
||||||
value={selectedState}
|
|
||||||
onChange={(e) => setSelectedState(e.target.value)}
|
|
||||||
>
|
|
||||||
{states.map((state) => (
|
|
||||||
<MenuItem
|
|
||||||
key={state}
|
|
||||||
value={state}
|
|
||||||
>
|
|
||||||
<Typography textTransform={"capitalize"}>{state}</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{isFilterActive && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { StatBox } from "@/Components/v2/design-elements";
|
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
|
||||||
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
|
|
||||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface MonitorStatBoxesProps {
|
|
||||||
monitor?: Monitor;
|
|
||||||
monitorStats: MonitorStats | null;
|
|
||||||
certificateExpiry?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MonitorStatBoxes = ({
|
|
||||||
monitor,
|
|
||||||
monitorStats,
|
|
||||||
certificateExpiry,
|
|
||||||
}: MonitorStatBoxesProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!monitorStats || !monitor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
|
||||||
const timeSinceLastFailure =
|
|
||||||
timeOfLastFailure > 0
|
|
||||||
? Date.now() - timeOfLastFailure
|
|
||||||
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
|
|
||||||
|
|
||||||
// Determine time since last check
|
|
||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
|
|
||||||
|
|
||||||
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
|
|
||||||
|
|
||||||
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
|
|
||||||
const palette = getStatusPalette(monitor?.status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: "column", md: "row" }}
|
|
||||||
gap={theme.spacing(8)}
|
|
||||||
>
|
|
||||||
<StatBox
|
|
||||||
palette={palette}
|
|
||||||
title={t("pages.common.monitors.statBoxes.activeFor")}
|
|
||||||
subtitle={streakTime}
|
|
||||||
/>
|
|
||||||
<StatBox
|
|
||||||
title={t("pages.common.monitors.statBoxes.lastCheck")}
|
|
||||||
subtitle={lastCheckTime}
|
|
||||||
/>
|
|
||||||
<StatBox
|
|
||||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
|
||||||
subtitle={monitorStats?.lastResponseTime + " ms"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{monitor?.type === "http" && (
|
|
||||||
<StatBox
|
|
||||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
|
||||||
subtitle={certificateExpiry || "N/A"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { BaseChart, BaseBox } from "@/Components/v2/design-elements";
|
|
||||||
import { Clock } from "lucide-react";
|
|
||||||
import {
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
Tooltip,
|
|
||||||
CartesianGrid,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Text,
|
|
||||||
} from "recharts";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import {
|
|
||||||
formatDateWithTz,
|
|
||||||
tickDateFormatLookup,
|
|
||||||
tooltipDateFormatLookup,
|
|
||||||
} from "@/Utils/TimeUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { GroupedCheck } from "@/Types/Check";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
type XTickProps = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
payload: { value: any };
|
|
||||||
range: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
|
||||||
const format = tickDateFormatLookup(range);
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
x={x}
|
|
||||||
y={y + 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={400}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResponseTimeToolTipProps = {
|
|
||||||
active?: boolean | undefined;
|
|
||||||
payload?: any[];
|
|
||||||
label?: string | number;
|
|
||||||
range: string;
|
|
||||||
theme: any;
|
|
||||||
uiTimezone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ResponseTimeToolTip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
range,
|
|
||||||
theme,
|
|
||||||
uiTimezone,
|
|
||||||
}: ResponseTimeToolTipProps) => {
|
|
||||||
if (!label) return null;
|
|
||||||
if (!payload) return null;
|
|
||||||
if (!active) return null;
|
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
|
||||||
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
|
|
||||||
return (
|
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
|
||||||
<Typography>Response time: {responseTime} ms</Typography>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HistogramDetails = ({
|
|
||||||
checks,
|
|
||||||
range,
|
|
||||||
}: {
|
|
||||||
checks: GroupedCheck[];
|
|
||||||
range: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={
|
|
||||||
<Clock
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title="Response times"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={300}
|
|
||||||
>
|
|
||||||
<AreaChart data={checks?.slice()}>
|
|
||||||
<CartesianGrid
|
|
||||||
stroke={theme.palette.divider}
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeOpacity={1}
|
|
||||||
fill="transparent"
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="colorUv"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={theme.palette.primary.main}
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={theme.palette.primary.light}
|
|
||||||
stopOpacity={0}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<XAxis
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
dataKey="bucketDate"
|
|
||||||
tick={(props) => (
|
|
||||||
<XTick
|
|
||||||
{...props}
|
|
||||||
range={range}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
content={(props) => (
|
|
||||||
<ResponseTimeToolTip
|
|
||||||
{...props}
|
|
||||||
range={range}
|
|
||||||
theme={theme}
|
|
||||||
uiTimezone={uiTimezone}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="avgResponseTime"
|
|
||||||
stroke={theme.palette.primary.main}
|
|
||||||
fill="url(#colorUv)"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
|
||||||
import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import type { MonitorStatus } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
import { getStatusColor } from "@/Utils/MonitorUtils";
|
|
||||||
|
|
||||||
const processData = (checks: CheckSnapshot[]) => {
|
|
||||||
if (checks.length === 0) return [];
|
|
||||||
const formattedData: { score: number; date: string }[] = [];
|
|
||||||
|
|
||||||
const calculateScore = (entry: CheckSnapshot) => {
|
|
||||||
const accessibility = entry?.accessibility || 0;
|
|
||||||
const bestPractices = entry?.bestPractices || 0;
|
|
||||||
const performance = entry?.performance || 0;
|
|
||||||
const seo = entry?.seo || 0;
|
|
||||||
return Math.floor((accessibility + bestPractices + performance + seo) / 4);
|
|
||||||
};
|
|
||||||
|
|
||||||
checks.forEach((entry) => {
|
|
||||||
const score = calculateScore(entry);
|
|
||||||
const date = entry.createdAt;
|
|
||||||
formattedData.push({ score, date });
|
|
||||||
});
|
|
||||||
|
|
||||||
return formattedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HistogramPageSpeed = ({
|
|
||||||
checks,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
checks: CheckSnapshot[];
|
|
||||||
status: MonitorStatus;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const scores = processData(checks ?? []);
|
|
||||||
const color = getStatusColor(status, theme);
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
flexWrap="nowrap"
|
|
||||||
gap={theme.spacing(1.5)}
|
|
||||||
height="100px"
|
|
||||||
maxWidth="400px"
|
|
||||||
width="100%"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
overflow={"hidden"}
|
|
||||||
sx={{
|
|
||||||
cursor: "default",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={"100%"}
|
|
||||||
>
|
|
||||||
<AreaChart
|
|
||||||
data={scores}
|
|
||||||
margin={{ top: 10, bottom: -5 }}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<CartesianGrid
|
|
||||||
stroke={theme.palette.divider}
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeOpacity={1}
|
|
||||||
fill="transparent"
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ stroke: theme.palette.divider }}
|
|
||||||
content={<HistogramPageSpeedTooltip />}
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id={`pagespeed-chart-${status}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={color}
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={color}
|
|
||||||
stopOpacity={0}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="score"
|
|
||||||
stroke={color}
|
|
||||||
fill={`url(#pagespeed-chart-${status})`}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { BaseChart } from "@/Components/v2/design-elements";
|
|
||||||
import { TrendingUp } from "lucide-react";
|
|
||||||
import { XTick } from "@/Components/v2/monitors/";
|
|
||||||
import {
|
|
||||||
XAxis,
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
Tooltip,
|
|
||||||
CartesianGrid,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from "recharts";
|
|
||||||
import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors";
|
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import type { Palette } from "@mui/material/styles";
|
|
||||||
type PaletteColorKey = Extract<
|
|
||||||
keyof Palette,
|
|
||||||
"primary" | "error" | "success" | "warning"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ConfigItem {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
palette: PaletteColorKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Record<string, ConfigItem> = {
|
|
||||||
seo: {
|
|
||||||
id: "seo",
|
|
||||||
text: "SEO",
|
|
||||||
palette: "primary",
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
id: "performance",
|
|
||||||
text: "performance",
|
|
||||||
palette: "success",
|
|
||||||
},
|
|
||||||
bestPractices: {
|
|
||||||
id: "bestPractices",
|
|
||||||
text: "best practices",
|
|
||||||
palette: "warning",
|
|
||||||
},
|
|
||||||
accessibility: {
|
|
||||||
id: "accessibility",
|
|
||||||
text: "accessibility",
|
|
||||||
palette: "error",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HistogramPageSpeedDetails = ({
|
|
||||||
checks,
|
|
||||||
range,
|
|
||||||
}: {
|
|
||||||
checks: CheckSnapshot[];
|
|
||||||
range: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={
|
|
||||||
<TrendingUp
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title="Score history"
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
minWidth={25}
|
|
||||||
height={215}
|
|
||||||
>
|
|
||||||
<AreaChart data={checks}>
|
|
||||||
<XAxis
|
|
||||||
dataKey={"createdAt"}
|
|
||||||
tick={(props) => (
|
|
||||||
<XTick
|
|
||||||
{...props}
|
|
||||||
range={range}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<CartesianGrid
|
|
||||||
stroke={theme.palette.divider}
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeOpacity={1}
|
|
||||||
fill="transparent"
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={{ stroke: theme.palette.divider }}
|
|
||||||
content={<HistogramPageSpeedScoresTooltip config={config} />}
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
{Object.values(config).map(({ id, palette }) => {
|
|
||||||
const startColor = theme.palette[palette].main;
|
|
||||||
const endColor = theme.palette[palette].light;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<linearGradient
|
|
||||||
id={id}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
key={id}
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={startColor}
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={endColor}
|
|
||||||
stopOpacity={0}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</defs>
|
|
||||||
{Object.keys(config).map((key) => {
|
|
||||||
const { palette } = config[key];
|
|
||||||
const strokeColor = theme.palette[palette].main;
|
|
||||||
const bgColor = theme.palette.primary.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
connectNulls
|
|
||||||
key={key}
|
|
||||||
dataKey={key}
|
|
||||||
stackId={1}
|
|
||||||
stroke={strokeColor}
|
|
||||||
fill={`url(#${config[key].id})`}
|
|
||||||
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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 { formatDateWithTz } from "@/Utils/TimeUtils";
|
|
||||||
import type { ConfigItem } from "@/Components/v2/monitors";
|
|
||||||
import type { TooltipProps } from "recharts";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
interface HistogramPageSpeedScoresTooltipProps
|
|
||||||
extends Partial<TooltipProps<number, string>> {
|
|
||||||
config: Record<string, ConfigItem>;
|
|
||||||
}
|
|
||||||
export const HistogramPageSpeedScoresTooltip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
config,
|
|
||||||
}: HistogramPageSpeedScoresTooltipProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
border: 1,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
py: theme.spacing(2),
|
|
||||||
px: theme.spacing(4),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
|
|
||||||
</Typography>
|
|
||||||
{Object.keys(config)
|
|
||||||
.reverse()
|
|
||||||
.map((key) => {
|
|
||||||
const { palette } = config[key];
|
|
||||||
const dotColor = theme.palette[palette].main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
key={`${key}-tooltip`}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(3)}
|
|
||||||
mt={theme.spacing(1)}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={theme.spacing(4)}
|
|
||||||
height={theme.spacing(4)}
|
|
||||||
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
textTransform="capitalize"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{config[key].text}
|
|
||||||
</Typography>
|
|
||||||
<Typography>{Math.floor(payload[0].payload[key])}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
|
||||||
import type { TooltipProps } from "recharts";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
export const HistogramPageSpeedTooltip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: TooltipProps<number, string>) => {
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
if (!active || !payload || payload.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = payload[0]?.payload;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
alignItems="flex-start"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: theme.palette.secondary.main,
|
|
||||||
border: 1,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
p: theme.spacing(4),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography>
|
|
||||||
{formatDateWithTz(data?.date, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
|
||||||
</Typography>
|
|
||||||
<Typography>Score: {data?.score}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { BaseChart } from "@/Components/v2/design-elements";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
|
||||||
import type { GroupedCheck } from "@/Types/Check";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
interface XLabelProps {
|
|
||||||
p1: GroupedCheck;
|
|
||||||
p2: GroupedCheck;
|
|
||||||
range: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const XLabel = ({ p1, p2, range }: XLabelProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D";
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<text
|
|
||||||
x={0}
|
|
||||||
y="100%"
|
|
||||||
dy={-3}
|
|
||||||
textAnchor="start"
|
|
||||||
fontSize={11}
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(p1.bucketDate, dateFormat, uiTimezone)}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
x="100%"
|
|
||||||
y="100%"
|
|
||||||
dy={-3}
|
|
||||||
textAnchor="end"
|
|
||||||
fontSize={11}
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(p2.bucketDate, dateFormat, uiTimezone)}
|
|
||||||
</text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface HistogramStatusProps {
|
|
||||||
title: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
checks?: GroupedCheck[];
|
|
||||||
range: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HistogramStatus = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
checks = [],
|
|
||||||
range,
|
|
||||||
}: HistogramStatusProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const CustomTooltip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{ payload: GroupedCheck }>;
|
|
||||||
}) => {
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
const d = payload[0]?.payload;
|
|
||||||
const avg = d?.avgResponseTime ?? 0;
|
|
||||||
const titleText = t("common.charts.labels.averageResponseTime");
|
|
||||||
const fmt = range === "30d" ? "MMM D, YYYY" : "ddd, MMM D, YYYY, h:mm A";
|
|
||||||
let dateLabel = "";
|
|
||||||
if (d?.bucketDate) {
|
|
||||||
const base = new Date(d.bucketDate);
|
|
||||||
const midBucket =
|
|
||||||
range === "30d" ? new Date(base.getTime() + 12 * 60 * 60 * 1000) : base;
|
|
||||||
dateLabel = formatDateWithTz(midBucket.toISOString(), fmt, uiTimezone);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
p: 1,
|
|
||||||
bgcolor: "background.paper",
|
|
||||||
border: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dateLabel ? (
|
|
||||||
<Typography
|
|
||||||
sx={{ color: "text.secondary" }}
|
|
||||||
variant="caption"
|
|
||||||
>
|
|
||||||
{dateLabel}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
<Typography variant="caption">{titleText}</Typography>
|
|
||||||
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalChecks = checks.reduce((acc, check) => acc + (check.totalChecks || 0), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<Stack gap={theme.spacing(8)}>
|
|
||||||
<Stack
|
|
||||||
position="relative"
|
|
||||||
direction="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Typography>Total checks: {totalChecks}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={155}
|
|
||||||
>
|
|
||||||
<BarChart data={checks}>
|
|
||||||
<XAxis
|
|
||||||
stroke={theme.palette.divider}
|
|
||||||
height={15}
|
|
||||||
tick={false}
|
|
||||||
label={
|
|
||||||
<XLabel
|
|
||||||
p1={checks[0]}
|
|
||||||
p2={checks[checks.length - 1]}
|
|
||||||
range={range}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={false}
|
|
||||||
content={<CustomTooltip />}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="avgResponseTime"
|
|
||||||
maxBarSize={7}
|
|
||||||
background={{ fill: "transparent" }}
|
|
||||||
>
|
|
||||||
{checks?.map((groupedCheck, index) => {
|
|
||||||
const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime);
|
|
||||||
return (
|
|
||||||
<Cell
|
|
||||||
key={groupedCheck.bucketDate || `check-${index}`}
|
|
||||||
fill={theme.palette[fillColor].main}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Stack>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { BaseChart } from "@/Components/v2/design-elements";
|
|
||||||
import { FileText } from "lucide-react";
|
|
||||||
import type { CheckAudits, CheckSnapshot } from "@/Types/Check";
|
|
||||||
import { Pie, PieChart, ResponsiveContainer, Label } from "recharts";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { alpha } from "@mui/material/styles";
|
|
||||||
import { useState } from "react";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const CenterLabel = ({ viewBox, value }: any) => {
|
|
||||||
const { cx, cy } = viewBox;
|
|
||||||
return (
|
|
||||||
<foreignObject
|
|
||||||
x={cx - 25}
|
|
||||||
y={cy - 10}
|
|
||||||
width={50}
|
|
||||||
height={50}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="h1"
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</foreignObject>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PiePageSpeed = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const [hoverTitle, setHoverTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!latestCheck) return null;
|
|
||||||
const LABELS: Record<string, string> = {
|
|
||||||
FCP: t("pages.pageSpeed.charts.common.fcp"),
|
|
||||||
SI: t("pages.pageSpeed.charts.common.si"),
|
|
||||||
LCP: t("pages.pageSpeed.charts.common.lcp"),
|
|
||||||
TBT: t("pages.pageSpeed.charts.common.tbt"),
|
|
||||||
CLS: t("pages.pageSpeed.charts.common.cls"),
|
|
||||||
};
|
|
||||||
const metrics: { key: keyof CheckAudits; color: string; weight: number }[] = [
|
|
||||||
{ key: "fcp", color: alpha("#1DE9B6", 0.6), weight: 0.1 },
|
|
||||||
{ key: "si", color: alpha("#7C4DFF", 0.6), weight: 0.1 },
|
|
||||||
{ key: "lcp", color: alpha("#FFB200", 0.6), weight: 0.25 },
|
|
||||||
{ key: "tbt", color: alpha("#00AFFE", 0.6), weight: 0.3 },
|
|
||||||
{ key: "cls", color: alpha("#FF4181", 0.6), weight: 0.25 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const scores = metrics.flatMap(({ key, color, weight }) => {
|
|
||||||
const audit = latestCheck?.audits?.[key];
|
|
||||||
const val = Math.floor((audit?.score ?? 0) * 100);
|
|
||||||
const inverse = 100 - val;
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: `${key.toUpperCase()} Inverse`,
|
|
||||||
value: inverse * weight,
|
|
||||||
fill: "transparent",
|
|
||||||
stroke: color,
|
|
||||||
weight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: key.toUpperCase(),
|
|
||||||
value: val * weight,
|
|
||||||
fill: color,
|
|
||||||
stroke: color,
|
|
||||||
weight,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalScore =
|
|
||||||
(latestCheck.audits?.fcp?.score || 0) * 0.1 +
|
|
||||||
(latestCheck.audits?.si?.score || 0) * 0.1 +
|
|
||||||
(latestCheck.audits?.lcp?.score || 0) * 0.25 +
|
|
||||||
(latestCheck.audits?.tbt?.score || 0) * 0.3 +
|
|
||||||
(latestCheck.audits?.cls?.score || 0) * 0.25;
|
|
||||||
const pageSpeedPalette = getPageSpeedPalette(Math.floor(totalScore * 100));
|
|
||||||
|
|
||||||
const score = [
|
|
||||||
{
|
|
||||||
value: 100,
|
|
||||||
fill: alpha(theme.palette[pageSpeedPalette].light || "#ffffff", 0.6),
|
|
||||||
stroke: "none",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={
|
|
||||||
<FileText
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={t("pages.pageSpeed.charts.pie.title")}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
open={Boolean(hoverTitle)}
|
|
||||||
title={hoverTitle || ""}
|
|
||||||
arrow
|
|
||||||
followCursor
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
"& .recharts-wrapper:focus, & .recharts-surface:focus": {
|
|
||||||
outline: "none",
|
|
||||||
},
|
|
||||||
"& .recharts-wrapper *:focus": { outline: "none" },
|
|
||||||
"& svg:focus, & g:focus, & path:focus": { outline: "none" },
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={250}
|
|
||||||
>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={score}
|
|
||||||
dataKey="value"
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
outerRadius="65%"
|
|
||||||
>
|
|
||||||
<Label
|
|
||||||
position={"center"}
|
|
||||||
content={<CenterLabel value={Math.floor(totalScore * 100)} />}
|
|
||||||
/>
|
|
||||||
</Pie>
|
|
||||||
<Pie
|
|
||||||
data={scores}
|
|
||||||
innerRadius="70%"
|
|
||||||
outerRadius="80%"
|
|
||||||
label={({ name, value }) => `${name}: ${Math.round(value)}`}
|
|
||||||
dataKey="value"
|
|
||||||
stroke="none"
|
|
||||||
onMouseEnter={(_, index: number) => {
|
|
||||||
const d = scores[index];
|
|
||||||
if (!d) return;
|
|
||||||
const isInverse = String(d.name).includes("Inverse");
|
|
||||||
const pair = isInverse && scores[index + 1] ? scores[index + 1] : d;
|
|
||||||
const base = String(pair.name).replace(" Inverse", "");
|
|
||||||
const full = LABELS[base] ?? base;
|
|
||||||
const displayVal = Math.round(Number(pair.value) || 0);
|
|
||||||
setHoverTitle(`${full}: ${displayVal}`);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setHoverTitle(null)}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { BarChart3 } from "lucide-react";
|
|
||||||
import { BaseChart } from "@/Components/v2/design-elements";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
const MetricBox = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
weight,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
weight: number;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const palette = getPageSpeedPalette(value);
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction={"row"}
|
|
||||||
sx={{
|
|
||||||
border: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
p={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<Typography textTransform={"uppercase"}>{label}</Typography>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent={"space-between"}
|
|
||||||
>
|
|
||||||
<Typography>{`${value}%`}</Typography>
|
|
||||||
<Typography>{`${t("pages.pageSpeed.charts.legend.weight")}: ${weight}%`}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Box
|
|
||||||
width={4}
|
|
||||||
bgcolor={theme.palette[palette].light}
|
|
||||||
sx={{
|
|
||||||
borderTopRightRadius: theme.shape.borderRadius,
|
|
||||||
borderBottomRightRadius: theme.shape.borderRadius,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PiePageSpeedLegend = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!latestCheck) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={
|
|
||||||
<BarChart3
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={t("pages.pageSpeed.charts.legend.title")}
|
|
||||||
>
|
|
||||||
<Stack gap={theme.spacing(4)}>
|
|
||||||
<MetricBox
|
|
||||||
label={t("pages.pageSpeed.charts.common.si")}
|
|
||||||
value={Math.floor((latestCheck.audits?.si?.score || 0) * 100)}
|
|
||||||
weight={10}
|
|
||||||
/>
|
|
||||||
<MetricBox
|
|
||||||
label={t("pages.pageSpeed.charts.common.fcp")}
|
|
||||||
value={Math.floor((latestCheck.audits?.fcp?.score || 0) * 100)}
|
|
||||||
weight={10}
|
|
||||||
/>
|
|
||||||
<MetricBox
|
|
||||||
label={t("pages.pageSpeed.charts.common.cls")}
|
|
||||||
value={Math.floor((latestCheck.audits?.cls?.score || 0) * 100)}
|
|
||||||
weight={25}
|
|
||||||
/>
|
|
||||||
<MetricBox
|
|
||||||
label={t("pages.pageSpeed.charts.common.tbt")}
|
|
||||||
value={Math.floor((latestCheck.audits?.tbt?.score || 0) * 100)}
|
|
||||||
weight={30}
|
|
||||||
/>
|
|
||||||
<MetricBox
|
|
||||||
label={t("pages.pageSpeed.charts.common.lcp")}
|
|
||||||
value={Math.floor((latestCheck.audits?.lcp?.score || 0) * 100)}
|
|
||||||
weight={25}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { BaseChart } from "@/Components/v2/design-elements";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { Gauge } from "lucide-react";
|
|
||||||
import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const chartData = [
|
|
||||||
{ name: "max", value: max - avg, color: "transparent" },
|
|
||||||
{ name: "avg", value: avg, color: "red" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const palette = getResponseTimeColor(avg);
|
|
||||||
const msg: Record<string, string> = {
|
|
||||||
success: "Excellent",
|
|
||||||
warning: "Average",
|
|
||||||
danger: "Poor",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon={
|
|
||||||
<Gauge
|
|
||||||
size={20}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={t("common.charts.labels.averageResponseTime")}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
height="100%"
|
|
||||||
position={"relative"}
|
|
||||||
justifyContent={"flex-end"}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={155}
|
|
||||||
>
|
|
||||||
<RadialBarChart
|
|
||||||
cy="100%"
|
|
||||||
data={chartData}
|
|
||||||
startAngle={180}
|
|
||||||
endAngle={0}
|
|
||||||
innerRadius={"120%"}
|
|
||||||
outerRadius={"200%"}
|
|
||||||
>
|
|
||||||
<RadialBar
|
|
||||||
dataKey="value"
|
|
||||||
background={{ fill: theme.palette[palette].light }}
|
|
||||||
>
|
|
||||||
<Cell visibility={"hidden"} />
|
|
||||||
<Cell fill={theme.palette[palette].main} />
|
|
||||||
</RadialBar>
|
|
||||||
</RadialBarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<Stack
|
|
||||||
direction={"row"}
|
|
||||||
justifyContent={"space-between"}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">{t("common.charts.labels.low")}</Typography>
|
|
||||||
<Typography variant="body2">{t("common.charts.labels.high")}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
position="absolute"
|
|
||||||
top={"50%"}
|
|
||||||
right={"50%"}
|
|
||||||
sx={{
|
|
||||||
transform: "translate(50%, 0%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
textAlign={"center"}
|
|
||||||
>
|
|
||||||
{msg[palette]}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
textAlign={"center"}
|
|
||||||
>{`${avg?.toFixed()}ms`}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export * from "./ControlsFilter";
|
|
||||||
export * from "./MonitorStatBoxes";
|
|
||||||
export * from "./charts/HistogramStatus";
|
|
||||||
export * from "./charts/RadialAvgResponse";
|
|
||||||
export * from "./charts/HistogramDetails";
|
|
||||||
export * from "./charts/HistogramPageSpeed";
|
|
||||||
export * from "./charts/HistogramPageSpeedTooltip";
|
|
||||||
export * from "./charts/PiePageSpeed";
|
|
||||||
export * from "./charts/PiePageSpeedLegend";
|
|
||||||
export * from "./charts/HistogramPageSpeedDetails";
|
|
||||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
|
||||||
@@ -10,24 +10,21 @@ const initialState = {
|
|||||||
msg: null,
|
msg: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const register = createAsyncThunk(
|
export const register = createAsyncThunk("auth/register", async (form, thunkApi) => {
|
||||||
"auth/register",
|
try {
|
||||||
async ({ user, token }, thunkApi) => {
|
const res = await networkService.registerUser(form);
|
||||||
try {
|
return res.data;
|
||||||
const res = await networkService.registerUser(user, token);
|
} catch (error) {
|
||||||
return res.data;
|
if (error.response.data) {
|
||||||
} catch (error) {
|
return thunkApi.rejectWithValue(error.response.data);
|
||||||
if (error.response.data) {
|
|
||||||
return thunkApi.rejectWithValue(error.response.data);
|
|
||||||
}
|
|
||||||
const payload = {
|
|
||||||
status: false,
|
|
||||||
msg: error.message ? error.message : "Unknown error",
|
|
||||||
};
|
|
||||||
return thunkApi.rejectWithValue(payload);
|
|
||||||
}
|
}
|
||||||
|
const payload = {
|
||||||
|
status: false,
|
||||||
|
msg: error.message ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
return thunkApi.rejectWithValue(payload);
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
|
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const initialState = {
|
|||||||
distributedUptimeEnabled: false,
|
distributedUptimeEnabled: false,
|
||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -74,9 +73,6 @@ const uiSlice = createSlice({
|
|||||||
setStarPromptOpen: (state, action) => {
|
setStarPromptOpen: (state, action) => {
|
||||||
state.starPromptOpen = action.payload;
|
state.starPromptOpen = action.payload;
|
||||||
},
|
},
|
||||||
setChartType: (state, action) => {
|
|
||||||
state.chartType = action.payload;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,5 +88,4 @@ export const {
|
|||||||
setDistributedUptimeEnabled,
|
setDistributedUptimeEnabled,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import useSWR from "swr";
|
|
||||||
import type { SWRConfiguration } from "swr";
|
|
||||||
import type { AxiosRequestConfig } from "axios";
|
|
||||||
import { get, patch, post, deleteOp } from "@/Utils/ApiClient";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useToast } from "@/Hooks/UseToast";
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
msg: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
|
|
||||||
const res = await get<ApiResponse<T>>(url, config);
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useGet = <T>(
|
|
||||||
url: string | null,
|
|
||||||
axiosConfig?: AxiosRequestConfig,
|
|
||||||
swrConfig?: SWRConfiguration
|
|
||||||
) => {
|
|
||||||
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
|
|
||||||
url,
|
|
||||||
(url: string) => fetcher<T>(url, axiosConfig),
|
|
||||||
swrConfig
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: data?.data ?? null,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch: mutate,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePost = <B = any, R = any>() => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { toastError, toastSuccess } = useToast();
|
|
||||||
|
|
||||||
const postFn = async (
|
|
||||||
endpoint: string,
|
|
||||||
body: B,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<ApiResponse<R> | null> => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await post<ApiResponse<R>>(endpoint, body, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toastSuccess(res.data?.msg || "Operation successful");
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
|
||||||
console.error(err);
|
|
||||||
toastError(errMsg);
|
|
||||||
setError(errMsg);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { post: postFn, loading, error };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePatch = <B = any, R = any>() => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { toastError, toastSuccess } = useToast();
|
|
||||||
|
|
||||||
const patchFn = async (
|
|
||||||
endpoint: string,
|
|
||||||
body?: B,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<ApiResponse<R> | null> => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await patch<ApiResponse<R>>(endpoint, body, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toastSuccess(res.data?.msg || "Operation successful");
|
|
||||||
return res.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
|
||||||
toastError(errMsg);
|
|
||||||
setError(errMsg);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { patch: patchFn, loading, error };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDelete = <R = any>() => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { toastError, toastSuccess } = useToast();
|
|
||||||
|
|
||||||
const deleteFn = async (
|
|
||||||
endpoint: string,
|
|
||||||
config?: AxiosRequestConfig
|
|
||||||
): Promise<ApiResponse<R> | null> => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await deleteOp<ApiResponse<R>>(endpoint, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toastSuccess(res.data?.msg || "Operation successful");
|
|
||||||
return res.data;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
|
|
||||||
toastError(errMsg);
|
|
||||||
setError(errMsg);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteFn, loading, error };
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { toast, type ToastOptions } from "react-toastify";
|
|
||||||
|
|
||||||
export const useToast = () => {
|
|
||||||
const showToast = (message: string, options?: ToastOptions) => {
|
|
||||||
toast.dismiss();
|
|
||||||
const baseStyle: React.CSSProperties = {
|
|
||||||
whiteSpace: "pre-line",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
};
|
|
||||||
toast(message, {
|
|
||||||
...options,
|
|
||||||
style: { ...baseStyle, ...(options?.style || {}) },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
|
||||||
showToast(msg, { ...opts, type: "success" });
|
|
||||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
|
||||||
showToast(msg, { ...opts, type: "error" });
|
|
||||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
|
||||||
showToast(msg, { ...opts, type: "info" });
|
|
||||||
|
|
||||||
return { showToast, toastSuccess, toastError, toastInfo };
|
|
||||||
};
|
|
||||||
@@ -137,7 +137,7 @@ const useFetchChecksByMonitor = ({
|
|||||||
return [checks, checksCount, isLoading, networkError];
|
return [checks, checksCount, isLoading, networkError];
|
||||||
};
|
};
|
||||||
|
|
||||||
const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
|
const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
|
||||||
const [summary, setSummary] = useState(undefined);
|
const [summary, setSummary] = useState(undefined);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [networkError, setNetworkError] = useState(false);
|
const [networkError, setNetworkError] = useState(false);
|
||||||
@@ -147,7 +147,7 @@ const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const res = await networkService.getChecksAndSummaryByTeamId({ dateRange });
|
const res = await networkService.getChecksAndSummaryByTeamId();
|
||||||
setSummary(res.data.data);
|
setSummary(res.data.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNetworkError(true);
|
setNetworkError(true);
|
||||||
@@ -158,7 +158,7 @@ const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchSummary();
|
fetchSummary();
|
||||||
}, [dateRange, updateTrigger]);
|
}, [updateTrigger]);
|
||||||
|
|
||||||
return [summary, isLoading, networkError];
|
return [summary, isLoading, networkError];
|
||||||
};
|
};
|
||||||
@@ -185,9 +185,36 @@ const useResolveIncident = () => {
|
|||||||
return [resolveIncident, isLoading];
|
return [resolveIncident, isLoading];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useAcknowledgeChecks = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const acknowledge = async (setUpdateTrigger, monitorId = null) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (monitorId) {
|
||||||
|
await networkService.updateMonitorChecksStatus({ monitorId, ack: true });
|
||||||
|
} else {
|
||||||
|
await networkService.updateAllChecksStatus({ ack: true });
|
||||||
|
}
|
||||||
|
setUpdateTrigger((prev) => !prev);
|
||||||
|
} catch (error) {
|
||||||
|
const toastMessage = monitorId
|
||||||
|
? t("checkHooks.failureResolveMonitor")
|
||||||
|
: t("checkHooks.failureResolveAll");
|
||||||
|
createToast({ body: toastMessage });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { acknowledge, isLoading };
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFetchChecksByMonitor,
|
useFetchChecksByMonitor,
|
||||||
useFetchChecksTeam,
|
useFetchChecksTeam,
|
||||||
useFetchChecksSummaryByTeamId,
|
useFetchChecksSummaryByTeamId,
|
||||||
useResolveIncident,
|
useResolveIncident,
|
||||||
|
useAcknowledgeChecks,
|
||||||
};
|
};
|
||||||
@@ -344,6 +344,7 @@ export const useDeleteMonitor = () => {
|
|||||||
const deleteMonitor = async ({ monitor, redirect }) => {
|
const deleteMonitor = async ({ monitor, redirect }) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
console.log(monitor);
|
||||||
await networkService.deleteMonitorById({ monitorId: monitor.id });
|
await networkService.deleteMonitorById({ monitorId: monitor.id });
|
||||||
createToast({ body: "Monitor deleted successfully!" });
|
createToast({ body: "Monitor deleted successfully!" });
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const useGetNotificationById = (id, setNotification) => {
|
|||||||
const notificationData = {
|
const notificationData = {
|
||||||
address: notification?.address,
|
address: notification?.address,
|
||||||
notificationName: notification?.notificationName,
|
notificationName: notification?.notificationName,
|
||||||
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id,
|
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id,
|
||||||
};
|
};
|
||||||
|
|
||||||
setNotification(notificationData);
|
setNotification(notificationData);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -167,11 +167,7 @@ const PasswordPanel = () => {
|
|||||||
value={localData.password}
|
value={localData.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors[idToName["edit-current-password"]] ? true : false}
|
error={errors[idToName["edit-current-password"]] ? true : false}
|
||||||
helperText={
|
helperText={errors[idToName["edit-current-password"]]}
|
||||||
errors[idToName["edit-current-password"]]
|
|
||||||
? t(errors[idToName["edit-current-password"]])
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
endAdornment={<PasswordEndAdornment />}
|
endAdornment={<PasswordEndAdornment />}
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
@@ -197,11 +193,7 @@ const PasswordPanel = () => {
|
|||||||
value={localData.newPassword}
|
value={localData.newPassword}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors[idToName["edit-new-password"]] ? true : false}
|
error={errors[idToName["edit-new-password"]] ? true : false}
|
||||||
helperText={
|
helperText={errors[idToName["edit-new-password"]]}
|
||||||
errors[idToName["edit-new-password"]]
|
|
||||||
? t(errors[idToName["edit-new-password"]])
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
endAdornment={<PasswordEndAdornment />}
|
endAdornment={<PasswordEndAdornment />}
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
@@ -227,11 +219,7 @@ const PasswordPanel = () => {
|
|||||||
value={localData.confirm}
|
value={localData.confirm}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors[idToName["edit-confirm-password"]] ? true : false}
|
error={errors[idToName["edit-confirm-password"]] ? true : false}
|
||||||
helperText={
|
helperText={errors[idToName["edit-confirm-password"]]}
|
||||||
errors[idToName["edit-confirm-password"]]
|
|
||||||
? t(errors[idToName["edit-confirm-password"]])
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
endAdornment={<PasswordEndAdornment />}
|
endAdornment={<PasswordEndAdornment />}
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -210,11 +210,7 @@ const ProfilePanel = () => {
|
|||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors[idToName["edit-first-name"]] ? true : false}
|
error={errors[idToName["edit-first-name"]] ? true : false}
|
||||||
helperText={
|
helperText={errors[idToName["edit-first-name"]]}
|
||||||
errors[idToName["edit-first-name"]]
|
|
||||||
? t(errors[idToName["edit-first-name"]])
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -232,11 +228,7 @@ const ProfilePanel = () => {
|
|||||||
value={localData.lastName}
|
value={localData.lastName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
error={errors[idToName["edit-last-name"]] ? true : false}
|
error={errors[idToName["edit-last-name"]] ? true : false}
|
||||||
helperText={
|
helperText={errors[idToName["edit-last-name"]]}
|
||||||
errors[idToName["edit-last-name"]]
|
|
||||||
? t(errors[idToName["edit-last-name"]])
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
flex={1}
|
flex={1}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user