Spaces:
Running
Running
| import React, { useState, useEffect } from 'react'; | |
| import { Outlet, useLocation, useNavigate } from 'react-router-dom'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| BarChart3, | |
| LayoutDashboard, | |
| Database, | |
| FileText, | |
| MessageSquare, | |
| Settings, | |
| Sun, | |
| Moon, | |
| Menu, | |
| X, | |
| Home, | |
| Sparkles, | |
| LogOut, | |
| Brain, | |
| Shield, | |
| PanelLeftClose, | |
| PanelLeftOpen, | |
| Activity, | |
| GitBranch, | |
| Users, | |
| Terminal, | |
| Command, | |
| Camera, | |
| Rocket, | |
| Sliders, | |
| Lightbulb, | |
| Layers, | |
| ChevronRight, | |
| TrendingDown, | |
| Wifi, | |
| WifiOff, | |
| Cpu, | |
| Search, | |
| } from 'lucide-react'; | |
| import { useAuth } from '@/contexts/AuthContext'; | |
| import { useUserStore } from '@/store/userStore'; | |
| import LogoImage from '@/components/LogoImage'; | |
| import apiService, { api } from '@/services/api'; | |
| const AppLayout: React.FC = () => { | |
| const location = useLocation(); | |
| const navigate = useNavigate(); | |
| const { signOut } = useAuth(); | |
| const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); | |
| const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); | |
| useEffect(() => { | |
| // Dispatch resize event when sidebar collapses or expands so all charts resize smoothly | |
| const timer = setTimeout(() => { | |
| window.dispatchEvent(new Event('resize')); | |
| }, 150); | |
| return () => clearTimeout(timer); | |
| }, [isSidebarCollapsed]); | |
| const { isDark, toggleTheme } = useUserStore(); | |
| // V3: Command Palette State | |
| const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); | |
| const [paletteSearch, setPaletteSearch] = useState(''); | |
| const [searchResults, setSearchResults] = useState<any[]>([]); | |
| const [isSearching, setIsSearching] = useState(false); | |
| // WebSocket State | |
| const [wsConnected, setWsConnected] = useState(false); | |
| const [liveMetrics, setLiveMetrics] = useState<any>(null); | |
| // Workspace State | |
| const { workspaces, setWorkspaces, activeWorkspaceId, setActiveWorkspace, activeWorkspaceRole } = useUserStore(); | |
| useEffect(() => { | |
| // Fetch user workspaces | |
| const fetchWorkspaces = async () => { | |
| try { | |
| const res = await api.get('/api/v1/collaboration/workspaces'); | |
| setWorkspaces(res.data.workspaces); | |
| // Auto-select personal workspace if none selected | |
| if (!activeWorkspaceId && res.data.workspaces.length > 0) { | |
| const personal = res.data.workspaces.find((w: any) => w.role === 'Owner'); | |
| if (personal) setActiveWorkspace(personal.id, personal.role); | |
| } | |
| } catch (err) { | |
| console.error("Failed to fetch workspaces:", err); | |
| } | |
| }; | |
| fetchWorkspaces(); | |
| }, []); | |
| useEffect(() => { | |
| // Connect to WebSocket | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| // When running locally, Vite proxies /api, but for WS it's better to use direct or proxied path | |
| const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`; | |
| let ws: WebSocket; | |
| let reconnectTimer: NodeJS.Timeout; | |
| const connectWs = () => { | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| setWsConnected(true); | |
| console.log("WebSocket connected!"); | |
| }; | |
| ws.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'REALTIME_METRICS') { | |
| setLiveMetrics(data.payload); | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| }; | |
| ws.onclose = () => { | |
| setWsConnected(false); | |
| // Attempt to reconnect after 3 seconds | |
| reconnectTimer = setTimeout(connectWs, 3000); | |
| }; | |
| ws.onerror = (err) => { | |
| ws.close(); | |
| }; | |
| }; | |
| connectWs(); | |
| return () => { | |
| clearTimeout(reconnectTimer); | |
| if (ws) ws.close(); | |
| }; | |
| }, []); | |
| // Debounced search | |
| useEffect(() => { | |
| if (paletteSearch.length < 2) { | |
| setSearchResults([]); | |
| return; | |
| } | |
| const delayDebounceFn = setTimeout(async () => { | |
| setIsSearching(true); | |
| try { | |
| const res = await apiService.globalSearch(paletteSearch); | |
| if (res.data && res.data.results) { | |
| setSearchResults(res.data.results); | |
| } | |
| } catch (error) { | |
| console.error("Global search error:", error); | |
| } finally { | |
| setIsSearching(false); | |
| } | |
| }, 300); | |
| return () => clearTimeout(delayDebounceFn); | |
| }, [paletteSearch]); | |
| useEffect(() => { | |
| // Sync class with store state | |
| if (!isDark) { | |
| document.documentElement.classList.add('light-theme'); | |
| } else { | |
| document.documentElement.classList.remove('light-theme'); | |
| } | |
| }, [isDark]); | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
| e.preventDefault(); | |
| setIsCommandPaletteOpen((prev) => !prev); | |
| } | |
| if (e.key === 'Escape') { | |
| setIsCommandPaletteOpen(false); | |
| } | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => window.removeEventListener('keydown', handleKeyDown); | |
| }, []); | |
| // Close mobile menu on route change | |
| useEffect(() => { | |
| setIsMobileMenuOpen(false); | |
| }, [location.pathname]); | |
| // Check if ML results exist | |
| const hasMLResults = typeof window !== 'undefined' && localStorage.getItem('hasMLResults') === 'true'; | |
| const navGroups = [ | |
| { | |
| title: 'Data Foundation', | |
| items: [ | |
| { path: '/', label: 'Home', icon: Home }, | |
| { path: '/data-hub', label: 'Data Hub', icon: Database, roles: ['Owner', 'Admin', 'Analyst'] }, | |
| { path: '/lineage', label: 'Data Lineage', icon: GitBranch, roles: ['Owner', 'Admin'] }, | |
| ] | |
| }, | |
| { | |
| title: 'Intelligence & Analytics', | |
| items: [ | |
| { path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, | |
| ] | |
| }, | |
| { | |
| title: 'Advanced AI Lab', | |
| items: [ | |
| { path: '/chat', label: 'AI Analyst', icon: MessageSquare }, | |
| { path: '/ml-predictions', label: 'AutoML & Predict', icon: Brain, roles: ['Owner', 'Admin', 'Analyst'] }, | |
| { path: '/computer-vision', label: 'Computer Vision', icon: Camera, roles: ['Owner', 'Admin', 'Analyst'] }, | |
| { path: '/simulator', label: 'Scenario Simulator', icon: Sliders, roles: ['Owner', 'Admin', 'Analyst'] }, | |
| ] | |
| }, | |
| { | |
| title: 'Output & Tools', | |
| items: [ | |
| { path: '/reports', label: 'Reports', icon: FileText }, | |
| { path: '/collaborate', label: 'Collaborate', icon: Users }, | |
| { path: '/developer', label: 'Developer', icon: Terminal, roles: ['Owner'] }, | |
| { path: '/settings', label: 'Settings', icon: Settings, roles: ['Owner', 'Admin'] }, | |
| ] | |
| } | |
| ]; | |
| // Filter navigation items based on current role | |
| const currentRole = activeWorkspaceRole || 'Owner'; | |
| const filteredNavGroups = navGroups.map(group => ({ | |
| ...group, | |
| items: group.items.filter(item => !item.roles || item.roles.includes(currentRole)) | |
| })).filter(group => group.items.length > 0); | |
| const navItems = filteredNavGroups.flatMap(group => group.items); | |
| const isActive = (path: string) => { | |
| if (path === '/') return location.pathname === '/'; | |
| return location.pathname.startsWith(path); | |
| }; | |
| return ( | |
| <div | |
| className="flex h-screen overflow-hidden transition-colors duration-300" | |
| style={{ | |
| backgroundColor: 'var(--bg-primary)', | |
| color: 'var(--text-primary)' | |
| }} | |
| > | |
| {/* Premium Sidebar */} | |
| <motion.aside | |
| initial={false} | |
| className={` | |
| fixed inset-y-0 left-0 z-50 flex flex-col border-r transition-all duration-300 | |
| ${isDark ? 'glass-premium' : 'bg-white/95 backdrop-blur-xl'} | |
| md:static | |
| /* Slide logic handled by classes */ | |
| ${isMobileMenuOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0'} | |
| /* Widths */ | |
| w-[280px] md:w-20 | |
| ${isSidebarCollapsed ? 'lg:w-20' : 'lg:w-64'} | |
| `} | |
| style={{ | |
| borderColor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', | |
| boxShadow: isDark && isMobileMenuOpen ? '4px 0 24px rgba(0,0,0,0.5)' : (isDark ? '4px 0 24px rgba(0,0,0,0.3)' : '4px 0 24px rgba(0,0,0,0.05)') | |
| }} | |
| > | |
| {/* Logo Section */} | |
| <div | |
| className={`h-16 flex items-center justify-between px-5 border-b transition-all duration-300 | |
| md:justify-center | |
| ${isSidebarCollapsed ? 'lg:justify-center' : 'lg:justify-start'} | |
| `} | |
| style={{ borderColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }} | |
| > | |
| <div | |
| className="flex items-center gap-3 cursor-pointer group" | |
| onClick={() => navigate('/')} | |
| > | |
| <div className="relative shrink-0"> | |
| <LogoImage size={36} className="rounded-lg" /> | |
| <div className="absolute inset-0 bg-green-400/20 rounded-full blur-lg opacity-0 group-hover:opacity-100 transition-opacity" /> | |
| </div> | |
| <span | |
| className={`text-lg font-semibold tracking-tight md:hidden whitespace-nowrap | |
| ${isSidebarCollapsed ? 'lg:hidden' : 'lg:block'} | |
| `} | |
| style={{ color: isDark ? '#f8fafc' : '#0f172a' }} | |
| > | |
| DataVision | |
| </span> | |
| </div> | |
| <button | |
| onClick={() => setIsMobileMenuOpen(false)} | |
| className="md:hidden p-2 rounded-xl hover:bg-white/10 transition-colors touch-target" | |
| > | |
| <X className="w-5 h-5" style={{ color: isDark ? '#9ca3af' : '#64748b' }} /> | |
| </button> | |
| </div> | |
| {/* Workspace Switcher */} | |
| {workspaces.length > 1 && ( | |
| <div className={`p-4 border-b transition-all duration-300 md:hidden ${isSidebarCollapsed ? 'lg:hidden' : 'lg:block'}`} | |
| style={{ borderColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }} | |
| > | |
| <div className="text-[10px] font-bold tracking-wider uppercase mb-2" style={{ color: isDark ? '#64748b' : '#94a3b8' }}> | |
| Workspace | |
| </div> | |
| <select | |
| value={activeWorkspaceId || ''} | |
| onChange={(e) => { | |
| const ws = workspaces.find(w => w.id === e.target.value); | |
| if (ws) { | |
| setActiveWorkspace(ws.id, ws.role); | |
| // Give time for state to update, then reload to fetch fresh data for the new workspace context | |
| setTimeout(() => window.location.reload(), 100); | |
| } | |
| }} | |
| className="w-full bg-black/5 dark:bg-white/10 border border-black/10 dark:border-white/10 rounded-lg p-2 text-sm outline-none cursor-pointer focus:ring-2 focus:ring-emerald-500" | |
| style={{ color: 'var(--text-primary)' }} | |
| > | |
| {workspaces.map(ws => ( | |
| <option key={ws.id} value={ws.id} className="bg-white dark:bg-slate-800"> | |
| {ws.name} ({ws.role}) | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| )} | |
| {/* Navigation */} | |
| <nav className="flex-1 p-4 space-y-4 overflow-y-auto overflow-x-hidden"> | |
| {navGroups.map((group, groupIndex) => ( | |
| <div key={groupIndex} className="space-y-1.5"> | |
| <div className={`text-[10px] font-bold tracking-wider uppercase pl-4 mb-2 md:hidden ${isSidebarCollapsed ? 'lg:hidden' : 'lg:block'}`} style={{ color: isDark ? '#64748b' : '#94a3b8' }}> | |
| {group.title} | |
| </div> | |
| {group.items.map((item) => { | |
| const active = isActive(item.path); | |
| return ( | |
| <motion.button | |
| key={item.path} | |
| onClick={() => navigate(item.path)} | |
| whileHover={{ scale: 1.02 }} | |
| whileTap={{ scale: 0.98 }} | |
| className={` | |
| nav-item-premium w-full flex items-center gap-3 px-4 py-3 | |
| md:justify-center md:px-0 | |
| ${isSidebarCollapsed ? 'lg:justify-center lg:px-0' : 'lg:justify-start lg:px-4'} | |
| ${active ? 'active' : ''} | |
| `} | |
| title={item.label} // Tooltip for tablet/collapsed | |
| style={{ | |
| color: active | |
| ? '#2dd4bf' | |
| : (isDark ? '#9ca3af' : '#64748b'), | |
| background: active | |
| ? (isDark ? 'rgba(45, 212, 191, 0.1)' : 'rgba(20, 184, 166, 0.08)') | |
| : 'transparent', | |
| borderColor: active ? 'rgba(45, 212, 191, 0.2)' : 'transparent', | |
| }} | |
| > | |
| <item.icon className="w-5 h-5 flex-shrink-0" /> | |
| <span className={`font-medium md:hidden whitespace-nowrap | |
| ${isSidebarCollapsed ? 'lg:hidden' : 'lg:block'} | |
| `}>{item.label}</span> | |
| {item.path === '/chat' && ( | |
| <Sparkles className={`w-3.5 h-3.5 ml-auto text-green-400 opacity-60 md:hidden | |
| ${isSidebarCollapsed ? 'lg:hidden' : 'lg:inline-block'} | |
| `} /> | |
| )} | |
| {(item as any).badge && ( | |
| <span className={`ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white md:hidden | |
| ${isSidebarCollapsed ? 'lg:hidden' : 'lg:inline-block'} | |
| `}>{(item as any).badge}</span> | |
| )} | |
| </motion.button> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </nav> | |
| {/* Footer - Sign Out Button */} | |
| <div | |
| className="p-4 border-t" | |
| style={{ borderColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }} | |
| > | |
| <button | |
| onClick={() => { | |
| if (confirm('Are you sure you want to sign out?')) { | |
| signOut(); | |
| } | |
| }} | |
| className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 hover:bg-red-500/10 group | |
| md:justify-center md:px-0 | |
| ${isSidebarCollapsed ? 'lg:justify-center lg:px-0' : 'lg:justify-start lg:px-4'} | |
| `} | |
| style={{ color: isDark ? '#9ca3af' : '#64748b' }} | |
| title="Sign Out" | |
| > | |
| <LogOut className="w-5 h-5 group-hover:text-red-400 transition-colors shrink-0" /> | |
| <span className={`font-medium group-hover:text-red-400 transition-colors md:hidden whitespace-nowrap | |
| ${isSidebarCollapsed ? 'lg:hidden' : 'lg:block'} | |
| `}>Sign Out</span> | |
| </button> | |
| </div> | |
| </motion.aside> | |
| {/* Mobile Backdrop */} | |
| <AnimatePresence> | |
| {isMobileMenuOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| onClick={() => setIsMobileMenuOpen(false)} | |
| className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden" | |
| /> | |
| )} | |
| </AnimatePresence> | |
| {/* Main Content Area */} | |
| <div className="flex-1 flex flex-col min-w-0 overflow-hidden"> | |
| {/* Premium Header */} | |
| <header | |
| className={` | |
| h-14 lg:h-16 flex items-center justify-between px-4 lg:px-6 | |
| border-b backdrop-blur-xl sticky top-0 z-30 | |
| ${isDark ? 'glass-premium' : 'bg-white/80'} | |
| `} | |
| style={{ | |
| borderColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)', | |
| }} | |
| > | |
| {/* Mobile Menu Toggle */} | |
| <button | |
| onClick={() => setIsMobileMenuOpen(true)} | |
| className="lg:hidden p-2.5 rounded-xl hover:bg-white/10 transition-colors touch-target" | |
| > | |
| <Menu className="w-5 h-5" style={{ color: isDark ? '#9ca3af' : '#64748b' }} /> | |
| </button> | |
| {/* Page Title with Breadcrumb feel + Desktop Toggle */} | |
| <div className="flex items-center gap-2 lg:gap-3"> | |
| {/* Desktop Toggle Button */} | |
| <button | |
| onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} | |
| className="hidden lg:flex p-2 rounded-xl hover:bg-white/5 transition-colors" | |
| style={{ color: isDark ? '#9ca3af' : '#64748b' }} | |
| title={isSidebarCollapsed ? "Expand Sidebar" : "Collapse Sidebar"} | |
| > | |
| {isSidebarCollapsed ? <PanelLeftOpen className="w-5 h-5" /> : <PanelLeftClose className="w-5 h-5" />} | |
| </button> | |
| <div className="flex items-center gap-1.5 text-sm lg:text-base font-medium"> | |
| <span className="opacity-40" style={{ color: isDark ? '#f8fafc' : '#0f172a' }}>DataVision</span> | |
| <span className="opacity-20 text-xs" style={{ color: isDark ? '#f8fafc' : '#0f172a' }}>/</span> | |
| <motion.span | |
| key={location.pathname} | |
| initial={{ opacity: 0, x: -4 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| className="font-semibold text-sm lg:text-base" | |
| style={{ color: isDark ? '#f8fafc' : '#0f172a' }} | |
| > | |
| {navItems.find(item => isActive(item.path))?.label || 'Overview'} | |
| </motion.span> | |
| </div> | |
| </div> | |
| {/* Right Actions */} | |
| <div className="flex items-center gap-2"> | |
| {/* Search Trigger Button */} | |
| <button | |
| onClick={() => setIsCommandPaletteOpen(true)} | |
| className="flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs transition-all duration-200 border" | |
| style={{ | |
| backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)', | |
| borderColor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', | |
| color: isDark ? '#94a3b8' : '#64748b' | |
| }} | |
| > | |
| <Search className="w-3.5 h-3.5" /> | |
| <span className="hidden sm:inline">Search...</span> | |
| <kbd className="hidden sm:inline-flex px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px] leading-none shrink-0">⌘K</kbd> | |
| </button> | |
| {/* Live Metrics Widget */} | |
| <div | |
| className="hidden md:flex items-center gap-3 px-3 py-1.5 rounded-xl border text-xs" | |
| style={{ | |
| backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)', | |
| borderColor: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', | |
| color: isDark ? '#94a3b8' : '#64748b' | |
| }} | |
| title={wsConnected ? "Connected to Live Data Stream" : "Reconnecting..."} | |
| > | |
| {wsConnected ? <Wifi className="w-3.5 h-3.5 text-emerald-400" /> : <WifiOff className="w-3.5 h-3.5 text-rose-400" />} | |
| {liveMetrics && wsConnected ? ( | |
| <div className="flex gap-3 text-[10px] sm:text-xs"> | |
| <span className="flex items-center gap-1"> | |
| <Users className="w-3 h-3 text-blue-400" /> | |
| {liveMetrics.active_users} | |
| </span> | |
| <span className="flex items-center gap-1"> | |
| <Cpu className="w-3 h-3 text-orange-400" /> | |
| {liveMetrics.cpu_load}% | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-[10px]">Connecting...</span> | |
| )} | |
| </div> | |
| {/* Theme toggle visible on desktop in header (Hidden on Dashboard due to AI Themes) */} | |
| {location.pathname !== '/dashboard' && ( | |
| <button | |
| onClick={toggleTheme} | |
| className="hidden lg:flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-200" | |
| style={{ | |
| backgroundColor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', | |
| }} | |
| > | |
| {isDark | |
| ? <Sun className="w-5 h-5 text-amber-400" /> | |
| : <Moon className="w-5 h-5 text-slate-500" /> | |
| } | |
| </button> | |
| )} | |
| </div> | |
| </header> | |
| {/* Page Content with Smooth Transitions */} | |
| <main className="flex-1 overflow-auto"> | |
| <motion.div | |
| key={location.pathname} | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }} | |
| className="w-full h-full p-2 lg:p-4" | |
| > | |
| <Outlet context={{ isDark, cardBg: isDark ? '#141414' : '#ffffff', textPrimary: isDark ? '#f8fafc' : '#0f172a', textMuted: isDark ? '#9ca3af' : '#64748b', borderColor: isDark ? '#262626' : '#e2e8f0' }} /> | |
| </motion.div> | |
| </main> | |
| </div> | |
| {/* V3 Command Palette Modal */} | |
| <AnimatePresence> | |
| {isCommandPaletteOpen && ( | |
| <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] p-4"> | |
| {/* Backdrop */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={() => setIsCommandPaletteOpen(false)} | |
| className="fixed inset-0 bg-black/70 backdrop-blur-md" | |
| /> | |
| {/* Palette Container */} | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.95, y: -20 }} | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.95, y: -20 }} | |
| transition={{ type: 'spring', duration: 0.3 }} | |
| className={`w-full max-w-xl rounded-2xl border shadow-2xl overflow-hidden relative z-10 flex flex-col ${ | |
| isDark ? 'bg-zinc-900/90 border-white/10 text-white' : 'bg-white border-gray-200 text-gray-800' | |
| }`} | |
| > | |
| {/* Search Bar */} | |
| <div className="flex items-center gap-3 px-4 border-b border-white/5" style={{ borderColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }}> | |
| <Search className="w-5 h-5 text-gray-400 shrink-0" /> | |
| <input | |
| autoFocus | |
| type="text" | |
| placeholder="Search pages or shortcuts (e.g. 'Toggle Theme')..." | |
| value={paletteSearch} | |
| onChange={(e) => setPaletteSearch(e.target.value)} | |
| className="w-full py-4 text-sm bg-transparent focus:outline-none placeholder-gray-500" | |
| /> | |
| <button | |
| onClick={() => setIsCommandPaletteOpen(false)} | |
| className="px-2 py-1 text-[10px] rounded bg-white/10 text-gray-400 font-semibold" | |
| > | |
| ESC | |
| </button> | |
| </div> | |
| {/* Items List */} | |
| <div className="max-h-[300px] overflow-y-auto p-2 space-y-1"> | |
| {/* Navigation Items */} | |
| <div className="px-3 py-1.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider"> | |
| Pages & Navigation | |
| </div> | |
| {navItems | |
| .filter(item => item.label.toLowerCase().includes(paletteSearch.toLowerCase())) | |
| .map((item) => ( | |
| <button | |
| key={item.path} | |
| onClick={() => { | |
| navigate(item.path); | |
| setIsCommandPaletteOpen(false); | |
| setPaletteSearch(''); | |
| }} | |
| className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-left transition-all ${ | |
| isDark ? 'hover:bg-white/5 hover:text-white' : 'hover:bg-gray-50' | |
| }`} | |
| style={{ color: isDark ? '#9ca3af' : undefined }} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <item.icon className="w-4 h-4 text-teal-400" /> | |
| <span>{item.label}</span> | |
| </div> | |
| <ChevronRight className="w-3.5 h-3.5 opacity-40" /> | |
| </button> | |
| ))} | |
| {/* Global Search Results from API */} | |
| {searchResults.length > 0 && ( | |
| <> | |
| <div className="px-3 py-1.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider pt-3"> | |
| Global Search Results | |
| </div> | |
| {searchResults.map((result: any) => ( | |
| <button | |
| key={result.id} | |
| onClick={() => { | |
| navigate(result.link); | |
| setIsCommandPaletteOpen(false); | |
| setPaletteSearch(''); | |
| }} | |
| className={`w-full flex flex-col px-3 py-2.5 rounded-lg text-sm text-left transition-all ${ | |
| isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| {result.type === 'anomaly' && <TrendingDown className="w-4 h-4 text-red-400" />} | |
| {result.type === 'dataset' && <Database className="w-4 h-4 text-blue-400" />} | |
| {result.type === 'report' && <FileText className="w-4 h-4 text-purple-400" />} | |
| <span className={isDark ? 'text-white' : 'text-gray-800 font-medium'}>{result.title}</span> | |
| </div> | |
| <span className="text-xs mt-1 ml-7 opacity-60" style={{ color: isDark ? '#9ca3af' : '#64748b' }}> | |
| {result.description} | |
| </span> | |
| </button> | |
| ))} | |
| </> | |
| )} | |
| {/* Custom Action Shortcuts */} | |
| {paletteSearch.trim() !== '' && ( | |
| <> | |
| <div className="px-3 py-1.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider pt-3"> | |
| Actions | |
| </div> | |
| {/* Toggle Theme Action */} | |
| {"toggle theme".includes(paletteSearch.toLowerCase()) && ( | |
| <button | |
| onClick={() => { | |
| toggleTheme(); | |
| setIsCommandPaletteOpen(false); | |
| setPaletteSearch(''); | |
| }} | |
| className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-left transition-all ${ | |
| isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <Command className="w-4 h-4 text-emerald-400" /> | |
| <span>Toggle Theme (Dark/Light)</span> | |
| </div> | |
| <ChevronRight className="w-3.5 h-3.5 opacity-40" /> | |
| </button> | |
| )} | |
| {/* Sign Out Action */} | |
| {"sign out logout".includes(paletteSearch.toLowerCase()) && ( | |
| <button | |
| onClick={() => { | |
| if (confirm('Are you sure you want to sign out?')) { | |
| signOut(); | |
| } | |
| setIsCommandPaletteOpen(false); | |
| setPaletteSearch(''); | |
| }} | |
| className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-left transition-all ${ | |
| isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <Command className="w-4 h-4 text-red-400" /> | |
| <span>Sign Out</span> | |
| </div> | |
| <ChevronRight className="w-3.5 h-3.5 opacity-40" /> | |
| </button> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| }; | |
| export default AppLayout; | |