diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/love/epi/src/components/Sidebar/Sidebar.tsx	Mon Dec 01 20:35:56 2025 -0800
@@ -0,0 +1,176 @@
+import { useAtom } from 'jotai';
+import { chatsAtom, currentChatId } from '@/atoms/chatAtoms';
+import type { Chat } from '@/atoms/chatAtoms';
+import { useEffect, useState } from 'react';
+import { Loader2, MessageSquare, Plus, ChevronDown } from 'lucide-react';
+import { apiUrl } from '@/utils';
+import { useNavigate } from '@tanstack/react-router';
+
+export function Sidebar() {
+  const [chats, setChats] = useAtom(chatsAtom);
+  const [loading, setLoading] = useState(true);
+  const [loadingMore, setLoadingMore] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [cursor, setCursor] = useState<string | null>(null);
+  const [hasMore, setHasMore] = useState(true);
+  const [currentChatIdValue, setCurrentChatId] = useAtom(currentChatId);
+  const navigate = useNavigate();
+
+  const LIMIT = 12;
+
+  const fetchChats = async (currentCursor: string | null = null, append: boolean = false) => {
+    try {
+      if (append) {
+        setLoadingMore(true);
+      } else {
+        setLoading(true);
+      }
+      setError(null);
+
+      const url = currentCursor 
+        ? apiUrl(`/chats?limit=${LIMIT}&cursor=${currentCursor}`)
+        : apiUrl(`/chats?limit=${LIMIT}`);
+
+      const res = await fetch(url);
+
+      if (!res.ok) {
+        throw new Error('Failed to fetch chats');
+      }
+
+      const data: Chat[] = (await res.json()).chats;
+      
+      if (data.length < LIMIT) {
+        setHasMore(false);
+      }
+
+      if (data.length > 0) {
+        setCursor(data[data.length - 1].id);
+      }
+
+      setChats((prev) => ({
+        ...prev,
+        ...data.reduce((acc, chat) => {
+          acc[chat.id] = chat;
+          return acc;
+        }, {} as Record<string, Chat>),
+      }));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Something went wrong');
+      console.error('Error fetching chats:', err);
+    } finally {
+      setLoading(false);
+      setLoadingMore(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchChats();
+  }, []);
+
+  const loadMore = () => {
+    if (!loadingMore && hasMore && cursor) {
+      fetchChats(cursor, true);
+    }
+  };
+
+  const chatList = Object.values(chats);
+
+  if (loading) {
+    return (
+      <div className="w-[35%] h-full border-r border-gray-800 bg-gray-900 flex items-center justify-center">
+        <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div className="w-[35%] h-full border-r border-gray-800 bg-gray-900 flex flex-col items-center justify-center p-4">
+        <p className="text-red-400 text-sm text-center">Error: {error}</p>
+        <button
+          onClick={() => window.location.reload()}
+          className="mt-3 px-4 py-2 text-xs font-medium text-white bg-gray-800 hover:bg-gray-700 rounded-md transition-colors"
+        >
+          Retry
+        </button>
+      </div>
+    );
+  }
+
+  return (
+    <div className="w-[35%] h-full flex flex-col border-r border-gray-800 bg-gray-900 text-gray-100 h-screen">
+      
+      {/* HEADER: Static height, does not scroll */}
+      <div className="p-4 border-b border-gray-800 flex justify-between items-center shrink-0">
+        <h2 className="text-lg font-semibold text-white tracking-tight">Chats</h2>
+        <button 
+          onClick={() => window.location.replace('/')}
+          className="p-1.5 hover:bg-gray-800 rounded-md text-gray-400 hover:text-white transition-colors"
+        >
+          <Plus className="w-5 h-5" />
+        </button>
+      </div>
+
+      <div className="flex-1 overflow-y-scroll">
+        {chatList.length === 0 ? (
+          <div className="flex flex-col items-center justify-center h-full text-gray-500">
+            <MessageSquare className="w-10 h-10 mb-3 opacity-20" />
+            <p className="text-sm font-medium">No chats yet</p>
+          </div>
+        ) : (
+          <ul className="flex flex-col p-2 gap-1">
+            {chatList.map((chat) => {
+              const isActive = currentChatIdValue === chat.id;
+              return (
+                <li key={chat.id}>
+                  <button
+                    onClick={() => {
+                      navigate(
+                        { to: '/chat/$chatId', params: { chatId: chat.id } },
+                      );
+                      setCurrentChatId(chat.id);
+                    }}
+                    className={`w-full text-left px-3 py-3 rounded-lg transition-all h-[8%] duration-200 ease-in-out group ${
+                      isActive
+                        ? 'bg-gray-800 text-white'
+                        : 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
+                    }`}
+                  >
+                    <div className="flex justify-between items-center">
+                      <div className="flex-1 min-w-0 pr-2">
+                        <h3 className={`text-sm font-medium truncate ${isActive ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
+                          {chat.title || 'Untitled Chat'}
+                        </h3>
+                      </div>
+                    </div>
+                  </button>
+                </li>
+              );
+            })}
+            
+            {hasMore && (
+              <li className="pt-2 pb-4 px-2">
+                <button
+                  onClick={loadMore}
+                  disabled={loadingMore}
+                  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"
+                >
+                  {loadingMore ? (
+                    <>
+                      <Loader2 className="w-3 h-3 animate-spin" />
+                      Loading...
+                    </>
+                  ) : (
+                    <>
+                      Load More <ChevronDown className="w-3 h-3" />
+                    </>
+                  )}
+                </button>
+              </li>
+            )}
+          </ul>
+        )}
+      </div>
+    </div>
+  );
+}