| import { useState, useRef, useEffect } from 'react' |
| import { motion, AnimatePresence } from 'framer-motion' |
| import { useChatStore, useAuthStore } from '../../store' |
| import { |
| Plus, MessageSquare, Trash2, Pencil, Check, X, |
| Stethoscope, LogOut, Shield, ChevronRight, Search |
| } from 'lucide-react' |
| import { useNavigate } from 'react-router-dom' |
| import toast from 'react-hot-toast' |
| import clsx from 'clsx' |
|
|
| function ConvItem({ conv, isActive, onSelect, onDelete, onRename }) { |
| const [editing, setEditing] = useState(false) |
| const [title, setTitle] = useState(conv.title) |
| const [showActions, setShowActions] = useState(false) |
| const inputRef = useRef() |
|
|
| useEffect(() => { if (editing) inputRef.current?.focus() }, [editing]) |
|
|
| const saveRename = async () => { |
| if (title.trim() && title !== conv.title) await onRename(conv.id, title.trim()) |
| setEditing(false) |
| } |
|
|
| const handleKey = (e) => { |
| if (e.key === 'Enter') saveRename() |
| if (e.key === 'Escape') { setTitle(conv.title); setEditing(false) } |
| } |
|
|
| return ( |
| <div |
| className={clsx('sidebar-item relative', isActive && 'active')} |
| onClick={() => !editing && onSelect(conv.id)} |
| onMouseEnter={() => setShowActions(true)} |
| onMouseLeave={() => setShowActions(false)} |
| > |
| <MessageSquare size={14} className="shrink-0 opacity-60" /> |
| |
| {editing ? ( |
| <input |
| ref={inputRef} |
| value={title} |
| onChange={e => setTitle(e.target.value)} |
| onKeyDown={handleKey} |
| onBlur={saveRename} |
| onClick={e => e.stopPropagation()} |
| className="flex-1 bg-surface-4 text-white text-sm px-2 py-0.5 rounded outline-none border border-accent/40 min-w-0" |
| /> |
| ) : ( |
| <span className="flex-1 truncate text-sm">{conv.title}</span> |
| )} |
| |
| <AnimatePresence> |
| {showActions && !editing && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="flex items-center gap-0.5 shrink-0" |
| onClick={e => e.stopPropagation()} |
| > |
| <button |
| onClick={() => setEditing(true)} |
| className="p-1 hover:text-white text-gray-500 rounded hover:bg-white/10" |
| > |
| <Pencil size={12} /> |
| </button> |
| <button |
| onClick={() => onDelete(conv.id)} |
| className="p-1 hover:text-red-400 text-gray-500 rounded hover:bg-white/10" |
| > |
| <Trash2 size={12} /> |
| </button> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| ) |
| } |
|
|
| export default function Sidebar({ onClose }) { |
| const { conversations, activeConvId, createConversation, selectConversation, deleteConversation, renameConversation, fetchConversations } = useChatStore() |
| const { user, logout } = useAuthStore() |
| const navigate = useNavigate() |
| const [search, setSearch] = useState('') |
|
|
| useEffect(() => { fetchConversations() }, []) |
|
|
| const handleNew = async () => { |
| await createConversation() |
| onClose?.() |
| } |
|
|
| const handleDelete = async (id) => { |
| if (!confirm('Delete this conversation?')) return |
| try { await deleteConversation(id) } |
| catch { toast.error('Could not delete conversation') } |
| } |
|
|
| const handleRename = async (id, title) => { |
| try { await renameConversation(id, title) } |
| catch { toast.error('Could not rename') } |
| } |
|
|
| const filtered = conversations.filter(c => |
| c.title.toLowerCase().includes(search.toLowerCase()) |
| ) |
|
|
| |
| const now = new Date() |
| const groups = { Today: [], 'Last 7 days': [], Older: [] } |
| filtered.forEach(c => { |
| const d = new Date(c.updated_at) |
| const diffDays = (now - d) / 86400000 |
| if (diffDays < 1) groups['Today'].push(c) |
| else if (diffDays < 7) groups['Last 7 days'].push(c) |
| else groups['Older'].push(c) |
| }) |
|
|
| return ( |
| <div className="flex flex-col h-full bg-surface-1 w-64 border-r border-white/5"> |
| {/* Logo */} |
| <div className="px-4 pt-5 pb-3 flex items-center justify-between"> |
| <div className="flex items-center gap-2.5"> |
| <img src="/logo.png" alt="MedRAG Logo" className="w-7 h-7 rounded-lg object-cover shrink-0" /> |
| <span className="font-bold text-white text-sm tracking-tight">MedRAG</span> |
| </div> |
| <button |
| onClick={onClose} |
| className="p-1 text-gray-500 hover:text-white rounded hover:bg-white/10 md:hidden" |
| > |
| <X size={16} /> |
| </button> |
| </div> |
| |
| {/* New Chat button */} |
| <div className="px-3 mb-3"> |
| <button |
| onClick={handleNew} |
| className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl bg-accent/10 hover:bg-accent/20 border border-accent/20 text-accent text-sm font-medium transition-all duration-150 group" |
| > |
| <Plus size={15} className="group-hover:rotate-90 transition-transform duration-200" /> |
| New Chat |
| </button> |
| </div> |
| |
| {/* Search */} |
| <div className="px-3 mb-2"> |
| <div className="flex items-center gap-2 bg-surface-3 rounded-lg px-3 py-2 border border-white/5"> |
| <Search size={13} className="text-gray-500 shrink-0" /> |
| <input |
| value={search} |
| onChange={e => setSearch(e.target.value)} |
| placeholder="Search chats…" |
| className="bg-transparent text-sm text-white placeholder-gray-600 outline-none flex-1 min-w-0" |
| /> |
| </div> |
| </div> |
| |
| {/* Conversation list */} |
| <div className="flex-1 overflow-y-auto px-2 pb-2 space-y-4"> |
| {Object.entries(groups).map(([label, convs]) => |
| convs.length > 0 && ( |
| <div key={label}> |
| <p className="px-2 py-1 text-[10px] font-semibold text-gray-600 uppercase tracking-wider">{label}</p> |
| {convs.map(c => ( |
| <ConvItem |
| key={c.id} |
| conv={c} |
| isActive={c.id === activeConvId} |
| onSelect={(id) => { |
| selectConversation(id) |
| if (window.innerWidth < 768) { |
| onClose?.() |
| } |
| }} |
| onDelete={handleDelete} |
| onRename={handleRename} |
| /> |
| ))} |
| </div> |
| ) |
| )} |
| {filtered.length === 0 && ( |
| <p className="text-center text-gray-600 text-xs py-8"> |
| {search ? 'No chats found' : 'No conversations yet'} |
| </p> |
| )} |
| </div> |
| |
| {/* Bottom user area */} |
| <div className="border-t border-white/5 p-3 space-y-1"> |
| {user?.role === 'admin' && ( |
| <button |
| onClick={() => navigate('/admin')} |
| className="sidebar-item w-full text-med-purple" |
| > |
| <Shield size={14} className="shrink-0" /> |
| Admin Dashboard |
| <ChevronRight size={12} className="ml-auto" /> |
| </button> |
| )} |
| <div className="sidebar-item"> |
| <div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center text-accent text-xs font-bold shrink-0"> |
| {user?.full_name?.[0]?.toUpperCase() ?? 'U'} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-xs text-white font-medium truncate">{user?.full_name}</p> |
| <p className="text-[10px] text-gray-500 truncate">{user?.email}</p> |
| </div> |
| <button |
| onClick={logout} |
| className="text-gray-500 hover:text-red-400 p-1 rounded shrink-0" |
| title="Sign out" |
| > |
| <LogOut size={13} /> |
| </button> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|