style: revamp the backaoffice shell

This commit is contained in:
Nathnael
2026-06-20 09:25:23 +00:00
parent 9caf9bbd59
commit e4955c75de
7 changed files with 439 additions and 924 deletions

View File

@@ -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>
);
};