mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
style: revamp the backaoffice shell
This commit is contained in:
@@ -1,264 +1,248 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
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 { ChevronDown, Train } from "lucide-react";
|
||||
import { Box, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import "./FreightSidebar.css";
|
||||
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 sidebarItemKey = (item: SidebarItem, parentKey: string) =>
|
||||
item.href ?? `${parentKey}::${item.label}`;
|
||||
const BRAND_LOGO = "/assets/logo.svg";
|
||||
|
||||
const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
|
||||
items.flatMap((item) => {
|
||||
const hrefs: string[] = [];
|
||||
if (item.href) hrefs.push(item.href.toLowerCase());
|
||||
if (item.children?.length)
|
||||
hrefs.push(...collectSidebarHrefs(item.children));
|
||||
return hrefs;
|
||||
});
|
||||
// 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 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 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 flattenSectionItems = (sections: SidebarSection[]) =>
|
||||
sections.flatMap((section) => section.items);
|
||||
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 items = useMemo(() => flattenSectionItems(sections), [sections]);
|
||||
const activePath = activeHref?.toLowerCase() ?? "";
|
||||
|
||||
const isHrefActive = useCallback(
|
||||
(href: string) => {
|
||||
const normalized = href.toLowerCase();
|
||||
return (
|
||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||
);
|
||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
|
||||
const branchContainsActive = useCallback(
|
||||
(branch: SidebarItem[]) =>
|
||||
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
|
||||
const branchActive = useCallback(
|
||||
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
|
||||
[isHrefActive],
|
||||
);
|
||||
|
||||
const defaultExpanded = useMemo(() => {
|
||||
// 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 = (entries: SidebarItem[], parentKey: string) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.children?.length) continue;
|
||||
const key = sidebarItemKey(entry, parentKey);
|
||||
const walk = (items: SidebarItem[], parentKey: string) => {
|
||||
items.forEach((item, i) => {
|
||||
if (!item.children?.length) return;
|
||||
const key = itemKey(parentKey, item, i);
|
||||
acc[key] =
|
||||
branchContainsActive(entry.children) ||
|
||||
(entry.href ? isHrefActive(entry.href) : false);
|
||||
walk(entry.children, key);
|
||||
}
|
||||
(item.href ? isHrefActive(item.href) : false) ||
|
||||
branchActive(item.children);
|
||||
walk(item.children, key);
|
||||
});
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.children?.length) continue;
|
||||
const key = item.href ?? item.label;
|
||||
acc[key] =
|
||||
activePath === key.toLowerCase() ||
|
||||
activePath.startsWith(`${key.toLowerCase()}/`) ||
|
||||
branchContainsActive(item.children);
|
||||
walk(item.children, key);
|
||||
}
|
||||
|
||||
sections.forEach((section) => walk(section.items, section.title));
|
||||
return acc;
|
||||
}, [activePath, branchContainsActive, isHrefActive, items]);
|
||||
|
||||
const [expanded, setExpanded] =
|
||||
useState<Record<string, boolean>>(defaultExpanded);
|
||||
}, [sections, isHrefActive, branchActive]);
|
||||
|
||||
const [openMap, setOpenMap] = useState(defaultOpen);
|
||||
useEffect(() => {
|
||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||
}, [defaultExpanded]);
|
||||
setOpenMap((current) => ({ ...defaultOpen, ...current }));
|
||||
}, [defaultOpen]);
|
||||
|
||||
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(href);
|
||||
}
|
||||
};
|
||||
const toggle = useCallback(
|
||||
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
||||
};
|
||||
const renderItem = useCallback(
|
||||
(item: SidebarItem, key: string): ReactNode => {
|
||||
const hasChildren = !!item.children?.length;
|
||||
|
||||
const renderNavBranch = (
|
||||
children: SidebarItem[],
|
||||
depth: number,
|
||||
parentKey: string,
|
||||
): ReactNode =>
|
||||
children.map((child) => {
|
||||
const key = sidebarItemKey(child, parentKey);
|
||||
const isGroup = Boolean(child.children?.length) && !child.href;
|
||||
|
||||
if (isGroup) {
|
||||
const isOpen = expanded[key] ?? false;
|
||||
const groupActive = branchContainsActive(child.children!);
|
||||
if (hasChildren) {
|
||||
const isLink = !!item.href;
|
||||
const active =
|
||||
(isLink ? isHrefActive(item.href!) : false) ||
|
||||
branchActive(item.children!);
|
||||
const isOpen = openMap[key] ?? false;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className="fsb-group"
|
||||
data-active={groupActive}
|
||||
onClick={() => toggleExpanded(key)}
|
||||
>
|
||||
<span className="fsb-group-label">{child.label}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
color: groupActive ? "#1B9E7A" : undefined,
|
||||
// `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);
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="fsb-branch">
|
||||
{renderNavBranch(child.children!, depth + 1, key)}
|
||||
</div>
|
||||
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)),
|
||||
)}
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (!child.href) return null;
|
||||
|
||||
const childActive = isHrefActive(child.href);
|
||||
if (!item.href) return null;
|
||||
const active = isHrefActive(item.href);
|
||||
|
||||
return (
|
||||
<a
|
||||
<NavLink
|
||||
key={key}
|
||||
href={child.href}
|
||||
className="fsb-child"
|
||||
data-active={childActive}
|
||||
onClick={(e) => navigateTo(e, child.href!)}
|
||||
>
|
||||
<span className="fsb-dot" />
|
||||
<span className="fsb-item-label">{child.label}</span>
|
||||
</a>
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={() => onNavigate?.(item.href!)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
[branchActive, isHrefActive, onNavigate, openMap, toggle],
|
||||
);
|
||||
|
||||
const renderTopLevelItem = (item: SidebarItem) => {
|
||||
if (!item.href) return null;
|
||||
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const itemHref = item.href.toLowerCase();
|
||||
const childActive = hasChildren
|
||||
? branchContainsActive(item.children!)
|
||||
: false;
|
||||
const isCurrentItem = hasChildren
|
||||
? activePath === itemHref
|
||||
: isHrefActive(itemHref);
|
||||
const isActive = isCurrentItem || childActive;
|
||||
const isOpen = expanded[item.href] ?? false;
|
||||
|
||||
return (
|
||||
<Box key={item.href}>
|
||||
<Link to={item.href} className="fsb-item" data-active={isActive}>
|
||||
{item.icon && <span className="fsb-icon">{item.icon}</span>}
|
||||
<span className="fsb-item-label">{item.label}</span>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="fsb-chevron-btn"
|
||||
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleExpanded(item.href!);
|
||||
}}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="fsb-chevron"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{hasChildren && isOpen && (
|
||||
<div className="fsb-branch">
|
||||
{renderNavBranch(item.children!, 0, item.href)}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="aside"
|
||||
className="fsb-aside"
|
||||
m="4px"
|
||||
bg="edr-bg"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
>
|
||||
<div className="fsb-brand">
|
||||
<div className="fsb-logo">
|
||||
<Train size={23} color="white" strokeWidth={2.1} />
|
||||
</div>
|
||||
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||
<Text
|
||||
size="md"
|
||||
fw={700}
|
||||
style={{
|
||||
letterSpacing: "-0.3px",
|
||||
lineHeight: 1.2,
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
EDR Freight
|
||||
</Text>
|
||||
const renderedSections = useMemo(
|
||||
() =>
|
||||
sections.map((section) => (
|
||||
<Box key={section.title}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={ "text-edr-muted!" }
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
Backoffice Console
|
||||
{section.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
{section.items.map((item, i) =>
|
||||
renderItem(item, itemKey(section.title, item, i)),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)),
|
||||
[renderItem, sections],
|
||||
);
|
||||
|
||||
<nav className="fsb-nav">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className="fsb-section-label">{section.title}</div>
|
||||
<Stack gap={3}>
|
||||
{section.items.map((item) => renderTopLevelItem(item))}
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</Box>
|
||||
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="never" px="sm" pb="md">
|
||||
<Stack gap="lg">{renderedSections}</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user