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 }