tahamajs's picture
download
raw
24.5 kB
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.