348 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|