mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
267 lines
7.7 KiB
TypeScript
267 lines
7.7 KiB
TypeScript
import {
|
|
AppShell,
|
|
Box,
|
|
Group,
|
|
NavLink,
|
|
ScrollArea,
|
|
Stack,
|
|
Text,
|
|
UnstyledButton,
|
|
} from "@mantine/core";
|
|
import { ChevronDown, X } from "lucide-react";
|
|
import {
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
|
|
import type { SidebarItem, SidebarSection } from "./types";
|
|
import { Link } from "react-router-dom";
|
|
|
|
export interface FreightSidebarProps {
|
|
sections: SidebarSection[];
|
|
activeHref?: string;
|
|
onNavigate?: (href: string) => void;
|
|
/** Close handler for the mobile drawer (X button, hidden on desktop). */
|
|
onClose?: () => void;
|
|
}
|
|
|
|
const BRAND_LOGO = "/assets/logo.svg";
|
|
|
|
// Active / inactive NavLink styling, expressed through the shared edr-* theme
|
|
// tokens (bridged into Tailwind in index.css). Items are pills floating on the
|
|
// page background — the navbar itself has no surface of its own.
|
|
const navClassNames = (active: boolean) =>
|
|
active
|
|
? {
|
|
root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
|
label: "text-edr-primary-dark! font-medium! text-sm!",
|
|
section: "text-edr-primary-dark!",
|
|
}
|
|
: {
|
|
root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
|
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
|
section: "text-edr-text!",
|
|
};
|
|
|
|
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
|
`${parentKey}/${item.href ?? item.label}/${index}`;
|
|
|
|
const collectHrefs = (items: SidebarItem[]): string[] =>
|
|
items.flatMap((item) => [
|
|
...(item.href ? [item.href.toLowerCase()] : []),
|
|
...(item.children?.length ? collectHrefs(item.children) : []),
|
|
]);
|
|
|
|
const FreightSidebar = ({
|
|
sections,
|
|
activeHref,
|
|
onNavigate,
|
|
onClose,
|
|
}: FreightSidebarProps) => {
|
|
const activePath = activeHref?.toLowerCase() ?? "";
|
|
|
|
const isHrefActive = useCallback(
|
|
(href: string) => {
|
|
const normalized = href.toLowerCase();
|
|
return (
|
|
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
|
);
|
|
},
|
|
[activePath],
|
|
);
|
|
|
|
const branchActive = useCallback(
|
|
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
|
|
[isHrefActive],
|
|
);
|
|
|
|
// Branches containing the active route start expanded; manual toggles win
|
|
// afterwards (merge keeps user intent while still opening newly-active paths).
|
|
const defaultOpen = useMemo(() => {
|
|
const acc: Record<string, boolean> = {};
|
|
const walk = (items: SidebarItem[], parentKey: string) => {
|
|
items.forEach((item, i) => {
|
|
if (!item.children?.length) return;
|
|
const key = itemKey(parentKey, item, i);
|
|
acc[key] =
|
|
(item.href ? isHrefActive(item.href) : false) ||
|
|
branchActive(item.children);
|
|
walk(item.children, key);
|
|
});
|
|
};
|
|
sections.forEach((section) => walk(section.items, section.title));
|
|
return acc;
|
|
}, [sections, isHrefActive, branchActive]);
|
|
|
|
const [openMap, setOpenMap] = useState(defaultOpen);
|
|
useEffect(() => {
|
|
setOpenMap((current) => ({ ...defaultOpen, ...current }));
|
|
}, [defaultOpen]);
|
|
|
|
const toggle = useCallback(
|
|
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
|
|
[],
|
|
);
|
|
|
|
const renderItem = useCallback(
|
|
(item: SidebarItem, key: string): ReactNode => {
|
|
const hasChildren = !!item.children?.length;
|
|
|
|
if (hasChildren) {
|
|
const isLink = !!item.href;
|
|
const active = isLink ? isHrefActive(item.href!) : false;
|
|
const isOpen = openMap[key] ?? false;
|
|
|
|
return (
|
|
// `opened` is controlled so a link-parent navigates on row click
|
|
// without collapsing; the chevron is the only toggle affordance.
|
|
<NavLink
|
|
key={key}
|
|
label={item.label}
|
|
leftSection={item.icon}
|
|
active={active}
|
|
opened={isOpen}
|
|
classNames={navClassNames(active)}
|
|
onClick={() => toggle(key)}
|
|
rightSection={
|
|
<Box
|
|
component="span"
|
|
role="button"
|
|
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggle(key);
|
|
}}
|
|
className="flex cursor-pointer items-center"
|
|
>
|
|
<ChevronDown
|
|
size={16}
|
|
className="text-edr-muted transition-transform duration-200"
|
|
style={{
|
|
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
|
}}
|
|
/>
|
|
</Box>
|
|
}
|
|
>
|
|
{item.children!.map((child, i) =>
|
|
renderItem(child, itemKey(key, child, i)),
|
|
)}
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
if (!item.href) return null;
|
|
const active = isHrefActive(item.href);
|
|
|
|
return (
|
|
<NavLink
|
|
key={key}
|
|
label={item.label}
|
|
leftSection={item.icon}
|
|
active={active}
|
|
component={Link}
|
|
classNames={navClassNames(active)}
|
|
to={item.href!}
|
|
/>
|
|
);
|
|
},
|
|
[branchActive, isHrefActive, onNavigate, openMap, toggle],
|
|
);
|
|
|
|
const renderedSections = useMemo(
|
|
() =>
|
|
sections.map((section) => (
|
|
<Box key={section.title}>
|
|
<Text
|
|
size="xs"
|
|
tt="uppercase"
|
|
px="sm"
|
|
mb={6}
|
|
className={"text-edr-muted!"}
|
|
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
|
>
|
|
{section.title}
|
|
</Text>
|
|
<Stack gap={2}>
|
|
{section.items.map((item, i) =>
|
|
renderItem(item, itemKey(section.title, item, i)),
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
)),
|
|
[renderItem, sections],
|
|
);
|
|
|
|
return (
|
|
<AppShell.Navbar
|
|
withBorder={false}
|
|
// White surface + hairline right border, matching the portal AppLayout.
|
|
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
|
|
// lose to the navbar's default --mantine-color-body).
|
|
style={{
|
|
backgroundColor: "var(--mantine-color-edr-card-6)",
|
|
borderRight: "1px solid var(--mantine-color-edr-border-6)",
|
|
}}
|
|
>
|
|
{/* Brand — aligns with the 64px header for a continuous top edge */}
|
|
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
|
|
<Group gap={10} wrap="nowrap">
|
|
<img
|
|
src={BRAND_LOGO}
|
|
alt="EDR Freight"
|
|
className="size-8 shrink-0 object-contain"
|
|
/>
|
|
<Box>
|
|
<Text
|
|
className="text-edr-primary! leading-tight"
|
|
fw={700}
|
|
fz={15}
|
|
style={{ letterSpacing: "-0.01em" }}
|
|
>
|
|
EDR Freight
|
|
</Text>
|
|
<Text
|
|
className="text-edr-muted!"
|
|
fz={10}
|
|
fw={500}
|
|
style={{ letterSpacing: "0.02em" }}
|
|
>
|
|
Backoffice Console
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
{onClose && (
|
|
<UnstyledButton
|
|
onClick={onClose}
|
|
hiddenFrom="sm"
|
|
aria-label="Close sidebar"
|
|
>
|
|
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
|
|
</UnstyledButton>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Nav */}
|
|
<AppShell.Section
|
|
grow
|
|
component={ScrollArea}
|
|
type="hover"
|
|
scrollbars="y"
|
|
scrollbarSize={6}
|
|
scrollHideDelay={500}
|
|
px="sm"
|
|
pb="md"
|
|
>
|
|
<Stack gap="lg">{renderedSections}</Stack>
|
|
</AppShell.Section>
|
|
</AppShell.Navbar>
|
|
);
|
|
};
|
|
|
|
export default FreightSidebar;
|