diff --git a/matrixgw_frontend/src/App.tsx b/matrixgw_frontend/src/App.tsx
index 4b0d983..22acab2 100644
--- a/matrixgw_frontend/src/App.tsx
+++ b/matrixgw_frontend/src/App.tsx
@@ -11,8 +11,8 @@ import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { HomeRoute } from "./routes/HomeRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute";
-import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
+import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
interface AuthContext {
signedIn: boolean;
diff --git a/matrixgw_frontend/src/widgets/BaseAuthenticatedPage.tsx b/matrixgw_frontend/src/widgets/BaseAuthenticatedPage.tsx
deleted file mode 100644
index 79607a4..0000000
--- a/matrixgw_frontend/src/widgets/BaseAuthenticatedPage.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export function BaseAuthenticatedPage(): React.ReactElement {
- return
todo authenticated
;
-}
diff --git a/matrixgw_frontend/src/widgets/dashboard/BaseAuthenticatedPage.tsx b/matrixgw_frontend/src/widgets/dashboard/BaseAuthenticatedPage.tsx
new file mode 100644
index 0000000..7973152
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/BaseAuthenticatedPage.tsx
@@ -0,0 +1,96 @@
+import { mdiMessageTextFast } from "@mdi/js";
+import Icon from "@mdi/react";
+import Box from "@mui/material/Box";
+import { useTheme } from "@mui/material/styles";
+import Toolbar from "@mui/material/Toolbar";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import * as React from "react";
+import { Outlet } from "react-router";
+import DashboardHeader from "./DashboardHeader";
+import DashboardSidebar from "./DashboardSidebar";
+
+export default function BaseAuthenticatedPage(): React.ReactElement {
+ const theme = useTheme();
+
+ const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
+ React.useState(true);
+ const [isMobileNavigationExpanded, setIsMobileNavigationExpanded] =
+ React.useState(false);
+
+ const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
+
+ const isNavigationExpanded = isOverMdViewport
+ ? isDesktopNavigationExpanded
+ : isMobileNavigationExpanded;
+
+ const setIsNavigationExpanded = React.useCallback(
+ (newExpanded: boolean) => {
+ if (isOverMdViewport) {
+ setIsDesktopNavigationExpanded(newExpanded);
+ } else {
+ setIsMobileNavigationExpanded(newExpanded);
+ }
+ },
+ [
+ isOverMdViewport,
+ setIsDesktopNavigationExpanded,
+ setIsMobileNavigationExpanded,
+ ]
+ );
+
+ const handleToggleHeaderMenu = React.useCallback(
+ (isExpanded: boolean) => {
+ setIsNavigationExpanded(isExpanded);
+ },
+ [setIsNavigationExpanded]
+ );
+
+ const layoutRef = React.useRef(null);
+
+ return (
+
+ }
+ title=""
+ menuOpen={isNavigationExpanded}
+ onToggleMenu={handleToggleHeaderMenu}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardHeader.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardHeader.tsx
new file mode 100644
index 0000000..5c98db7
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardHeader.tsx
@@ -0,0 +1,130 @@
+import * as React from "react";
+import { styled, useTheme } from "@mui/material/styles";
+import Box from "@mui/material/Box";
+import MuiAppBar from "@mui/material/AppBar";
+import IconButton from "@mui/material/IconButton";
+import Toolbar from "@mui/material/Toolbar";
+import Tooltip from "@mui/material/Tooltip";
+import Typography from "@mui/material/Typography";
+import MenuIcon from "@mui/icons-material/Menu";
+import MenuOpenIcon from "@mui/icons-material/MenuOpen";
+import Stack from "@mui/material/Stack";
+import { Link } from "react-router";
+import ThemeSwitcher from "./ThemeSwitcher";
+
+const AppBar = styled(MuiAppBar)(({ theme }) => ({
+ borderWidth: 0,
+ borderBottomWidth: 1,
+ borderStyle: "solid",
+ borderColor: (theme.vars ?? theme).palette.divider,
+ boxShadow: "none",
+ zIndex: theme.zIndex.drawer + 1,
+}));
+
+const LogoContainer = styled("div")({
+ position: "relative",
+ height: 40,
+ display: "flex",
+ alignItems: "center",
+ "& img": {
+ maxHeight: 40,
+ },
+});
+
+export interface DashboardHeaderProps {
+ logo?: React.ReactNode;
+ title?: string;
+ menuOpen: boolean;
+ onToggleMenu: (open: boolean) => void;
+}
+
+export default function DashboardHeader({
+ logo,
+ title,
+ menuOpen,
+ onToggleMenu,
+}: DashboardHeaderProps) {
+ const theme = useTheme();
+
+ const handleMenuOpen = React.useCallback(() => {
+ onToggleMenu(!menuOpen);
+ }, [menuOpen, onToggleMenu]);
+
+ const getMenuIcon = React.useCallback(
+ (isExpanded: boolean) => {
+ const expandMenuActionText = "Expand";
+ const collapseMenuActionText = "Collapse";
+
+ return (
+
+
+
+ {isExpanded ? : }
+
+
+
+ );
+ },
+ [handleMenuOpen]
+ );
+
+ return (
+
+
+
+
+ {getMenuIcon(menuOpen)}
+
+
+ {logo ? {logo} : null}
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardSidebar.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebar.tsx
new file mode 100644
index 0000000..26111f6
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebar.tsx
@@ -0,0 +1,281 @@
+import * as React from "react";
+import { useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import Box from "@mui/material/Box";
+import Drawer from "@mui/material/Drawer";
+import List from "@mui/material/List";
+import Toolbar from "@mui/material/Toolbar";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import PersonIcon from "@mui/icons-material/Person";
+import BarChartIcon from "@mui/icons-material/BarChart";
+import DescriptionIcon from "@mui/icons-material/Description";
+import LayersIcon from "@mui/icons-material/Layers";
+import { matchPath, useLocation } from "react-router";
+import DashboardSidebarContext from "./DashboardSidebarContext";
+import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from "./constants";
+import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
+import DashboardSidebarHeaderItem from "./DashboardSidebarHeaderItem";
+import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
+import {
+ getDrawerSxTransitionMixin,
+ getDrawerWidthTransitionMixin,
+} from "./mixins";
+
+export interface DashboardSidebarProps {
+ expanded?: boolean;
+ setExpanded: (expanded: boolean) => void;
+ disableCollapsibleSidebar?: boolean;
+ container?: Element;
+}
+
+export default function DashboardSidebar({
+ expanded = true,
+ setExpanded,
+ disableCollapsibleSidebar = false,
+ container,
+}: DashboardSidebarProps) {
+ const theme = useTheme();
+
+ const { pathname } = useLocation();
+
+ const [expandedItemIds, setExpandedItemIds] = React.useState([]);
+
+ const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
+ const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
+
+ const [isFullyExpanded, setIsFullyExpanded] = React.useState(expanded);
+ const [isFullyCollapsed, setIsFullyCollapsed] = React.useState(!expanded);
+
+ React.useEffect(() => {
+ if (expanded) {
+ const drawerWidthTransitionTimeout = setTimeout(() => {
+ setIsFullyExpanded(true);
+ }, theme.transitions.duration.enteringScreen);
+
+ return () => clearTimeout(drawerWidthTransitionTimeout);
+ }
+
+ setIsFullyExpanded(false);
+
+ return () => {};
+ }, [expanded, theme.transitions.duration.enteringScreen]);
+
+ React.useEffect(() => {
+ if (!expanded) {
+ const drawerWidthTransitionTimeout = setTimeout(() => {
+ setIsFullyCollapsed(true);
+ }, theme.transitions.duration.leavingScreen);
+
+ return () => clearTimeout(drawerWidthTransitionTimeout);
+ }
+
+ setIsFullyCollapsed(false);
+
+ return () => {};
+ }, [expanded, theme.transitions.duration.leavingScreen]);
+
+ const mini = !disableCollapsibleSidebar && !expanded;
+
+ const handleSetSidebarExpanded = React.useCallback(
+ (newExpanded: boolean) => () => {
+ setExpanded(newExpanded);
+ },
+ [setExpanded]
+ );
+
+ const handlePageItemClick = React.useCallback(
+ (itemId: string, hasNestedNavigation: boolean) => {
+ if (hasNestedNavigation && !mini) {
+ setExpandedItemIds((previousValue) =>
+ previousValue.includes(itemId)
+ ? previousValue.filter(
+ (previousValueItemId) => previousValueItemId !== itemId
+ )
+ : [...previousValue, itemId]
+ );
+ } else if (!isOverSmViewport && !hasNestedNavigation) {
+ setExpanded(false);
+ }
+ },
+ [mini, setExpanded, isOverSmViewport]
+ );
+
+ const hasDrawerTransitions =
+ isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
+
+ const getDrawerContent = React.useCallback(
+ (viewport: "phone" | "tablet" | "desktop") => (
+
+
+
+
+ Main items
+ }
+ href="/employees"
+ selected={
+ !!matchPath("/employees/*", pathname) || pathname === "/"
+ }
+ />
+
+
+ Example items
+
+ }
+ href="/reports"
+ selected={!!matchPath("/reports", pathname)}
+ defaultExpanded={!!matchPath("/reports", pathname)}
+ expanded={expandedItemIds.includes("reports")}
+ nestedNavigation={
+
+ }
+ href="/reports/sales"
+ selected={!!matchPath("/reports/sales", pathname)}
+ />
+ }
+ href="/reports/traffic"
+ selected={!!matchPath("/reports/traffic", pathname)}
+ />
+
+ }
+ />
+ }
+ href="/integrations"
+ selected={!!matchPath("/integrations", pathname)}
+ />
+
+
+
+ ),
+ [mini, hasDrawerTransitions, isFullyExpanded, expandedItemIds, pathname]
+ );
+
+ const getDrawerSharedSx = React.useCallback(
+ (isTemporary: boolean) => {
+ const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
+
+ return {
+ displayPrint: "none",
+ width: drawerWidth,
+ flexShrink: 0,
+ ...getDrawerWidthTransitionMixin(expanded),
+ ...(isTemporary ? { position: "absolute" } : {}),
+ [`& .MuiDrawer-paper`]: {
+ position: "absolute",
+ width: drawerWidth,
+ boxSizing: "border-box",
+ backgroundImage: "none",
+ ...getDrawerWidthTransitionMixin(expanded),
+ },
+ };
+ },
+ [expanded, mini]
+ );
+
+ const sidebarContextValue = React.useMemo(() => {
+ return {
+ onPageItemClick: handlePageItemClick,
+ mini,
+ fullyExpanded: isFullyExpanded,
+ fullyCollapsed: isFullyCollapsed,
+ hasDrawerTransitions,
+ };
+ }, [
+ handlePageItemClick,
+ mini,
+ isFullyExpanded,
+ isFullyCollapsed,
+ hasDrawerTransitions,
+ ]);
+
+ return (
+
+
+ {getDrawerContent("phone")}
+
+
+ {getDrawerContent("tablet")}
+
+
+ {getDrawerContent("desktop")}
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarContext.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarContext.tsx
new file mode 100644
index 0000000..56efe06
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarContext.tsx
@@ -0,0 +1,5 @@
+import * as React from "react";
+
+const DashboardSidebarContext = React.createContext(null);
+
+export default DashboardSidebarContext;
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarDividerItem.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarDividerItem.tsx
new file mode 100644
index 0000000..d5e3178
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarDividerItem.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import Divider from "@mui/material/Divider";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import DashboardSidebarContext from "./DashboardSidebarContext";
+import { getDrawerSxTransitionMixin } from "./mixins";
+
+export default function DashboardSidebarDividerItem() {
+ const sidebarContext = React.useContext(DashboardSidebarContext);
+ if (!sidebarContext) {
+ throw new Error("Sidebar context was used without a provider.");
+ }
+ const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
+
+ return (
+
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarHeaderItem.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarHeaderItem.tsx
new file mode 100644
index 0000000..d4dc3ce
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarHeaderItem.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import ListSubheader from "@mui/material/ListSubheader";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import DashboardSidebarContext from "./DashboardSidebarContext";
+import { DRAWER_WIDTH } from "./constants";
+import { getDrawerSxTransitionMixin } from "./mixins";
+
+export interface DashboardSidebarHeaderItemProps {
+ children?: React.ReactNode;
+}
+
+export default function DashboardSidebarHeaderItem({
+ children,
+}: DashboardSidebarHeaderItemProps) {
+ const sidebarContext = React.useContext(DashboardSidebarContext);
+ if (!sidebarContext) {
+ throw new Error("Sidebar context was used without a provider.");
+ }
+ const {
+ mini = false,
+ fullyExpanded = true,
+ hasDrawerTransitions,
+ } = sidebarContext;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarPageItem.tsx b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarPageItem.tsx
new file mode 100644
index 0000000..637db5b
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/DashboardSidebarPageItem.tsx
@@ -0,0 +1,253 @@
+import * as React from "react";
+import { type Theme, type SxProps } from "@mui/material/styles";
+import Avatar from "@mui/material/Avatar";
+import Box from "@mui/material/Box";
+import Collapse from "@mui/material/Collapse";
+import Grow from "@mui/material/Grow";
+import ListItem from "@mui/material/ListItem";
+import ListItemButton from "@mui/material/ListItemButton";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import ListItemText from "@mui/material/ListItemText";
+import Paper from "@mui/material/Paper";
+import Typography from "@mui/material/Typography";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import { Link } from "react-router";
+import DashboardSidebarContext from "./DashboardSidebarContext";
+import { MINI_DRAWER_WIDTH } from "./constants";
+
+export interface DashboardSidebarPageItemProps {
+ id: string;
+ title: string;
+ icon?: React.ReactNode;
+ href: string;
+ action?: React.ReactNode;
+ defaultExpanded?: boolean;
+ expanded?: boolean;
+ selected?: boolean;
+ disabled?: boolean;
+ nestedNavigation?: React.ReactNode;
+}
+
+export default function DashboardSidebarPageItem({
+ id,
+ title,
+ icon,
+ href,
+ action,
+ defaultExpanded = false,
+ expanded = defaultExpanded,
+ selected = false,
+ disabled = false,
+ nestedNavigation,
+}: DashboardSidebarPageItemProps) {
+ const sidebarContext = React.useContext(DashboardSidebarContext);
+ if (!sidebarContext) {
+ throw new Error("Sidebar context was used without a provider.");
+ }
+ const {
+ onPageItemClick,
+ mini = false,
+ fullyExpanded = true,
+ fullyCollapsed = false,
+ } = sidebarContext;
+
+ const [isHovered, setIsHovered] = React.useState(false);
+
+ const handleClick = React.useCallback(() => {
+ if (onPageItemClick) {
+ onPageItemClick(id, !!nestedNavigation);
+ }
+ }, [onPageItemClick, id, nestedNavigation]);
+
+ let nestedNavigationCollapseSx: SxProps = { display: "none" };
+ if (mini && fullyCollapsed) {
+ nestedNavigationCollapseSx = {
+ fontSize: 18,
+ position: "absolute",
+ top: "41.5%",
+ right: "2px",
+ transform: "translateY(-50%) rotate(-90deg)",
+ };
+ } else if (!mini && fullyExpanded) {
+ nestedNavigationCollapseSx = {
+ ml: 0.5,
+ fontSize: 20,
+ transform: `rotate(${expanded ? 0 : -90}deg)`,
+ transition: (theme: Theme) =>
+ theme.transitions.create("transform", {
+ easing: theme.transitions.easing.sharp,
+ duration: 100,
+ }),
+ };
+ }
+
+ const hasExternalHref = href
+ ? href.startsWith("http://") || href.startsWith("https://")
+ : false;
+
+ const LinkComponent = hasExternalHref ? "a" : Link;
+
+ const miniNestedNavigationSidebarContextValue = React.useMemo(() => {
+ return {
+ onPageItemClick: onPageItemClick ?? (() => {}),
+ mini: false,
+ fullyExpanded: true,
+ fullyCollapsed: false,
+ hasDrawerTransitions: false,
+ };
+ }, [onPageItemClick]);
+
+ return (
+
+ {
+ setIsHovered(true);
+ },
+ onMouseLeave: () => {
+ setIsHovered(false);
+ },
+ }
+ : {})}
+ sx={{
+ display: "block",
+ py: 0,
+ px: 1,
+ overflowX: "hidden",
+ }}
+ >
+
+ {icon || mini ? (
+
+
+ {icon ?? null}
+ {!icon && mini ? (
+
+ {title
+ .split(" ")
+ .slice(0, 2)
+ .map((titleWord) => titleWord.charAt(0).toUpperCase())}
+
+ ) : null}
+
+ {mini ? (
+
+ {title}
+
+ ) : null}
+
+ ) : null}
+ {!mini ? (
+
+ ) : null}
+ {action && !mini && fullyExpanded ? action : null}
+ {nestedNavigation ? (
+
+ ) : null}
+
+ {nestedNavigation && mini ? (
+
+
+
+
+ {nestedNavigation}
+
+
+
+
+ ) : null}
+
+ {nestedNavigation && !mini ? (
+
+ {nestedNavigation}
+
+ ) : null}
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/SidebarDividerItem.tsx b/matrixgw_frontend/src/widgets/dashboard/SidebarDividerItem.tsx
new file mode 100644
index 0000000..d5e3178
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/SidebarDividerItem.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import Divider from "@mui/material/Divider";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import DashboardSidebarContext from "./DashboardSidebarContext";
+import { getDrawerSxTransitionMixin } from "./mixins";
+
+export default function DashboardSidebarDividerItem() {
+ const sidebarContext = React.useContext(DashboardSidebarContext);
+ if (!sidebarContext) {
+ throw new Error("Sidebar context was used without a provider.");
+ }
+ const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
+
+ return (
+
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/ThemeSwitcher.tsx b/matrixgw_frontend/src/widgets/dashboard/ThemeSwitcher.tsx
new file mode 100644
index 0000000..0fd38c6
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/ThemeSwitcher.tsx
@@ -0,0 +1,59 @@
+import * as React from "react";
+import { useTheme, useColorScheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import IconButton from "@mui/material/IconButton";
+import Tooltip from "@mui/material/Tooltip";
+import DarkModeIcon from "@mui/icons-material/DarkMode";
+import LightModeIcon from "@mui/icons-material/LightMode";
+import type {} from "@mui/material/themeCssVarsAugmentation";
+
+export default function ThemeSwitcher() {
+ const theme = useTheme();
+
+ const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
+ const preferredMode = prefersDarkMode ? "dark" : "light";
+
+ const { mode, setMode } = useColorScheme();
+
+ const paletteMode = !mode || mode === "system" ? preferredMode : mode;
+
+ const toggleMode = React.useCallback(() => {
+ setMode(paletteMode === "dark" ? "light" : "dark");
+ }, [setMode, paletteMode]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/matrixgw_frontend/src/widgets/dashboard/constants.ts b/matrixgw_frontend/src/widgets/dashboard/constants.ts
new file mode 100644
index 0000000..780f97a
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/constants.ts
@@ -0,0 +1,2 @@
+export const DRAWER_WIDTH = 240; // px
+export const MINI_DRAWER_WIDTH = 90; // px
diff --git a/matrixgw_frontend/src/widgets/dashboard/mixins.ts b/matrixgw_frontend/src/widgets/dashboard/mixins.ts
new file mode 100644
index 0000000..bee5cd9
--- /dev/null
+++ b/matrixgw_frontend/src/widgets/dashboard/mixins.ts
@@ -0,0 +1,23 @@
+import { type Theme } from "@mui/material/styles";
+
+export function getDrawerSxTransitionMixin(
+ isExpanded: boolean,
+ property: string
+) {
+ return {
+ transition: (theme: Theme) =>
+ theme.transitions.create(property, {
+ easing: theme.transitions.easing.sharp,
+ duration: isExpanded
+ ? theme.transitions.duration.enteringScreen
+ : theme.transitions.duration.leavingScreen,
+ }),
+ };
+}
+
+export function getDrawerWidthTransitionMixin(isExpanded: boolean) {
+ return {
+ ...getDrawerSxTransitionMixin(isExpanded, "width"),
+ overflowX: "hidden",
+ };
+}