Spaces:
Sleeping
Sleeping
| import { NavLink, useNavigate, useLocation } from 'react-router-dom'; | |
| import { useState, useEffect } from 'react'; | |
| interface SidebarProps { | |
| onExpandChange?: (expanded: boolean) => void; | |
| } | |
| export default function Sidebar({ onExpandChange }: SidebarProps) { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const user = JSON.parse(localStorage.getItem('qh_user') || 'null'); | |
| const [drawerOpen, setDrawerOpen] = useState(false); | |
| const [isMobile, setIsMobile] = useState(false); | |
| const [isExpanded, setIsExpanded] = useState(false); | |
| const [isDark, setIsDark] = useState(() => { | |
| return localStorage.getItem('qh_theme') === 'dark' || document.documentElement.getAttribute('data-theme') === 'dark'; | |
| }); | |
| useEffect(() => { | |
| const check = () => setIsMobile(window.innerWidth <= 768); | |
| check(); | |
| window.addEventListener('resize', check); | |
| return () => window.removeEventListener('resize', check); | |
| }, []); | |
| // Apply theme on mount and when toggled | |
| useEffect(() => { | |
| document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); | |
| localStorage.setItem('qh_theme', isDark ? 'dark' : 'light'); | |
| }, [isDark]); | |
| // Close drawer on route change | |
| useEffect(() => { setDrawerOpen(false); }, [location.pathname]); | |
| const handleLogout = () => { | |
| localStorage.removeItem('qh_token'); | |
| localStorage.removeItem('qh_user'); | |
| navigate('/login'); | |
| }; | |
| const toggleTheme = () => setIsDark(prev => !prev); | |
| const themeIcon = isDark ? ( | |
| <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd"/></svg> | |
| ) : ( | |
| <svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg> | |
| ); | |
| const allLinks = [ | |
| { | |
| section: 'Overview', | |
| items: [ | |
| { to: '/dashboard', label: 'Dashboard', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg> }, | |
| { to: '/holdings', label: 'Holdings', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd"/></svg> }, | |
| ] | |
| }, | |
| { | |
| section: 'Research', | |
| items: [ | |
| { to: '/market', label: 'Markets', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd"/></svg> }, | |
| { to: '/factors', label: 'Factor Analysis', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg> }, | |
| { to: '/sentiment', label: 'Sentiment', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd"/></svg> }, | |
| { to: '/research', label: 'Research', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd"/></svg> }, | |
| { to: '/calendar', label: 'Calendar', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd"/></svg> }, | |
| ] | |
| }, | |
| { | |
| section: 'Quantitative', | |
| items: [ | |
| { to: '/strategies', label: 'Strategy Builder', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd"/></svg> }, | |
| { to: '/backtests', label: 'Backtests', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/></svg> }, | |
| ] | |
| }, | |
| { | |
| section: 'Portfolio', | |
| items: [ | |
| { to: '/portfolio', label: 'Optimization', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"/><path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"/></svg> }, | |
| { to: '/marketplace', label: 'Marketplace', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/></svg> }, | |
| ] | |
| }, | |
| ]; | |
| // Primary nav items for mobile bottom bar (5 key items) | |
| const mobileNavItems = [ | |
| allLinks[0].items[0], // Dashboard | |
| allLinks[1].items[0], // Markets | |
| allLinks[0].items[1], // Holdings | |
| allLinks[1].items[1], // Factor Analysis | |
| ]; | |
| // ββ Desktop Sidebar ββββββββββββββββββββββββββββββββββββββββββββββ | |
| const desktopSidebar = ( | |
| <aside | |
| className={`sidebar${isExpanded ? ' sidebar-expanded' : ''}`} | |
| onMouseEnter={() => { setIsExpanded(true); onExpandChange?.(true); }} | |
| onMouseLeave={() => { setIsExpanded(false); onExpandChange?.(false); }} | |
| > <div className="sidebar-brand"> | |
| <NavLink to="/dashboard" className="sidebar-logo"> | |
| <svg width="32" height="32" viewBox="0 0 32 32" fill="none"> | |
| <rect width="32" height="32" rx="8" fill="#005241"/> | |
| <path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/> | |
| </svg> | |
| <span className="sidebar-brand-text"> | |
| Quant<em>Hedge</em> | |
| </span> | |
| </NavLink> | |
| </div> | |
| <nav className="sidebar-nav"> | |
| {allLinks.map((group) => ( | |
| <div key={group.section} className="sidebar-section"> | |
| <div className="sidebar-section-label">{group.section}</div> | |
| {group.items.map((link) => ( | |
| <NavLink | |
| key={link.to} | |
| to={link.to} | |
| className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`} | |
| > | |
| <span className="sidebar-icon">{link.icon}</span> | |
| <span className="sidebar-label">{link.label}</span> | |
| </NavLink> | |
| ))} | |
| </div> | |
| ))} | |
| </nav> | |
| <div className="sidebar-footer"> | |
| {user && ( | |
| <div className="sidebar-user"> | |
| <div className="sidebar-avatar"> | |
| {(user.username || 'U').charAt(0).toUpperCase()} | |
| </div> | |
| <div className="sidebar-user-info"> | |
| <div className="sidebar-user-name">{user.full_name || user.username}</div> | |
| <div className="sidebar-user-email">{user.email}</div> | |
| </div> | |
| </div> | |
| )} | |
| <button className="sidebar-link" onClick={toggleTheme}> | |
| <span className="sidebar-icon">{themeIcon}</span> | |
| <span className="sidebar-label">{isDark ? 'Light Mode' : 'Dark Mode'}</span> | |
| </button> | |
| <button className="sidebar-link sidebar-logout" onClick={handleLogout}> | |
| <span className="sidebar-icon"> | |
| <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg> | |
| </span> | |
| <span className="sidebar-label">Log Out</span> | |
| </button> | |
| </div> | |
| </aside> | |
| ); | |
| // ββ Mobile Bottom Tab Bar + Drawer βββββββββββββββββββββββββββββββ | |
| const mobileNav = ( | |
| <> | |
| <nav className="mobile-nav"> | |
| <div className="mobile-nav-items"> | |
| {mobileNavItems.map((item) => ( | |
| <NavLink | |
| key={item.to} | |
| to={item.to} | |
| className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`} | |
| > | |
| {item.icon} | |
| <span>{item.label}</span> | |
| </NavLink> | |
| ))} | |
| {/* More button */} | |
| <button | |
| className={`mobile-nav-item ${drawerOpen ? 'active' : ''}`} | |
| onClick={() => setDrawerOpen(!drawerOpen)} | |
| > | |
| <svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"> | |
| <path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd"/> | |
| </svg> | |
| <span>More</span> | |
| </button> | |
| </div> | |
| </nav> | |
| {/* Slide-up drawer with all nav items */} | |
| {drawerOpen && ( | |
| <> | |
| <div className="mobile-drawer-overlay" onClick={() => setDrawerOpen(false)} /> | |
| <div className="mobile-drawer"> | |
| <div className="mobile-drawer-handle" /> | |
| {allLinks.map((group) => ( | |
| <div key={group.section} className="sidebar-section"> | |
| <div className="sidebar-section-label">{group.section}</div> | |
| {group.items.map((link) => ( | |
| <NavLink | |
| key={link.to} | |
| to={link.to} | |
| className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`} | |
| onClick={() => setDrawerOpen(false)} | |
| > | |
| <span className="sidebar-icon">{link.icon}</span> | |
| <span className="sidebar-label">{link.label}</span> | |
| </NavLink> | |
| ))} | |
| </div> | |
| ))} | |
| {/* User + Logout in drawer */} | |
| <div style={{ borderTop: '1px solid var(--border-subtle)', marginTop: '0.5rem', paddingTop: '0.75rem' }}> | |
| {user && ( | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 1rem', marginBottom: '0.5rem' }}> | |
| <div className="sidebar-avatar"> | |
| {(user.username || 'U').charAt(0).toUpperCase()} | |
| </div> | |
| <div> | |
| <div style={{ fontSize: '0.85rem', fontWeight: 600 }}>{user.full_name || user.username}</div> | |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{user.email}</div> | |
| </div> | |
| </div> | |
| )} | |
| <button className="sidebar-link" onClick={toggleTheme} style={{ width: '100%' }}> | |
| <span className="sidebar-icon">{themeIcon}</span> | |
| <span className="sidebar-label">{isDark ? 'Light Mode' : 'Dark Mode'}</span> | |
| </button> | |
| <button className="sidebar-link sidebar-logout" onClick={handleLogout} style={{ width: '100%' }}> | |
| <span className="sidebar-icon"> | |
| <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg> | |
| </span> | |
| <span className="sidebar-label">Log Out</span> | |
| </button> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </> | |
| ); | |
| return ( | |
| <> | |
| {isMobile ? mobileNav : desktopSidebar} | |
| </> | |
| ); | |
| } | |