mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard
This commit is contained in:
427
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal file
427
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Box,
|
||||
Burger,
|
||||
Divider,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
Train,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
children?: SidebarItem[];
|
||||
/** Optional group heading; a small label is rendered when it changes. */
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export interface AppLayoutProps {
|
||||
title?: string;
|
||||
sidebarItems: SidebarItem[];
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
enableThemeToggle?: boolean;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
onLogout?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_KEY = "edr-theme";
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0].toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getActivePage(
|
||||
items: SidebarItem[],
|
||||
activePath: string,
|
||||
): { label: string } | null {
|
||||
const path = activePath.toLowerCase();
|
||||
for (const item of items) {
|
||||
if (
|
||||
path === item.href.toLowerCase() ||
|
||||
path.startsWith(item.href.toLowerCase() + "/")
|
||||
) {
|
||||
return { label: item.label };
|
||||
}
|
||||
if (item.children) {
|
||||
const childMatch = item.children.find(
|
||||
(c) =>
|
||||
path === c.href.toLowerCase() ||
|
||||
path.startsWith(c.href.toLowerCase() + "/"),
|
||||
);
|
||||
if (childMatch) return { label: childMatch.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Shared NavLink styling — green tint only when active, quiet neutral otherwise.
|
||||
const navLinkStyles = {
|
||||
root: {
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
fontWeight: 500,
|
||||
},
|
||||
label: { fontSize: "var(--mantine-font-size-sm)" },
|
||||
} as const;
|
||||
|
||||
export function AppLayout({
|
||||
title = "EDR Freight",
|
||||
sidebarItems,
|
||||
activeHref = "",
|
||||
onNavigate,
|
||||
enableThemeToggle = false,
|
||||
userName = "User",
|
||||
userEmail,
|
||||
onLogout,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [theme, setTheme] = useState<Theme>(() =>
|
||||
enableThemeToggle ? getStoredTheme() : "light",
|
||||
);
|
||||
|
||||
const activePath = activeHref.toLowerCase();
|
||||
const navigate = (href: string) => onNavigate?.(href);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
};
|
||||
|
||||
const initials = getInitials(userName);
|
||||
const activePage = getActivePage(sidebarItems, activePath);
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
layout="alt"
|
||||
navbar={{
|
||||
width: 256,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpen },
|
||||
}}
|
||||
header={{ height: 60 }}
|
||||
padding={0}
|
||||
>
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<AppShell.Header
|
||||
withBorder
|
||||
style={{ background: "var(--mantine-color-body)" }}
|
||||
>
|
||||
<Group h="100%" px="lg" justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Burger
|
||||
opened={mobileOpen}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Text fw={600} size="md">
|
||||
{activePage ? activePage.label : title}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Right: utility actions + user menu */}
|
||||
<Group gap={4}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<Languages size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Indicator color="red" size={7} offset={5} zIndex={10}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={18} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
|
||||
{enableThemeToggle && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
width={220}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
px={6}
|
||||
py={4}
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Avatar color="edr-green" radius="xl" size={30}>
|
||||
<Text fw={600} fz={11}>
|
||||
{initials}
|
||||
</Text>
|
||||
</Avatar>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
visibleFrom="sm"
|
||||
maw={120}
|
||||
truncate
|
||||
>
|
||||
{userName}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Box px="sm" py="xs">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{userName}
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => navigate("/profile")}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={15} />}
|
||||
color="red"
|
||||
onClick={onLogout}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
{/* ── Sidebar ─────────────────────────────────────────────────────────── */}
|
||||
<AppShell.Navbar
|
||||
withBorder
|
||||
style={{
|
||||
// Soft, near-neutral gray — warm enough to avoid the cold blue tint.
|
||||
background: "#f7f7f6",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Brand */}
|
||||
<Box
|
||||
h={60}
|
||||
px="md"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: "var(--mantine-color-edr-green-6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Train size={18} color="white" strokeWidth={2.2} />
|
||||
</Box>
|
||||
<Text fw={650} size="md">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Nav links */}
|
||||
<ScrollArea flex={1} type="never" p="sm">
|
||||
<Stack gap={2}>
|
||||
{sidebarItems.map((item, i) => {
|
||||
const active = isItemActive(item);
|
||||
const hasChildren = !!item.children?.length;
|
||||
const childActive =
|
||||
item.children?.some((c) =>
|
||||
activePath.startsWith(c.href.toLowerCase()),
|
||||
) ?? false;
|
||||
|
||||
const prevSection = sidebarItems[i - 1]?.section;
|
||||
const sectionLabel =
|
||||
item.section && item.section !== prevSection ? (
|
||||
<Text
|
||||
key={`section-${item.section}`}
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="dimmed"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mt={i === 0 ? 0 : "md"}
|
||||
mb={4}
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
{item.section}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Fragment key={item.href}>
|
||||
{sectionLabel}
|
||||
<NavLink
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active || childActive}
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
defaultOpened={childActive}
|
||||
styles={navLinkStyles}
|
||||
>
|
||||
{item.children!.map((child) => {
|
||||
const cActive =
|
||||
activePath === child.href.toLowerCase();
|
||||
return (
|
||||
<NavLink
|
||||
key={child.href}
|
||||
label={child.label}
|
||||
active={cActive}
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
onClick={() => navigate(child.href)}
|
||||
styles={navLinkStyles}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavLink>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={item.href}>
|
||||
{sectionLabel}
|
||||
<NavLink
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active}
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
onClick={() => navigate(item.href)}
|
||||
styles={navLinkStyles}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Bottom user */}
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar color="edr-green" radius="xl" size={30}>
|
||||
<Text fw={600} fz={11}>
|
||||
{initials}
|
||||
</Text>
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{userName}
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
|
||||
{/* ── Main ────────────────────────────────────────────────────────────── */}
|
||||
<AppShell.Main style={{ background: "var(--mantine-color-body)" }}>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
Reference in New Issue
Block a user