this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add chat deletion and renaming feature

+609 -10
+55
apps/web/src/components/chat-delete-dialog.tsx
··· 1 + import { useDeleteChat } from "#/hooks/use-delete-chat"; 2 + import { 3 + AlertDialog, 4 + AlertDialogAction, 5 + AlertDialogCancel, 6 + AlertDialogContent, 7 + AlertDialogDescription, 8 + AlertDialogFooter, 9 + AlertDialogHeader, 10 + AlertDialogTitle, 11 + } from "./ui/alert-dialog"; 12 + 13 + interface ChatDeleteDialogProps { 14 + chatId: string; 15 + chatTitle: string; 16 + open: boolean; 17 + onOpenChange: (open: boolean) => void; 18 + } 19 + 20 + export function ChatDeleteDialog({ 21 + chatId, 22 + chatTitle, 23 + open, 24 + onOpenChange, 25 + }: ChatDeleteDialogProps) { 26 + const deleteChat = useDeleteChat(); 27 + 28 + async function handleDelete() { 29 + await deleteChat.mutateAsync(chatId); 30 + onOpenChange(false); 31 + } 32 + 33 + return ( 34 + <AlertDialog open={open} onOpenChange={onOpenChange}> 35 + <AlertDialogContent> 36 + <AlertDialogHeader> 37 + <AlertDialogTitle>Delete chat</AlertDialogTitle> 38 + <AlertDialogDescription> 39 + Are you sure you want to delete "{chatTitle}"? This action cannot be 40 + undone. 41 + </AlertDialogDescription> 42 + </AlertDialogHeader> 43 + <AlertDialogFooter> 44 + <AlertDialogCancel>Cancel</AlertDialogCancel> 45 + <AlertDialogAction 46 + onClick={handleDelete} 47 + disabled={deleteChat.isPending} 48 + > 49 + Delete 50 + </AlertDialogAction> 51 + </AlertDialogFooter> 52 + </AlertDialogContent> 53 + </AlertDialog> 54 + ); 55 + }
+79
apps/web/src/components/chat-rename-dialog.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import { useRenameChat } from "#/hooks/use-rename-chat"; 3 + import { Stack } from "./layout"; 4 + import { Button } from "./ui/button"; 5 + import { 6 + Dialog, 7 + DialogContent, 8 + DialogFooter, 9 + DialogHeader, 10 + DialogTitle, 11 + } from "./ui/dialog"; 12 + import { Input } from "./ui/input"; 13 + 14 + interface ChatRenameDialogProps { 15 + chatId: string; 16 + currentTitle: string; 17 + open: boolean; 18 + onOpenChange: (open: boolean) => void; 19 + } 20 + 21 + export function ChatRenameDialog({ 22 + chatId, 23 + currentTitle, 24 + open, 25 + onOpenChange, 26 + }: ChatRenameDialogProps) { 27 + const [title, setTitle] = useState(currentTitle); 28 + const renameChat = useRenameChat(); 29 + const inputRef = useRef<HTMLInputElement>(null); 30 + 31 + useEffect(() => { 32 + if (open) { 33 + setTitle(currentTitle); 34 + } 35 + }, [open, currentTitle]); 36 + 37 + async function handleSubmit(e: React.FormEvent) { 38 + e.preventDefault(); 39 + const trimmed = title.trim(); 40 + if (!trimmed || trimmed === currentTitle) { 41 + onOpenChange(false); 42 + return; 43 + } 44 + await renameChat.mutateAsync({ id: chatId, title: trimmed }); 45 + onOpenChange(false); 46 + } 47 + 48 + return ( 49 + <Dialog open={open} onOpenChange={onOpenChange}> 50 + <DialogContent> 51 + <DialogHeader> 52 + <DialogTitle>Rename chat</DialogTitle> 53 + </DialogHeader> 54 + <form onSubmit={handleSubmit}> 55 + <Stack> 56 + <Input 57 + ref={inputRef} 58 + value={title} 59 + onChange={(e) => setTitle(e.target.value)} 60 + autoFocus 61 + /> 62 + <DialogFooter> 63 + <Button 64 + type="button" 65 + variant="outline" 66 + onClick={() => onOpenChange(false)} 67 + > 68 + Cancel 69 + </Button> 70 + <Button type="submit" disabled={renameChat.isPending}> 71 + Rename 72 + </Button> 73 + </DialogFooter> 74 + </Stack> 75 + </form> 76 + </DialogContent> 77 + </Dialog> 78 + ); 79 + }
+66 -9
apps/web/src/components/chat-sidebar-item.tsx
··· 1 1 import { Link, useLocation } from "@tanstack/react-router"; 2 + import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"; 3 + import { useState } from "react"; 2 4 import type { ChatSummary } from "#/lib/types"; 3 - import { SidebarMenuButton, SidebarMenuItem } from "./ui/sidebar"; 5 + import { ChatDeleteDialog } from "./chat-delete-dialog"; 6 + import { ChatRenameDialog } from "./chat-rename-dialog"; 7 + import { 8 + DropdownMenu, 9 + DropdownMenuContent, 10 + DropdownMenuItem, 11 + DropdownMenuSeparator, 12 + DropdownMenuTrigger, 13 + } from "./ui/dropdown-menu"; 14 + import { 15 + SidebarMenuAction, 16 + SidebarMenuButton, 17 + SidebarMenuItem, 18 + useSidebar, 19 + } from "./ui/sidebar"; 4 20 5 21 interface ChatSidebarItemProps { 6 22 chat: ChatSummary; ··· 11 27 select: (location) => location.pathname, 12 28 }); 13 29 const isActive = pathname === `/chats/${chat.id}`; 30 + const { isMobile } = useSidebar(); 31 + const [renameOpen, setRenameOpen] = useState(false); 32 + const [deleteOpen, setDeleteOpen] = useState(false); 14 33 15 34 return ( 16 - <SidebarMenuItem> 17 - <SidebarMenuButton 18 - render={<Link to="/chats/$chatId" params={{ chatId: chat.id }} />} 19 - isActive={isActive} 20 - > 21 - <span className="truncate">{chat.title}</span> 22 - </SidebarMenuButton> 23 - </SidebarMenuItem> 35 + <> 36 + <SidebarMenuItem> 37 + <SidebarMenuButton 38 + render={<Link to="/chats/$chatId" params={{ chatId: chat.id }} />} 39 + isActive={isActive} 40 + > 41 + <span className="truncate">{chat.title}</span> 42 + </SidebarMenuButton> 43 + <DropdownMenu> 44 + <DropdownMenuTrigger render={<SidebarMenuAction />}> 45 + <MoreHorizontalIcon /> 46 + <span className="sr-only">More</span> 47 + </DropdownMenuTrigger> 48 + <DropdownMenuContent 49 + side={isMobile ? "bottom" : "right"} 50 + align={isMobile ? "end" : "start"} 51 + > 52 + <DropdownMenuItem onClick={() => setRenameOpen(true)}> 53 + <PencilIcon /> 54 + Rename 55 + </DropdownMenuItem> 56 + <DropdownMenuSeparator /> 57 + <DropdownMenuItem 58 + variant="destructive" 59 + onClick={() => setDeleteOpen(true)} 60 + > 61 + <Trash2Icon /> 62 + Delete 63 + </DropdownMenuItem> 64 + </DropdownMenuContent> 65 + </DropdownMenu> 66 + </SidebarMenuItem> 67 + 68 + <ChatRenameDialog 69 + chatId={chat.id} 70 + currentTitle={chat.title ?? ""} 71 + open={renameOpen} 72 + onOpenChange={setRenameOpen} 73 + /> 74 + <ChatDeleteDialog 75 + chatId={chat.id} 76 + chatTitle={chat.title ?? ""} 77 + open={deleteOpen} 78 + onOpenChange={setDeleteOpen} 79 + /> 80 + </> 24 81 ); 25 82 }
+187
apps/web/src/components/ui/alert-dialog.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" 5 + 6 + import { cn } from "#/lib/utils" 7 + import { Button } from "#/components/ui/button" 8 + 9 + function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { 10 + return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> 11 + } 12 + 13 + function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { 14 + return ( 15 + <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> 16 + ) 17 + } 18 + 19 + function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { 20 + return ( 21 + <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> 22 + ) 23 + } 24 + 25 + function AlertDialogOverlay({ 26 + className, 27 + ...props 28 + }: AlertDialogPrimitive.Backdrop.Props) { 29 + return ( 30 + <AlertDialogPrimitive.Backdrop 31 + data-slot="alert-dialog-overlay" 32 + className={cn( 33 + "fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", 34 + className 35 + )} 36 + {...props} 37 + /> 38 + ) 39 + } 40 + 41 + function AlertDialogContent({ 42 + className, 43 + size = "default", 44 + ...props 45 + }: AlertDialogPrimitive.Popup.Props & { 46 + size?: "default" | "sm" 47 + }) { 48 + return ( 49 + <AlertDialogPortal> 50 + <AlertDialogOverlay /> 51 + <AlertDialogPrimitive.Popup 52 + data-slot="alert-dialog-content" 53 + data-size={size} 54 + className={cn( 55 + "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 56 + className 57 + )} 58 + {...props} 59 + /> 60 + </AlertDialogPortal> 61 + ) 62 + } 63 + 64 + function AlertDialogHeader({ 65 + className, 66 + ...props 67 + }: React.ComponentProps<"div">) { 68 + return ( 69 + <div 70 + data-slot="alert-dialog-header" 71 + className={cn( 72 + "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", 73 + className 74 + )} 75 + {...props} 76 + /> 77 + ) 78 + } 79 + 80 + function AlertDialogFooter({ 81 + className, 82 + ...props 83 + }: React.ComponentProps<"div">) { 84 + return ( 85 + <div 86 + data-slot="alert-dialog-footer" 87 + className={cn( 88 + "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end", 89 + className 90 + )} 91 + {...props} 92 + /> 93 + ) 94 + } 95 + 96 + function AlertDialogMedia({ 97 + className, 98 + ...props 99 + }: React.ComponentProps<"div">) { 100 + return ( 101 + <div 102 + data-slot="alert-dialog-media" 103 + className={cn( 104 + "mb-2 inline-flex size-16 items-center justify-center rounded-full bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", 105 + className 106 + )} 107 + {...props} 108 + /> 109 + ) 110 + } 111 + 112 + function AlertDialogTitle({ 113 + className, 114 + ...props 115 + }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { 116 + return ( 117 + <AlertDialogPrimitive.Title 118 + data-slot="alert-dialog-title" 119 + className={cn( 120 + "text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", 121 + className 122 + )} 123 + {...props} 124 + /> 125 + ) 126 + } 127 + 128 + function AlertDialogDescription({ 129 + className, 130 + ...props 131 + }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { 132 + return ( 133 + <AlertDialogPrimitive.Description 134 + data-slot="alert-dialog-description" 135 + className={cn( 136 + "text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", 137 + className 138 + )} 139 + {...props} 140 + /> 141 + ) 142 + } 143 + 144 + function AlertDialogAction({ 145 + className, 146 + ...props 147 + }: React.ComponentProps<typeof Button>) { 148 + return ( 149 + <Button 150 + data-slot="alert-dialog-action" 151 + className={cn(className)} 152 + {...props} 153 + /> 154 + ) 155 + } 156 + 157 + function AlertDialogCancel({ 158 + className, 159 + variant = "outline", 160 + size = "default", 161 + ...props 162 + }: AlertDialogPrimitive.Close.Props & 163 + Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { 164 + return ( 165 + <AlertDialogPrimitive.Close 166 + data-slot="alert-dialog-cancel" 167 + className={cn(className)} 168 + render={<Button variant={variant} size={size} />} 169 + {...props} 170 + /> 171 + ) 172 + } 173 + 174 + export { 175 + AlertDialog, 176 + AlertDialogAction, 177 + AlertDialogCancel, 178 + AlertDialogContent, 179 + AlertDialogDescription, 180 + AlertDialogFooter, 181 + AlertDialogHeader, 182 + AlertDialogMedia, 183 + AlertDialogOverlay, 184 + AlertDialogPortal, 185 + AlertDialogTitle, 186 + AlertDialogTrigger, 187 + }
+158
apps/web/src/components/ui/dialog.tsx
··· 1 + import * as React from "react" 2 + import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" 3 + 4 + import { cn } from "#/lib/utils" 5 + import { Button } from "#/components/ui/button" 6 + import { XIcon } from "lucide-react" 7 + 8 + function Dialog({ ...props }: DialogPrimitive.Root.Props) { 9 + return <DialogPrimitive.Root data-slot="dialog" {...props} /> 10 + } 11 + 12 + function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { 13 + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> 14 + } 15 + 16 + function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { 17 + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> 18 + } 19 + 20 + function DialogClose({ ...props }: DialogPrimitive.Close.Props) { 21 + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> 22 + } 23 + 24 + function DialogOverlay({ 25 + className, 26 + ...props 27 + }: DialogPrimitive.Backdrop.Props) { 28 + return ( 29 + <DialogPrimitive.Backdrop 30 + data-slot="dialog-overlay" 31 + className={cn( 32 + "fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", 33 + className 34 + )} 35 + {...props} 36 + /> 37 + ) 38 + } 39 + 40 + function DialogContent({ 41 + className, 42 + children, 43 + showCloseButton = true, 44 + ...props 45 + }: DialogPrimitive.Popup.Props & { 46 + showCloseButton?: boolean 47 + }) { 48 + return ( 49 + <DialogPortal> 50 + <DialogOverlay /> 51 + <DialogPrimitive.Popup 52 + data-slot="dialog-content" 53 + className={cn( 54 + "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 55 + className 56 + )} 57 + {...props} 58 + > 59 + {children} 60 + {showCloseButton && ( 61 + <DialogPrimitive.Close 62 + data-slot="dialog-close" 63 + render={ 64 + <Button 65 + variant="ghost" 66 + className="absolute top-4 right-4 bg-secondary" 67 + size="icon-sm" 68 + /> 69 + } 70 + > 71 + <XIcon 72 + /> 73 + <span className="sr-only">Close</span> 74 + </DialogPrimitive.Close> 75 + )} 76 + </DialogPrimitive.Popup> 77 + </DialogPortal> 78 + ) 79 + } 80 + 81 + function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 82 + return ( 83 + <div 84 + data-slot="dialog-header" 85 + className={cn("flex flex-col gap-1.5", className)} 86 + {...props} 87 + /> 88 + ) 89 + } 90 + 91 + function DialogFooter({ 92 + className, 93 + showCloseButton = false, 94 + children, 95 + ...props 96 + }: React.ComponentProps<"div"> & { 97 + showCloseButton?: boolean 98 + }) { 99 + return ( 100 + <div 101 + data-slot="dialog-footer" 102 + className={cn( 103 + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", 104 + className 105 + )} 106 + {...props} 107 + > 108 + {children} 109 + {showCloseButton && ( 110 + <DialogPrimitive.Close render={<Button variant="outline" />}> 111 + Close 112 + </DialogPrimitive.Close> 113 + )} 114 + </div> 115 + ) 116 + } 117 + 118 + function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { 119 + return ( 120 + <DialogPrimitive.Title 121 + data-slot="dialog-title" 122 + className={cn( 123 + "text-base leading-none font-medium", 124 + className 125 + )} 126 + {...props} 127 + /> 128 + ) 129 + } 130 + 131 + function DialogDescription({ 132 + className, 133 + ...props 134 + }: DialogPrimitive.Description.Props) { 135 + return ( 136 + <DialogPrimitive.Description 137 + data-slot="dialog-description" 138 + className={cn( 139 + "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", 140 + className 141 + )} 142 + {...props} 143 + /> 144 + ) 145 + } 146 + 147 + export { 148 + Dialog, 149 + DialogClose, 150 + DialogContent, 151 + DialogDescription, 152 + DialogFooter, 153 + DialogHeader, 154 + DialogOverlay, 155 + DialogPortal, 156 + DialogTitle, 157 + DialogTrigger, 158 + }
+1 -1
apps/web/src/components/ui/sidebar.tsx
··· 598 598 props: mergeProps<"button">( 599 599 { 600 600 className: cn( 601 - "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", 601 + "absolute top-1.5 right-3 flex aspect-square w-5 items-center justify-center rounded-xl p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", 602 602 showOnHover && 603 603 "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", 604 604 className,
+34
apps/web/src/hooks/use-delete-chat.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import { useNavigate } from "@tanstack/react-router"; 3 + import { client } from "#/lib/api"; 4 + import type { ChatSummary } from "#/lib/types"; 5 + 6 + export function useDeleteChat() { 7 + const queryClient = useQueryClient(); 8 + const navigate = useNavigate(); 9 + 10 + return useMutation({ 11 + mutationFn: async (id: string) => { 12 + const response = await client.api.chats[":id"].$delete({ 13 + param: { id }, 14 + }); 15 + 16 + if (!response.ok) { 17 + throw new Error("Failed to delete chat"); 18 + } 19 + 20 + return id; 21 + }, 22 + onSuccess: async (id) => { 23 + queryClient.setQueryData<ChatSummary[]>(["chats"], (chats = []) => 24 + chats.filter((chat) => chat.id !== id), 25 + ); 26 + queryClient.removeQueries({ queryKey: ["chat", id] }); 27 + 28 + const currentPath = window.location.pathname; 29 + if (currentPath === `/chats/${id}`) { 30 + await navigate({ to: "/chats" }); 31 + } 32 + }, 33 + }); 34 + }
+29
apps/web/src/hooks/use-rename-chat.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import { client } from "#/lib/api"; 3 + import type { ChatSummary } from "#/lib/types"; 4 + 5 + export function useRenameChat() { 6 + const queryClient = useQueryClient(); 7 + 8 + return useMutation({ 9 + mutationFn: async ({ id, title }: { id: string; title: string }) => { 10 + const response = await client.api.chats[":id"].$patch({ 11 + param: { id }, 12 + json: { title }, 13 + }); 14 + 15 + if (!response.ok) { 16 + throw new Error("Failed to rename chat"); 17 + } 18 + 19 + return response.json(); 20 + }, 21 + onSuccess: (updatedChat) => { 22 + queryClient.setQueryData<ChatSummary[]>(["chats"], (chats = []) => 23 + chats.map((chat) => 24 + chat.id === updatedChat.id ? { ...chat, title: updatedChat.title } : chat, 25 + ), 26 + ); 27 + }, 28 + }); 29 + }