* folders

* fixes

* storage fix
This commit is contained in:
Mckay Wrigley
2023-03-23 17:59:40 -06:00
committed by GitHub
parent 1a4b4401ee
commit f5118e3037
14 changed files with 677 additions and 226 deletions
+124
View File
@@ -0,0 +1,124 @@
import { Conversation, KeyValuePair } from "@/types";
import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react";
import { DragEvent, FC, KeyboardEvent, useEffect, useState } from "react";
interface Props {
selectedConversation: Conversation;
conversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
}
export const ConversationComponent: FC<Props> = ({ selectedConversation, conversation, loading, onSelectConversation, onDeleteConversation, onUpdateConversation }) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleRename(selectedConversation);
}
};
const handleDragStart = (e: DragEvent<HTMLButtonElement>, conversation: Conversation) => {
if (e.dataTransfer) {
e.dataTransfer.setData("conversation", JSON.stringify(conversation));
}
};
const handleRename = (conversation: Conversation) => {
onUpdateConversation(conversation, { key: "name", value: renameValue });
setRenameValue("");
setIsRenaming(false);
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
return (
<button
className={`flex w-full gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`}
onClick={() => onSelectConversation(conversation)}
disabled={loading}
draggable="true"
onDragStart={(e) => handleDragStart(e, conversation)}
>
<IconMessage size={16} />
{isRenaming && selectedConversation.id === conversation.id ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div>
)}
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && (
<div className="flex gap-1 -ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(selectedConversation.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</button>
);
};
+13 -107
View File
@@ -1,6 +1,6 @@
import { Conversation } from "@/types";
import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react";
import { FC, KeyboardEvent, useEffect, useState } from "react";
import { Conversation, KeyValuePair } from "@/types";
import { FC } from "react";
import { ConversationComponent } from "./Conversation";
interface Props {
loading: boolean;
@@ -8,116 +8,22 @@ interface Props {
selectedConversation: Conversation;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onRenameConversation: (conversation: Conversation, name: string) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
}
export const Conversations: FC<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onRenameConversation }) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleRename(selectedConversation);
}
};
const handleRename = (conversation: Conversation) => {
onRenameConversation(conversation, renameValue);
setRenameValue("");
setIsRenaming(false);
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
export const Conversations: FC<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onUpdateConversation }) => {
return (
<div className="flex flex-col-reverse gap-1 w-full pt-2">
{conversations.map((conversation, index) => (
<button
<ConversationComponent
key={index}
className={`flex gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`}
onClick={() => onSelectConversation(conversation)}
disabled={loading}
>
<IconMessage
className=""
size={16}
/>
{isRenaming && selectedConversation.id === conversation.id ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div>
)}
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && (
<div className="flex gap-1 -ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(selectedConversation.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</button>
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
</div>
);
+193
View File
@@ -0,0 +1,193 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types";
import { IconCaretDown, IconCaretRight, IconCheck, IconPencil, IconTrash, IconX } from "@tabler/icons-react";
import { FC, KeyboardEvent, useEffect, useState } from "react";
import { ConversationComponent } from "./Conversation";
interface Props {
searchTerm: string;
conversations: Conversation[];
currentFolder: ChatFolder;
onDeleteFolder: (folder: number) => void;
onUpdateFolder: (folder: number, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
}
export const Folder: FC<Props> = ({
searchTerm,
conversations,
currentFolder,
onDeleteFolder,
onUpdateFolder,
// conversation props
selectedConversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleRename();
}
};
const handleRename = () => {
onUpdateFolder(currentFolder.id, renameValue);
setRenameValue("");
setIsRenaming(false);
};
const handleDrop = (e: any, folder: ChatFolder) => {
if (e.dataTransfer) {
setIsOpen(true);
const conversation = JSON.parse(e.dataTransfer.getData("conversation"));
onUpdateConversation(conversation, { key: "folderId", value: folder.id });
e.target.style.background = "none";
}
};
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = "#343541";
};
const removeHighlight = (e: any) => {
e.target.style.background = "none";
};
useEffect(() => {
if (isRenaming) {
setIsDeleting(false);
} else if (isDeleting) {
setIsRenaming(false);
}
}, [isRenaming, isDeleting]);
useEffect(() => {
if (searchTerm) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [searchTerm]);
return (
<div>
<div
className={`mb-1 flex gap-3 items-center px-3 py-2 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer`}
onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
{isOpen ? <IconCaretDown size={16} /> : <IconCaretRight size={16} />}
{isRenaming ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{currentFolder.name}</div>
)}
{(isDeleting || isRenaming) && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteFolder(currentFolder.id);
} else if (isRenaming) {
handleRename();
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="flex gap-1 ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(currentFolder.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</div>
{isOpen
? conversations.map((conversation, index) => {
if (conversation.folderId === currentFolder.id) {
return (
<div
key={index}
className="ml-5 pl-2 border-l gap-2 pt-2"
>
<ConversationComponent
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
</div>
);
}
})
: null}
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
import { ChatFolder, Conversation, KeyValuePair } from "@/types";
import { FC } from "react";
import { Folder } from "./Folder";
interface Props {
searchTerm: string;
conversations: Conversation[];
folders: ChatFolder[];
onDeleteFolder: (folder: number) => void;
onUpdateFolder: (folder: number, name: string) => void;
// conversation props
selectedConversation: Conversation;
loading: boolean;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void;
}
export const Folders: FC<Props> = ({
searchTerm,
conversations,
folders,
onDeleteFolder,
onUpdateFolder,
// conversation props
selectedConversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation
}) => {
return (
<div className="flex flex-col gap-1 w-full pt-2">
{folders.map((folder, index) => (
<Folder
key={index}
searchTerm={searchTerm}
conversations={conversations.filter((c) => c.folderId)}
currentFolder={folder}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
// conversation props
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
</div>
);
};
+93 -27
View File
@@ -1,7 +1,8 @@
import { Conversation, KeyValuePair } from "@/types";
import { IconArrowBarLeft, IconPlus } from "@tabler/icons-react";
import { ChatFolder, Conversation, KeyValuePair } from "@/types";
import { IconArrowBarLeft, IconFolderPlus, IconPlus } from "@tabler/icons-react";
import { FC, useEffect, useState } from "react";
import { Conversations } from "./Conversations";
import { Folders } from "./Folders";
import { Search } from "./Search";
import { SidebarSettings } from "./SidebarSettings";
@@ -11,6 +12,10 @@ interface Props {
lightMode: "light" | "dark";
selectedConversation: Conversation;
apiKey: string;
folders: ChatFolder[];
onCreateFolder: (name: string) => void;
onDeleteFolder: (folderId: number) => void;
onUpdateFolder: (folderId: number, name: string) => void;
onNewConversation: () => void;
onToggleLightMode: (mode: "light" | "dark") => void;
onSelectConversation: (conversation: Conversation) => void;
@@ -23,17 +28,49 @@ interface Props {
onImportConversations: (conversations: Conversation[]) => void;
}
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, folders, onCreateFolder, onDeleteFolder, onUpdateFolder, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const [filteredConversations, setFilteredConversations] = useState<Conversation[]>(conversations);
const handleUpdateConversation = (conversation: Conversation, data: KeyValuePair) => {
onUpdateConversation(conversation, data);
setSearchTerm("");
};
const handleDeleteConversation = (conversation: Conversation) => {
onDeleteConversation(conversation);
setSearchTerm("");
};
const handleDrop = (e: any) => {
if (e.dataTransfer) {
const conversation = JSON.parse(e.dataTransfer.getData("conversation"));
onUpdateConversation(conversation, { key: "folderId", value: 0 });
e.target.style.background = "none";
}
};
const allowDrop = (e: any) => {
e.preventDefault();
};
const highlightDrop = (e: any) => {
e.target.style.background = "#343541";
};
const removeHighlight = (e: any) => {
e.target.style.background = "none";
};
useEffect(() => {
if (searchTerm) {
setFilteredConversations(conversations.filter((conversation) => {
const searchable = conversation.name.toLocaleLowerCase() + ' ' + conversation.messages.map((message) => message.content).join(" ");
return searchable.toLowerCase().includes(searchTerm.toLowerCase());
}
));
setFilteredConversations(
conversations.filter((conversation) => {
const searchable = conversation.name.toLocaleLowerCase() + " " + conversation.messages.map((message) => message.content).join(" ");
return searchable.toLowerCase().includes(searchTerm.toLowerCase());
})
);
} else {
setFilteredConversations(conversations);
}
@@ -43,19 +80,23 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
<aside className={`h-full flex flex-none space-y-2 p-2 flex-col bg-[#202123] w-[260px] z-10 sm:relative sm:top-0 absolute top-12 bottom-0`}>
<header className="flex items-center">
<button
className="flex gap-3 p-3 items-center w-full sm:w-[200px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
className="flex gap-3 p-3 items-center w-[190px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
onClick={() => {
onNewConversation();
setSearchTerm("");
}}
>
<IconPlus
className=""
size={16}
/>
<IconPlus size={16} />
New chat
</button>
<button
className="ml-2 flex gap-3 p-3 items-center rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
onClick={() => onCreateFolder("New folder")}
>
<IconFolderPlus size={16} />
</button>
<IconArrowBarLeft
className="ml-1 p-1 text-neutral-300 cursor-pointer hover:text-neutral-400 hidden sm:flex"
size={32}
@@ -71,20 +112,45 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
)}
<div className="flex-grow overflow-auto">
<Conversations
loading={loading}
conversations={filteredConversations}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={(conversation) => {
onDeleteConversation(conversation);
setSearchTerm("");
}}
onRenameConversation={(conversation, name) => {
onUpdateConversation(conversation, { key: "name", value: name });
setSearchTerm("");
}}
/>
{folders.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
<Folders
searchTerm={searchTerm}
conversations={filteredConversations.filter((conversation) => conversation.folderId !== 0)}
folders={folders}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
)}
{conversations.length > 0 ? (
<div
className="pt-2 h-full"
onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
<Conversations
loading={loading}
conversations={filteredConversations.filter((conversation) => conversation.folderId === 0)}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
) : (
<div className="mt-4 text-white text-center">
<div>No conversations.</div>
</div>
)}
</div>
<SidebarSettings