feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard

This commit is contained in:
ghost2023
2026-06-08 16:05:03 +03:00
parent 95fb544ec2
commit 41061462ff
7 changed files with 1110 additions and 342 deletions

View 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;