Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references
This commit is contained in:
311
management-dashboard-web-app/src/components/Sidebar.tsx
Normal file
311
management-dashboard-web-app/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user