Merge pull request 'Mobile slide-in drawer + conversation pagination' (#38) from mobile-drawer-conversation-pagination into main
Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
@@ -299,8 +299,23 @@ async def create_conversation():
|
||||
async def get_all_conversations():
|
||||
user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||
conversations = Conversation.filter(user=user).order_by("-updated_at")
|
||||
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
||||
|
||||
query = Conversation.filter(user=user)
|
||||
|
||||
search = request.args.get("search", "").strip()
|
||||
if search:
|
||||
query = query.filter(name__icontains=search)
|
||||
|
||||
query = query.order_by("-updated_at")
|
||||
|
||||
# `limit` is optional: when omitted we return the full list (backward
|
||||
# compatible with any non-frontend callers). `offset` only applies with it.
|
||||
limit = request.args.get("limit", type=int)
|
||||
if limit is not None:
|
||||
offset = request.args.get("offset", default=0, type=int)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
serialized_conversations = await PydListConversation.from_queryset(query)
|
||||
|
||||
return jsonify(serialized_conversations.model_dump())
|
||||
|
||||
|
||||
@@ -149,6 +149,14 @@ body {
|
||||
animation: fadeSlideUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.animate-drawer-in {
|
||||
animation: drawer-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes catPulse {
|
||||
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { ChatScreen } from "./components/ChatScreen";
|
||||
import { ConversationsPage } from "./components/ConversationsPage";
|
||||
import { LoginScreen } from "./components/LoginScreen";
|
||||
import { useAuthCheck } from "./hooks/useAuthCheck";
|
||||
import catIcon from "./assets/cat.png";
|
||||
@@ -38,6 +39,7 @@ const AppContainer = () => {
|
||||
path="/c/:conversationId"
|
||||
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
|
||||
/>
|
||||
<Route path="/conversations" element={<ConversationsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -131,9 +131,19 @@ class ConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConversations(): Promise<Conversation[]> {
|
||||
async getAllConversations(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
}): Promise<Conversation[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit != null) search.set("limit", String(params.limit));
|
||||
if (params?.offset != null) search.set("offset", String(params.offset));
|
||||
if (params?.search) search.set("search", params.search);
|
||||
const qs = search.toString();
|
||||
|
||||
const response = await userService.fetchWithRefreshToken(
|
||||
`${this.conversationBaseUrl}/`,
|
||||
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { QuestionBubble } from "./QuestionBubble";
|
||||
import { AnswerBubble } from "./AnswerBubble";
|
||||
import { ToolBubble } from "./ToolBubble";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { SidebarContent } from "./SidebarContent";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -114,6 +114,16 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
const sidebarProps = {
|
||||
conversations,
|
||||
onCreateNewConversation: handleCreateNewConversation,
|
||||
onSelectConversation: handleSelectConversation,
|
||||
onRenameConversation: renameConversation,
|
||||
onDeleteConversation: handleDeleteConversation,
|
||||
selectedId: selectedConversation?.id,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
@@ -139,67 +149,41 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
<h2
|
||||
className="text-lg font-bold text-cream tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
asksimba
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<PanelLeftClose size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onRenameConversation={renameConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAdminPanel(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowScheduler(true)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Scheduler</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarContent
|
||||
{...sidebarProps}
|
||||
onShowAdmin={() => setShowAdminPanel(true)}
|
||||
onShowScheduler={() => setShowScheduler(true)}
|
||||
onLogout={handleLogout}
|
||||
onCollapse={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{showConversations && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<aside className="w-72 max-w-[80%] h-full bg-sidebar-bg shadow-xl animate-drawer-in">
|
||||
<SidebarContent
|
||||
{...sidebarProps}
|
||||
onShowAdmin={() => {
|
||||
setShowConversations(false);
|
||||
setShowAdminPanel(true);
|
||||
}}
|
||||
onShowScheduler={() => {
|
||||
setShowConversations(false);
|
||||
setShowScheduler(true);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onCloseDrawer={() => setShowConversations(false)}
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
className="flex-1 bg-charcoal/40 backdrop-blur-sm"
|
||||
onClick={() => setShowConversations(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||
|
||||
@@ -214,37 +198,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
asksimba
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={() => setShowConversations((v) => !v)}
|
||||
>
|
||||
{showConversations ? <X size={16} /> : <Menu size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
onClick={() => setShowConversations(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{messages.length === 0 && !messagesLoading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||
{showConversations && (
|
||||
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onRenameConversation={renameConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-amber-soft/20 rounded-full blur-3xl" />
|
||||
<img src={catIcon} alt="Simba" className="relative w-36 h-36" />
|
||||
@@ -275,18 +239,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-6">
|
||||
{showConversations && (
|
||||
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={handleCreateNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
selectedId={selectedConversation?.id}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.speaker === "tool")
|
||||
return <ToolBubble key={index} text={msg.text} />;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { useConversationBrowser } from "../hooks/useConversationBrowser";
|
||||
import type { Conversation } from "../hooks/useConversations";
|
||||
import { Input } from "./ui/input";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
export const ConversationsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
items,
|
||||
search,
|
||||
setSearch,
|
||||
hasMore,
|
||||
loading,
|
||||
loadMore,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
} = useConversationBrowser();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(conversation: Conversation) => navigate(`/c/${conversation.id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleCreateNew = useCallback(() => navigate("/"), [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-screen h-[100dvh] flex flex-col bg-cream overflow-hidden">
|
||||
<header className="flex items-center gap-3 px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<img src={catIcon} alt="Simba" className="w-10 h-10" />
|
||||
<h1
|
||||
className="text-base font-bold text-charcoal"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
All conversations
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 flex flex-col gap-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-gray/60"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search conversations..."
|
||||
className="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={items}
|
||||
onCreateNewConversation={handleCreateNew}
|
||||
onSelectConversation={handleSelect}
|
||||
onRenameConversation={renameConversation}
|
||||
onDeleteConversation={deleteConversation}
|
||||
variant="light"
|
||||
/>
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<p className="text-center text-sm text-warm-gray/70 py-6">
|
||||
{search ? "No conversations match your search." : "No conversations yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="self-center px-4 py-2 rounded-xl text-sm text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Clock,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
Shield,
|
||||
ListTree,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import type { Conversation } from "../hooks/useConversations";
|
||||
import catIcon from "../assets/cat.png";
|
||||
|
||||
type SidebarContentProps = {
|
||||
conversations: Conversation[];
|
||||
onCreateNewConversation: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onRenameConversation: (id: string, title: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
selectedId?: string;
|
||||
isAdmin: boolean;
|
||||
onShowAdmin: () => void;
|
||||
onShowScheduler: () => void;
|
||||
onLogout: () => void;
|
||||
/** Desktop: collapse the sidebar. Renders a PanelLeftClose button. */
|
||||
onCollapse?: () => void;
|
||||
/** Mobile: close the drawer. Renders an X button. */
|
||||
onCloseDrawer?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dark sidebar body shared by the desktop sidebar and the mobile drawer.
|
||||
* Shows the recent conversations, a link to the full list, and admin/account
|
||||
* actions. Exactly one of `onCollapse`/`onCloseDrawer` is expected per usage.
|
||||
*/
|
||||
export const SidebarContent = ({
|
||||
conversations,
|
||||
onCreateNewConversation,
|
||||
onSelectConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
selectedId,
|
||||
isAdmin,
|
||||
onShowAdmin,
|
||||
onShowScheduler,
|
||||
onLogout,
|
||||
onCollapse,
|
||||
onCloseDrawer,
|
||||
}: SidebarContentProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
<h2
|
||||
className="text-lg font-bold text-cream tracking-tight"
|
||||
style={{ fontFamily: "var(--font-display)" }}
|
||||
>
|
||||
asksimba
|
||||
</h2>
|
||||
</div>
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<PanelLeftClose size={15} />
|
||||
</button>
|
||||
)}
|
||||
{onCloseDrawer && (
|
||||
<button
|
||||
onClick={onCloseDrawer}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
onCreateNewConversation={onCreateNewConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRenameConversation={onRenameConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||
<Link
|
||||
to="/conversations"
|
||||
onClick={onCloseDrawer}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<ListTree size={14} />
|
||||
<span>See all conversations</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={onShowAdmin}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowScheduler}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span>Scheduler</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Data source for the dedicated /conversations page: server-side search plus
|
||||
* "Load more" pagination. Detects whether more rows exist by asking for one
|
||||
* extra item beyond the page size.
|
||||
*/
|
||||
export function useConversationBrowser() {
|
||||
const [items, setItems] = useState<Conversation[]>([]);
|
||||
const [search, setSearchValue] = useState("");
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (offset: number, searchTerm: string) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
limit: PAGE_SIZE + 1,
|
||||
offset,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const page = fetched
|
||||
.slice(0, PAGE_SIZE)
|
||||
.map((c) => ({ id: c.id, title: c.name }));
|
||||
setHasMore(fetched.length > PAGE_SIZE);
|
||||
setItems((prev) => (offset === 0 ? page : [...prev, ...page]));
|
||||
} catch (err) {
|
||||
if (requestId === requestIdRef.current) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Debounce search changes; reset to the first page on each new term.
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => fetchPage(0, search), 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [search, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
fetchPage(items.length, search);
|
||||
}, [loading, hasMore, items.length, search, fetchPage]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
const previous = items;
|
||||
setItems((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
|
||||
);
|
||||
try {
|
||||
await conversationService.renameConversation(id, trimmed);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename conversation:", err);
|
||||
setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string) => {
|
||||
const previous = items;
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
try {
|
||||
await conversationService.deleteConversation(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete conversation:", err);
|
||||
setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
search,
|
||||
setSearch: setSearchValue,
|
||||
hasMore,
|
||||
loading,
|
||||
loadMore,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,10 @@ export type Conversation = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
// The sidebar/drawer only shows the most recent conversations; the full list
|
||||
// lives on the dedicated /conversations page.
|
||||
const SIDEBAR_LIMIT = 10;
|
||||
|
||||
export function useConversations() {
|
||||
const navigate = useNavigate();
|
||||
const { conversationId } = useParams();
|
||||
@@ -14,7 +18,9 @@ export function useConversations() {
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations();
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
limit: SIDEBAR_LIMIT,
|
||||
});
|
||||
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
|
||||
Reference in New Issue
Block a user