Mercurial
comparison love/epi/src/components/Sidebar/Sidebar.tsx @ 38:cf9caa4abc3e
[Love] FE and BE. Can chat and render images. Also created MCP for powerpoint generations.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 01 Dec 2025 20:35:56 -0800 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 37:fb9bcd3145cb | 38:cf9caa4abc3e |
|---|---|
| 1 import { useAtom } from 'jotai'; | |
| 2 import { chatsAtom, currentChatId } from '@/atoms/chatAtoms'; | |
| 3 import type { Chat } from '@/atoms/chatAtoms'; | |
| 4 import { useEffect, useState } from 'react'; | |
| 5 import { Loader2, MessageSquare, Plus, ChevronDown } from 'lucide-react'; | |
| 6 import { apiUrl } from '@/utils'; | |
| 7 import { useNavigate } from '@tanstack/react-router'; | |
| 8 | |
| 9 export function Sidebar() { | |
| 10 const [chats, setChats] = useAtom(chatsAtom); | |
| 11 const [loading, setLoading] = useState(true); | |
| 12 const [loadingMore, setLoadingMore] = useState(false); | |
| 13 const [error, setError] = useState<string | null>(null); | |
| 14 const [cursor, setCursor] = useState<string | null>(null); | |
| 15 const [hasMore, setHasMore] = useState(true); | |
| 16 const [currentChatIdValue, setCurrentChatId] = useAtom(currentChatId); | |
| 17 const navigate = useNavigate(); | |
| 18 | |
| 19 const LIMIT = 12; | |
| 20 | |
| 21 const fetchChats = async (currentCursor: string | null = null, append: boolean = false) => { | |
| 22 try { | |
| 23 if (append) { | |
| 24 setLoadingMore(true); | |
| 25 } else { | |
| 26 setLoading(true); | |
| 27 } | |
| 28 setError(null); | |
| 29 | |
| 30 const url = currentCursor | |
| 31 ? apiUrl(`/chats?limit=${LIMIT}&cursor=${currentCursor}`) | |
| 32 : apiUrl(`/chats?limit=${LIMIT}`); | |
| 33 | |
| 34 const res = await fetch(url); | |
| 35 | |
| 36 if (!res.ok) { | |
| 37 throw new Error('Failed to fetch chats'); | |
| 38 } | |
| 39 | |
| 40 const data: Chat[] = (await res.json()).chats; | |
| 41 | |
| 42 if (data.length < LIMIT) { | |
| 43 setHasMore(false); | |
| 44 } | |
| 45 | |
| 46 if (data.length > 0) { | |
| 47 setCursor(data[data.length - 1].id); | |
| 48 } | |
| 49 | |
| 50 setChats((prev) => ({ | |
| 51 ...prev, | |
| 52 ...data.reduce((acc, chat) => { | |
| 53 acc[chat.id] = chat; | |
| 54 return acc; | |
| 55 }, {} as Record<string, Chat>), | |
| 56 })); | |
| 57 } catch (err) { | |
| 58 setError(err instanceof Error ? err.message : 'Something went wrong'); | |
| 59 console.error('Error fetching chats:', err); | |
| 60 } finally { | |
| 61 setLoading(false); | |
| 62 setLoadingMore(false); | |
| 63 } | |
| 64 }; | |
| 65 | |
| 66 useEffect(() => { | |
| 67 fetchChats(); | |
| 68 }, []); | |
| 69 | |
| 70 const loadMore = () => { | |
| 71 if (!loadingMore && hasMore && cursor) { | |
| 72 fetchChats(cursor, true); | |
| 73 } | |
| 74 }; | |
| 75 | |
| 76 const chatList = Object.values(chats); | |
| 77 | |
| 78 if (loading) { | |
| 79 return ( | |
| 80 <div className="w-[35%] h-full border-r border-gray-800 bg-gray-900 flex items-center justify-center"> | |
| 81 <Loader2 className="w-6 h-6 animate-spin text-gray-400" /> | |
| 82 </div> | |
| 83 ); | |
| 84 } | |
| 85 | |
| 86 if (error) { | |
| 87 return ( | |
| 88 <div className="w-[35%] h-full border-r border-gray-800 bg-gray-900 flex flex-col items-center justify-center p-4"> | |
| 89 <p className="text-red-400 text-sm text-center">Error: {error}</p> | |
| 90 <button | |
| 91 onClick={() => window.location.reload()} | |
| 92 className="mt-3 px-4 py-2 text-xs font-medium text-white bg-gray-800 hover:bg-gray-700 rounded-md transition-colors" | |
| 93 > | |
| 94 Retry | |
| 95 </button> | |
| 96 </div> | |
| 97 ); | |
| 98 } | |
| 99 | |
| 100 return ( | |
| 101 <div className="w-[35%] h-full flex flex-col border-r border-gray-800 bg-gray-900 text-gray-100 h-screen"> | |
| 102 | |
| 103 {/* HEADER: Static height, does not scroll */} | |
| 104 <div className="p-4 border-b border-gray-800 flex justify-between items-center shrink-0"> | |
| 105 <h2 className="text-lg font-semibold text-white tracking-tight">Chats</h2> | |
| 106 <button | |
| 107 onClick={() => window.location.replace('/')} | |
| 108 className="p-1.5 hover:bg-gray-800 rounded-md text-gray-400 hover:text-white transition-colors" | |
| 109 > | |
| 110 <Plus className="w-5 h-5" /> | |
| 111 </button> | |
| 112 </div> | |
| 113 | |
| 114 <div className="flex-1 overflow-y-scroll"> | |
| 115 {chatList.length === 0 ? ( | |
| 116 <div className="flex flex-col items-center justify-center h-full text-gray-500"> | |
| 117 <MessageSquare className="w-10 h-10 mb-3 opacity-20" /> | |
| 118 <p className="text-sm font-medium">No chats yet</p> | |
| 119 </div> | |
| 120 ) : ( | |
| 121 <ul className="flex flex-col p-2 gap-1"> | |
| 122 {chatList.map((chat) => { | |
| 123 const isActive = currentChatIdValue === chat.id; | |
| 124 return ( | |
| 125 <li key={chat.id}> | |
| 126 <button | |
| 127 onClick={() => { | |
| 128 navigate( | |
| 129 { to: '/chat/$chatId', params: { chatId: chat.id } }, | |
| 130 ); | |
| 131 setCurrentChatId(chat.id); | |
| 132 }} | |
| 133 className={`w-full text-left px-3 py-3 rounded-lg transition-all h-[8%] duration-200 ease-in-out group ${ | |
| 134 isActive | |
| 135 ? 'bg-gray-800 text-white' | |
| 136 : 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200' | |
| 137 }`} | |
| 138 > | |
| 139 <div className="flex justify-between items-center"> | |
| 140 <div className="flex-1 min-w-0 pr-2"> | |
| 141 <h3 className={`text-sm font-medium truncate ${isActive ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}> | |
| 142 {chat.title || 'Untitled Chat'} | |
| 143 </h3> | |
| 144 </div> | |
| 145 </div> | |
| 146 </button> | |
| 147 </li> | |
| 148 ); | |
| 149 })} | |
| 150 | |
| 151 {hasMore && ( | |
| 152 <li className="pt-2 pb-4 px-2"> | |
| 153 <button | |
| 154 onClick={loadMore} | |
| 155 disabled={loadingMore} | |
| 156 className="w-full flex items-center justify-center gap-2 py-2 text-xs font-medium text-gray-500 hover:text-gray-300 transition-colors border border-dashed border-gray-700 rounded-md hover:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed" | |
| 157 > | |
| 158 {loadingMore ? ( | |
| 159 <> | |
| 160 <Loader2 className="w-3 h-3 animate-spin" /> | |
| 161 Loading... | |
| 162 </> | |
| 163 ) : ( | |
| 164 <> | |
| 165 Load More <ChevronDown className="w-3 h-3" /> | |
| 166 </> | |
| 167 )} | |
| 168 </button> | |
| 169 </li> | |
| 170 )} | |
| 171 </ul> | |
| 172 )} | |
| 173 </div> | |
| 174 </div> | |
| 175 ); | |
| 176 } |