Polish mobile layout and add collapsible sidebar
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
|
||||
@@ -43,6 +45,8 @@ function initialsFrom(s?: string) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
||||
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
breadcrumbs,
|
||||
@@ -79,7 +83,23 @@ export default function AppShell({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const drawerWidth = 254;
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, [desktopNavCollapsed]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groupItems = (items: NavItem[]) => {
|
||||
@@ -98,15 +118,15 @@ export default function AppShell({
|
||||
}, [nav, navBottom]);
|
||||
|
||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||
<Box sx={{ px: 1.25, pt: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
{groups.map(([section, rows]) => (
|
||||
<Box key={section || "_"} sx={{ mb: 1.25 }}>
|
||||
{section ? (
|
||||
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
|
||||
{section && !desktopNavCollapsed ? (
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
|
||||
{section}
|
||||
</Typography>
|
||||
) : null}
|
||||
<List sx={{ px: 0.75, pt: 0.75 }}>
|
||||
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
||||
{rows.map((item) => {
|
||||
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
|
||||
return (
|
||||
@@ -114,24 +134,28 @@ export default function AppShell({
|
||||
key={item.to}
|
||||
selected={selected}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
sx={(theme: any) => ({
|
||||
title={desktopNavCollapsed ? item.label : undefined}
|
||||
sx={(muiTheme: any) => ({
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
minHeight: 44,
|
||||
px: desktopNavCollapsed ? 1 : 1.5,
|
||||
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: theme.vars.palette.action.hover,
|
||||
borderColor: theme.vars.palette.divider,
|
||||
backgroundColor: muiTheme.vars.palette.action.hover,
|
||||
borderColor: muiTheme.vars.palette.divider,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} />
|
||||
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
@@ -143,16 +167,20 @@ export default function AppShell({
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ px: 2.25, py: 2.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<JobbjaktMark style={{ width: 22, height: 22 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
{!desktopNavCollapsed ? (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
@@ -179,66 +207,166 @@ export default function AppShell({
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
sx={(theme: any) => ({
|
||||
borderBottom: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
sx={(muiTheme: any) => ({
|
||||
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
zIndex: muiTheme.zIndex.drawer + 1,
|
||||
})}
|
||||
>
|
||||
<Toolbar sx={{ gap: 1.5, px: { xs: 2, md: 3 }, minHeight: { xs: 68, md: 76 } }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Toolbar
|
||||
sx={{
|
||||
gap: 1.25,
|
||||
px: { xs: 2, md: 3 },
|
||||
py: { xs: 1.25, md: 0 },
|
||||
minHeight: { xs: 68, md: 76 },
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
flexWrap: { xs: "wrap", md: "nowrap" },
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, width: "100%", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.9, minWidth: 0, pl: 0.25 }}>
|
||||
<JobbjaktMark style={{ width: 18, height: 18, flex: "0 0 auto" }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: -0.2 }} noWrap>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
{rightActions}
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.userName || user.displayName || user.email || t("user")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
{user ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
) : <Box sx={{ width: 42, height: 42 }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flex: "0 0 auto" }}>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
'& > *': { minWidth: 0 },
|
||||
}}
|
||||
>
|
||||
{rightActions}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => setDesktopNavCollapsed((value) => !value)}
|
||||
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
flex: { xs: "1 1 100%", md: "0 1 auto" },
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", flex: { xs: "1 1 auto", md: "0 1 auto" }, justifyContent: "flex-end" }}>
|
||||
{rightActions}
|
||||
</Box>
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
||||
{user.userName || user.displayName || user.email || t("user")}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
anchorEl={userMenuAnchor}
|
||||
@@ -278,15 +406,15 @@ export default function AppShell({
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "none", md: "block" },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
boxSizing: "border-box",
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
},
|
||||
@@ -302,12 +430,12 @@ export default function AppShell({
|
||||
open={drawerOpen}
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "block", md: "none" },
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
},
|
||||
})}
|
||||
@@ -324,18 +452,18 @@ export default function AppShell({
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%" }}>
|
||||
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 2 }}>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
||||
{breadcrumbs.map((c) => (
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600 }} noWrap>
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{c}
|
||||
</Typography>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }} noWrap>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user