Zelyanoth's picture
Implement immediate Celery Beat schedule updates
1d6d1e6
raw
history blame
23.7 kB
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();
// Detect mobile devices with enhanced responsive breakpoints
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768; // Only consider mobile devices with screens less than 768px
const tablet = window.innerWidth >= 768 && window.innerWidth < 1024; // Tablet breakpoint
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
setIsMobile(mobile);
setIsTouchDevice(touch);
// Auto-collapse on mobile only, not on tablets
if (mobile && !isCollapsed) {
toggleSidebar();
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [isCollapsed, toggleSidebar]);
// Enhanced loading state with skeleton
useEffect(() => {
setIsMounted(true);
const timer = setTimeout(() => setIsLoading(false), 300);
return () => clearTimeout(timer);
}, []);
// Enhanced keyboard shortcuts
useEffect(() => {
const handleGlobalKeyDown = (e) => {
// Toggle sidebar with Ctrl/Cmd + B
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault();
toggleSidebar();
}
// Close sidebar with Escape
if (e.key === 'Escape' && isMobile && !isCollapsed) {
toggleSidebar();
}
};
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, [isCollapsed, isMobile, toggleSidebar]);
// Enhanced hover effects for desktop
useEffect(() => {
if (!isMobile && !isCollapsed) {
setIsExpanded(true);
} else {
setIsExpanded(false);
}
}, [isMobile, isCollapsed]);
// Handle collapse/expand animations
useEffect(() => {
if (isCollapsed !== undefined) {
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), 300);
return () => clearTimeout(timer);
}
}, [isCollapsed]);
// Enhanced menu items with icons and metadata using design system
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'
}
];
// Keyboard navigation handler
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;
}
};
// Enhanced responsive sidebar classes with mobile-first approach using design system
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' : ''}`;
// Enhanced toggle button with responsive design using design system
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`;
// Enhanced navigation with responsive spacing using design system
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')
}`;
// Enhanced navigation list with responsive spacing using design system
const navListClasses = `nav-list space-y-0 ${
isMobile ? 'px-1 py-1' : (isCollapsed ? 'px-1 py-0.5' : 'px-2 py-3')
}`;
// Enhanced navigation item with responsive hover effects using design system
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`;
// Enhanced navigation link with responsive design using design system
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]);
// Enhanced icon classes with responsive sizing using design system
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 */
`;
// Enhanced label classes with responsive typography using design system
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' : ''}
`;
// Enhanced description classes with responsive visibility using design system
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
`;
// Enhanced badge classes with responsive sizing using design system
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
`;
// Enhanced count indicator classes with responsive sizing using design system
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
`;
// Enhanced skeleton loading component with responsive design using design system
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>
);
// Enhanced ripple effect handler
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);
};
// Enhanced touch feedback handler
const handleTouchStart = (e) => {
if (isTouchDevice) {
e.currentTarget.classList.add('touch-active');
}
};
const handleTouchEnd = (e) => {
if (isTouchDevice) {
e.currentTarget.classList.remove('touch-active');
}
};
// Mobile backdrop overlay
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>
</>
);
}
// Skip to content link for accessibility
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);