Primeiro Commit

This commit is contained in:
luisfepsale
2025-10-22 12:52:23 -03:00
parent 27820254e7
commit 235fb538e1
19 changed files with 2132 additions and 426 deletions
+300 -134
View File
@@ -1,8 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { chatService, StoredChat, StoredFolder } from "@/services/chat";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,111 +34,153 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
folderId?: string;
}
interface ChatFolder {
id: string;
name: string;
}
interface ChatSidebarProps {
isCollapsed: boolean;
onToggleCollapse: () => void;
onNewChat: () => void;
onSelectChat?: (chat: StoredChat) => void;
currentChatId?: string;
}
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => {
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
const [editingFolder, setEditingFolder] = useState<StoredFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<StoredFolder | null>(null);
const [deletingChat, setDeletingChat] = useState<StoredChat | null>(null);
const [folders, setFolders] = useState<ChatFolder[]>([
{ id: "work", name: "Trabalho" },
{ id: "personal", name: "Pessoal" },
]);
// Estado carregado do localStorage via chatService
const [chats, setChats] = useState<StoredChat[]>([]);
const [folders, setFolders] = useState<StoredFolder[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [conversations, setConversations] = useState<Conversation[]>([
{
id: "1",
title: "Conversa sobre IA",
lastMessage: "Como funciona o machine learning?",
timestamp: "Há 5 min",
},
{
id: "2",
title: "Projeto HGTX",
lastMessage: "Discutindo arquitetura do sistema",
timestamp: "Há 1 hora",
folderId: "work",
},
{
id: "3",
title: "Ideias criativas",
lastMessage: "Brainstorm para novo produto",
timestamp: "Ontem",
folderId: "personal",
},
]);
// Carrega chats e pastas do localStorage quando o componente monta
useEffect(() => {
loadChatsAndFolders();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
// Escuta evento customizado para recarregar quando chat é atualizado
const handleChatUpdate = () => {
loadChatsAndFolders();
};
window.addEventListener('chatUpdated', handleChatUpdate);
// Cleanup
return () => {
window.removeEventListener('chatUpdated', handleChatUpdate);
};
}, []);
const loadChatsAndFolders = () => {
const loadedChats = chatService.getAllChats();
const loadedFolders = chatService.getAllFolders();
setChats(loadedChats);
setFolders(loadedFolders);
// Expande todas as pastas por padrão
setExpandedFolders(new Set(loadedFolders.map(f => f.id)));
};
const handleCreateFolder = () => {
if (newFolderName.trim()) {
const newFolder: ChatFolder = {
id: Date.now().toString(),
name: newFolderName,
};
setFolders([...folders, newFolder]);
setNewFolderName("");
setIsCreateFolderOpen(false);
try {
const newFolder: StoredFolder = {
id: chatService.generateChatId(), // Usa mesmo gerador de ID
name: newFolderName,
createdAt: new Date(),
chatIds: [],
};
chatService.saveFolder(newFolder);
loadChatsAndFolders();
setNewFolderName("");
setIsCreateFolderOpen(false);
toast({
title: "Pasta criada",
description: `Pasta "${newFolderName}" criada com sucesso.`,
});
} catch (error) {
toast({
title: "Erro ao criar pasta",
description: "Não foi possível criar a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleEditFolder = () => {
if (editingFolder && newFolderName.trim()) {
setFolders(
folders.map((f) =>
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
)
);
setNewFolderName("");
setEditingFolder(null);
setIsEditFolderOpen(false);
try {
const updatedFolder: StoredFolder = {
...editingFolder,
name: newFolderName,
};
chatService.saveFolder(updatedFolder);
loadChatsAndFolders();
setNewFolderName("");
setEditingFolder(null);
setIsEditFolderOpen(false);
toast({
title: "Pasta renomeada",
description: `Pasta renomeada para "${newFolderName}".`,
});
} catch (error) {
toast({
title: "Erro ao renomear pasta",
description: "Não foi possível renomear a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleDeleteFolder = () => {
if (deletingFolder) {
// Remove folder and move conversations back to "no folder"
setConversations(
conversations.map((conv) =>
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
)
);
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
try {
// Remove chats da pasta (volta para "Sem Pasta")
const updatedChats = chats.map(chat => {
if (chat.folderId === deletingFolder.id) {
const updated = { ...chat, folderId: undefined };
chatService.saveChat(updated);
return updated;
}
return chat;
});
chatService.deleteFolder(deletingFolder.id);
loadChatsAndFolders();
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
toast({
title: "Pasta excluída",
description: "As conversas foram movidas para 'Sem Pasta'.",
});
} catch (error) {
toast({
title: "Erro ao excluir pasta",
description: "Não foi possível excluir a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const openEditFolder = (folder: ChatFolder) => {
const openEditFolder = (folder: StoredFolder) => {
setEditingFolder(folder);
setNewFolderName(folder.name);
setIsEditFolderOpen(true);
};
const openDeleteFolder = (folder: ChatFolder) => {
const openDeleteFolder = (folder: StoredFolder) => {
setDeletingFolder(folder);
setIsDeleteFolderOpen(true);
};
@@ -149,37 +195,112 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
setExpandedFolders(newExpanded);
};
const moveToFolder = (conversationId: string, folderId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId } : conv
)
);
const moveToFolder = (chatId: string, folderId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: folderId,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
const folder = folders.find(f => f.id === folderId);
toast({
title: "Chat movido",
description: `Movido para a pasta "${folder?.name}".`,
});
}
} catch (error) {
toast({
title: "Erro ao mover chat",
description: "Não foi possível mover o chat. Tente novamente.",
variant: "destructive",
});
}
};
const removeFromFolder = (conversationId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
)
);
const removeFromFolder = (chatId: string) => {
try {
const chat = chats.find(c => c.id === chatId);
if (chat) {
const updatedChat: StoredChat = {
...chat,
folderId: undefined,
};
chatService.saveChat(updatedChat);
loadChatsAndFolders();
toast({
title: "Chat removido da pasta",
description: "Chat movido para 'Sem Pasta'.",
});
}
} catch (error) {
toast({
title: "Erro ao remover chat",
description: "Não foi possível remover o chat da pasta.",
variant: "destructive",
});
}
};
const filteredConversations = conversations.filter(
(conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDeleteChat = () => {
if (deletingChat) {
try {
chatService.deleteChat(deletingChat.id);
loadChatsAndFolders();
setDeletingChat(null);
setIsDeleteChatOpen(false);
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
const conversationsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
toast({
title: "Chat excluído",
description: "A conversa foi excluída com sucesso.",
});
} catch (error) {
toast({
title: "Erro ao excluir chat",
description: "Não foi possível excluir o chat. Tente novamente.",
variant: "destructive",
});
}
}
};
const openDeleteChat = (chat: StoredChat) => {
setDeletingChat(chat);
setIsDeleteChatOpen(true);
};
const handleSelectChat = (chat: StoredChat) => {
if (onSelectChat) {
onSelectChat(chat);
}
};
// Filtrar chats pela busca
const filteredChats = chats.filter((chat) => {
const searchLower = searchQuery.toLowerCase();
const titleMatch = chat.title.toLowerCase().includes(searchLower);
const contentMatch = chat.messages.some(msg =>
msg.content.toLowerCase().includes(searchLower)
);
return titleMatch || contentMatch;
});
// Separar chats sem pasta
const chatsWithoutFolder = filteredChats.filter((c) => !c.folderId);
// Agrupar chats por pasta
const chatsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folderId === folder.id);
return acc;
}, {} as Record<string, Conversation[]>);
}, {} as Record<string, StoredChat[]>);
if (isCollapsed) {
return (
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<Button
variant="ghost"
size="icon"
@@ -193,7 +314,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
}
return (
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
{/* Header with collapse button */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
@@ -330,10 +451,10 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{conversationsByFolder[folder.id]?.length || 0}
{chatsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -363,17 +484,18 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
</DropdownMenu>
</div>
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
<div className="ml-6 space-y-1">
{conversationsByFolder[folder.id].map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
{chatsByFolder[folder.id].map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
@@ -381,48 +503,90 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
</div>
))}
{/* Conversations without folder */}
{conversationsWithoutFolder.length > 0 && (
{/* Chats without folder */}
{chatsWithoutFolder.length > 0 && (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Sem Pasta
</div>
{conversationsWithoutFolder.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
{chatsWithoutFolder.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
)}
</div>
</ScrollArea>
{/* Delete Chat Alert */}
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50">
<AlertDialogHeader>
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
interface ConversationItemProps {
conversation: Conversation;
interface ChatItemProps {
chat: StoredChat;
isSelected: boolean;
onSelect: () => void;
folders: ChatFolder[];
onMoveToFolder: (conversationId: string, folderId: string) => void;
onRemoveFromFolder: (conversationId: string) => void;
folders: StoredFolder[];
onMoveToFolder: (chatId: string, folderId: string) => void;
onRemoveFromFolder: (chatId: string) => void;
onDelete: () => void;
}
const ConversationItem = ({
conversation,
const ChatItem = ({
chat,
isSelected,
onSelect,
folders,
onMoveToFolder,
onRemoveFromFolder,
}: ConversationItemProps) => {
onDelete,
}: ChatItemProps) => {
// Pega a última mensagem do usuário
const lastUserMessage = chat.messages
.filter(m => m.role === 'user')
.slice(-1)[0];
// Formata timestamp relativo
const timeAgo = formatDistanceToNow(new Date(chat.updatedAt), {
addSuffix: true,
locale: ptBR,
});
// Limita o título a 40 caracteres
const truncatedTitle = chat.title.length > 40
? chat.title.substring(0, 40) + '...'
: chat.title;
// Limita a mensagem a 50 caracteres
const truncatedMessage = lastUserMessage?.content.length > 50
? lastUserMessage.content.substring(0, 50) + '...'
: lastUserMessage?.content;
return (
<div
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
@@ -433,13 +597,17 @@ const ConversationItem = ({
onClick={onSelect}
>
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{conversation.title}</p>
<p className="text-xs text-muted-foreground truncate">
{conversation.lastMessage}
<div className="flex-1 min-w-0 overflow-hidden">
<p className="text-sm font-medium truncate" title={chat.title}>
{truncatedTitle}
</p>
{lastUserMessage && (
<p className="text-xs text-muted-foreground truncate" title={lastUserMessage.content}>
{truncatedMessage}
</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{conversation.timestamp}
{timeAgo}
</p>
</div>
@@ -448,21 +616,16 @@ const ConversationItem = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem className="gap-2">
<Edit className="w-3 h-3" />
Renomear
</DropdownMenuItem>
{conversation.folderId && (
{chat.folderId && (
<DropdownMenuItem
className="gap-2"
onClick={() => onRemoveFromFolder(conversation.id)}
onClick={() => onRemoveFromFolder(chat.id)}
>
<FolderInput className="w-3 h-3" />
Remover da Pasta
@@ -473,14 +636,17 @@ const ConversationItem = ({
<DropdownMenuItem
key={folder.id}
className="gap-2"
onClick={() => onMoveToFolder(conversation.id, folder.id)}
onClick={() => onMoveToFolder(chat.id, folder.id)}
>
<Folder className="w-3 h-3" />
Mover para {folder.name}
</DropdownMenuItem>
))}
<DropdownMenuItem className="gap-2 text-destructive">
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={onDelete}
>
<Trash2 className="w-3 h-3" />
Excluir
</DropdownMenuItem>