feat(layout): redesign sidebar, topbar, and admin shell viewport

This commit is contained in:
Arthur Bassi 2026-06-17 17:43:28 -03:00
parent 59e32152c6
commit 7b8ff5e04b
3 changed files with 280 additions and 215 deletions

View file

@ -3,31 +3,28 @@ import { Outlet } from "react-router-dom";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route"; import { ProtectedRoute } from "@/components/auth/protected-route";
import { import { AppSidebar } from "@/components/layout/app-sidebar";
AppSidebar, import { getSidebarWidth } from "@/config/menu";
SIDEBAR_COLLAPSED_WIDTH,
SIDEBAR_WIDTH,
} from "@/components/layout/app-sidebar";
import { AppTopbar } from "@/components/layout/app-topbar"; import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() { export default function AdminShellLayout() {
const [navCollapsed, setNavCollapsed] = useState(false); const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = navCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; const sidebarWidth = getSidebarWidth(navCollapsed);
return ( return (
<ProtectedRoute> <ProtectedRoute>
<Box className="flex min-h-screen w-full bg-background"> <Box className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar collapsed={navCollapsed} /> <AppSidebar collapsed={navCollapsed} />
<Box <Box
className="flex min-h-screen min-w-0 flex-1 flex-col" className="flex h-screen min-w-0 flex-1 flex-col overflow-hidden"
sx={{ width: `calc(100% - ${sidebarWidth}px)` }} sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
> >
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} /> <AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<Box <Box
component="main" component="main"
className="flex-1 p-2.5" className="flex-1 overflow-auto p-2.5"
sx={{ backgroundColor: "var(--color-bg-page)" }} sx={{ backgroundColor: "var(--color-bg-page)" }}
> >
<Outlet /> <Outlet />

View file

@ -1,76 +1,117 @@
import { useState } from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { import { Box, Drawer } from "@mui/material";
Box,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
} from "@mui/material";
import { navGroups, type NavItemDef } from "@/config/menu"; import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { cn } from "@/lib/utils"; import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
const SIDEBAR_WIDTH = 220; function getNavItemAppearance(isActive: boolean, isHover: boolean) {
const SIDEBAR_COLLAPSED_WIDTH = 56; if (isActive) {
return {
itemColor: "var(--primary)",
iconColor: "var(--primary)",
itemBg: "var(--color-sidebar-item-active-bg)",
};
}
if (isHover) {
return {
itemColor: "var(--primary-hover)",
iconColor: "var(--primary-hover)",
itemBg: "var(--sidebar-accent)",
};
}
return {
itemColor: "var(--foreground)",
iconColor: "var(--muted-foreground)",
itemBg: "transparent",
};
}
type NavItemProps = NavItemDef & { type NavItemProps = NavItemDef & {
collapsed: boolean; collapsed: boolean;
end?: boolean; end?: boolean;
}; };
function NavItem({ label, icon: Icon, path, collapsed, end }: NavItemProps) { function NavItem({ label, icon: Icon, path, collapsed, end, badge }: NavItemProps) {
const [hovered, setHovered] = useState(false);
return ( return (
<NavLink to={path} end={end} className="no-underline"> <NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
{({ isActive }) => ( {({ isActive }) => {
<ListItemButton const isHover = hovered && !isActive;
title={collapsed ? label : undefined} const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
className={cn("mb-1 rounded-md", isActive && !collapsed && "font-semibold")}
sx={{ return (
justifyContent: collapsed ? "center" : "flex-start", <Box
px: collapsed ? 1 : 1.75, onMouseEnter={() => setHovered(true)}
py: 1.25, onMouseLeave={() => setHovered(false)}
color: "var(--color-sidebar-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-sidebar-hover-bg)",
color: "var(--color-sidebar-text-hover)",
},
...(isActive && collapsed
? { backgroundColor: "var(--color-sidebar-hover-bg)" }
: isActive
? {
backgroundColor: "var(--color-sidebar-active-bg)",
color: "var(--color-sidebar-active-text)",
borderLeft: "3px solid var(--primary)",
pl: "13px",
}
: {}),
}}
>
<ListItemIcon
sx={{ sx={{
minWidth: collapsed ? 0 : 34, position: "relative",
justifyContent: "center", display: "flex",
color: "inherit", alignItems: "center",
mr: collapsed ? 0 : 0.5, width: "100%",
height: 40,
gap: "12px",
px: collapsed ? 0 : "12px",
justifyContent: collapsed ? "center" : "flex-start",
borderRadius: "6px",
cursor: "pointer",
fontFamily: "var(--font-sans)",
fontSize: 14,
fontWeight: isActive ? 600 : 500,
color: itemColor,
backgroundColor: itemBg,
boxShadow: isActive ? "var(--color-sidebar-item-active-shadow)" : "none",
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
mb: "4px",
textAlign: "left",
}} }}
> >
<Icon size={18} /> {isActive && (
</ListItemIcon> <Box
{!collapsed && ( component="span"
<ListItemText sx={{
primary={label} position: "absolute",
primaryTypographyProps={{ left: 0,
fontSize: "0.875rem", top: 8,
fontWeight: "inherit", bottom: 8,
noWrap: true, width: 3,
}} borderRadius: "0 3px 3px 0",
/> backgroundColor: "var(--primary)",
)} }}
</ListItemButton> />
)} )}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{!collapsed && (
<>
<Box className="truncate flex-1">{label}</Box>
{badge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
)}
</>
)}
</Box>
);
}}
</NavLink> </NavLink>
); );
} }
@ -80,7 +121,7 @@ type AppSidebarProps = {
}; };
export function AppSidebar({ collapsed }: AppSidebarProps) { export function AppSidebar({ collapsed }: AppSidebarProps) {
const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH; const width = getSidebarWidth(collapsed);
return ( return (
<Drawer <Drawer
@ -93,83 +134,76 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
boxSizing: "border-box", boxSizing: "border-box",
backgroundColor: "var(--color-sidebar-bg)", backgroundColor: "var(--color-sidebar-bg)",
borderRight: "1px solid var(--color-sidebar-border)", borderRight: "1px solid var(--color-sidebar-border)",
transition: "width var(--duration-normal, 250ms) var(--ease-default, ease)", borderRadius: 0,
transition: "width 200ms var(--ease-default, ease)",
overflowX: "hidden", overflowX: "hidden",
}, },
}} }}
> >
<Box <Box className="flex h-full flex-col overflow-hidden">
className="flex flex-col"
sx={{
height: "100vh",
px: collapsed ? 1 : 2.5,
py: 2.5,
}}
>
<Box <Box
className={cn("mb-7 flex items-center", collapsed && "justify-center")} className="flex shrink-0 items-center"
sx={{ minHeight: 40 }} sx={{
height: "var(--spacing-topbar)",
gap: "10px",
px: collapsed ? 0 : "16px",
justifyContent: collapsed ? "center" : "flex-start",
backgroundColor: "var(--card)",
borderBottom: "1px solid var(--color-sidebar-border)",
}}
> >
<Box {collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
component="img"
src="/logo.png"
alt="SeaHaven Industries"
sx={{
maxWidth: collapsed ? 32 : 140,
height: "auto",
display: "block",
transition: "max-width var(--duration-normal, 250ms) var(--ease-default, ease)",
mx: collapsed ? "auto" : 0,
}}
/>
</Box> </Box>
<List disablePadding className="flex-1 overflow-y-auto overflow-x-hidden"> <Box component="nav" className="flex-1 overflow-y-auto overflow-x-hidden" sx={{ py: 1 }}>
{navGroups.map((group, groupIndex) => ( {navGroups.map((group, groupIndex) => (
<Box key={group.title} component="li" sx={{ listStyle: "none" }}> <Box key={group.title || `group-${groupIndex}`}>
{!collapsed && ( {collapsed ? (
<Typography groupIndex > 0 && (
className="text-label" <Box
sx={{
height: 1,
backgroundColor: "var(--color-sidebar-border)",
mx: "14px",
my: "10px",
}}
/>
)
) : group.title ? (
<Box
component="p"
className="text-nav-group"
sx={{ sx={{
px: 1.75, m: 0,
mt: groupIndex === 0 ? 0 : 2.25, px: "10px",
mb: 0.75, pt: "11px",
color: "var(--color-sidebar-label)", pb: "6px",
}} }}
> >
{group.title} {group.title}
</Typography> </Box>
) : (
<Box sx={{ height: 8 }} />
)} )}
{collapsed && groupIndex > 0 && ( <Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
<Box {group.items.map(({ label, icon, path, badge }) => (
sx={{ <Box component="li" key={path} sx={{ listStyle: "none" }}>
borderTop: "1px solid var(--color-sidebar-border)", <NavItem
mt: 1, label={label}
pt: 1, icon={icon}
mx: 0.5, path={path}
}} badge={badge}
/> collapsed={collapsed}
)} end={path === "/"}
/>
<List disablePadding component="ul"> </Box>
{group.items.map(({ label, icon, path }) => (
<NavItem
key={path}
label={label}
icon={icon}
path={path}
collapsed={collapsed}
end={path === "/"}
/>
))} ))}
</List> </Box>
</Box> </Box>
))} ))}
</List> </Box>
</Box> </Box>
</Drawer> </Drawer>
); );
} }
export { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH };

