add delete chat mutation

This commit is contained in:
thierryverse 2025-11-12 14:12:07 +02:00
parent bd4e5d627d
commit b866170e6a
11 changed files with 486 additions and 445 deletions

View file

@ -2,491 +2,511 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
Calendar, Calendar,
ExternalLink, ExternalLink,
MessageCircleMore, MessageCircleMore,
MoreHorizontal, MoreHorizontal,
Search, Search,
Tag, Tag,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react"; import { AnimatePresence, motion, type Variants } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Card,
DialogContent, CardDescription,
DialogDescription, CardFooter,
DialogFooter, CardHeader,
DialogHeader, CardTitle,
DialogTitle, } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
PaginationItem, PaginationItem,
PaginationLink, PaginationLink,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { useAtom, useAtomValue } from "jotai";
import { useAtomValue } from "jotai"; import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.query.atom";
import { activeSearchSpaceChatsAtom } from "@/atoms/chats/queries/active-search-space-chats.atom"; import { deleteChatMutationAtom } from "@/atoms/chats/mutations/delete-chat.mutation.atom";
export interface Chat { export interface Chat {
created_at: string; created_at: string;
id: number; id: number;
type: "DOCUMENT" | "CHAT"; type: "DOCUMENT" | "CHAT";
title: string; title: string;
search_space_id: number; search_space_id: number;
state_version: number; state_version: number;
} }
export interface ChatDetails { export interface ChatDetails {
type: "DOCUMENT" | "CHAT"; type: "DOCUMENT" | "CHAT";
title: string; title: string;
initial_connectors: string[]; initial_connectors: string[];
messages: any[]; messages: any[];
created_at: string; created_at: string;
id: number; id: number;
search_space_id: number; search_space_id: number;
state_version: number; state_version: number;
} }
interface ChatsPageClientProps { interface ChatsPageClientProps {
searchSpaceId: string; searchSpaceId: string;
} }
const pageVariants: Variants = { const pageVariants: Variants = {
initial: { opacity: 0 }, initial: { opacity: 0 },
enter: { opacity: 1, transition: { duration: 0.3, ease: "easeInOut" } }, enter: { opacity: 1, transition: { duration: 0.3, ease: "easeInOut" } },
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } }, exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
}; };
const chatCardVariants: Variants = { const chatCardVariants: Variants = {
initial: { y: 20, opacity: 0 }, initial: { y: 20, opacity: 0 },
animate: { y: 0, opacity: 1 }, animate: { y: 0, opacity: 1 },
exit: { y: -20, opacity: 0 }, exit: { y: -20, opacity: 0 },
}; };
const MotionCard = motion(Card); const MotionCard = motion(Card);
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { export default function ChatsPageClient({
const router = useRouter(); searchSpaceId,
const [filteredChats, setFilteredChats] = useState<Chat[]>([]); }: ChatsPageClientProps) {
const [searchQuery, setSearchQuery] = useState(""); const router = useRouter();
const [currentPage, setCurrentPage] = useState(1); const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
const [totalPages, setTotalPages] = useState(1); const [searchQuery, setSearchQuery] = useState("");
const [selectedType, setSelectedType] = useState<string>("all"); const [currentPage, setCurrentPage] = useState(1);
const [sortOrder, setSortOrder] = useState<string>("newest"); const [totalPages, setTotalPages] = useState(1);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [selectedType, setSelectedType] = useState<string>("all");
const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null); const [sortOrder, setSortOrder] = useState<string>("newest");
const [isDeleting, setIsDeleting] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const {isFetching , data : chats, error} = useAtomValue(activeSearchSpaceChatsAtom); const [chatToDelete, setChatToDelete] = useState<{
id: number;
title: string;
} | null>(null);
const {
isFetching: isFetchingChats,
data: chats,
error: fetchError,
} = useAtomValue(activeSearchSpaceChatsAtom);
const [
{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }
] = useAtom(deleteChatMutationAtom);
const chatsPerPage = 9; const chatsPerPage = 9;
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Get initial page from URL params if it exists // Get initial page from URL params if it exists
useEffect(() => { useEffect(() => {
const pageParam = searchParams.get("page"); const pageParam = searchParams.get("page");
if (pageParam) { if (pageParam) {
const pageNumber = parseInt(pageParam, 10); const pageNumber = parseInt(pageParam, 10);
if (!Number.isNaN(pageNumber) && pageNumber > 0) { if (!Number.isNaN(pageNumber) && pageNumber > 0) {
setCurrentPage(pageNumber); setCurrentPage(pageNumber);
} }
} }
}, [searchParams]); }, [searchParams]);
useEffect(() => {
if (fetchError) {
console.error("Error fetching chats:", fetchError);
}
}, [fetchError]);
useEffect(() => { useEffect(() => {
if (error) { if (deleteError) {
console.error("Error fetching chats:", error); console.error("Error deleting chat:", deleteError);
} }
}, [error]); }, [deleteError]);
// Filter and sort chats based on search query, type, and sort order // Filter and sort chats based on search query, type, and sort order
useEffect(() => { useEffect(() => {
let result = [...(chats || [])]; let result = [...(chats || [])];
// Filter by search term // Filter by search term
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter((chat) => chat.title.toLowerCase().includes(query)); result = result.filter((chat) =>
} chat.title.toLowerCase().includes(query)
);
}
// Filter by type // Filter by type
if (selectedType !== "all") { if (selectedType !== "all") {
result = result.filter((chat) => chat.type === selectedType); result = result.filter((chat) => chat.type === selectedType);
} }
// Sort chats // Sort chats
result.sort((a, b) => { result.sort((a, b) => {
const dateA = new Date(a.created_at).getTime(); const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime(); const dateB = new Date(b.created_at).getTime();
return sortOrder === "newest" ? dateB - dateA : dateA - dateB; return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
}); });
setFilteredChats(result); setFilteredChats(result);
setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage))); setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage)));
// Reset to first page when filters change // Reset to first page when filters change
if (currentPage !== 1 && (searchQuery || selectedType !== "all" || sortOrder !== "newest")) { if (
setCurrentPage(1); currentPage !== 1 &&
} (searchQuery || selectedType !== "all" || sortOrder !== "newest")
}, [chats, searchQuery, selectedType, sortOrder, currentPage]); ) {
setCurrentPage(1);
}
}, [chats, searchQuery, selectedType, sortOrder, currentPage]);
// Function to handle chat deletion // Function to handle chat deletion
const handleDeleteChat = async () => { const handleDeleteChat = async () => {
// if (!chatToDelete) return; if (!chatToDelete) return;
// setIsDeleting(true); await deleteChat(chatToDelete.id);
// try {
// const token = localStorage.getItem("surfsense_bearer_token");
// if (!token) {
// setIsDeleting(false);
// return;
// }
// const response = await fetch( setDeleteDialogOpen(false);
// `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, setChatToDelete(null);
// { };
// method: "DELETE",
// headers: {
// Authorization: `Bearer ${token}`,
// "Content-Type": "application/json",
// },
// }
// );
// if (!response.ok) { // Calculate pagination
// throw new Error(`Failed to delete chat: ${response.statusText}`); const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page
// } const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
// // Close dialog and refresh chats // Get unique chat types for filter dropdown
// setDeleteDialogOpen(false); const chatTypes = chats
// setChatToDelete(null); ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))]
: [];
// // Update local state by removing the deleted chat return (
// setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); <motion.div
// } catch (error) { className="container p-6 mx-auto"
// console.error("Error deleting chat:", error); initial="initial"
// } finally { animate="enter"
// setIsDeleting(false); exit="exit"
// } variants={pageVariants}
}; >
<div className="flex flex-col space-y-4 md:space-y-6">
<div className="flex flex-col space-y-2">
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1>
<p className="text-muted-foreground">
View, search, and manage all your chats.
</p>
</div>
// Calculate pagination {/* Filter and Search Bar */}
const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page <div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page <div className="flex flex-1 items-center gap-2">
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat); <div className="relative w-full md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search chats..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
// Get unique chat types for filter dropdown <Select value={selectedType} onValueChange={setSelectedType}>
const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; <SelectTrigger className="w-full md:w-40">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{chatTypes.map((type) => (
<SelectItem key={type} value={type}>
{type === "all"
? "All Types"
: type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
return ( <div className="flex items-center gap-2">
<motion.div <Select value={sortOrder} onValueChange={setSortOrder}>
className="container p-6 mx-auto" <SelectTrigger className="w-40">
initial="initial" <SelectValue placeholder="Sort order" />
animate="enter" </SelectTrigger>
exit="exit" <SelectContent>
variants={pageVariants} <SelectGroup>
> <SelectItem value="newest">Newest First</SelectItem>
<div className="flex flex-col space-y-4 md:space-y-6"> <SelectItem value="oldest">Oldest First</SelectItem>
<div className="flex flex-col space-y-2"> </SelectGroup>
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1> </SelectContent>
<p className="text-muted-foreground">View, search, and manage all your chats.</p> </Select>
</div> </div>
</div>
{/* Filter and Search Bar */} {/* Status Messages */}
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0"> {isFetchingChats && (
<div className="flex flex-1 items-center gap-2"> <div className="flex items-center justify-center h-40">
<div className="relative w-full md:w-80"> <div className="flex flex-col items-center gap-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<Input <p className="text-sm text-muted-foreground">Loading chats...</p>
type="text" </div>
placeholder="Search chats..." </div>
className="pl-8" )}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={selectedType} onValueChange={setSelectedType}> {fetchError && !isFetchingChats && (
<SelectTrigger className="w-full md:w-40"> <div className="border border-destructive/50 text-destructive p-4 rounded-md">
<SelectValue placeholder="Filter by type" /> <h3 className="font-medium">Error loading chats</h3>
</SelectTrigger> <p className="text-sm">{fetchError.message}</p>
<SelectContent> </div>
<SelectGroup> )}
{chatTypes.map((type) => (
<SelectItem key={type} value={type}>
{type === "all" ? "All Types" : type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2"> {!isFetchingChats && !fetchError && filteredChats.length === 0 && (
<Select value={sortOrder} onValueChange={setSortOrder}> <div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
<SelectTrigger className="w-40"> <MessageCircleMore className="h-8 w-8 text-muted-foreground" />
<SelectValue placeholder="Sort order" /> <h3 className="font-medium">No chats found</h3>
</SelectTrigger> <p className="text-sm text-muted-foreground">
<SelectContent> {searchQuery || selectedType !== "all"
<SelectGroup> ? "Try adjusting your search filters"
<SelectItem value="newest">Newest First</SelectItem> : "Start a new chat to get started"}
<SelectItem value="oldest">Oldest First</SelectItem> </p>
</SelectGroup> </div>
</SelectContent> )}
</Select>
</div>
</div>
{/* Status Messages */} {/* Chat Grid */}
{isFetching && ( {!isFetchingChats && !fetchError && filteredChats.length > 0 && (
<div className="flex items-center justify-center h-40"> <AnimatePresence mode="wait">
<div className="flex flex-col items-center gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div> {currentChats.map((chat, index) => (
<p className="text-sm text-muted-foreground">Loading chats...</p> <MotionCard
</div> key={chat.id}
</div> variants={chatCardVariants}
)} initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }}
className="overflow-hidden hover:shadow-md transition-shadow"
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="line-clamp-1">
{chat.title || `Chat ${chat.id}`}
</CardTitle>
<CardDescription>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
<span>
{format(new Date(chat.created_at), "MMM d, yyyy")}
</span>
</span>
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(
`/dashboard/${chat.search_space_id}/researcher/${chat.id}`
)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
<span>View Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setChatToDelete({
id: chat.id,
title: chat.title || `Chat ${chat.id}`,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
{error && !isFetching && ( <CardFooter className="flex items-center justify-between gap-2 w-full">
<div className="border border-destructive/50 text-destructive p-4 rounded-md"> <Badge variant="secondary" className="text-xs">
<h3 className="font-medium">Error loading chats</h3> <Tag className="mr-1 h-3 w-3" />
<p className="text-sm">{error.message}</p> {chat.type || "Unknown"}
</div> </Badge>
)} <Button
size="sm"
onClick={() =>
router.push(
`/dashboard/${chat.search_space_id}/researcher/${chat.id}`
)
}
>
<MessageCircleMore className="h-4 w-4" />
<span>View Chat</span>
</Button>
</CardFooter>
</MotionCard>
))}
</div>
</AnimatePresence>
)}
{!isFetching && !error && filteredChats.length === 0 && ( {/* Pagination */}
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center"> {!isFetchingChats && !fetchError && totalPages > 1 && (
<MessageCircleMore className="h-8 w-8 text-muted-foreground" /> <Pagination className="mt-8">
<h3 className="font-medium">No chats found</h3> <PaginationContent>
<p className="text-sm text-muted-foreground"> <PaginationItem>
{searchQuery || selectedType !== "all" <PaginationPrevious
? "Try adjusting your search filters" href={`?page=${Math.max(1, currentPage - 1)}`}
: "Start a new chat to get started"} onClick={(e) => {
</p> e.preventDefault();
</div> if (currentPage > 1) setCurrentPage(currentPage - 1);
)} }}
className={
currentPage <= 1 ? "pointer-events-none opacity-50" : ""
}
/>
</PaginationItem>
{/* Chat Grid */} {Array.from({ length: totalPages }).map((_, index) => {
{!isFetching && !error && filteredChats.length > 0 && ( const pageNumber = index + 1;
<AnimatePresence mode="wait"> const isVisible =
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> pageNumber === 1 ||
{currentChats.map((chat, index) => ( pageNumber === totalPages ||
<MotionCard (pageNumber >= currentPage - 1 &&
key={chat.id} pageNumber <= currentPage + 1);
variants={chatCardVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }}
className="overflow-hidden hover:shadow-md transition-shadow"
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="line-clamp-1">
{chat.title || `Chat ${chat.id}`}
</CardTitle>
<CardDescription>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
<span>{format(new Date(chat.created_at), "MMM d, yyyy")}</span>
</span>
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(
`/dashboard/${chat.search_space_id}/researcher/${chat.id}`
)
}
>
<ExternalLink className="mr-2 h-4 w-4" />
<span>View Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setChatToDelete({
id: chat.id,
title: chat.title || `Chat ${chat.id}`,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardFooter className="flex items-center justify-between gap-2 w-full"> if (!isVisible) {
<Badge variant="secondary" className="text-xs"> // Show ellipsis at appropriate positions
<Tag className="mr-1 h-3 w-3" /> if (pageNumber === 2 || pageNumber === totalPages - 1) {
{chat.type || "Unknown"} return (
</Badge> <PaginationItem key={pageNumber}>
<Button <span className="flex h-9 w-9 items-center justify-center">
size="sm" ...
onClick={() => </span>
router.push(`/dashboard/${chat.search_space_id}/researcher/${chat.id}`) </PaginationItem>
} );
> }
<MessageCircleMore className="h-4 w-4" /> return null;
<span>View Chat</span> }
</Button>
</CardFooter>
</MotionCard>
))}
</div>
</AnimatePresence>
)}
{/* Pagination */} return (
{!isFetching && !error && totalPages > 1 && ( <PaginationItem key={pageNumber}>
<Pagination className="mt-8"> <PaginationLink
<PaginationContent> href={`?page=${pageNumber}`}
<PaginationItem> onClick={(e) => {
<PaginationPrevious e.preventDefault();
href={`?page=${Math.max(1, currentPage - 1)}`} setCurrentPage(pageNumber);
onClick={(e) => { }}
e.preventDefault(); isActive={pageNumber === currentPage}
if (currentPage > 1) setCurrentPage(currentPage - 1); >
}} {pageNumber}
className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} </PaginationLink>
/> </PaginationItem>
</PaginationItem> );
})}
{Array.from({ length: totalPages }).map((_, index) => { <PaginationItem>
const pageNumber = index + 1; <PaginationNext
const isVisible = href={`?page=${Math.min(totalPages, currentPage + 1)}`}
pageNumber === 1 || onClick={(e) => {
pageNumber === totalPages || e.preventDefault();
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1); if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}}
className={
currentPage >= totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
if (!isVisible) { {/* Delete Confirmation Dialog */}
// Show ellipsis at appropriate positions <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
if (pageNumber === 2 || pageNumber === totalPages - 1) { <DialogContent className="sm:max-w-md">
return ( <DialogHeader>
<PaginationItem key={pageNumber}> <DialogTitle className="flex items-center gap-2">
<span className="flex h-9 w-9 items-center justify-center">...</span> <Trash2 className="h-5 w-5 text-destructive" />
</PaginationItem> <span>Delete Chat</span>
); </DialogTitle>
} <DialogDescription>
return null; Are you sure you want to delete{" "}
} <span className="font-medium">{chatToDelete?.title}</span>? This
action cannot be undone.
return ( </DialogDescription>
<PaginationItem key={pageNumber}> </DialogHeader>
<PaginationLink <DialogFooter className="flex gap-2 sm:justify-end">
href={`?page=${pageNumber}`} <Button
onClick={(e) => { variant="outline"
e.preventDefault(); onClick={() => setDeleteDialogOpen(false)}
setCurrentPage(pageNumber); disabled={isDeletingChat}
}} >
isActive={pageNumber === currentPage} Cancel
> </Button>
{pageNumber} <Button
</PaginationLink> variant="destructive"
</PaginationItem> onClick={handleDeleteChat}
); disabled={isDeletingChat}
})} className="gap-2"
>
<PaginationItem> {isDeletingChat ? (
<PaginationNext <>
href={`?page=${Math.min(totalPages, currentPage + 1)}`} <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
onClick={(e) => { Deleting...
e.preventDefault(); </>
if (currentPage < totalPages) setCurrentPage(currentPage + 1); ) : (
}} <>
className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} <Trash2 className="h-4 w-4" />
/> Delete
</PaginationItem> </>
</PaginationContent> )}
</Pagination> </Button>
)} </DialogFooter>
</div> </DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */} </motion.div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> );
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.title}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteChat}
disabled={isDeleting}
className="gap-2"
>
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
);
} }

