3 Commits

Author SHA1 Message Date
3de26c0fff Simplify dashboard code 2025-11-04 22:02:56 +01:00
79b5a767f3 Improve sidebar 2025-11-04 21:51:20 +01:00
fdcd565431 Add base authenticated route 2025-11-04 21:31:54 +01:00
12 changed files with 715 additions and 4 deletions

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
export function BaseAuthenticatedPage(): React.ReactElement {
return <p>todo authenticated</p>;
}

View File

@@ -0,0 +1,92 @@
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(false);
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<HTMLDivElement>(null);
return (
<Box
ref={layoutRef}
sx={{
position: "relative",
display: "flex",
overflow: "hidden",
height: "100%",
width: "100%",
}}
>
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar
expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded}
container={layoutRef?.current ?? undefined}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minWidth: 0,
}}
>
<Toolbar sx={{ displayPrint: "none" }} />
<Box
component="main"
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
overflow: "auto",
padding: "50px",
}}
>
<Outlet />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,125 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import MuiAppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles";
import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import * as React from "react";
import { RouterLink } from "../RouterLink";
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 {
menuOpen: boolean;
onToggleMenu: (open: boolean) => void;
}
export default function DashboardHeader({
menuOpen,
onToggleMenu,
}: DashboardHeaderProps) {
const handleMenuOpen = React.useCallback(() => {
onToggleMenu(!menuOpen);
}, [menuOpen, onToggleMenu]);
const getMenuIcon = React.useCallback(
(isExpanded: boolean) => {
const expandMenuActionText = "Expand";
const collapseMenuActionText = "Collapse";
return (
<Tooltip
title={`${
isExpanded ? collapseMenuActionText : expandMenuActionText
} menu`}
enterDelay={200}
>
<div>
<IconButton
size="small"
aria-label={`${
isExpanded ? collapseMenuActionText : expandMenuActionText
} navigation menu`}
onClick={handleMenuOpen}
>
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
</IconButton>
</div>
</Tooltip>
);
},
[handleMenuOpen]
);
return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: "none" }}>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
flexWrap: "wrap",
width: "100%",
}}
>
<Stack direction="row" alignItems="center">
<Box sx={{ mr: 3 }}>{getMenuIcon(menuOpen)}</Box>
<RouterLink to="/">
<Stack direction="row" alignItems="center">
<LogoContainer>
<Icon path={mdiMessageTextFast} size="2em" />
</LogoContainer>
<Typography
variant="h6"
sx={{
fontWeight: "700",
ml: 1,
whiteSpace: "nowrap",
lineHeight: 1,
}}
>
MatrixGW
</Typography>
</Stack>
</RouterLink>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ marginLeft: "auto" }}
>
<Stack direction="row" alignItems="center">
<ThemeSwitcher />
</Stack>
</Stack>
</Stack>
</Toolbar>
</AppBar>
);
}

View File

@@ -0,0 +1,205 @@
import { mdiBug, mdiForum, mdiKeyVariant, mdiLinkLock } from "@mdi/js";
import Icon from "@mdi/react";
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 { useTheme } from "@mui/material/styles";
import type {} from "@mui/material/themeCssVarsAugmentation";
import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react";
import DashboardSidebarContext from "./DashboardSidebarContext";
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from "./constants";
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 isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
const [isFullyExpanded, setIsFullyExpanded] = 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]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => {
setExpanded(newExpanded);
},
[setExpanded]
);
const handlePageItemClick = React.useCallback(() => {
if (!isOverSmViewport) {
setExpanded(false);
}
}, [mini, setExpanded, isOverSmViewport]);
const hasDrawerTransitions =
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => (
<React.Fragment>
<Toolbar />
<Box
component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
overflow: "auto",
scrollbarGutter: mini ? "stable" : "auto",
overflowX: "hidden",
pt: !mini ? 0 : 2,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
: {}),
}}
>
<List
dense
sx={{
padding: mini ? 0 : 0.5,
mb: 4,
width: mini ? MINI_DRAWER_WIDTH : "auto",
}}
>
<DashboardSidebarPageItem
title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />}
href="/"
/>
<DashboardSidebarDividerItem />
<DashboardSidebarPageItem
title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
href="/matrixlink"
/>
<DashboardSidebarPageItem
title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
href="/tokens"
/>
<DashboardSidebarPageItem
title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />}
href="/wsdebug"
/>
</List>
</Box>
</React.Fragment>
),
[mini, hasDrawerTransitions, isFullyExpanded]
);
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,
hasDrawerTransitions,
};
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]);
return (
<DashboardSidebarContext.Provider value={sidebarContextValue}>
<Drawer
container={container}
variant="temporary"
open={expanded}
onClose={handleSetSidebarExpanded(false)}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: {
xs: "block",
sm: disableCollapsibleSidebar ? "block" : "none",
md: "none",
},
...getDrawerSharedSx(true),
}}
>
{getDrawerContent("phone")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: {
xs: "none",
sm: disableCollapsibleSidebar ? "none" : "block",
md: "none",
},
...getDrawerSharedSx(false),
}}
>
{getDrawerContent("tablet")}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
...getDrawerSharedSx(false),
}}
>
{getDrawerContent("desktop")}
</Drawer>
</DashboardSidebarContext.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import * as React from "react";
const DashboardSidebarContext = React.createContext<{
onPageItemClick: () => void;
mini: boolean;
fullyExpanded: boolean;
hasDrawerTransitions: boolean;
} | null>(null);
export default DashboardSidebarContext;