View file

@ -1,17 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import MenuIcon from "@mui/icons-material/Menu";
import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
import PersonOutlineOutlinedIcon from "@mui/icons-material/PersonOutlineOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { import {
AppBar, AppBar,
Avatar,
Box, Box,
Divider, Divider,
IconButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
Menu, Menu,
@ -19,8 +13,10 @@ import {
Toolbar, Toolbar,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type AppTopbarProps = { type AppTopbarProps = {
onToggleNav: () => void; onToggleNav: () => void;
@ -30,7 +26,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuthContext(); const { user, logout } = useAuthContext();
const [notifAnchor, setNotifAnchor] = useState<null | HTMLElement>(null);
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null); const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => { const handleLogout = () => {
@ -47,100 +42,133 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
.toUpperCase() .toUpperCase()
: "U"; : "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return ( return (
<AppBar <AppBar
position="sticky" position="sticky"
elevation={0} elevation={0}
square
sx={{ sx={{
height: "var(--spacing-topbar)", height: "var(--spacing-topbar)",
background: "var(--color-header-gradient)", background: "var(--color-header-gradient)",
boxShadow: "var(--color-header-shadow)", boxShadow: "var(--color-header-shadow)",
color: "var(--color-header-text)", color: "var(--color-header-text)",
borderRadius: 0,
}} }}
> >
<Toolbar <Toolbar
disableGutters disableGutters
className="flex justify-between px-4" className="flex items-center"
sx={{ minHeight: "var(--spacing-topbar) !important" }} sx={{
> minHeight: "var(--spacing-topbar) !important",
<Box className="flex items-center"> height: "var(--spacing-topbar)",
<IconButton gap: "18px",
onClick={onToggleNav} px: "16px",
aria-label="Toggle navigation" pr: "22px",
sx={{
color: "var(--color-header-icon)",
mr: 1.5,
"&:hover": { backgroundColor: "var(--color-header-surface-hover)" },
}}
>
<MenuIcon />
</IconButton>
</Box>
<Box className="flex items-center gap-1">
<IconButton
title="Notifications"
onClick={(event) => setNotifAnchor(event.currentTarget)}
sx={{
color: "var(--color-header-text-muted)",
"&:hover": {
color: "var(--color-header-text)",
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<NotificationsNoneOutlinedIcon fontSize="small" />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{
mx: 0.5,
borderColor: "var(--color-header-surface)",
alignSelf: "center",
height: 24,
}}
/>
<IconButton
title="Account"
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
color: "var(--color-header-text-muted)",
"&:hover": {
color: "var(--color-header-text)",
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<PersonOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</Toolbar>
<Menu
anchorEl={notifAnchor}
open={Boolean(notifAnchor)}
onClose={() => setNotifAnchor(null)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
slotProps={{
paper: {
sx: { width: 300, mt: 1, boxShadow: "var(--shadow-lg)" },
},
}} }}
> >
<Box sx={{ px: 2, py: 1.5, borderBottom: "1px solid var(--border)" }}> <Box
<Typography variant="subtitle2">Notifications</Typography> component="button"
type="button"
onClick={onToggleNav}
aria-label="Toggle navigation"
title="Toggle sidebar"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 38,
height: 38,
borderRadius: "6px",
border: "none",
cursor: "pointer",
backgroundColor: "var(--color-header-surface)",
color: "var(--color-header-icon)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-surface-hover)",
},
}}
>
<MenuIcon size={18} />
</Box> </Box>
<Box className="flex flex-col items-center gap-2 px-4 py-6 text-muted-foreground">
<CheckCircleOutlineIcon sx={{ fontSize: 28, color: "var(--muted)" }} /> <Box sx={{ flex: 1 }} />
<Typography variant="body2" color="text.secondary">
No new notifications <Box
</Typography> component="button"
type="button"
aria-haspopup="true"
aria-expanded={Boolean(userAnchor)}
onClick={(event) => setUserAnchor(event.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
flexShrink: 0,
gap: "10px",
py: "5px",
pl: "6px",
pr: "10px",
borderRadius: "9999px",
border: "none",
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--color-header-text)",
transition: "background-color var(--duration-fast, 150ms) var(--ease-default, ease)",
"&:hover": {
backgroundColor: "var(--color-header-chip-hover)",
},
}}
>
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 34,
height: 34,
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
boxShadow: "0 0 0 2px var(--color-header-avatar-ring)",
fontFamily: "var(--font-display)",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
{userInitials}
</Box>
<Box className="text-left leading-tight">
<Typography
component="p"
sx={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "#ffffff",
lineHeight: 1.25,
}}
>
{user?.fullname || "User"}
</Typography>
<Typography
component="p"
sx={{
fontSize: 11,
color: "var(--color-header-text-muted)",
lineHeight: 1.25,
}}
>
{userRole}
</Typography>
</Box>
<ChevronDown
size={15}
className="shrink-0"
style={{ color: "var(--color-header-text-muted)" }}
/>
</Box> </Box>
</Menu> </Toolbar>
<Menu <Menu
anchorEl={userAnchor} anchorEl={userAnchor}
@ -155,19 +183,25 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
}} }}
> >
<Box className="flex items-center gap-3 px-4 py-3"> <Box className="flex items-center gap-3 px-4 py-3">
<Avatar <Box
className="flex shrink-0 items-center justify-center"
sx={{ sx={{
width: 40, width: 40,
height: 40, height: 40,
bgcolor: "var(--accent)", borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem", fontSize: "0.875rem",
fontWeight: 600, fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)", border: "2px solid var(--color-header-avatar-ring)",
}} }}
> >
{userInitials} {userInitials}
</Avatar> </Box>
<Box> <Box>
<Typography variant="body2" fontWeight={600}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{user?.email || ""} {user?.email || ""}
</Typography> </Typography>