mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-04-30 16:22:29 +00:00
Compare commits
5 Commits
07c1f3a647
...
8e133366b2
Author | SHA1 | Date | |
---|---|---|---|
|
8e133366b2 | ||
|
72450b9217 | ||
|
0ebda34a52 | ||
|
1deda793bd | ||
|
28b9cca413 |
@ -159,6 +159,7 @@ Perplexica runs on Next.js and handles all API requests. It works right away on
|
|||||||
|
|
||||||
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
||||||
[](https://repocloud.io/details/?app_id=267)
|
[](https://repocloud.io/details/?app_id=267)
|
||||||
|
[](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dperplexica)
|
||||||
|
|
||||||
## Upcoming Features
|
## Upcoming Features
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import DeleteChat from '@/components/DeleteChat';
|
import DeleteChat from '@/components/DeleteChat';
|
||||||
|
import BatchDeleteChats from '@/components/BatchDeleteChats';
|
||||||
import { cn, formatTimeDifference } from '@/lib/utils';
|
import { cn, formatTimeDifference } from '@/lib/utils';
|
||||||
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
|
import { BookOpenText, Check, ClockIcon, Delete, ScanEye, Search, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,7 +17,13 @@ export interface Chat {
|
|||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const [chats, setChats] = useState<Chat[]>([]);
|
const [chats, setChats] = useState<Chat[]>([]);
|
||||||
|
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [selectedChats, setSelectedChats] = useState<string[]>([]);
|
||||||
|
const [hoveredChatId, setHoveredChatId] = useState<string | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchChats = async () => {
|
const fetchChats = async () => {
|
||||||
@ -31,12 +39,71 @@ const Page = () => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
setChats(data.chats);
|
setChats(data.chats);
|
||||||
|
setFilteredChats(data.chats);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchChats();
|
fetchChats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.trim() === '') {
|
||||||
|
setFilteredChats(chats);
|
||||||
|
} else {
|
||||||
|
const filtered = chats.filter((chat) =>
|
||||||
|
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredChats(filtered);
|
||||||
|
}
|
||||||
|
}, [searchQuery, chats]);
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => {
|
||||||
|
setSelectionMode(!selectionMode);
|
||||||
|
setSelectedChats([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChatSelection = (chatId: string) => {
|
||||||
|
if (selectedChats.includes(chatId)) {
|
||||||
|
setSelectedChats(selectedChats.filter(id => id !== chatId));
|
||||||
|
} else {
|
||||||
|
setSelectedChats([...selectedChats, chatId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllChats = () => {
|
||||||
|
if (selectedChats.length === filteredChats.length) {
|
||||||
|
setSelectedChats([]);
|
||||||
|
} else {
|
||||||
|
setSelectedChats(filteredChats.map(chat => chat.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedChats = () => {
|
||||||
|
if (selectedChats.length === 0) return;
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDeleteComplete = () => {
|
||||||
|
setSelectedChats([]);
|
||||||
|
setSelectionMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChatsAfterDelete = (newChats: Chat[]) => {
|
||||||
|
setChats(newChats);
|
||||||
|
setFilteredChats(newChats.filter(chat =>
|
||||||
|
searchQuery.trim() === '' ||
|
||||||
|
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
return loading ? (
|
return loading ? (
|
||||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
<div className="flex flex-row items-center justify-center min-h-screen">
|
||||||
<svg
|
<svg
|
||||||
@ -64,32 +131,145 @@ const Page = () => {
|
|||||||
<h1 className="text-3xl font-medium p-2">Library</h1>
|
<h1 className="text-3xl font-medium p-2">Library</h1>
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="relative mt-6 mb-6">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Search className="w-5 h-5 text-black/50 dark:text-white/50" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="block w-full p-2 pl-10 pr-10 bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 rounded-md text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Search your threads..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-black/50 dark:text-white/50 hover:text-black dark:hover:text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thread Count and Selection Controls */}
|
||||||
|
<div className="mb-4">
|
||||||
|
{!selectionMode ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-black/70 dark:text-white/70">
|
||||||
|
You have {chats.length} threads in Perplexica
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-black/70 dark:text-white/70">
|
||||||
|
{selectedChats.length} selected thread{selectedChats.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={selectAllChats}
|
||||||
|
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
|
||||||
|
>
|
||||||
|
{selectedChats.length === filteredChats.length ? 'Deselect all' : 'Select all'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
className="text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white text-sm transition duration-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={deleteSelectedChats}
|
||||||
|
disabled={selectedChats.length === 0}
|
||||||
|
className={cn(
|
||||||
|
"text-sm transition duration-200",
|
||||||
|
selectedChats.length === 0
|
||||||
|
? "text-red-400/50 hover:text-red-500/50 cursor-not-allowed"
|
||||||
|
: "text-red-400 hover:text-red-500 cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chats.length === 0 && (
|
|
||||||
<div className="flex flex-row items-center justify-center min-h-screen">
|
{filteredChats.length === 0 && (
|
||||||
|
<div className="flex flex-row items-center justify-center min-h-[50vh]">
|
||||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||||
No chats found.
|
{searchQuery ? 'No threads found matching your search.' : 'No threads found.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chats.length > 0 && (
|
|
||||||
|
{filteredChats.length > 0 && (
|
||||||
<div className="flex flex-col pb-20 lg:pb-2">
|
<div className="flex flex-col pb-20 lg:pb-2">
|
||||||
{chats.map((chat, i) => (
|
{filteredChats.map((chat, i) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col space-y-4 py-6',
|
'flex flex-col space-y-4 py-6',
|
||||||
i !== chats.length - 1
|
i !== filteredChats.length - 1
|
||||||
? 'border-b border-white-200 dark:border-dark-200'
|
? 'border-b border-white-200 dark:border-dark-200'
|
||||||
: '',
|
: '',
|
||||||
)}
|
)}
|
||||||
key={i}
|
key={i}
|
||||||
|
onMouseEnter={() => setHoveredChatId(chat.id)}
|
||||||
|
onMouseLeave={() => setHoveredChatId(null)}
|
||||||
>
|
>
|
||||||
<Link
|
<div className="flex items-center">
|
||||||
href={`/c/${chat.id}`}
|
{/* Checkbox - visible when in selection mode or when hovering */}
|
||||||
className="text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer"
|
{(selectionMode || hoveredChatId === chat.id) && (
|
||||||
>
|
<div
|
||||||
{chat.title}
|
className="mr-3 cursor-pointer"
|
||||||
</Link>
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectionMode) setSelectionMode(true);
|
||||||
|
toggleChatSelection(chat.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-5 h-5 border rounded flex items-center justify-center transition-colors",
|
||||||
|
selectedChats.includes(chat.id)
|
||||||
|
? "bg-blue-500 border-blue-500"
|
||||||
|
: "border-gray-400 dark:border-gray-600"
|
||||||
|
)}>
|
||||||
|
{selectedChats.includes(chat.id) && (
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat Title */}
|
||||||
|
<Link
|
||||||
|
href={`/c/${chat.id}`}
|
||||||
|
className={cn(
|
||||||
|
"text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer",
|
||||||
|
selectionMode && "pointer-events-none text-black dark:text-white hover:text-black dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (selectionMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleChatSelection(chat.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
|
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
|
||||||
<ClockIcon size={15} />
|
<ClockIcon size={15} />
|
||||||
@ -97,16 +277,30 @@ const Page = () => {
|
|||||||
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
{formatTimeDifference(new Date(), chat.createdAt)} Ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DeleteChat
|
|
||||||
chatId={chat.id}
|
{/* Delete button - only visible when not in selection mode */}
|
||||||
chats={chats}
|
{!selectionMode && (
|
||||||
setChats={setChats}
|
<DeleteChat
|
||||||
/>
|
chatId={chat.id}
|
||||||
|
chats={chats}
|
||||||
|
setChats={updateChatsAfterDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Batch Delete Confirmation Dialog */}
|
||||||
|
<BatchDeleteChats
|
||||||
|
chatIds={selectedChats}
|
||||||
|
chats={chats}
|
||||||
|
setChats={updateChatsAfterDelete}
|
||||||
|
onComplete={handleBatchDeleteComplete}
|
||||||
|
isOpen={isDeleteDialogOpen}
|
||||||
|
setIsOpen={setIsDeleteDialogOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
118
src/components/BatchDeleteChats.tsx
Normal file
118
src/components/BatchDeleteChats.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Description,
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
Transition,
|
||||||
|
TransitionChild,
|
||||||
|
} from '@headlessui/react';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Chat } from '@/app/library/page';
|
||||||
|
|
||||||
|
interface BatchDeleteChatsProps {
|
||||||
|
chatIds: string[];
|
||||||
|
chats: Chat[];
|
||||||
|
setChats: (chats: Chat[]) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BatchDeleteChats = ({
|
||||||
|
chatIds,
|
||||||
|
chats,
|
||||||
|
setChats,
|
||||||
|
onComplete,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
}: BatchDeleteChatsProps) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (chatIds.length === 0) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
for (const chatId of chatIds) {
|
||||||
|
await fetch(`/api/chats/${chatId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newChats = chats.filter(chat => !chatIds.includes(chat.id));
|
||||||
|
setChats(newChats);
|
||||||
|
|
||||||
|
toast.success(`${chatIds.length} thread${chatIds.length > 1 ? 's' : ''} deleted`);
|
||||||
|
onComplete();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error('Failed to delete threads');
|
||||||
|
} finally {
|
||||||
|
setIsOpen(false);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="relative z-50"
|
||||||
|
onClose={() => {
|
||||||
|
if (!loading) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogBackdrop className="fixed inset-0 bg-black/30" />
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100 scale-200"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
|
||||||
|
Delete Confirmation
|
||||||
|
</DialogTitle>
|
||||||
|
<Description className="text-sm dark:text-white/70 text-black/70">
|
||||||
|
Are you sure you want to delete {chatIds.length} selected thread{chatIds.length !== 1 ? 's' : ''}?
|
||||||
|
</Description>
|
||||||
|
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!loading) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="text-red-400 text-sm hover:text-red-500 transition duration-200"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BatchDeleteChats;
|
Reference in New Issue
Block a user