View File

@@ -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 (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
: {}),
}}
/>
</li>
);
}

View File

@@ -0,0 +1,142 @@
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
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 Typography from "@mui/material/Typography";
import type {} from "@mui/material/themeCssVarsAugmentation";
import * as React from "react";
import { Link, matchPath, useLocation } from "react-router";
import DashboardSidebarContext from "./DashboardSidebarContext";
import { MINI_DRAWER_WIDTH } from "./constants";
export interface DashboardSidebarPageItemProps {
title: string;
icon?: React.ReactNode;
href: string;
action?: React.ReactNode;
disabled?: boolean;
}
export default function DashboardSidebarPageItem({
title,
icon,
href,
action,
disabled = false,
}: DashboardSidebarPageItemProps) {
const { pathname } = useLocation();
const sidebarContext = React.useContext(DashboardSidebarContext);
if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider.");
}
const {
onPageItemClick,
mini = false,
fullyExpanded = true,
} = sidebarContext;
const hasExternalHref = href
? href.startsWith("http://") || href.startsWith("https://")
: false;
const LinkComponent = hasExternalHref ? "a" : Link;
const selected = !!matchPath(href, pathname);
return (
<React.Fragment>
<ListItem disablePadding style={{ padding: "5px" }}>
<ListItemButton
selected={selected}
disabled={disabled}
sx={{
height: mini ? 50 : "auto",
}}
{...{
LinkComponent,
...(hasExternalHref
? {
target: "_blank",
rel: "noopener noreferrer",
}
: {}),
to: href,
onClick: onPageItemClick,
}}
>
{icon || mini ? (
<Box
sx={
mini
? {
position: "absolute",
left: "50%",
top: "calc(50% - 6px)",
transform: "translate(-50%, -50%)",
}
: {}
}
>
<ListItemIcon
sx={{
display: "flex",
alignItems: "center",
justifyContent: mini ? "center" : "auto",
}}
>
{icon ?? null}
{!icon && mini ? (
<Avatar
sx={{
fontSize: 10,
height: 16,
width: 16,
}}
>
{title
.split(" ")
.slice(0, 2)
.map((titleWord) => titleWord.charAt(0).toUpperCase())}
</Avatar>
) : null}
</ListItemIcon>
{mini ? (
<Typography
variant="caption"
sx={{
position: "absolute",
bottom: -18,
left: "50%",
transform: "translateX(-50%)",
fontSize: 10,
fontWeight: 500,
textAlign: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: MINI_DRAWER_WIDTH - 28,
}}
>
{title}
</Typography>
) : null}
</Box>
) : null}
{!mini ? (
<ListItemText
primary={title}
sx={{
whiteSpace: "nowrap",
zIndex: 1,
}}
/>
) : null}
{action && !mini && fullyExpanded ? action : null}
</ListItemButton>
</ListItem>
</React.Fragment>
);
}

View File

@@ -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 (
<li>
<Divider
sx={{
borderBottomWidth: 1,
my: 1,
mx: -0.5,
...(hasDrawerTransitions
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
: {}),
}}
/>
</li>
);
}

View File

@@ -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 (
<Tooltip
title={`${paletteMode === "dark" ? "Light" : "Dark"} mode`}
enterDelay={1000}
>
<div>
<IconButton
size="small"
aria-label={`Switch to ${
paletteMode === "dark" ? "light" : "dark"
} mode`}
onClick={toggleMode}
>
<React.Fragment>
<LightModeIcon
sx={{
display: "inline",
[theme.getColorSchemeSelector("dark")]: {
display: "none",
},
}}
/>
<DarkModeIcon
sx={{
display: "none",
[theme.getColorSchemeSelector("dark")]: {
display: "inline",
},
}}
/>
</React.Fragment>
</IconButton>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export const DRAWER_WIDTH = 240; // px
export const MINI_DRAWER_WIDTH = 90; // px

View File

@@ -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",
};
}