Files
usda-vision/management-dashboard-web-app/src/components/Sidebar.tsx

312 lines
11 KiB
TypeScript
Executable File

import { useState, useCallback, useEffect, useRef } from 'react'
import type { User } from '../lib/supabase'
interface SidebarProps {
user: User
currentView: string
onViewChange: (view: string) => void
isExpanded?: boolean
isMobileOpen?: boolean
isHovered?: boolean
setIsHovered?: (hovered: boolean) => void
}
interface MenuItem {
id: string
name: string
icon: React.ReactElement
requiredRoles?: string[]
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
}
export function Sidebar({
user,
currentView,
onViewChange,
isExpanded = true,
isMobileOpen = false,
isHovered = false,
setIsHovered
}: SidebarProps) {
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({})
const menuItems: MenuItem[] = [
{
id: 'dashboard',
name: 'Dashboard',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
</svg>
),
},
{
id: 'user-management',
name: 'User Management',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
requiredRoles: ['admin']
},
{
id: 'experiments',
name: 'Experiments',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
),
requiredRoles: ['admin', 'conductor']
},
{
id: 'video-library',
name: 'Video Library',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
},
{
id: 'analytics',
name: 'Analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
requiredRoles: ['admin', 'conductor', 'analyst']
},
{
id: 'data-entry',
name: 'Data Entry',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
),
requiredRoles: ['admin', 'conductor', 'data recorder']
},
{
id: 'vision-system',
name: 'Vision System',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
}
]
// const isActive = (path: string) => location.pathname === path;
const isActive = useCallback(
(id: string) => currentView === id,
[currentView]
)
useEffect(() => {
// Auto-open submenu if current view is in a submenu
menuItems.forEach((nav, index) => {
if (nav.subItems) {
nav.subItems.forEach((subItem) => {
if (isActive(subItem.id)) {
setOpenSubmenu(index)
}
})
}
})
}, [currentView, isActive, menuItems])
useEffect(() => {
if (openSubmenu !== null) {
const key = `submenu-${openSubmenu}`
if (subMenuRefs.current[key]) {
setSubMenuHeight((prevHeights) => ({
...prevHeights,
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
}))
}
}
}, [openSubmenu])
const handleSubmenuToggle = (index: number) => {
setOpenSubmenu((prevOpenSubmenu) => {
if (prevOpenSubmenu === index) {
return null
}
return index
})
}
const hasAccess = (item: MenuItem): boolean => {
if (!item.requiredRoles) return true
return item.requiredRoles.some(role => user.roles.includes(role as any))
}
const renderMenuItems = (items: MenuItem[]) => (
<ul className="flex flex-col gap-4">
{items.map((nav, index) => {
if (!hasAccess(nav)) return null
return (
<li key={nav.id}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index)}
className={`menu-item group ${openSubmenu === index
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={`menu-item-icon-size ${openSubmenu === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
<svg
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
? "rotate-180 text-brand-500"
: ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
) : (
<button
onClick={() => onViewChange(nav.id)}
className={`menu-item group ${isActive(nav.id) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`menu-item-icon-size ${isActive(nav.id)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
</button>
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => {
subMenuRefs.current[`submenu-${index}`] = el
}}
className="overflow-hidden transition-all duration-300"
style={{
height:
openSubmenu === index
? `${subMenuHeight[`submenu-${index}`]}px`
: "0px",
}}
>
<ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => {
if (subItem.requiredRoles && !subItem.requiredRoles.some(role => user.roles.includes(role as any))) {
return null
}
return (
<li key={subItem.id}>
<button
onClick={() => onViewChange(subItem.id)}
className={`menu-dropdown-item ${isActive(subItem.id)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{subItem.name}
</button>
</li>
)
})}
</ul>
</div>
)}
</li>
)
})}
</ul>
)
return (
<aside
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${isExpanded || isMobileOpen
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)}
onMouseLeave={() => setIsHovered && setIsHovered(false)}
>
<div
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<div>
{isExpanded || isHovered || isMobileOpen ? (
<>
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
</>
) : (
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center text-white font-bold text-lg">
P
</div>
)}
</div>
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
)}
</h2>
{renderMenuItems(menuItems)}
</div>
</div>
</nav>
</div>
</aside>
)
}