Files
jobtrackingapp/job-tracker-ui/src/layout/AppShell.tsx
T

348 lines
10 KiB
TypeScript

import React, { useMemo, useState } from "react";
import {
AppBar,
Avatar,
Badge,
Box,
Breadcrumbs,
Divider,
Drawer,
IconButton,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Toolbar,
Typography,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg";
export type NavItem = {
to: string;
label: string;
icon: React.ReactNode;
hidden?: boolean;
section?: string;
};
function initialsFrom(s?: string) {
const v = (s ?? "").trim();
if (!v) return "?";
const parts = v.split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export default function AppShell({
pageTitle,
breadcrumbs,
pathname,
nav,
navBottom,
onNavigate,
onToggleDrawer,
drawerOpen,
user,
notificationsCount,
onOpenNotifications,
onOpenSettings,
onOpenProfile,
onSignOut,
rightActions,
children,
}: {
pageTitle: string;
breadcrumbs: string[];
pathname: string;
nav: NavItem[];
navBottom: NavItem[];
onNavigate: (to: string) => void;
onToggleDrawer: (open: boolean) => void;
drawerOpen: boolean;
user?: { email?: string; userName?: string; roleLabel?: string };
notificationsCount?: number;
onOpenNotifications?: () => void;
onOpenSettings?: () => void;
onOpenProfile?: () => void;
onSignOut?: () => void;
rightActions?: React.ReactNode;
children: React.ReactNode;
}) {
const drawerWidth = 254;
const grouped = useMemo(() => {
const groupItems = (items: NavItem[]) => {
const map = new Map<string, NavItem[]>();
for (const it of items.filter((x) => !x.hidden)) {
const key = it.section || "";
map.set(key, [...(map.get(key) ?? []), it]);
}
return Array.from(map.entries());
};
return {
top: groupItems(nav),
bottom: groupItems(navBottom),
};
}, [nav, navBottom]);
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: 1.25 }}>
{section ? (
<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 }}>
{rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
return (
<ListItemButton
key={item.to}
selected={selected}
onClick={() => onNavigate(item.to)}
sx={(theme: any) => ({
borderRadius: 2,
mb: 0.5,
border: "1px solid transparent",
"&.Mui-selected": {
backgroundColor: theme.vars.palette.action.hover,
borderColor: theme.vars.palette.divider,
},
})}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{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 }} />
</ListItemButton>
);
})}
</List>
</Box>
))}
</Box>
);
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 }}>
<JobbjaktMark style={{ width: 22, height: 22 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Jobbjakt
</Typography>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Track your hunt
</Typography>
</Box>
<Divider />
{renderNavList(grouped.top)}
<Box sx={{ flex: 1 }} />
<Divider />
{renderNavList(grouped.bottom)}
</Box>
);
const initials = initialsFrom(user?.userName || user?.email);
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const userMenuOpen = Boolean(userMenuAnchor);
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
position="fixed"
color="inherit"
elevation={0}
sx={(theme: any) => ({
borderBottom: `1px solid ${theme.vars.palette.grey[300]}`,
backgroundColor: theme.vars.palette.background.default,
backgroundImage: "none",
zIndex: theme.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>
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }}>
{/* Top-bar search removed (unused). */}
</Box>
<IconButton
color="secondary"
size="small"
title="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="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 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.email || "User"}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{user.roleLabel || ""}
</Typography>
</Box>
</Box>
) : null}
<Menu
anchorEl={userMenuAnchor}
open={userMenuOpen}
onClose={() => setUserMenuAnchor(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onOpenProfile?.();
}}
>
Profile
</MenuItem>
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onOpenSettings?.();
}}
>
Settings
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
onSignOut?.();
}}
>
Sign out
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={(theme: 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,
backgroundImage: "none",
boxShadow: "none",
},
})}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
{drawerContent}
</Drawer>
<Drawer
variant="temporary"
open={drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
sx={(theme: any) => ({
display: { xs: "block", md: "none" },
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
backgroundColor: theme.vars.palette.background.default,
backgroundImage: "none",
},
})}
>
{drawerContent}
</Drawer>
<Box
component="main"
sx={{
flex: 1,
p: { xs: 2, sm: 3 },
bgcolor: "background.default",
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={{ minWidth: 0 }}>
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5 }}>
{breadcrumbs.map((c) => (
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600 }} noWrap>
{c}
</Typography>
))}
</Breadcrumbs>
<Typography variant="h5" sx={{ fontWeight: 600 }} noWrap>
{pageTitle}
</Typography>
</Box>
</Box>
{children}
</Box>
</Box>
</Box>
);
}