diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 682bd610..d8efe149 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate, useRouterState } from '@tanstack/react-router'; import { Menu, Plus, LogOut, Crown, Settings, LayoutGrid } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -32,7 +32,14 @@ import { DiscordIcon, GitHubIcon } from './icons/CompanyIcons'; import { cn } from '@/lib/utils'; import { Conversation, ConversationSettings } from '@shared/types'; import { UserAvatar } from '@/components/chat/UserAvatar'; +import { SidebarConversationItem } from '@/components/sidebar/SidebarConversationItem'; +import { RenameDialogDrawer } from '@/components/history/RenameDialogDrawer'; +import { + useDeleteConversation, + useRenameConversation, +} from '@/services/conversationService'; import { useProfile } from '@/services/profileService'; +import { useToast } from '@/hooks/use-toast'; interface SidebarProps { isSidebarOpen: boolean; @@ -46,6 +53,28 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) { const { user, signOut } = useAuth(); const isMobile = useIsMobile(); const { data: profile } = useProfile(); + const { toast } = useToast(); + const activeEditorId = useRouterState({ + select: (state) => state.location.pathname.match(/^\/editor\/([^/]+)/)?.[1], + }); + const [renameTarget, setRenameTarget] = useState(null); + const [renameTitle, setRenameTitle] = useState(''); + const [renameOpen, setRenameOpen] = useState(false); + + const deleteConversation = useDeleteConversation({ + onDeleted: (conversationId) => { + if (activeEditorId === conversationId) { + navigate({ to: '/' }); + } + }, + }); + + const renameConversation = useRenameConversation({ + onRenamed: () => { + setRenameOpen(false); + setRenameTarget(null); + }, + }); // Get 10 most recent conversations const { data: recentConversations } = useQuery({ @@ -82,6 +111,40 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) { navigate({ to: path }); }; + const closeMobileSidebar = () => { + if (isMobile) { + setIsSidebarOpen(false); + } + }; + + const handleRenameRequest = ( + conversationId: string, + currentTitle: string, + ) => { + const conversation = recentConversations?.find( + (item) => item.id === conversationId, + ); + if (!conversation) return; + setRenameTarget(conversation); + setRenameTitle(currentTitle || conversation.title); + setRenameOpen(true); + }; + + const handleRenameSave = () => { + if (!renameTarget) return; + if (!renameTitle.trim()) { + toast({ + title: 'Title cannot be empty', + variant: 'default', + }); + return; + } + renameConversation.mutate({ + conversationId: renameTarget.id, + newTitle: renameTitle.trim(), + }); + }; + const renderUserSectionTrigger = () => { if (isSidebarOpen) { return ( @@ -228,35 +291,34 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) { {isSidebarOpen && submenu && ( - + {submenu.length > 0 && ( + + View all creations + + )} + )} ))} @@ -399,6 +461,16 @@ function DesktopSidebar({ isSidebarOpen, setIsSidebarOpen }: SidebarProps) { + { + setRenameOpen(open); + if (!open) setRenameTarget(null); + }} + newTitle={renameTitle} + onNewTitleChange={setRenameTitle} + onRename={handleRenameSave} + /> ); } diff --git a/src/components/sidebar/SidebarConversationItem.tsx b/src/components/sidebar/SidebarConversationItem.tsx new file mode 100644 index 00000000..99f558e7 --- /dev/null +++ b/src/components/sidebar/SidebarConversationItem.tsx @@ -0,0 +1,126 @@ +import { Link } from '@tanstack/react-router'; +import { ExternalLink, MoreVertical, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Conversation } from '@shared/types'; + +interface SidebarConversationItemProps { + conversation: Pick; + onDelete: (conversationId: string) => void; + onRename: (conversationId: string, currentTitle: string) => void; + onNavigate?: () => void; +} + +export function SidebarConversationItem({ + conversation, + onDelete, + onRename, + onNavigate, +}: SidebarConversationItemProps) { + return ( +
  • + + + {conversation.title || 'Untitled creation'} + + +
    + + + + + + + + + + Open + + + { + event.preventDefault(); + onRename(conversation.id, conversation.title); + }} + > + + Rename + + + event.preventDefault()} + > + + Delete + + + + + + + + Delete creation + + + Are you sure you want to delete “ + {conversation.title || 'Untitled creation'}”? This action + cannot be undone. + + + + Cancel + onDelete(conversation.id)} + > + Delete + + + + +
    +
  • + ); +} diff --git a/src/services/conversationService.ts b/src/services/conversationService.ts index e191f320..165bf500 100644 --- a/src/services/conversationService.ts +++ b/src/services/conversationService.ts @@ -1,9 +1,199 @@ import { useAuth } from '@/contexts/AuthContext'; +import { useToast } from '@/hooks/use-toast'; +import { HistoryConversation } from '@/types/misc'; import { Conversation } from '@shared/types'; import { supabase } from '@/lib/supabase'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; +type ConversationListItem = Pick & + Partial; + +async function removeConversationImages( + userId: string, + conversationId: string, +) { + const { data: list } = await supabase.storage + .from('images') + .list(`${userId}/${conversationId}`); + if (list?.length) { + await supabase.storage + .from('images') + .remove(list.map((file) => `${userId}/${conversationId}/${file.name}`)); + } +} + +function patchConversationListCaches( + queryClient: QueryClient, + conversationId: string, + mode: 'remove' | { rename: string }, +) { + const patch = (items: T[] | undefined) => { + if (!items) return items; + if (mode === 'remove') { + return items.filter((item) => item.id !== conversationId); + } + return items.map((item) => + item.id === conversationId ? { ...item, title: mode.rename } : item, + ); + }; + + queryClient.setQueryData( + ['conversations'], + (old: HistoryConversation[] | undefined) => patch(old), + ); + queryClient.setQueryData( + ['conversations', 'recent'], + (old: Conversation[] | undefined) => patch(old), + ); + if (mode !== 'remove') { + queryClient.setQueryData( + ['conversation', conversationId], + (old: Conversation | undefined) => + old ? { ...old, title: mode.rename } : old, + ); + } else { + queryClient.removeQueries({ queryKey: ['conversation', conversationId] }); + queryClient.removeQueries({ queryKey: ['messages', conversationId] }); + } +} + +export function useDeleteConversation(options?: { + onDeleted?: (conversationId: string) => void; +}) { + const { user } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (conversationId: string) => { + const { error } = await supabase + .from('conversations') + .delete() + .eq('id', conversationId); + + if (error) throw error; + + if (user?.id) { + void removeConversationImages(user.id, conversationId); + } + }, + onMutate: async (conversationId) => { + await queryClient.cancelQueries({ queryKey: ['conversations'] }); + await queryClient.cancelQueries({ + queryKey: ['conversations', 'recent'], + }); + const previousConversations = queryClient.getQueryData(['conversations']); + const previousRecent = queryClient.getQueryData([ + 'conversations', + 'recent', + ]); + patchConversationListCaches(queryClient, conversationId, 'remove'); + return { previousConversations, previousRecent }; + }, + onSuccess: (_data, conversationId) => { + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['conversations', 'recent'] }); + options?.onDeleted?.(conversationId); + toast({ + title: 'Creation deleted', + description: 'The conversation was removed successfully.', + }); + }, + onError: (error, _conversationId, context) => { + console.error('Error deleting conversation:', error); + queryClient.setQueryData( + ['conversations'], + context?.previousConversations, + ); + queryClient.setQueryData( + ['conversations', 'recent'], + context?.previousRecent, + ); + toast({ + title: 'Error', + description: 'Failed to delete conversation', + variant: 'destructive', + }); + }, + }); +} + +export function useRenameConversation(options?: { onRenamed?: () => void }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + conversationId, + newTitle, + }: { + conversationId: string; + newTitle: string; + }) => { + const { error } = await supabase + .from('conversations') + .update({ title: newTitle }) + .eq('id', conversationId); + + if (error) throw error; + }, + onMutate: async ({ conversationId, newTitle }) => { + await queryClient.cancelQueries({ queryKey: ['conversations'] }); + await queryClient.cancelQueries({ + queryKey: ['conversations', 'recent'], + }); + const previousConversations = queryClient.getQueryData(['conversations']); + const previousRecent = queryClient.getQueryData([ + 'conversations', + 'recent', + ]); + const previousConversation = queryClient.getQueryData([ + 'conversation', + conversationId, + ]); + patchConversationListCaches(queryClient, conversationId, { + rename: newTitle, + }); + return { previousConversations, previousRecent, previousConversation }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['conversations'] }); + queryClient.invalidateQueries({ queryKey: ['conversations', 'recent'] }); + options?.onRenamed?.(); + toast({ + title: 'Creation renamed', + description: 'The conversation title was updated.', + }); + }, + onError: (error, { conversationId }, context) => { + console.error('Error renaming conversation:', error); + queryClient.setQueryData( + ['conversations'], + context?.previousConversations, + ); + queryClient.setQueryData( + ['conversations', 'recent'], + context?.previousRecent, + ); + queryClient.setQueryData( + ['conversation', conversationId], + context?.previousConversation, + ); + toast({ + title: 'Error', + description: 'Failed to rename conversation', + variant: 'destructive', + }); + }, + }); +} + const defaultConversation: Conversation = { id: '', title: '', diff --git a/src/views/HistoryView.tsx b/src/views/HistoryView.tsx index d4932984..a2ff5b3e 100644 --- a/src/views/HistoryView.tsx +++ b/src/views/HistoryView.tsx @@ -20,6 +20,10 @@ import { HistoryConversation } from '../types/misc.ts'; import { ConversationCard } from '@/components/history/ConversationCard'; import { VisualCard } from '@/components/history/VisualCard'; import { RenameDialogDrawer } from '@/components/history/RenameDialogDrawer'; +import { + useDeleteConversation, + useRenameConversation, +} from '@/services/conversationService'; import { cn } from '@/lib/utils'; const VIEW_TRANSITION_PROPS = { @@ -49,6 +53,14 @@ export function HistoryView() { const queryClient = useQueryClient(); const shouldReduceMotion = useReducedMotion(); + const deleteConversation = useDeleteConversation(); + const renameConversation = useRenameConversation({ + onRenamed: () => { + setEditingConversation(null); + setOpen(false); + }, + }); + const handleOpenChange = (open: boolean) => { setOpen(open); if (!open) { @@ -107,108 +119,6 @@ export function HistoryView() { } }, [conversationQuery.isError, toast]); - const deleteConversation = useMutation({ - mutationFn: async (conversationId: string) => { - const { error } = await supabase - .from('conversations') - .delete() - .eq('id', conversationId); - - if (error) throw error; - - supabase.storage - .from('images') - .list(`${user?.id}/${conversationId}`) - .then(({ data: list }) => { - if (list) { - const filesToRemove = list.map( - (file) => `${user?.id}/${conversationId}/${file.name}`, - ); - supabase.storage.from('images').remove(filesToRemove); - } - }); - }, - onMutate: async (conversationId) => { - await queryClient.cancelQueries({ queryKey: ['conversations'] }); - const previousConversations = queryClient.getQueryData(['conversations']); - queryClient.setQueryData( - ['conversations'], - (old: HistoryConversation[]) => - old.filter((conv) => conv.id !== conversationId), - ); - return { previousConversations }; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['conversations'] }); - toast({ - title: 'Success', - description: 'Conversation deleted successfully', - }); - }, - onError: (error: unknown, _conversationId: string, context) => { - console.error('Error deleting conversation:', error); - queryClient.setQueryData( - ['conversations'], - context?.previousConversations, - ); - toast({ - title: 'Error', - description: 'Failed to delete conversation', - variant: 'destructive', - }); - }, - }); - - const renameConversation = useMutation({ - mutationFn: async ({ - conversationId, - newTitle, - }: { - conversationId: string; - newTitle: string; - }) => { - const { error } = await supabase - .from('conversations') - .update({ title: newTitle }) - .eq('id', conversationId); - - if (error) throw error; - }, - onMutate: async ({ conversationId, newTitle }) => { - await queryClient.cancelQueries({ queryKey: ['conversations'] }); - const previousConversations = queryClient.getQueryData(['conversations']); - queryClient.setQueryData( - ['conversations'], - (old: HistoryConversation[]) => - old.map((conv) => - conv.id === conversationId ? { ...conv, title: newTitle } : conv, - ), - ); - return { previousConversations }; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['conversations'] }); - toast({ - title: 'Success', - description: 'Conversation renamed successfully', - }); - setEditingConversation(null); - setOpen(false); - }, - onError: (error: unknown, _variables, context) => { - console.error('Error renaming conversation:', error); - queryClient.setQueryData( - ['conversations'], - context?.previousConversations, - ); - toast({ - title: 'Error', - description: 'Failed to rename conversation', - variant: 'destructive', - }); - }, - }); - const togglePrivacy = useMutation({ mutationFn: async ({ conversationId,