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: 'Analytics', | |
| items: [ | |
| { to: '/portfolio-health', label: 'Health Score', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clipRule="evenodd" /></svg> }, | |
| { to: '/bias-detector', label: 'Bias Detector', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" /><path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg> }, | |
| { to: '/crisis-replay', label: 'Crisis Replay', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" /></svg> }, | |
| { to: '/portfolio-dna', label: 'Portfolio DNA', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 3.5a1.5 1.5 0 013 0V4a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-.5a1.5 1.5 0 000 3h.5a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-.5a1.5 1.5 0 00-3 0v.5a1 1 0 01-1 1H6a1 1 0 01-1-1v-3a1 1 0 00-1-1h-.5a1.5 1.5 0 010-3H4a1 1 0 001-1V6a1 1 0 011-1h3a1 1 0 001-1v-.5z" /></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> }, | |
| { to: '/copilot', label: 'HedgeAI', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" clipRule="evenodd" /></svg> }, | |
| ] | |
| }, | |
| { | |
| section: 'AI Engine', | |
| items: [ | |
| { to: '/pattern-intelligence', label: 'Patterns', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg> }, | |
| { to: '/pinescript-lab', label: 'Pine Script', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg> }, | |
| { to: '/paper-trading', label: 'Paper Trading', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z" /><path fillRule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clipRule="evenodd" /></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} | |
| </> | |
| ); | |
| } | |