| import React, { useState, useEffect, useCallback, memo } from 'react'; |
| import { NavLink, useLocation } from 'react-router-dom'; |
|
|
| const Sidebar = ({ isCollapsed, toggleSidebar }) => { |
| const [isMounted, setIsMounted] = useState(false); |
| const [isLoading, setIsLoading] = useState(true); |
| const [isHovered, setIsHovered] = useState(false); |
| const [isAnimating, setIsAnimating] = useState(false); |
| const [isMobile, setIsMobile] = useState(false); |
| const [isTouchDevice, setIsTouchDevice] = useState(false); |
| const [focusedIndex, setFocusedIndex] = useState(-1); |
| const [isExpanded, setIsExpanded] = useState(false); |
| const location = useLocation(); |
|
|
| |
| useEffect(() => { |
| const checkMobile = () => { |
| const mobile = window.innerWidth < 768; |
| const tablet = window.innerWidth >= 768 && window.innerWidth < 1024; |
| const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; |
| setIsMobile(mobile); |
| setIsTouchDevice(touch); |
|
|
| |
| if (mobile && !isCollapsed) { |
| toggleSidebar(); |
| } |
| }; |
|
|
| checkMobile(); |
| window.addEventListener('resize', checkMobile); |
| return () => window.removeEventListener('resize', checkMobile); |
| }, [isCollapsed, toggleSidebar]); |
|
|
| |
| useEffect(() => { |
| setIsMounted(true); |
| const timer = setTimeout(() => setIsLoading(false), 300); |
| return () => clearTimeout(timer); |
| }, []); |
|
|
|
|
| |
| useEffect(() => { |
| const handleGlobalKeyDown = (e) => { |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === 'b') { |
| e.preventDefault(); |
| toggleSidebar(); |
| } |
|
|
| |
| if (e.key === 'Escape' && isMobile && !isCollapsed) { |
| toggleSidebar(); |
| } |
| }; |
|
|
| document.addEventListener('keydown', handleGlobalKeyDown); |
| return () => document.removeEventListener('keydown', handleGlobalKeyDown); |
| }, [isCollapsed, isMobile, toggleSidebar]); |
|
|
| |
| useEffect(() => { |
| if (!isMobile && !isCollapsed) { |
| setIsExpanded(true); |
| } else { |
| setIsExpanded(false); |
| } |
| }, [isMobile, isCollapsed]); |
|
|
| |
| useEffect(() => { |
| if (isCollapsed !== undefined) { |
| setIsAnimating(true); |
| const timer = setTimeout(() => setIsAnimating(false), 300); |
| return () => clearTimeout(timer); |
| } |
| }, [isCollapsed]); |
|
|
| |
| const menuItems = [ |
| { |
| path: '/dashboard', |
| label: 'Dashboard', |
| icon: 'dashboard', |
| description: 'Overview and analytics', |
| iconColor: 'text-primary-600', |
| animationDelay: 0, |
| ariaLabel: 'Dashboard - Overview and analytics', |
| gradient: 'from-primary-500 to-primary-600' |
| }, |
| { |
| path: '/sources', |
| label: 'Sources', |
| icon: 'rss_feed', |
| description: 'Content sources management', |
| iconColor: 'text-accent-600', |
| animationDelay: 100, |
| ariaLabel: 'Sources - Content sources management', |
| gradient: 'from-accent-500 to-accent-600' |
| }, |
| { |
| path: '/accounts', |
| label: 'Accounts', |
| icon: 'account_circle', |
| description: 'Social media accounts', |
| iconColor: 'text-success-600', |
| animationDelay: 200, |
| ariaLabel: 'Accounts - Social media accounts', |
| gradient: 'from-success-500 to-success-600' |
| }, |
| { |
| path: '/posts', |
| label: 'Posts', |
| icon: 'post_add', |
| description: 'Content posts', |
| iconColor: 'text-warning-600', |
| animationDelay: 300, |
| ariaLabel: 'Posts - Content posts', |
| gradient: 'from-warning-500 to-warning-600' |
| }, |
| { |
| path: '/schedule', |
| label: 'Schedule', |
| icon: 'schedule', |
| description: 'Posting schedule', |
| iconColor: 'text-info-600', |
| animationDelay: 400, |
| ariaLabel: 'Schedule - Posting schedule', |
| gradient: 'from-info-500 to-info-600' |
| } |
| ]; |
|
|
| |
| const handleKeyDown = (e, index) => { |
| if (!e.currentTarget.classList.contains('nav-link')) return; |
|
|
| switch (e.key) { |
| case 'ArrowDown': |
| e.preventDefault(); |
| setFocusedIndex(prev => (prev < menuItems.length - 1 ? prev + 1 : 0)); |
| break; |
| case 'ArrowUp': |
| e.preventDefault(); |
| setFocusedIndex(prev => (prev > 0 ? prev - 1 : menuItems.length - 1)); |
| break; |
| case 'Enter': |
| case ' ': |
| e.preventDefault(); |
| e.currentTarget.click(); |
| break; |
| case 'Escape': |
| if (isMobile && !isCollapsed) { |
| toggleSidebar(); |
| } |
| break; |
| default: |
| break; |
| } |
| }; |
|
|
| |
| const sidebarClasses = `sidebar transition-all duration-300 ease-in-out ${isCollapsed ? 'collapsed' : '' |
| } ${isMobile ? (isCollapsed ? 'w-26' : 'w-64') : (isCollapsed ? 'w-26' : 'w-64') |
| } ${isMounted ? 'animate-slide-in-left' : ''} ${isMobile ? 'fixed top-16 left-0 bottom-0 z-[60]' : 'fixed top-16 left-0 z-[60] h-[calc(100vh-4rem)]' |
| } ${isExpanded ? 'shadow-xl' : ''}`; |
|
|
| |
| const toggleClasses = `sidebar-toggle flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-lg transition-all duration-200 ease-in-out hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 relative overflow-hidden touch-manipulation active:scale-95 ${isMobile ? (isCollapsed ? 'mx-auto mt-2' : 'absolute top-3 right-1.5 sm:top-4 sm:right-2') : (isCollapsed ? 'mx-auto mt-2' : 'mx-auto mt-4') |
| } backdrop-blur-sm bg-white/90 border border-transparent hover:border-primary-200 shadow-sm hover:shadow-md`; |
|
|
| |
| const navClasses = `sidebar-nav h-full flex flex-col transition-all duration-300 ${isMobile ? 'justify-start pt-2 pb-4' : (isCollapsed ? 'justify-start py-1' : 'pt-8 pb-4') |
| }`; |
|
|
| |
| const navListClasses = `nav-list space-y-0 ${isMobile ? 'px-1 py-1' : (isCollapsed ? 'px-1 py-0.5' : 'px-2 py-3') |
| }`; |
|
|
| |
| const navItemClasses = (index) => `nav-item relative transition-all duration-200 ease-in-out group ${isMobile ? 'my-0.5 mx-0.5' : (isCollapsed ? 'my-0.5 mx-0.5' : 'my-1 mx-0.5') |
| } ${focusedIndex === index ? 'ring-2 ring-primary-500 ring-offset-2' : '' |
| } hover:bg-white/20 overflow-hidden z-10`; |
|
|
| |
| const navLinkClasses = useCallback(({ isActive }) => ` |
| nav-link group relative flex items-center px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium rounded-lg transition-all duration-200 ease-in-out |
| ${isActive |
| ? 'bg-gradient-to-r from-primary-600 to-primary-700 text-white shadow-md transform scale-105' |
| : 'text-secondary-700 hover:bg-accent-100 hover:shadow-sm transform hover:scale-102' |
| } |
| ${isMobile ? (isCollapsed ? 'justify-center px-1 sm:px-1.5 py-2 sm:py-2.5' : 'justify-start px-1 sm:px-1.5 py-1 sm:py-1.5') : (isCollapsed ? 'justify-start px-1.5' : 'justify-start px-2 py-2')} |
| focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 |
| disabled:opacity-50 disabled:cursor-not-allowed |
| relative overflow-hidden |
| before:absolute before:inset-0 before:bg-gradient-to-r before:from-primary-500 before:to-primary-600 before:opacity-0 before:transition-opacity before:duration-200 before:rounded-lg |
| group-hover:before:opacity-10 |
| ${isTouchDevice ? 'touch-manipulation' : ''} |
| ${focusedIndex >= 0 ? 'focus:ring-2 focus:ring-primary-500 focus:ring-offset-2' : ''} |
| border border-transparent hover:border-primary-200 |
| hover:shadow-md hover:shadow-primary-500/10 |
| active:scale-95 active:shadow-inner |
| min-h-[36px] sm:min-h-[40px] /* Reduced touch target size */ |
| ${isCollapsed ? 'p-0' : ''} /* Remove padding when collapsed to ensure icons are visible */ |
| `, [isCollapsed, isMobile, isTouchDevice, focusedIndex]); |
|
|
| |
| const iconClasses = ` |
| nav-icon flex-shrink-0 w-5 h-5 transition-all duration-200 ease-in-out |
| ${isMobile ? (isCollapsed ? 'mx-auto text-base sm:text-lg' : 'mr-2 sm:mr-3 text-base') : (isCollapsed ? 'mx-auto text-lg' : 'mr-3 text-base')} |
| group-hover:rotate-12 group-hover:scale-110 |
| transition-transform duration-300 ease-out |
| shadow-sm hover:shadow-md |
| z-20 |
| material-icons |
| flex items-center justify-center |
| ${isCollapsed ? 'scale-110' : ''} /* Scale up icons when collapsed for better visibility */ |
| `; |
|
|
| |
| const labelClasses = ` |
| nav-label transition-all duration-200 ease-in-out |
| ${isMobile ? (isCollapsed ? 'opacity-0 max-w-0 overflow-hidden' : 'opacity-100 max-w-full text-xs sm:text-sm font-medium') : (isCollapsed ? 'opacity-100 max-w-full text-xs font-medium' : 'opacity-100 max-w-full text-sm font-medium')} |
| group-hover:text-primary-600 |
| transition-colors duration-200 |
| tracking-tight |
| text-secondary-900 |
| font-medium |
| ${isCollapsed && !isMobile ? 'absolute left-12 top-1/2 transform -translate-y-1/2 bg-white px-2 py-1 rounded shadow-lg z-20 whitespace-nowrap' : ''} |
| `; |
|
|
| |
| const descriptionClasses = ` |
| nav-description text-xs text-secondary-500 mt-0.5 sm:mt-1 transition-all duration-200 ease-in-out |
| ${isMobile ? 'hidden' : (isCollapsed ? 'opacity-0 max-w-0 overflow-hidden' : 'opacity-100 max-w-full')} |
| group-hover:text-secondary-700 |
| transition-colors duration-200 |
| tracking-normal |
| font-normal |
| leading-relaxed |
| `; |
|
|
| |
| const badgeClasses = ` |
| badge absolute top-0.5 right-0.5 px-1 py-0.5 text-xs font-medium rounded-full |
| ${isMobile ? (isCollapsed ? 'opacity-0 scale-0' : 'opacity-100 scale-100') : (isCollapsed ? 'opacity-0 scale-0' : 'opacity-100 scale-100')} |
| transition-all duration-200 ease-in-out |
| animate-bounce-subtle |
| bg-gradient-to-r from-primary-500 to-primary-600 text-white |
| shadow-sm |
| backdrop-blur-sm |
| border border-white/20 |
| font-semibold |
| tracking-wide |
| `; |
|
|
| |
| const countClasses = ` |
| count-indicator absolute top-0.5 right-0.5 w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center |
| ${isMobile ? (isCollapsed ? 'opacity-0 scale-0' : 'opacity-100 scale-100') : (isCollapsed ? 'opacity-0 scale-0' : 'opacity-100 scale-100')} |
| transition-all duration-200 ease-in-out |
| animate-pulse-slow |
| bg-gradient-to-br from-secondary-500 to-secondary-600 text-white rounded-full text-xs |
| shadow-sm |
| backdrop-blur-sm |
| border border-white/20 |
| font-semibold |
| tracking-wide |
| `; |
|
|
| |
| const SkeletonLoader = () => ( |
| <div className={`space-y-2.5 sm:space-y-3 p-3 sm:p-4 ${isMobile ? 'p-2' : 'p-4'}`}> |
| {[...Array(isMobile ? 4 : 5)].map((_, index) => ( |
| <div key={index} className="animate-pulse"> |
| <div className="flex items-center space-x-2 sm:space-x-3"> |
| <div className={`w-4 h-4 sm:w-5 sm:h-5 bg-gradient-to-br from-secondary-200 to-secondary-300 rounded animate-pulse ${isMobile ? 'w-3 h-3' : 'w-5 h-5'} backdrop-blur-sm`}></div> |
| <div className="flex-1 space-y-1.5 sm:space-y-2"> |
| <div className={`h-2.5 sm:h-3 bg-gradient-to-r from-secondary-200 to-secondary-300 rounded ${isMobile ? 'w-1/2' : 'w-3/4'} animate-pulse`}></div> |
| {!isMobile && <div className="h-2 bg-gradient-to-r from-secondary-200 to-secondary-300 rounded w-1/2 animate-pulse"></div>} |
| </div> |
| </div> |
| </div> |
| ))} |
| </div> |
| ); |
|
|
|
|
| |
| const createRipple = (event) => { |
| const button = event.currentTarget; |
| const circle = document.createElement('span'); |
| const diameter = Math.max(button.clientWidth, button.clientHeight); |
| const radius = diameter / 2; |
|
|
| circle.style.width = circle.style.height = `${diameter}px`; |
| circle.style.left = `${event.clientX - button.offsetLeft - radius}px`; |
| circle.style.top = `${event.clientY - button.offsetTop - radius}px`; |
| circle.classList.add('ripple'); |
|
|
| const ripple = button.getElementsByClassName('ripple')[0]; |
| if (ripple) { |
| ripple.remove(); |
| } |
|
|
| button.appendChild(circle); |
| }; |
|
|
| |
| const handleTouchStart = (e) => { |
| if (isTouchDevice) { |
| e.currentTarget.classList.add('touch-active'); |
| } |
| }; |
|
|
| const handleTouchEnd = (e) => { |
| if (isTouchDevice) { |
| e.currentTarget.classList.remove('touch-active'); |
| } |
| }; |
|
|
| |
| if (isMobile && !isCollapsed) { |
| return ( |
| <> |
| <SkipToContent /> |
| <div |
| className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300" |
| onClick={() => toggleSidebar()} |
| aria-label="Close sidebar overlay" |
| role="button" |
| tabIndex={0} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| toggleSidebar(); |
| } |
| }} |
| ></div> |
| <aside |
| className={sidebarClasses} |
| role="navigation" |
| aria-label="Mobile navigation" |
| aria-modal="true" |
| aria-expanded="true" |
| > |
| <button |
| onClick={(e) => { |
| createRipple(e); |
| toggleSidebar(); |
| }} |
| className={toggleClasses} |
| aria-label="Close sidebar" |
| aria-expanded={false} |
| title="Close sidebar" |
| type="button" |
| > |
| <span className="text-secondary-600 group-hover:text-primary-600 transition-all duration-300"> |
| ✕ |
| </span> |
| </button> |
| |
| <nav className={navClasses} aria-label="Main navigation"> |
| |
| <ul className={navListClasses} role="menu"> |
| {menuItems.map((item, index) => ( |
| <li |
| key={index} |
| className={navItemClasses(index)} |
| role="none" |
| style={{ animationDelay: `${item.animationDelay}ms` }} |
| > |
| <NavLink |
| to={item.path} |
| className={navLinkClasses} |
| title={item.label} |
| onTouchStart={handleTouchStart} |
| onTouchEnd={handleTouchEnd} |
| role="menuitem" |
| aria-current={location.pathname === item.path ? 'page' : undefined} |
| aria-label={item.ariaLabel} |
| aria-describedby={`description-${index}`} |
| onKeyDown={(e) => handleKeyDown(e, index)} |
| onFocus={() => setFocusedIndex(index)} |
| onBlur={() => setFocusedIndex(-1)} |
| > |
| <span className={iconContainerClasses} aria-hidden="true"> |
| <span className={`transition-all duration-300 ease-out ${item.iconColor}`}> |
| <i className="material-icons">{item.icon}</i> |
| </span> |
| </span> |
| |
| {!isCollapsed && ( |
| <div className="flex-1 min-w-0 relative z-10"> |
| <div className="flex items-center justify-between pr-2"> |
| <span className={labelClasses}>{item.label}</span> |
| <div className="flex items-center space-x-1"> |
| {item.badge && ( |
| <span className={badgeClasses} aria-label="New feature"> |
| {item.badge} |
| </span> |
| )} |
| {item.count && ( |
| <span className={countClasses} aria-label={`${item.count} items`}> |
| {item.count} |
| </span> |
| )} |
| </div> |
| </div> |
| <span |
| id={`description-${index}`} |
| className={descriptionClasses} |
| > |
| {item.description} |
| </span> |
| </div> |
| )} |
| </NavLink> |
| </li> |
| ))} |
| </ul> |
| </nav> |
| </aside> |
| </> |
| ); |
| } |
|
|
| |
| const SkipToContent = () => ( |
| <a |
| href="#main-content" |
| className="skip-link sr-only focus:not-sr-only focus:absolute focus:top-3 sm:top-4 focus:left-3 sm:left-4 bg-primary-600 text-white px-3 sm:px-4 py-2 rounded-lg text-sm" |
| > |
| Skip to main content |
| </a> |
| ); |
|
|
| if (isLoading) { |
| return ( |
| <aside className={sidebarClasses} aria-label="Loading navigation"> |
| <SkipToContent /> |
| <div className="flex items-center justify-center h-full"> |
| <SkeletonLoader /> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| return ( |
| <aside |
| className={sidebarClasses} |
| role="navigation" |
| aria-label="Main navigation" |
| style={{ |
| background: 'linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(248,250,252,0.95) 100%)', |
| backdropFilter: 'blur(10px)', |
| borderRight: 'none', |
| boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' |
| }} |
| onMouseEnter={() => setIsHovered(true)} |
| onMouseLeave={() => setIsHovered(false)} |
| aria-hidden={isMobile} |
| > |
| <SkipToContent /> |
| <button |
| onClick={(e) => { |
| createRipple(e); |
| toggleSidebar(); |
| }} |
| className={toggleClasses} |
| aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} |
| aria-expanded={!isCollapsed} |
| title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} |
| type="button" |
| > |
| <span className="text-secondary-600 group-hover:text-primary-600 transition-all duration-300 transform group-hover:rotate-180"> |
| {isCollapsed ? '»' : '«'} |
| </span> |
| <style jsx>{` |
| .ripple { |
| position: absolute; |
| border-radius: 50%; |
| background-color: rgba(145, 0, 41, 0.3); |
| transform: scale(0); |
| animation: ripple 600ms linear; |
| pointer-events: none; |
| } |
| @keyframes ripple { |
| to { |
| transform: scale(4); |
| opacity: 0; |
| } |
| } |
| .touch-active { |
| transform: scale(0.95); |
| transition: transform 0.1s ease; |
| } |
| `}</style> |
| </button> |
| |
| <nav className={navClasses} aria-label="Main navigation"> |
| <ul className={navListClasses} role="menu"> |
| {menuItems.map((item, index) => ( |
| <li |
| key={index} |
| className={navItemClasses(index)} |
| role="none" |
| style={{ animationDelay: `${item.animationDelay}ms` }} |
| > |
| <NavLink |
| to={item.path} |
| className={navLinkClasses} |
| title={item.label} |
| onTouchStart={handleTouchStart} |
| onTouchEnd={handleTouchEnd} |
| role="menuitem" |
| aria-current={location.pathname === item.path ? 'page' : undefined} |
| aria-label={item.ariaLabel} |
| aria-describedby={`description-${index}`} |
| onKeyDown={(e) => handleKeyDown(e, index)} |
| onFocus={() => setFocusedIndex(index)} |
| onBlur={() => setFocusedIndex(-1)} |
| > |
| <span className={iconClasses} aria-hidden="true"> |
| <span className={`transition-all duration-300 ease-out ${item.iconColor}`}> |
| <i className="material-icons">{item.icon}</i> |
| </span> |
| </span> |
| |
| {!isCollapsed && ( |
| <div className="flex-1 min-w-0 relative z-10"> |
| <div className="flex items-center justify-between pr-2"> |
| <span className={labelClasses}>{item.label}</span> |
| <div className="flex items-center space-x-1"> |
| {item.badge && ( |
| <span className={badgeClasses} aria-label="New feature"> |
| {item.badge} |
| </span> |
| )} |
| {item.count && ( |
| <span className={countClasses} aria-label={`${item.count} items`}> |
| {item.count} |
| </span> |
| )} |
| </div> |
| </div> |
| <span |
| id={`description-${index}`} |
| className={descriptionClasses} |
| > |
| {item.description} |
| </span> |
| </div> |
| )} |
| |
| {!isCollapsed && ( |
| <div className="ml-auto flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-all duration-200"> |
| <div className={`w-1.5 h-1.5 rounded-full animate-pulse`} style={{ backgroundColor: `var(--${item.gradient.split(' ')[0].replace('from-', '')}-500)` }}></div> |
| <div className={`w-1 h-1 rounded-full animate-ping`} style={{ backgroundColor: `var(--${item.gradient.split(' ')[0].replace('from-', '')}-400)`, animationDelay: '0.2s' }}></div> |
| </div> |
| )} |
| |
| {location.pathname === item.path && !isCollapsed && ( |
| <div className="absolute left-0 top-0 bottom-0 w-1 rounded-r-lg animate-pulse" style={{ background: `linear-gradient(to bottom, var(--${item.gradient.split(' ')[0].replace('from-', '')}-500), var(--${item.gradient.split(' ')[1].replace('to-', '')}-600))` }}></div> |
| )} |
| |
| {!isCollapsed && ( |
| <div className="absolute inset-0 rounded-lg opacity-0 group-hover:opacity-5 transition-opacity duration-200" style={{ background: `linear-gradient(to right, var(--${item.gradient.split(' ')[0].replace('from-', '')}-500), var(--${item.gradient.split(' ')[1].replace('to-', '')}-600))` }}></div> |
| )} |
| </NavLink> |
| </li> |
| ))} |
| </ul> |
| </nav> |
| |
| {isHovered && !isCollapsed && !isMobile && ( |
| <div className="absolute inset-0 bg-gradient-to-r from-primary-50 to-transparent opacity-30 pointer-events-none transition-opacity duration-300"></div> |
| )} |
| </aside> |
| ); |
| }; |
|
|
| export default memo(Sidebar); |