| import { useState, useEffect, useCallback, useRef } from "react"; | |
| import { Outlet, NavLink, useNavigate, useLocation, useSearchParams } from "react-router-dom"; | |
| import { useKBar } from "kbar"; | |
| import { useAuth } from "@/context/AuthContext"; | |
| import { useTheme } from "@/hooks/useTheme"; | |
| import { VoiceAssistantWidget } from "@/components/voice/VoiceAssistantWidget"; | |
| import { SmartNotificationCenter } from "@/components/notifications/SmartNotificationCenter"; | |
| import { SystemStatusBar } from "./SystemStatusBar"; | |
| import { FloatingDock } from "./FloatingDock"; | |
| import api from "@/lib/api"; | |
| import { useWebSocket } from "@/hooks/useWebSocket"; | |
| import { toast } from "react-toastify"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| LayoutDashboard, Home, MessageSquare, Users, FileText, BookOpen, | |
| Calendar, Settings, HelpCircle, LogOut, Menu, X, ChevronDown, | |
| Search, Sun, Moon, User, FolderOpen, FileUp, Briefcase, | |
| BarChart3, Tag, Award, FlaskConical, Shield, Brain, Zap, ShieldCheck, Heart, | |
| PanelLeft, Inbox, Globe, Wrench, Package, Database, Activity, | |
| Loader2, Video, PenTool | |
| } from "lucide-react"; | |
| // ============================================ | |
| // Types & Configuration | |
| // ============================================ | |
| interface NavItem { | |
| to: string; | |
| icon: any; | |
| label: string; | |
| description: string; | |
| badge?: string; | |
| roles?: string[]; | |
| } | |
| interface NavSection { | |
| id: string; | |
| label: string; | |
| icon: any; | |
| items: NavItem[]; | |
| } | |
| const SECTIONS: NavSection[] = [ | |
| { | |
| id: "main", | |
| label: "Main", | |
| icon: Home, | |
| items: [ | |
| { to: "/dashboard", icon: LayoutDashboard, label: "Dashboard", description: "Your research hub", badge: "new" }, | |
| { to: "/workspace", icon: PanelLeft, label: "Workspace", description: "Multi-panel view" }, | |
| { to: "/calendar", icon: Calendar, label: "Calendar", description: "Event schedules" }, | |
| { to: "/conference", icon: Video, label: "Conference", description: "Virtual conferences" }, | |
| { to: "/roadmap", icon: Activity, label: "Roadmap", description: "Future plans" }, | |
| ] | |
| }, | |
| { | |
| id: "communication", | |
| label: "Network", | |
| icon: Globe, | |
| items: [ | |
| { to: "/peer-room", icon: MessageSquare, label: "Peer Room", description: "Peer collaboration" }, | |
| { to: "/inbox", icon: Inbox, label: "Inbox", description: "All conversations", badge: "3" }, | |
| { to: "/messages", icon: MessageSquare, label: "Messages", description: "Chat & groups" }, | |
| { to: "/groups", icon: Users, label: "Groups", description: "Research groups" }, | |
| { to: "/networking", icon: Globe, label: "Networking", description: "Connect with peers" }, | |
| ] | |
| }, | |
| { | |
| id: "content", | |
| label: "Content", | |
| icon: FileText, | |
| items: [ | |
| { to: "/articles", icon: FileText, label: "Articles", description: "Discover papers" }, | |
| { to: "/my-posts", icon: PenTool, label: "My Posts", description: "Manage posts" }, | |
| { to: "/collections", icon: FolderOpen, label: "Collections", description: "Saved papers" }, | |
| { to: "/submit-preprint", icon: FileUp, label: "Preprint", description: "Submit paper" }, | |
| { to: "/editor", icon: BookOpen, label: "Sci Editor", description: "Write papers with LaTeX" }, | |
| { to: "/authoring-studio", icon: PenTool, label: "Authoring", description: "Write & edit" }, | |
| ] | |
| }, | |
| { | |
| id: "lab", | |
| label: "Lab & AI", | |
| icon: FlaskConical, | |
| items: [ | |
| { to: "/ai-co-scientist", icon: Brain, label: "AI Co-Scientist", description: "Virtual collaborator", badge: "new" }, | |
| { to: "/gap-analysis", icon: Activity, label: "Gap Analysis", description: "Research gaps" }, | |
| { to: "/grant-assistant", icon: Award, label: "Grants", description: "Proposal writing" }, | |
| { to: "/research-assistant", icon: Zap, label: "Research Assistant", description: "AI-powered help" }, | |
| { to: "/lab-inventory", icon: FlaskConical, label: "Lab", description: "Inventory & safety" }, | |
| { to: "/peer-review", icon: Shield, label: "Review", description: "Anonymous peer review" }, | |
| { to: "/projects", icon: Briefcase, label: "Projects", description: "Manage projects" }, | |
| { to: "/trust-dashboard", icon: ShieldCheck, label: "Trust Dashboard", description: "Security & verify" }, | |
| ] | |
| }, | |
| { | |
| id: "data", | |
| label: "Data", | |
| icon: BarChart3, | |
| items: [ | |
| { to: "/analytics", icon: BarChart3, label: "Analytics", description: "Research metrics" }, | |
| { to: "/discovery", icon: Search, label: "Discovery", description: "Find trends" }, | |
| { to: "/tags", icon: Tag, label: "Tags", description: "Browse by topic" }, | |
| { to: "/recommendations", icon: Heart, label: "Recommendations", description: "Personalized" }, | |
| ] | |
| }, | |
| { | |
| id: "tools", | |
| label: "Tools", | |
| icon: Wrench, | |
| items: [ | |
| { to: "/tools", icon: Wrench, label: "App Store", description: "Research tools" }, | |
| { to: "/tools/builder", icon: Package, label: "Build Tool Studio", description: "Visual editor" }, | |
| { to: "/tools/installed", icon: Package, label: "Installed Tools", description: "Manage add-ons" }, | |
| { to: "/data-hub", icon: Database, label: "Data Hub", description: "Research data" }, | |
| { to: "/api-docs", icon: FileText, label: "API Docs", description: "Developer resources" }, | |
| { to: "/status", icon: Activity, label: "Status", description: "Service status" }, | |
| ] | |
| } | |
| ]; | |
| const ADMIN_ITEMS: NavItem[] = [ | |
| { to: "/admin", icon: Shield, label: "Admin Dashboard", description: "System administration", roles: ["admin"] }, | |
| { to: "/admin/users", icon: Users, label: "User Management", description: "Manage users", roles: ["admin"] }, | |
| ]; | |
| // ============================================ | |
| // Message Reply Toast Component | |
| // ============================================ | |
| const MessageReplyToast = ({ closeToast, message }: { closeToast?: () => void; message: any }) => { | |
| const [replyText, setReplyText] = useState(""); | |
| const [isSending, setIsSending] = useState(false); | |
| const handleReply = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!replyText.trim()) return; | |
| setIsSending(true); | |
| try { | |
| await api.post(`/messages/conversations/${message.from}`, { | |
| body: replyText.trim(), | |
| messageType: "text", | |
| }); | |
| toast.success("Reply sent successfully!", { position: "top-right", autoClose: 2000 }); | |
| if (closeToast) closeToast(); | |
| } catch (err) { | |
| console.error(err); | |
| toast.error("Failed to send reply.", { position: "top-right", autoClose: 3000 }); | |
| } finally { | |
| setIsSending(false); | |
| } | |
| }; | |
| return ( | |
| <div className="w-full flex flex-col gap-2 p-1 text-slate-800 dark:text-slate-100"> | |
| <div className="flex flex-col min-w-0"> | |
| <span className="font-bold text-sm text-primary dark:text-sky-400"> | |
| New Message from {message.from} | |
| </span> | |
| <span className="text-xs text-slate-600 dark:text-slate-350 line-clamp-2 mt-0.5 break-words"> | |
| {message.body} | |
| </span> | |
| </div> | |
| <form onSubmit={handleReply} className="flex items-center gap-2 mt-1"> | |
| <input | |
| type="text" | |
| value={replyText} | |
| onChange={(e) => setReplyText(e.target.value)} | |
| disabled={isSending} | |
| placeholder="Type a quick reply..." | |
| className="flex-1 min-w-0 text-xs px-2.5 py-1.5 rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-primary" | |
| autoFocus | |
| /> | |
| <button | |
| type="submit" | |
| disabled={isSending || !replyText.trim()} | |
| className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-primary text-white hover:brightness-105 transition-all flex items-center justify-center min-w-[56px]" | |
| > | |
| {isSending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : "Reply"} | |
| </button> | |
| </form> | |
| </div> | |
| ); | |
| }; | |
| // ============================================ | |
| // Main Layout Component | |
| // ============================================ | |
| export default function MainLayout() { | |
| const { user, isLoading, logout, updateUserStatus } = useAuth(); | |
| const { isDark, toggleTheme } = useTheme(); | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { query: { toggle } } = useKBar(); | |
| const [searchParams] = useSearchParams(); | |
| const [mobileMenuOpen, setMobileMenuOpen] = useState(false); | |
| const [activeDropdown, setActiveDropdown] = useState<string | null>(null); | |
| const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const profileRef = useRef<HTMLDivElement>(null); | |
| const isAdmin = user?.role === "admin"; | |
| useWebSocket(useCallback((data: any) => { | |
| if (data.type === "new_message") { | |
| const msg = data.message; | |
| if (location.pathname !== "/messages") { | |
| toast(<MessageReplyToast message={msg} />, { | |
| position: "top-right", | |
| autoClose: 10000, | |
| closeOnClick: false, | |
| pauseOnHover: true, | |
| draggable: false, | |
| className: "premium-glass bg-white/60 dark:bg-slate-950/40 backdrop-blur-3xl border border-white/30 dark:border-white/10 shadow-2xl rounded-2xl", | |
| }); | |
| } | |
| } | |
| }, [location.pathname])); | |
| useEffect(() => { | |
| if (!isLoading && !user) { | |
| navigate("/login", { replace: true }); | |
| } | |
| }, [user, isLoading, navigate]); | |
| useEffect(() => { | |
| setMobileMenuOpen(false); | |
| setActiveDropdown(null); | |
| setProfileDropdownOpen(false); | |
| }, [location.pathname]); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setActiveDropdown(null); | |
| } | |
| if (profileRef.current && !profileRef.current.contains(event.target as Node)) { | |
| setProfileDropdownOpen(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => document.removeEventListener("mousedown", handleClickOutside); | |
| }, []); | |
| const handleLogout = async () => { | |
| await logout(); | |
| navigate("/login", { replace: true }); | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-950 dark:to-gray-900"> | |
| <div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto" /> | |
| </div> | |
| ); | |
| } | |
| const isEmbedded = searchParams.get("embed") === "true"; | |
| if (!user) return null; | |
| if (isEmbedded) { | |
| return <Outlet />; | |
| } | |
| return ( | |
| <div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 text-gray-900 dark:text-gray-100 font-sans transition-colors duration-300"> | |
| {/* Top Navbar */} | |
| <header className="sticky top-0 z-50 w-full bg-white/70 dark:bg-slate-950/70 backdrop-blur-xl border-b border-gray-200/50 dark:border-white/10 shadow-sm"> | |
| <div className="mx-auto px-4 h-16 flex items-center justify-between gap-4 lg:gap-8"> | |
| {/* Logo & Mobile Menu Toggle */} | |
| <div className="flex items-center gap-4"> | |
| <button | |
| onClick={() => setMobileMenuOpen(!mobileMenuOpen)} | |
| className="lg:hidden p-2 -ml-2 rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" | |
| > | |
| {mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} | |
| </button> | |
| <div className="flex items-center gap-2 cursor-pointer" onClick={() => navigate("/dashboard")}> | |
| <div className="w-8 h-8 rounded-xl bg-gradient-to-br from-teal-500 to-emerald-500 flex items-center justify-center shadow-lg shadow-teal-500/20"> | |
| <span className="text-white font-bold text-lg leading-none">IE</span> | |
| </div> | |
| <span className="text-xl font-bold bg-gradient-to-r from-teal-600 to-emerald-600 bg-clip-text text-transparent hidden sm:block"> | |
| Research | |
| </span> | |
| </div> | |
| {/* Sync connection status next to logo */} | |
| <div className="ml-2"> | |
| <SystemStatusBar compact={true} showDetails={true} /> | |
| </div> | |
| </div> | |
| {/* Search, Actions & Profile */} | |
| <div className="flex items-center gap-1 sm:gap-2 lg:gap-3 ml-auto"> | |
| {/* Search Trigger */} | |
| <button | |
| onClick={toggle} | |
| className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-100 dark:bg-gray-800 text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 transition-colors w-48 lg:w-64 border border-transparent dark:border-white/5" | |
| > | |
| <Search className="w-4 h-4" /> | |
| <span className="flex-1 text-left truncate">Search...</span> | |
| <kbd className="hidden lg:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700"> | |
| ⌘K | |
| </kbd> | |
| </button> | |
| <button onClick={toggle} className="sm:hidden p-2 rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"> | |
| <Search className="w-5 h-5" /> | |
| </button> | |
| {/* Quick Actions */} | |
| <div className="hidden sm:flex items-center gap-1"> | |
| <SmartNotificationCenter /> | |
| <VoiceAssistantWidget /> | |
| <button | |
| onClick={toggleTheme} | |
| className="p-2 rounded-xl text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" | |
| > | |
| {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} | |
| </button> | |
| </div> | |
| {/* Profile Dropdown */} | |
| <div className="relative ml-1 sm:ml-2" ref={profileRef}> | |
| <button | |
| onClick={() => setProfileDropdownOpen(!profileDropdownOpen)} | |
| className="flex items-center gap-2 p-1 pl-1 pr-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors border border-transparent dark:border-white/5" | |
| > | |
| <div className="relative w-8 h-8 rounded-full bg-gradient-to-br from-teal-500 to-emerald-500 text-white flex items-center justify-center font-bold text-sm shadow-inner"> | |
| {user.username?.[0]?.toUpperCase() ?? "U"} | |
| <span className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-950 ${ | |
| user.status === 'away' ? 'bg-amber-400' : | |
| user.status === 'busy' ? 'bg-red-500' : | |
| user.status === 'offline' ? 'bg-gray-400' : | |
| 'bg-emerald-500' | |
| }`} /> | |
| </div> | |
| <ChevronDown className="w-3.5 h-3.5 text-gray-500 hidden md:block" /> | |
| </button> | |
| <AnimatePresence> | |
| {profileDropdownOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 10, scale: 0.95 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, y: 10, scale: 0.95 }} | |
| transition={{ duration: 0.2 }} | |
| className="absolute top-[calc(100%+8px)] right-0 w-64 p-2 bg-white/90 dark:bg-slate-900/90 backdrop-blur-2xl border border-gray-200/50 dark:border-white/10 shadow-2xl rounded-2xl" | |
| > | |
| <div className="p-3 border-b border-gray-100 dark:border-gray-800"> | |
| <p className="text-sm font-bold text-gray-900 dark:text-white truncate"> | |
| {user.username ?? user.email} | |
| </p> | |
| <p className="text-xs text-gray-500 dark:text-gray-400 capitalize mt-0.5 mb-3"> | |
| {user.role === "admin" ? "Administrator" : user.role || "Researcher"} | |
| </p> | |
| {/* Status Selector */} | |
| <div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg gap-1"> | |
| {[ | |
| { id: 'online', label: 'Online', color: 'bg-emerald-500 text-emerald-700 dark:text-emerald-300' }, | |
| { id: 'away', label: 'Away', color: 'bg-amber-400 text-amber-700 dark:text-amber-300' }, | |
| { id: 'busy', label: 'Busy', color: 'bg-red-500 text-red-700 dark:text-red-300' }, | |
| { id: 'offline', label: 'Offline', color: 'bg-gray-400 text-gray-700 dark:text-gray-300' } | |
| ].map(st => { | |
| const isActive = (user.status || 'online') === st.id; | |
| return ( | |
| <button | |
| key={st.id} | |
| onClick={() => updateUserStatus?.(st.id as any)} | |
| className={`flex-1 text-[10px] font-semibold py-1 rounded-md transition-all ${ | |
| isActive | |
| ? `bg-white dark:bg-gray-700 shadow-sm ${st.color}` | |
| : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600' | |
| }`} | |
| > | |
| {st.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div className="p-2 space-y-1"> | |
| <NavLink to="/profile" className="flex items-center gap-3 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800"> | |
| <User className="w-4 h-4" /> My Profile | |
| </NavLink> | |
| <NavLink to="/settings" className="flex items-center gap-3 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800"> | |
| <Settings className="w-4 h-4" /> Settings | |
| </NavLink> | |
| <NavLink to="/help" className="flex items-center gap-3 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800"> | |
| <HelpCircle className="w-4 h-4" /> Help Center | |
| </NavLink> | |
| {isAdmin && ( | |
| <> | |
| <div className="my-1 border-t border-gray-100 dark:border-gray-800" /> | |
| {ADMIN_ITEMS.map((item) => ( | |
| <NavLink key={item.to} to={item.to} className="flex items-center gap-3 px-3 py-2 text-sm text-amber-600 dark:text-amber-500 rounded-xl hover:bg-amber-50 dark:hover:bg-amber-900/20"> | |
| <item.icon className="w-4 h-4" /> {item.label} | |
| </NavLink> | |
| ))} | |
| </> | |
| )} | |
| <div className="my-1 border-t border-gray-100 dark:border-gray-800" /> | |
| <button onClick={handleLogout} className="w-full flex items-center gap-3 px-3 py-2 text-sm text-red-600 dark:text-red-400 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20"> | |
| <LogOut className="w-4 h-4" /> Log out | |
| </button> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| {/* Mobile Navigation Drawer */} | |
| <AnimatePresence> | |
| {mobileMenuOpen && ( | |
| <> | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 lg:hidden" | |
| onClick={() => setMobileMenuOpen(false)} | |
| /> | |
| <motion.div | |
| initial={{ x: "-100%" }} | |
| animate={{ x: 0 }} | |
| exit={{ x: "-100%" }} | |
| transition={{ type: "spring", damping: 26, stiffness: 220 }} | |
| className="fixed top-0 left-0 bottom-0 w-[85%] max-w-sm bg-white dark:bg-slate-900 z-50 flex flex-col overflow-hidden shadow-2xl border-r border-gray-200 dark:border-white/10" | |
| > | |
| <div className="p-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-gray-50/50 dark:bg-gray-800/50"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-8 h-8 rounded-xl bg-gradient-to-br from-teal-500 to-emerald-500 flex items-center justify-center"> | |
| <span className="text-white font-bold text-lg leading-none">IE</span> | |
| </div> | |
| <span className="text-xl font-bold bg-gradient-to-r from-teal-600 to-emerald-600 bg-clip-text text-transparent"> | |
| Research | |
| </span> | |
| </div> | |
| <button onClick={() => setMobileMenuOpen(false)} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> | |
| <X className="w-5 h-5 text-gray-500" /> | |
| </button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto py-4 px-3 space-y-6"> | |
| {SECTIONS.map((section) => ( | |
| <div key={section.id}> | |
| <h3 className="px-3 text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2"> | |
| {section.label} | |
| </h3> | |
| <div className="space-y-1"> | |
| {section.items.map((item) => ( | |
| <NavLink | |
| key={item.to} | |
| to={item.to} | |
| onClick={() => setMobileMenuOpen(false)} | |
| className={({ isActive }) => ` | |
| flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors | |
| ${isActive | |
| ? "bg-teal-50 dark:bg-teal-900/20 text-teal-700 dark:text-teal-300" | |
| : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800" | |
| } | |
| `} | |
| > | |
| <item.icon className="w-4 h-4" /> | |
| <span className="flex-1">{item.label}</span> | |
| {item.badge && ( | |
| <span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-full bg-teal-100 text-teal-700 dark:bg-teal-900/50 dark:text-teal-400"> | |
| {item.badge} | |
| </span> | |
| )} | |
| </NavLink> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="p-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 flex justify-between items-center"> | |
| <button onClick={toggleTheme} className="p-2 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 shadow-sm"> | |
| {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} | |
| </button> | |
| <button onClick={handleLogout} className="px-4 py-2 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 font-medium text-sm flex items-center gap-2"> | |
| <LogOut className="w-4 h-4" /> Logout | |
| </button> | |
| </div> | |
| </motion.div> | |
| </> | |
| )} | |
| </AnimatePresence> | |
| {/* Main Content Area */} | |
| <main className="flex-1 relative z-10 w-full overflow-hidden flex flex-col bg-transparent pb-16 lg:pb-0"> | |
| <div className="flex-1 h-full overflow-y-auto"> | |
| <Outlet /> | |
| </div> | |
| </main> | |
| <FloatingDock /> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 24.5 kB
- Xet hash:
- bce807f49ee1d1f1bf0b907f414b3ae175a7248d615ab41fcc080617b1b80e94
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.