First Commit
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user