312 lines
11 KiB
TypeScript
Executable File
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>
|
|
)
|
|
}
|