Spaces:
Sleeping
Sleeping
| import { useState } from "react"; | |
| import { motion, AnimatePresence } from "motion/react"; | |
| import { | |
| Plus, | |
| MessageSquare, | |
| Trash2, | |
| ChevronLeft, | |
| ChevronRight, | |
| Settings, | |
| LogOut, | |
| } from "lucide-react"; | |
| import maintivalogoUrl from "../../../assets/maintiva-logo.jpg"; | |
| import type { ChatSession, StoredUser } from "./types"; | |
| interface SidebarProps { | |
| sessions: ChatSession[]; | |
| activeSessionId: string | null; | |
| isLoadingSessions: boolean; | |
| user: StoredUser | null; | |
| onNewChat: () => void; | |
| onSelectSession: (id: string) => void; | |
| onDeleteSession: (id: string) => void; | |
| onLogout: () => void; | |
| mobileOpen: boolean; | |
| onMobileClose: () => void; | |
| } | |
| function formatTime(dateStr: string | null | undefined): string { | |
| if (!dateStr) return ""; | |
| const d = new Date(dateStr); | |
| const now = new Date(); | |
| const diffMs = now.getTime() - d.getTime(); | |
| const diffDays = Math.floor(diffMs / 86400000); | |
| if (diffDays === 0) { | |
| return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); | |
| } | |
| if (diffDays === 1) return "Yesterday"; | |
| if (diffDays < 7) return `${diffDays}d ago`; | |
| return d.toLocaleDateString([], { month: "short", day: "numeric" }); | |
| } | |
| export default function Sidebar({ | |
| sessions, | |
| activeSessionId, | |
| isLoadingSessions, | |
| user, | |
| onNewChat, | |
| onSelectSession, | |
| onDeleteSession, | |
| onLogout, | |
| mobileOpen, | |
| onMobileClose, | |
| }: SidebarProps) { | |
| const [collapsed, setCollapsed] = useState(false); | |
| const [hoveredSession, setHoveredSession] = useState<string | null>(null); | |
| return ( | |
| <> | |
| {/* Mobile backdrop */} | |
| <AnimatePresence> | |
| {mobileOpen && ( | |
| <motion.div | |
| className="fixed inset-0 bg-black/40 z-40 md:hidden" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| onClick={onMobileClose} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| <motion.div | |
| className={[ | |
| "flex flex-col h-full flex-shrink-0 bg-white border-r border-neutral-100 overflow-hidden", | |
| // Mobile: fixed overlay drawer; desktop: in-flow | |
| "fixed inset-y-0 left-0 z-50 md:relative md:inset-auto md:z-auto", | |
| // Mobile slide in/out; always visible on desktop | |
| mobileOpen ? "translate-x-0" : "-translate-x-full", | |
| "md:translate-x-0", | |
| // CSS transition for mobile only; framer-motion handles desktop width | |
| "transition-transform duration-300 ease-in-out md:transition-none", | |
| // Never overflow phone screen | |
| "max-w-[85vw] md:max-w-none", | |
| ].join(" ")} | |
| animate={{ width: collapsed ? 72 : 280 }} | |
| transition={{ duration: 0.3, ease: "easeInOut" }} | |
| > | |
| {/* Collapse toggle — desktop only */} | |
| <button | |
| onClick={() => setCollapsed((v) => !v)} | |
| className="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 z-20 w-6 h-6 rounded-full bg-white border border-neutral-200 shadow-sm text-neutral-500 hover:text-brand-green hover:border-brand-green transition-all items-center justify-center" | |
| aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} | |
| > | |
| {collapsed ? ( | |
| <ChevronRight className="h-3.5 w-3.5" /> | |
| ) : ( | |
| <ChevronLeft className="h-3.5 w-3.5" /> | |
| )} | |
| </button> | |
| {/* Header */} | |
| <div className="flex items-center gap-3 px-4 py-4 border-b border-neutral-100"> | |
| <motion.div | |
| className="w-9 h-9 rounded-xl overflow-hidden flex-shrink-0 shadow-md" | |
| whileHover={{ scale: 1.05 }} | |
| > | |
| <img src={maintivalogoUrl} alt="Maintiva" className="w-full h-full object-cover" /> | |
| </motion.div> | |
| <AnimatePresence> | |
| {!collapsed && ( | |
| <motion.div | |
| initial={{ opacity: 0, width: 0 }} | |
| animate={{ opacity: 1, width: "auto" }} | |
| exit={{ opacity: 0, width: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="overflow-hidden" | |
| > | |
| <p className="font-bold text-neutral-900 text-base whitespace-nowrap">Maintiva Agent</p> | |
| <p className="text-xs text-neutral-400 whitespace-nowrap">AI Data Assistant</p> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| {/* New Chat button */} | |
| <div className="px-3 py-3"> | |
| <button | |
| onClick={() => { onNewChat(); onMobileClose(); }} | |
| className={`w-full flex items-center gap-3 rounded-xl px-3 py-2.5 bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm font-semibold shadow-md shadow-brand-green/20 hover:shadow-lg hover:shadow-brand-green/25 hover:brightness-105 transition-all ${ | |
| collapsed ? "justify-center px-0" : "" | |
| }`} | |
| > | |
| <Plus className="h-4 w-4 flex-shrink-0" /> | |
| <AnimatePresence> | |
| {!collapsed && ( | |
| <motion.span | |
| initial={{ opacity: 0, width: 0 }} | |
| animate={{ opacity: 1, width: "auto" }} | |
| exit={{ opacity: 0, width: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="overflow-hidden whitespace-nowrap" | |
| > | |
| New Chat | |
| </motion.span> | |
| )} | |
| </AnimatePresence> | |
| </button> | |
| </div> | |
| {/* Sessions */} | |
| <div className="flex-1 overflow-y-auto"> | |
| {!collapsed && sessions.length > 0 && ( | |
| <p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider px-4 pt-2 pb-1"> | |
| Recent | |
| </p> | |
| )} | |
| {isLoadingSessions ? ( | |
| <div className="flex justify-center py-4"> | |
| <div className="w-4 h-4 rounded-full border-2 border-brand-green border-t-transparent animate-spin" /> | |
| </div> | |
| ) : ( | |
| <div className="px-2 space-y-0.5"> | |
| {sessions.map((session) => { | |
| const isActive = session.id === activeSessionId; | |
| return ( | |
| <div | |
| key={session.id} | |
| className={`relative flex items-center gap-3 rounded-xl px-3 py-2.5 cursor-pointer transition-all duration-150 ${ | |
| isActive | |
| ? "bg-brand-green-50 text-brand-green" | |
| : "text-neutral-700 hover:bg-neutral-50" | |
| }`} | |
| onClick={() => { onSelectSession(session.id); onMobileClose(); }} | |
| onMouseEnter={() => setHoveredSession(session.id)} | |
| onMouseLeave={() => setHoveredSession(null)} | |
| > | |
| <MessageSquare | |
| className={`h-4 w-4 flex-shrink-0 ${ | |
| isActive ? "text-brand-green" : "text-neutral-400" | |
| }`} | |
| /> | |
| <AnimatePresence> | |
| {!collapsed && ( | |
| <motion.div | |
| initial={{ opacity: 0, width: 0 }} | |
| animate={{ opacity: 1, width: "auto" }} | |
| exit={{ opacity: 0, width: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="flex-1 min-w-0 overflow-hidden" | |
| > | |
| <p className="text-sm font-medium truncate">{session.title}</p> | |
| <p className="text-xs text-neutral-400 truncate"> | |
| {formatTime(session.updatedAt ?? session.createdAt)} | |
| </p> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {!collapsed && hoveredSession === session.id && ( | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onDeleteSession(session.id); | |
| }} | |
| className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all" | |
| aria-label="Delete session" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="border-t border-neutral-100 p-3"> | |
| <div className="flex items-center gap-3 rounded-xl px-2 py-2"> | |
| <div className="h-7 w-7 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex items-center justify-center flex-shrink-0"> | |
| <span className="text-xs font-bold text-white"> | |
| {user?.name?.[0]?.toUpperCase() ?? "U"} | |
| </span> | |
| </div> | |
| <AnimatePresence> | |
| {!collapsed && ( | |
| <motion.div | |
| initial={{ opacity: 0, width: 0 }} | |
| animate={{ opacity: 1, width: "auto" }} | |
| exit={{ opacity: 0, width: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="flex-1 min-w-0 overflow-hidden" | |
| > | |
| <p className="text-sm font-semibold text-neutral-900 truncate whitespace-nowrap"> | |
| {user?.name ?? "User"} | |
| </p> | |
| <p className="text-xs text-neutral-400 truncate whitespace-nowrap"> | |
| {user?.email ?? ""} | |
| </p> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {!collapsed && ( | |
| <div className="flex items-center gap-0.5"> | |
| <button | |
| className="p-1.5 rounded-lg text-neutral-400 hover:text-brand-green hover:bg-brand-green-50 transition-all" | |
| aria-label="Settings" | |
| > | |
| <Settings className="h-4 w-4" /> | |
| </button> | |
| <button | |
| onClick={onLogout} | |
| className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all" | |
| aria-label="Logout" | |
| > | |
| <LogOut className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| )} | |
| {collapsed && ( | |
| <button | |
| onClick={onLogout} | |
| className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all" | |
| aria-label="Logout" | |
| > | |
| <LogOut className="h-4 w-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </motion.div> | |
| </> | |
| ); | |
| } | |