"use client"; import { deleteThreadAction, updateThreadAction } from "@/app/api/chat/actions"; import { appStore } from "@/app/store"; import { useToRef } from "@/hooks/use-latest"; import { Archive, ChevronRight, Loader, PencilLine, Trash, UploadIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; import { type PropsWithChildren, useState } from "react"; import { toast } from "sonner"; import { mutate } from "swr"; import { safe } from "ts-safe"; import { Button } from "ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "ui/dialog"; import { Input } from "ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; import { Command, CommandGroup, CommandItem, CommandList, CommandSeparator, } from "ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "ui/dropdown-menu"; import { useTranslations } from "next-intl"; import { addItemToArchiveAction } from "@/app/api/archive/actions"; import { useShallow } from "zustand/shallow"; import { ChatExportPopup } from "./export/chat-export-popup"; type Props = PropsWithChildren<{ threadId: string; beforeTitle?: string; onDeleted?: () => void; side?: "top" | "bottom" | "left" | "right"; align?: "start" | "end" | "center"; }>; export function ThreadDropdown({ threadId, children, beforeTitle, onDeleted, side, align, }: Props) { const router = useRouter(); const t = useTranslations(); const push = useToRef(router.push); const [currentThreadId, archiveList] = appStore( useShallow((state) => [state.currentThreadId, state.archiveList]), ); const [open, setOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleUpdate = async (title: string) => { safe() .ifOk(() => { if (!title) { throw new Error(t("Chat.Thread.titleRequired")); } }) .ifOk(() => updateThreadAction(threadId, { title })) .ifOk(() => mutate("/api/thread")) .watch(({ isOk, error }) => { if (isOk) { toast.success(t("Chat.Thread.threadUpdated")); } else { toast.error(error.message || t("Chat.Thread.failedToUpdateThread")); } }); }; const handleDelete = async (_e: React.MouseEvent) => { safe() .watch(() => setIsDeleting(true)) .ifOk(() => deleteThreadAction(threadId)) .watch(() => setIsDeleting(false)) .watch(() => setOpen(false)) .watch(({ isOk, error }) => { if (isOk) { toast.success(t("Chat.Thread.threadDeleted")); } else { toast.error(error.message || t("Chat.Thread.failedToDeleteThread")); } }) .ifOk(() => onDeleted?.()) .ifOk(() => { if (currentThreadId === threadId) { push.current("/"); } mutate("/api/thread"); }) .unwrap(); }; const handleAddToArchive = async (archiveId: string) => { safe() .ifOk(() => addItemToArchiveAction(archiveId, threadId)) .watch(({ isOk, error }) => { if (isOk) { toast.success(t("Archive.itemAddedToArchive")); if (location.pathname.startsWith(`/archive/${archiveId}`)) { router.refresh(); } } else { toast.error(error.message || t("Archive.failedToCreateArchive")); } }) .unwrap(); }; return ( {children} {t("Chat.Thread.chat")} {t("Chat.Thread.exportChat")} handleUpdate(title)} > {t("Chat.Thread.renameChat")} {t("Archive.addToArchive")} {archiveList.length === 0 ? ( {t("Archive.noArchives")} ) : ( archiveList.map((archive) => ( handleAddToArchive(archive.id)} className="cursor-pointer" > {archive.name} {archive.itemCount > 0 && ( {archive.itemCount} )} )) )} {t("Chat.Thread.deleteChat")} {isDeleting && ( )} ); } function UpdateThreadNameDialog({ initialTitle, children, onUpdated, }: PropsWithChildren<{ initialTitle: string; onUpdated: (title: string) => void; }>) { const [title, setTitle] = useState(initialTitle); const t = useTranslations(); return ( {children} {t("Chat.Thread.renameChat")} { if (e.key === "Enter") { onUpdated(title); } }} onInput={(e) => { setTitle(e.currentTarget.value); }} /> {t("Common.cancel")} onUpdated(title)}> {t("Common.update")} ); }