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 { ProtectedRoute } from "@/components/auth/protected-route";
import {
AppSidebar,
SIDEBAR_COLLAPSED_WIDTH,
SIDEBAR_WIDTH,
} from "@/components/layout/app-sidebar";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
export default function AdminShellLayout() {
const [navCollapsed, setNavCollapsed] = useState(false);
const sidebarWidth = navCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
const sidebarWidth = getSidebarWidth(navCollapsed);
return (
<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} />
<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)` }}
>
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
<Box
component="main"
className="flex-1 p-2.5"
className="flex-1 overflow-auto p-2.5"
sx={{ backgroundColor: "var(--color-bg-page)" }}
>
<Outlet />

View file

@ -1,76 +1,117 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
Box,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
} from "@mui/material";
import { Box, Drawer } from "@mui/material";
import { navGroups, type NavItemDef } from "@/config/menu";
import { cn } from "@/lib/utils";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { getSidebarWidth, navGroups, type NavItemDef } from "@/config/menu";
const SIDEBAR_WIDTH = 220;
const SIDEBAR_COLLAPSED_WIDTH = 56;
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
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 & {
collapsed: 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 (
<NavLink to={path} end={end} className="no-underline">
{({ isActive }) => (
<ListItemButton
title={collapsed ? label : undefined}
className={cn("mb-1 rounded-md", isActive && !collapsed && "font-semibold")}
sx={{
justifyContent: collapsed ? "center" : "flex-start",
px: collapsed ? 1 : 1.75,
py: 1.25,
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
<NavLink to={path} end={end} className="no-underline" title={collapsed ? label : undefined}>
{({ isActive }) => {
const isHover = hovered && !isActive;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(isActive, isHover);
return (
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
minWidth: collapsed ? 0 : 34,
justifyContent: "center",
color: "inherit",
mr: collapsed ? 0 : 0.5,
position: "relative",
display: "flex",
alignItems: "center",
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} />
</ListItemIcon>
{!collapsed && (
<ListItemText
primary={label}
primaryTypographyProps={{
fontSize: "0.875rem",
fontWeight: "inherit",
noWrap: true,
}}
/>
)}
</ListItemButton>
)}
{isActive && (
<Box
component="span"
sx={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
}}
/>
)}
<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>
);
}
@ -80,7 +121,7 @@ type AppSidebarProps = {
};
export function AppSidebar({ collapsed }: AppSidebarProps) {
const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
const width = getSidebarWidth(collapsed);
return (
<Drawer
@ -93,83 +134,76 @@ export function AppSidebar({ collapsed }: AppSidebarProps) {
boxSizing: "border-box",
backgroundColor: "var(--color-sidebar-bg)",
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",
},
}}
>
<Box
className="flex flex-col"
sx={{
height: "100vh",
px: collapsed ? 1 : 2.5,
py: 2.5,
}}
>
<Box className="flex h-full flex-col overflow-hidden">
<Box
className={cn("mb-7 flex items-center", collapsed && "justify-center")}
sx={{ minHeight: 40 }}
className="flex shrink-0 items-center"
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
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,
}}
/>
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
</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) => (
<Box key={group.title} component="li" sx={{ listStyle: "none" }}>
{!collapsed && (
<Typography
className="text-label"
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
groupIndex > 0 && (
<Box
sx={{
height: 1,
backgroundColor: "var(--color-sidebar-border)",
mx: "14px",
my: "10px",
}}
/>
)
) : group.title ? (
<Box
component="p"
className="text-nav-group"
sx={{
px: 1.75,
mt: groupIndex === 0 ? 0 : 2.25,
mb: 0.75,
color: "var(--color-sidebar-label)",
m: 0,
px: "10px",
pt: "11px",
pb: "6px",
}}
>
{group.title}
</Typography>
</Box>
) : (
<Box sx={{ height: 8 }} />
)}
{collapsed && groupIndex > 0 && (
<Box
sx={{
borderTop: "1px solid var(--color-sidebar-border)",
mt: 1,
pt: 1,
mx: 0.5,
}}
/>
)}
<List disablePadding component="ul">
{group.items.map(({ label, icon, path }) => (
<NavItem
key={path}
label={label}
icon={icon}
path={path}
collapsed={collapsed}
end={path === "/"}
/>
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
{group.items.map(({ label, icon, path, badge }) => (
<Box component="li" key={path} sx={{ listStyle: "none" }}>
<NavItem
label={label}
icon={icon}
path={path}
badge={badge}
collapsed={collapsed}
end={path === "/"}
/>
</Box>
))}
</List>
</Box>
</Box>
))}
</List>
</Box>
</Box>
</Drawer>
);
}
export { SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_WIDTH };

View file

@ -1,17 +1,11 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
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 {
AppBar,
Avatar,
Box,
Divider,
IconButton,
ListItemIcon,
ListItemText,
Menu,
@ -19,8 +13,10 @@ import {
Toolbar,
Typography,
} from "@mui/material";
import { ChevronDown, Menu as MenuIcon } from "lucide-react";
import { useAuthContext } from "@/providers/auth-context";
import { getPrimaryUserRole } from "@/lib/auth/user-utils";
type AppTopbarProps = {
onToggleNav: () => void;
@ -30,7 +26,6 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
const navigate = useNavigate();
const { user, logout } = useAuthContext();
const [notifAnchor, setNotifAnchor] = useState<null | HTMLElement>(null);
const [userAnchor, setUserAnchor] = useState<null | HTMLElement>(null);
const handleLogout = () => {
@ -47,100 +42,133 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
.toUpperCase()
: "U";
const userRole = getPrimaryUserRole(user?.userRoles);
return (
<AppBar
position="sticky"
elevation={0}
square
sx={{
height: "var(--spacing-topbar)",
background: "var(--color-header-gradient)",
boxShadow: "var(--color-header-shadow)",
color: "var(--color-header-text)",
borderRadius: 0,
}}
>
<Toolbar
disableGutters
className="flex justify-between px-4"
sx={{ minHeight: "var(--spacing-topbar) !important" }}
>
<Box className="flex items-center">
<IconButton
onClick={onToggleNav}
aria-label="Toggle navigation"
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)" },
},
className="flex items-center"
sx={{
minHeight: "var(--spacing-topbar) !important",
height: "var(--spacing-topbar)",
gap: "18px",
px: "16px",
pr: "22px",
}}
>
<Box sx={{ px: 2, py: 1.5, borderBottom: "1px solid var(--border)" }}>
<Typography variant="subtitle2">Notifications</Typography>
<Box
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 className="flex flex-col items-center gap-2 px-4 py-6 text-muted-foreground">
<CheckCircleOutlineIcon sx={{ fontSize: 28, color: "var(--muted)" }} />
<Typography variant="body2" color="text.secondary">
No new notifications
</Typography>
<Box sx={{ flex: 1 }} />
<Box
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>
</Menu>
</Toolbar>
<Menu
anchorEl={userAnchor}
@ -155,19 +183,25 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
}}
>
<Box className="flex items-center gap-3 px-4 py-3">
<Avatar
<Box
className="flex shrink-0 items-center justify-center"
sx={{
width: 40,
height: 40,
bgcolor: "var(--accent)",
borderRadius: "50%",
background: "linear-gradient(135deg, var(--accent), var(--primary))",
fontSize: "0.875rem",
fontWeight: 600,
color: "#ffffff",
border: "2px solid var(--color-header-avatar-ring)",
}}
>
{userInitials}
</Avatar>
</Box>
<Box>
<Typography variant="body2" fontWeight={600}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">
{user?.email || ""}
</Typography>