View file

@ -27,8 +27,8 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { useLLMPreferences } from "@/hooks/use-llm-configs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; import { activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom";
import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; import { chatUIAtom } from "@/atoms/chats/active-chat.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/active-seach-space.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/active-seach-space.atom";
export function DashboardClientLayout({ export function DashboardClientLayout({

View file

@ -0,0 +1,33 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { deleteChat } from "@/lib/apis/chat-apis";
import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom";
import { queryClient } from "@/lib/query-client/client";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { toast } from "sonner";
export const deleteChatMutationAtom = atomWithMutation((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = localStorage.getItem("surfsense_bearer_token");
return {
mutationKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""),
enabled: !!searchSpaceId && !!authToken,
mutationFn: async (chatId: number) => {
if (!authToken) {
throw new Error("No authentication token found");
}
if (!searchSpaceId) {
throw new Error("No search space id found");
}
return deleteChat(chatId, authToken);
},
onSuccess: () => {
toast.success("Chat deleted successfully");
queryClient.invalidateQueries({
queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId!),
});
},
};
});

View file

@ -18,18 +18,8 @@ export const activeChatAtom = atomWithQuery<ActiveChatState>((get) => {
const activeChatId = get(activeChatIdAtom); const activeChatId = get(activeChatIdAtom);
const authToken = localStorage.getItem("surfsense_bearer_token"); const authToken = localStorage.getItem("surfsense_bearer_token");
if (!activeChatId) {
return {
queryKey: [],
enabled: false,
queryFn: async () => {
return { chatId: null, chatDetails: null, podcast: null };
},
};
}
return { return {
queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId), queryKey: cacheKeys.activeSearchSpace.activeChat(activeChatId ?? ""),
enabled: !!activeChatId && !!authToken, enabled: !!activeChatId && !!authToken,
queryFn: async () => { queryFn: async () => {
if (!authToken) { if (!authToken) {

View file

@ -1,13 +1,14 @@
import { atomWithQuery } from "jotai-tanstack-query"; import { atomWithQuery } from "jotai-tanstack-query";
import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis"; import { fetchChatsBySearchSpace } from "@/lib/apis/chat-apis";
import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom"; import { activeSearchSpaceIdAtom } from "../../seach-spaces/active-seach-space.atom";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeSearchSpaceChatsAtom = atomWithQuery((get) => { export const activeSearchSpaceChatsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom); const searchSpaceId = get(activeSearchSpaceIdAtom);
const authToken = localStorage.getItem("surfsense_bearer_token"); const authToken = localStorage.getItem("surfsense_bearer_token");
return { return {
queryKey: ["chatsBySearchSpace", searchSpaceId], queryKey: cacheKeys.activeSearchSpace.chats(searchSpaceId ?? ""),
enabled: !!searchSpaceId && !!authToken, enabled: !!searchSpaceId && !!authToken,
queryFn: async () => { queryFn: async () => {
if (!authToken) { if (!authToken) {

View file

@ -4,8 +4,8 @@ import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { generatePodcast } from "@/lib/apis/podcast-apis"; import { generatePodcast } from "@/lib/apis/podcast-apis";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.atom"; import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/queries/active-chat.query.atom";
import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; import { chatUIAtom } from "@/atoms/chats/active-chat.atom";
import { ChatPanelView } from "./ChatPanelView"; import { ChatPanelView } from "./ChatPanelView";
export interface GeneratePodcastRequest { export interface GeneratePodcastRequest {

View file

@ -5,8 +5,8 @@ import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom";
import { chatUIAtom } from "@/atoms/chats/active-chat-ui.atom"; import { chatUIAtom } from "@/atoms/chats/active-chat.atom";
import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils";
import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import type { GeneratePodcastRequest } from "./ChatPanelContainer";
import { ConfigModal } from "./ConfigModal"; import { ConfigModal } from "./ConfigModal";

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useCallback, useContext, useState } from "react"; import { useCallback, useContext, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { activeChatAtom } from "@/atoms/chats/queries/active-chat.atom"; import { activeChatAtom } from "@/atoms/chats/queries/active-chat.query.atom";
import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import type { GeneratePodcastRequest } from "./ChatPanelContainer";
interface ConfigModalProps { interface ConfigModalProps {

View file

@ -72,9 +72,7 @@ export const deleteChat = async (chatId: number, authToken: string) => {
throw new Error(`Failed to delete chat: ${response.statusText}`); throw new Error(`Failed to delete chat: ${response.statusText}`);
} }
return true;
} catch (err) { } catch (err) {
console.error("Error deleting chat:", err); console.error("Error deleting chat:", err);
return false;
} }
}; };

View file

@ -2,6 +2,5 @@ export const cacheKeys = {
activeSearchSpace: { activeSearchSpace: {
chats : (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, chats : (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const,
activeChat : (chatId: string) => ["active-search-space", "active-chat", chatId] as const, activeChat : (chatId: string) => ["active-search-space", "active-chat", chatId] as const,
deleteChat : ( searchSpaceId: string, chatId: string) => ["active-search-space", "chats", searchSpaceId, "delete", chatId] as const,
}, },
}; };