feat(web): MS15 Phase 1 — Design System & App Shell (#451)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #451.
This commit is contained in:
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavBadge {
|
||||
label: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
interface NavItemConfig {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.JSX.Element;
|
||||
badge?: NavBadge;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItemConfig[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IconDashboard(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconProjects(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="4" width="14" height="10" rx="1.5" />
|
||||
<path d="M1 7h14" />
|
||||
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconProjectWorkspace(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="4" r="2" />
|
||||
<circle cx="3" cy="12" r="2" />
|
||||
<circle cx="13" cy="12" r="2" />
|
||||
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconKanban(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="6" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="11" y="2" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFileManager(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLogs(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 4h12M2 8h8M2 12h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTerminal(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChevronLeft(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 3L5 8l5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChevronRight(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 3l5 5-5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav groups definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [
|
||||
{
|
||||
href: "/",
|
||||
label: "Dashboard",
|
||||
icon: <IconDashboard />,
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{
|
||||
href: "/projects",
|
||||
label: "Projects",
|
||||
icon: <IconProjects />,
|
||||
},
|
||||
{
|
||||
href: "/workspace",
|
||||
label: "Project Workspace",
|
||||
icon: <IconProjectWorkspace />,
|
||||
},
|
||||
{
|
||||
href: "/kanban",
|
||||
label: "Kanban",
|
||||
icon: <IconKanban />,
|
||||
},
|
||||
{
|
||||
href: "/files",
|
||||
label: "File Manager",
|
||||
icon: <IconFileManager />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{
|
||||
href: "/logs",
|
||||
label: "Logs & Telemetry",
|
||||
icon: <IconLogs />,
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
{
|
||||
href: "#terminal",
|
||||
label: "Terminal",
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
items: [
|
||||
{
|
||||
href: "/settings",
|
||||
label: "Settings",
|
||||
icon: <IconSettings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: derive initials from display name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const first = parts[0] ?? "";
|
||||
if (parts.length === 1) {
|
||||
return first.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const last = parts[parts.length - 1] ?? "";
|
||||
return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavBadge component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavBadgeProps {
|
||||
badge: NavBadge;
|
||||
}
|
||||
|
||||
function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element {
|
||||
const pulseStyle: React.CSSProperties = badge.pulse
|
||||
? {
|
||||
background: "rgba(47,128,255,0.15)",
|
||||
color: "var(--primary-l)",
|
||||
}
|
||||
: {
|
||||
background: "var(--surface-2)",
|
||||
color: "var(--muted)",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: "0.68rem",
|
||||
fontFamily: "var(--mono)",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexShrink: 0,
|
||||
...pulseStyle,
|
||||
}}
|
||||
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
|
||||
>
|
||||
{badge.pulse && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-l)",
|
||||
boxShadow: "0 0 4px var(--primary)",
|
||||
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavItem component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavItemProps {
|
||||
item: NavItemConfig;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "11px",
|
||||
padding: "9px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
position: "relative",
|
||||
transition: "background 0.12s ease, color 0.12s ease",
|
||||
textDecoration: "none",
|
||||
justifyContent: collapsed ? "center" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const iconStyle: React.CSSProperties = {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
flexShrink: 0,
|
||||
opacity: isActive ? 1 : 0.7,
|
||||
transition: "opacity 0.12s ease",
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Active left accent bar */}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "6px",
|
||||
bottom: "6px",
|
||||
width: "3px",
|
||||
background: "var(--primary)",
|
||||
borderRadius: "0 2px 2px 0",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<span style={iconStyle}>{item.icon}</span>
|
||||
|
||||
{/* Label and badge — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const sharedProps = {
|
||||
style: baseStyle,
|
||||
"aria-current": isActive ? ("page" as const) : undefined,
|
||||
title: collapsed ? item.label : undefined,
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||
"[data-nav-icon]"
|
||||
);
|
||||
if (iconEl) iconEl.style.opacity = "1";
|
||||
}
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||
"[data-nav-icon]"
|
||||
);
|
||||
if (iconEl) iconEl.style.opacity = "0.7";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (item.href.startsWith("#")) {
|
||||
return (
|
||||
<a href={item.href} {...sharedProps}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={item.href} {...sharedProps}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UserCard component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserCardProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
|
||||
const displayName = user?.name ?? "User";
|
||||
const initials = getInitials(displayName);
|
||||
const role = user?.workspaceRole ?? "Member";
|
||||
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
padding: "12px 10px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.12s ease",
|
||||
justifyContent: collapsed ? "center" : undefined,
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={collapsed ? `${displayName} — ${role}` : undefined}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-label={`User: ${displayName}, Role: ${role}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={`${displayName} avatar`}
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and role — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online status dot */}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--success)",
|
||||
boxShadow: "0 0 6px var(--success)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="Online"
|
||||
role="img"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CollapseToggle component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CollapseToggleProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 10px 8px",
|
||||
display: "flex",
|
||||
justifyContent: collapsed ? "center" : "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: "var(--muted)",
|
||||
transition: "background 0.12s ease, color 0.12s ease",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main AppSidebar component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Application sidebar — navigation groups, collapse toggle, and user card.
|
||||
* Logo lives in AppHeader per MS15 design spec.
|
||||
*/
|
||||
export function AppSidebar(): React.JSX.Element {
|
||||
const pathname = usePathname();
|
||||
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
|
||||
{isMobile && mobileOpen && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
top: "var(--topbar-h)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
zIndex: 140,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
className="app-sidebar"
|
||||
data-collapsed={collapsed ? "true" : undefined}
|
||||
data-mobile-open={mobileOpen ? "true" : undefined}
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
{/* Sidebar body — scrollable nav area */}
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "10px 10px",
|
||||
}}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||
{/* Group label — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.67rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.09em",
|
||||
color: "var(--muted)",
|
||||
padding: "0 10px",
|
||||
marginBottom: "4px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nav items */}
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: item.href.startsWith("#")
|
||||
? false
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Collapse toggle — anchored at bottom of nav */}
|
||||
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
</nav>
|
||||
|
||||
{/* User card footer */}
|
||||
<UserCard collapsed={collapsed} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user