refactor: move codes into src folder

This commit is contained in:
steven
2023-03-31 16:12:09 +08:00
parent 483e750bc5
commit 861a22f13d
57 changed files with 44 additions and 8 deletions

View File

@ -0,0 +1,43 @@
import Icon from "./Icon";
export interface ActionConfirmModalProps {
title: string;
content: string;
confirmButtonStyle: string;
close: () => void;
confirm: () => void;
}
const ActionConfirmModal = (props: ActionConfirmModalProps) => {
const { close, confirm, title, content, confirmButtonStyle } = props;
return (
<div className="modal modal-middle modal-open">
<div className="modal-box relative">
<h3 className="font-bold text-lg">{title}</h3>
<button className="btn btn-sm btn-circle absolute right-4 top-4" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4">
<p className="text-gray-500">{content}</p>
</div>
<div className="modal-action">
<button className="btn btn-outline" onClick={close}>
Close
</button>
<button
className={`btn ${confirmButtonStyle}`}
onClick={() => {
confirm();
close();
}}
>
Confirm
</button>
</div>
</div>
</div>
);
};
export default ActionConfirmModal;

View File

@ -0,0 +1,47 @@
import { useEffect } from "react";
import { useChatStore } from "@/store";
import Icon from "../Icon";
import GitHubStarBadge from "../GitHubStarBadge";
interface Props {
className?: string;
}
const Header = (props: Props) => {
const { className } = props;
const chatStore = useChatStore();
const currentChat = chatStore.currentChat;
const title = currentChat?.title || "SQL Chat";
useEffect(() => {
document.title = `${title}`;
}, [title]);
return (
<div
className={`${
className || ""
} w-full flex flex-row justify-between items-center lg:grid lg:grid-cols-3 py-1 border-b z-1 transition-all duration-300`}
>
<div className="ml-2 flex justify-start items-center">
<label htmlFor="connection-drawer" className="w-8 h-8 p-1 mr-1 block lg:hidden rounded-md cursor-pointer hover:bg-gray-100">
<Icon.IoIosMenu className="text-gray-600 w-full h-auto" />
</label>
<span className="w-auto text-left block lg:hidden">{title}</span>
<GitHubStarBadge className="hidden lg:flex ml-2" />
</div>
<span className="w-auto text-center h-8 p-1 hidden lg:block">{title}</span>
<div className="mr-2 sm:mr-3 relative flex flex-row justify-end items-center">
<a
href="https://www.bytebase.com?source=sqlchat"
className="flex flex-row justify-center items-center h-10 px-3 py-1 rounded-md whitespace-nowrap hover:bg-gray-100"
target="_blank"
>
<img className="h-5 sm:h-6 w-auto" src="/craft-by-bytebase.webp" alt="" />
</a>
</div>
</div>
);
};
export default Header;

View File

@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import TextareaAutosize from "react-textarea-autosize";
import { useChatStore, useConnectionStore, useMessageStore, useUserStore } from "@/store";
import { CreatorRole } from "@/types";
import { generateUUID } from "@/utils";
import Icon from "../Icon";
interface Props {
disabled?: boolean;
sendMessage: () => Promise<void>;
}
const MessageTextarea = (props: Props) => {
const { disabled, sendMessage } = props;
const connectionStore = useConnectionStore();
const userStore = useUserStore();
const chatStore = useChatStore();
const messageStore = useMessageStore();
const [value, setValue] = useState<string>("");
const [isInIME, setIsInIME] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
};
const handleSend = async () => {
let chat = chatStore.currentChat;
if (!chat) {
const currentConnectionCtx = connectionStore.currentConnectionCtx;
if (!currentConnectionCtx) {
chat = chatStore.createChat();
} else {
chat = chatStore.createChat(currentConnectionCtx.connection.id, currentConnectionCtx.database?.name);
}
}
if (!value) {
toast.error("Please enter a message.");
return;
}
if (disabled) {
return;
}
messageStore.addMessage({
id: generateUUID(),
chatId: chat.id,
creatorId: userStore.currentUser.id,
creatorRole: CreatorRole.User,
createdAt: Date.now(),
content: value,
isGenerated: true,
});
setValue("");
textareaRef.current!.value = "";
await sendMessage();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey && !isInIME) {
event.preventDefault();
handleSend();
}
};
return (
<div className="w-full h-auto flex flex-row justify-between items-end border rounded-lg mb-2 px-2 py-1 relative shadow bg-white">
<TextareaAutosize
ref={textareaRef}
className="w-full h-full outline-none border-none bg-transparent leading-6 py-2 px-2 resize-none hide-scrollbar"
placeholder="Type a message..."
rows={1}
minRows={1}
maxRows={5}
onCompositionStart={() => setIsInIME(true)}
onCompositionEnd={() => setIsInIME(false)}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<button
className="w-8 p-1 -translate-y-1 cursor-pointer rounded-md hover:shadow hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={disabled}
onClick={handleSend}
>
<Icon.IoMdSend className="w-full h-auto text-indigo-600" />
</button>
</div>
);
};
export default MessageTextarea;

View File

@ -0,0 +1,132 @@
import { Menu, MenuItem } from "@mui/material";
import dayjs from "dayjs";
import { ReactElement, useState } from "react";
import { toast } from "react-hot-toast";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useChatStore, useConnectionStore, useMessageStore, useUserStore } from "@/store";
import { Message } from "@/types";
import Icon from "../Icon";
import { CodeBlock } from "../CodeBlock";
import EngineIcon from "../EngineIcon";
interface Props {
message: Message;
}
const MessageView = (props: Props) => {
const message = props.message;
const userStore = useUserStore();
const chatStore = useChatStore();
const connectionStore = useConnectionStore();
const messageStore = useMessageStore();
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLButtonElement | null>(null);
const isCurrentUser = message.creatorId === userStore.currentUser.id;
const showMenu = Boolean(menuAnchorEl);
const connection = connectionStore.getConnectionById(chatStore.getChatById(message.chatId)?.connectionId || "");
const handleMoreMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (menuAnchorEl) {
setMenuAnchorEl(null);
} else {
setMenuAnchorEl(event.currentTarget);
}
};
const copyMessage = () => {
navigator.clipboard.writeText(message.content);
toast.success("Copied to clipboard");
setMenuAnchorEl(null);
};
const deleteMessage = (message: Message) => {
messageStore.clearMessage((item) => item.id !== message.id);
setMenuAnchorEl(null);
};
return (
<div
className={`w-full max-w-full flex flex-row justify-start items-start my-4 group ${
isCurrentUser ? "justify-end pl-8 sm:pl-24" : "pr-8 sm:pr-24"
}`}
>
{isCurrentUser ? (
<>
<div className="w-auto max-w-full bg-indigo-600 text-white px-4 py-2 rounded-lg whitespace-pre-wrap break-all">
{message.content}
</div>
<div className="w-10 h-10 p-1 border rounded-full flex justify-center items-center ml-2 shrink-0">
<Icon.AiOutlineUser className="w-6 h-6" />
</div>
</>
) : (
<>
<div className="flex justify-center items-center mr-2 shrink-0">
{connection ? (
<EngineIcon className="w-10 h-auto p-1 border rounded-full" engine={connection.engineType} />
) : (
<img className="w-10 h-auto p-1" src="/chat-logo-bot.webp" alt="" />
)}
</div>
<div className="w-auto max-w-[calc(100%-4rem)] flex flex-col justify-start items-start">
<ReactMarkdown
className="w-auto max-w-full bg-gray-100 px-4 py-2 rounded-lg prose prose-neutral"
remarkPlugins={[remarkGfm]}
components={{
pre({ node, className, children, ...props }) {
const child = children[0] as ReactElement;
const match = /language-(\w+)/.exec(child.props.className || "");
const language = match ? match[1] : "text";
return (
<pre className={`${className || ""} w-full p-0 my-1`} {...props}>
<CodeBlock
key={Math.random()}
language={language || "text"}
value={String(child.props.children).replace(/\n$/, "")}
{...props}
/>
</pre>
);
},
code({ children }) {
return <code className="px-0">`{children}`</code>;
},
}}
>
{message.content}
</ReactMarkdown>
<span className="self-end text-sm text-gray-400 pt-1 pr-1">{dayjs(message.createdAt).format("lll")}</span>
</div>
<div className={`invisible group-hover:visible ${showMenu && "!visible"}`}>
<button
className="w-6 h-6 ml-1 mt-2 flex justify-center items-center text-gray-400 hover:text-gray-500"
onClick={handleMoreMenuClick}
>
<Icon.IoMdMore className="w-5 h-auto" />
</button>
<Menu
className="mt-1"
anchorEl={menuAnchorEl}
open={showMenu}
onClose={() => setMenuAnchorEl(null)}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
>
<MenuItem onClick={copyMessage}>
<Icon.BiClipboard className="w-4 h-auto mr-2 opacity-70" />
Copy
</MenuItem>
<MenuItem onClick={() => deleteMessage(message)}>
<Icon.BiTrash className="w-4 h-auto mr-2 opacity-70" />
Delete
</MenuItem>
</Menu>
</div>
</>
)}
</div>
);
};
export default MessageView;

View File

@ -0,0 +1,209 @@
import { head, last } from "lodash-es";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { getAssistantById, getPromptGeneratorOfAssistant, useChatStore, useMessageStore, useConnectionStore } from "@/store";
import { CreatorRole, Message } from "@/types";
import { countTextTokens, generateUUID } from "@/utils";
import Header from "./Header";
import EmptyView from "../EmptyView";
import MessageView from "./MessageView";
import MessageTextarea from "./MessageTextarea";
import MessageLoader from "../MessageLoader";
import DataStorageBanner from "../DataStorageBanner";
// The maximum number of tokens that can be sent to the OpenAI API.
// reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens
const MAX_TOKENS = 4000;
const ChatView = () => {
const connectionStore = useConnectionStore();
const chatStore = useChatStore();
const messageStore = useMessageStore();
const [isRequesting, setIsRequesting] = useState<boolean>(false);
const [showHeaderShadow, setShowHeaderShadow] = useState<boolean>(false);
const chatViewRef = useRef<HTMLDivElement>(null);
const currentChat = chatStore.currentChat;
const messageList = messageStore.messageList.filter((message) => message.chatId === currentChat?.id);
const lastMessage = last(messageList);
// Toggle header shadow.
useEffect(() => {
const handleChatViewScroll = () => {
setShowHeaderShadow((chatViewRef.current?.scrollTop || 0) > 0);
};
chatViewRef.current?.addEventListener("scroll", handleChatViewScroll);
return () => {
chatViewRef.current?.removeEventListener("scroll", handleChatViewScroll);
};
}, []);
useEffect(() => {
setTimeout(() => {
if (!chatViewRef.current) {
return;
}
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
});
}, [currentChat, messageList.length, lastMessage?.isGenerated]);
useEffect(() => {
setTimeout(() => {
if (!chatViewRef.current) {
return;
}
if (!lastMessage) {
return;
}
if (!lastMessage.isGenerated) {
chatViewRef.current.scrollTop = chatViewRef.current.scrollHeight;
}
});
}, [lastMessage?.isGenerated, lastMessage?.content]);
useEffect(() => {
if (
currentChat?.connectionId === connectionStore.currentConnectionCtx?.connection.id &&
currentChat?.databaseName === connectionStore.currentConnectionCtx?.database?.name
) {
return;
}
// Auto select the first chat when the current connection changes.
const chatList = chatStore.chatList.filter(
(chat) =>
chat.connectionId === connectionStore.currentConnectionCtx?.connection.id &&
chat.databaseName === connectionStore.currentConnectionCtx?.database?.name
);
chatStore.setCurrentChat(head(chatList));
}, [currentChat, connectionStore.currentConnectionCtx]);
const sendMessageToCurrentChat = async () => {
const currentChat = chatStore.getState().currentChat;
if (!currentChat) {
return;
}
if (isRequesting) {
return;
}
setIsRequesting(true);
const messageList = messageStore.getState().messageList.filter((message) => message.chatId === currentChat.id);
let prompt = "";
let tokens = 0;
if (connectionStore.currentConnectionCtx?.database) {
let schema = "";
try {
const tables = await connectionStore.getOrFetchDatabaseSchema(connectionStore.currentConnectionCtx?.database);
for (const table of tables) {
if (tokens < MAX_TOKENS / 2) {
tokens += countTextTokens(schema + table.structure);
schema += table.structure;
}
}
} catch (error: any) {
toast.error(error.message);
}
const promptGenerator = getPromptGeneratorOfAssistant(getAssistantById(currentChat.assistantId)!);
prompt = promptGenerator(schema);
}
let formatedMessageList = [];
for (let i = messageList.length - 1; i >= 0; i--) {
const message = messageList[i];
if (tokens < MAX_TOKENS) {
tokens += countTextTokens(message.content);
formatedMessageList.unshift({
role: message.creatorRole,
content: message.content,
});
}
}
formatedMessageList.unshift({
role: CreatorRole.System,
content: prompt,
});
const rawRes = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({
messages: formatedMessageList,
}),
});
setIsRequesting(false);
if (!rawRes.ok) {
console.error(rawRes);
let errorMessage = "Failed to request message, please check your network.";
try {
const res = await rawRes.json();
errorMessage = res.error.message;
} catch (error) {
// do nth
}
toast.error(errorMessage);
return;
}
const data = rawRes.body;
if (!data) {
toast.error("No data return");
return;
}
const message: Message = {
id: generateUUID(),
chatId: currentChat.id,
creatorId: currentChat.assistantId,
creatorRole: CreatorRole.Assistant,
createdAt: Date.now(),
content: "",
isGenerated: false,
};
messageStore.addMessage(message);
const reader = data.getReader();
const decoder = new TextDecoder("utf-8");
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (value) {
const char = decoder.decode(value);
if (char) {
message.content = message.content + char;
messageStore.updateMessage(message.id, {
content: message.content,
});
}
}
done = readerDone;
}
messageStore.updateMessage(message.id, {
isGenerated: true,
});
};
return (
<main
ref={chatViewRef}
className="drawer-content relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white"
>
<div className="sticky top-0 z-1 bg-white w-full flex flex-col justify-start items-start">
<DataStorageBanner />
<Header className={showHeaderShadow ? "shadow" : ""} />
</div>
<div className="p-2 w-full h-auto grow max-w-4xl py-1 px-4 sm:px-8 mx-auto">
{messageList.length === 0 ? (
<EmptyView className="mt-16" sendMessage={sendMessageToCurrentChat} />
) : (
messageList.map((message) => <MessageView key={message.id} message={message} />)
)}
{isRequesting && <MessageLoader />}
</div>
<div className="sticky bottom-0 w-full max-w-4xl py-2 px-4 sm:px-8 mx-auto bg-white bg-opacity-80 backdrop-blur">
<MessageTextarea disabled={isRequesting} sendMessage={sendMessageToCurrentChat} />
</div>
</main>
);
};
export default ChatView;

View File

@ -0,0 +1,20 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import ClearDataConfirmModal from "./ClearDataConfirmModal";
const ClearDataButton = () => {
const [showClearDataConfirmModal, setShowClearDataConfirmModal] = useState(false);
return (
<>
<button className="btn btn-sm btn-error" onClick={() => setShowClearDataConfirmModal(true)}>
Clear
</button>
{showClearDataConfirmModal &&
createPortal(<ClearDataConfirmModal close={() => setShowClearDataConfirmModal(false)} />, document.body)}
</>
);
};
export default ClearDataButton;

View File

@ -0,0 +1,43 @@
import { toast } from "react-hot-toast";
import Icon from "./Icon";
interface Props {
close: () => void;
}
const ClearDataConfirmModal = (props: Props) => {
const { close } = props;
const handleClearData = () => {
window.localStorage.clear();
close();
toast.success("Message cleared. The page will be reloaded.");
setTimeout(() => {
window.location.reload();
}, 500);
};
return (
<div className="modal modal-middle modal-open">
<div className="modal-box relative">
<h3 className="font-bold text-lg">Clear all data</h3>
<button className="btn btn-sm btn-circle absolute right-4 top-4" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4">
<p className="text-gray-500">SQL Chat saves all your data in your local browser. Are you sure to clear all of them?</p>
</div>
<div className="modal-action">
<button className="btn btn-outline" onClick={close}>
Close
</button>
<button className="btn btn-error" onClick={handleClearData}>
Clear data
</button>
</div>
</div>
</div>
);
};
export default ClearDataConfirmModal;

View File

@ -0,0 +1,74 @@
import { toast } from "react-hot-toast";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { useConnectionStore, useQueryStore } from "@/store";
import { checkStatementIsSelect } from "@/utils";
import Icon from "./Icon";
interface Props {
language: string;
value: string;
}
export const CodeBlock = (props: Props) => {
const { language, value } = props;
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const currentConnectionCtx = connectionStore.currentConnectionCtx;
// Only show execute button in the following situations:
// * SQL code, and it is a SELECT statement;
// * Connection setup;
const showExecuteButton =
language.toUpperCase() === "SQL" && checkStatementIsSelect(value) && currentConnectionCtx?.connection && currentConnectionCtx?.database;
const copyToClipboard = () => {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
toast.error("Failed to copy to clipboard");
return;
}
navigator.clipboard.writeText(value).then(() => {
toast.success("Copied to clipboard");
});
};
const handleExecuteQuery = () => {
if (!currentConnectionCtx) {
toast.error("Please select a connection first");
return;
}
queryStore.setContext({
connection: currentConnectionCtx.connection,
database: currentConnectionCtx.database,
statement: value,
});
queryStore.toggleDrawer(true);
};
return (
<div className="w-full max-w-full relative font-sans text-[16px]">
<div className="flex items-center justify-between py-2 px-4">
<span className="text-xs text-white font-mono">{language}</span>
<div className="flex items-center space-x-2">
<button
className="flex justify-center items-center rounded bg-none w-6 h-6 p-1 text-xs text-white bg-gray-500 opacity-70 hover:opacity-100"
onClick={copyToClipboard}
>
<Icon.BiClipboard className="w-full h-auto" />
</button>
{showExecuteButton && (
<button
className="flex justify-center items-center rounded bg-none w-6 h-6 p-1 text-xs text-white bg-gray-500 opacity-70 hover:opacity-100"
onClick={handleExecuteQuery}
>
<Icon.IoPlay className="w-full h-auto" />
</button>
)}
</div>
</div>
<SyntaxHighlighter language={language.toLowerCase()} style={oneDark} customStyle={{ margin: 0 }}>
{value}
</SyntaxHighlighter>
</div>
);
};

View File

@ -0,0 +1,274 @@
import { head } from "lodash-es";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useChatStore, useConnectionStore, useLayoutStore } from "@/store";
import { Chat, Connection } from "@/types";
import Icon from "./Icon";
import EngineIcon from "./EngineIcon";
import CreateConnectionModal from "./CreateConnectionModal";
import SettingModal from "./SettingModal";
import EditChatTitleModal from "./EditChatTitleModal";
interface State {
showCreateConnectionModal: boolean;
showSettingModal: boolean;
showEditChatTitleModal: boolean;
}
const ConnectionSidebar = () => {
const layoutStore = useLayoutStore();
const connectionStore = useConnectionStore();
const chatStore = useChatStore();
const [state, setState] = useState<State>({
showCreateConnectionModal: false,
showSettingModal: false,
showEditChatTitleModal: false,
});
const [editConnectionModalContext, setEditConnectionModalContext] = useState<Connection>();
const [editChatTitleModalContext, setEditChatTitleModalContext] = useState<Chat>();
const [isRequestingDatabase, setIsRequestingDatabase] = useState<boolean>(false);
const connectionList = connectionStore.connectionList;
const currentConnectionCtx = connectionStore.currentConnectionCtx;
const databaseList = connectionStore.databaseList.filter((database) => database.connectionId === currentConnectionCtx?.connection.id);
const chatList = chatStore.chatList.filter(
(chat) => chat.connectionId === currentConnectionCtx?.connection.id && chat.databaseName === currentConnectionCtx?.database?.name
);
useEffect(() => {
if (currentConnectionCtx?.connection) {
setIsRequestingDatabase(true);
connectionStore.getOrFetchDatabaseList(currentConnectionCtx.connection).finally(() => {
setIsRequestingDatabase(false);
});
} else {
setIsRequestingDatabase(false);
}
}, [currentConnectionCtx?.connection]);
const toggleCreateConnectionModal = (show = true) => {
setState({
...state,
showCreateConnectionModal: show,
});
setEditConnectionModalContext(undefined);
};
const toggleSettingModal = (show = true) => {
setState({
...state,
showSettingModal: show,
});
};
const toggleEditChatTitleModal = (show = true) => {
setState({
...state,
showEditChatTitleModal: show,
});
};
const handleConnectionSelect = async (connection: Connection) => {
const databaseList = await connectionStore.getOrFetchDatabaseList(connection);
connectionStore.setCurrentConnectionCtx({
connection,
database: head(databaseList),
});
};
const handleEditConnection = (connection: Connection) => {
setState({
...state,
showCreateConnectionModal: true,
});
setEditConnectionModalContext(connection);
};
const handleDatabaseNameSelect = async (databaseName: string) => {
if (!currentConnectionCtx?.connection) {
return;
}
const databaseList = await connectionStore.getOrFetchDatabaseList(currentConnectionCtx.connection);
const database = databaseList.find((database) => database.name === databaseName);
connectionStore.setCurrentConnectionCtx({
connection: currentConnectionCtx.connection,
database: database,
});
};
const handleCreateChat = () => {
if (!currentConnectionCtx) {
chatStore.createChat();
} else {
chatStore.createChat(currentConnectionCtx.connection.id, currentConnectionCtx.database?.name);
}
};
const handleChatSelect = (chat: Chat) => {
chatStore.setCurrentChat(chat);
layoutStore.toggleSidebar(false);
};
const handleEditChatTitle = (chat: Chat) => {
setEditChatTitleModalContext(chat);
setState({
...state,
showEditChatTitleModal: true,
});
};
const handleDeleteChat = (chat: Chat) => {
chatStore.clearChat((item) => item.id !== chat.id);
if (chatStore.currentChat?.id === chat.id) {
chatStore.setCurrentChat(undefined);
}
};
return (
<>
<aside className="drawer-side">
<label htmlFor="connection-drawer" className="drawer-overlay"></label>
<div className="w-80 h-full overflow-y-hidden border-r flex flex-row justify-start items-start">
<div className="w-16 h-full bg-gray-200 pl-2 py-4 pt-6 flex flex-col justify-between items-center">
<div className="w-full flex flex-col justify-start items-start">
<button
className={`w-full h-14 rounded-l-lg p-2 mt-1 group ${currentConnectionCtx === undefined && "bg-gray-100 shadow"}`}
onClick={() => connectionStore.setCurrentConnectionCtx(undefined)}
>
<img src="/chat-logo-bot.webp" className="w-7 h-auto mx-auto" alt="" />
</button>
{connectionList.map((connection) => (
<button
key={connection.id}
className={`relative w-full h-14 rounded-l-lg p-2 mt-2 group ${
currentConnectionCtx?.connection.id === connection.id && "bg-gray-100 shadow"
}`}
onClick={() => handleConnectionSelect(connection)}
>
<span
className="absolute right-0.5 -mt-1.5 opacity-60 hidden group-hover:block hover:opacity-80"
onClick={(e) => {
e.stopPropagation();
handleEditConnection(connection);
}}
>
<Icon.FiEdit3 className="w-3.5 h-auto" />
</span>
<EngineIcon engine={connection.engineType} className="w-auto h-full mx-auto" />
</button>
))}
<button
className="tooltip tooltip-right w-10 h-10 mt-4 ml-2 p-2 bg-gray-100 rounded-full text-gray-500 cursor-pointer"
data-tip="Create Connection"
onClick={() => toggleCreateConnectionModal(true)}
>
<Icon.AiOutlinePlus className="w-auto h-full mx-auto" />
</button>
</div>
<div className="w-full flex flex-col justify-end items-center">
<button
className="tooltip tooltip-right w-10 h-10 p-1 rounded-full flex flex-row justify-center items-center hover:bg-gray-100"
data-tip="Setting"
onClick={() => toggleSettingModal(true)}
>
<Icon.IoMdSettings className="text-gray-600 w-6 h-auto" />
</button>
</div>
</div>
<div className="relative p-4 pb-0 w-64 h-full overflow-y-auto flex flex-col justify-start items-start bg-gray-100">
<img className="px-4 shrink-0" src="/chat-logo.webp" alt="" />
<div className="w-full grow">
{isRequestingDatabase && (
<div className="w-full h-12 flex flex-row justify-start items-center px-4 sticky top-0 border z-1 mb-4 mt-2 rounded-lg text-sm text-gray-600">
<Icon.BiLoaderAlt className="w-4 h-auto animate-spin mr-1" /> Loading
</div>
)}
{databaseList.length > 0 && (
<div className="w-full sticky top-0 z-1 mb-4 mt-2">
<p className="text-sm leading-6 mb-1 text-gray-500">Select your database:</p>
<select
className="w-full select select-bordered"
value={currentConnectionCtx?.database?.name}
onChange={(e) => handleDatabaseNameSelect(e.target.value)}
>
{databaseList.map((database) => (
<option key={database.name} value={database.name}>
{database.name}
</option>
))}
</select>
</div>
)}
{chatList.map((chat) => (
<div
key={chat.id}
className={`w-full mt-2 first:mt-4 py-3 pl-4 pr-2 rounded-lg flex flex-row justify-start items-center cursor-pointer border border-transparent group hover:bg-gray-50 ${
chat.id === chatStore.currentChat?.id && "!bg-white border-gray-200 font-medium"
}`}
onClick={() => handleChatSelect(chat)}
>
{chat.id === chatStore.currentChat?.id ? (
<Icon.IoChatbubble className="w-5 h-auto mr-1.5 shrink-0" />
) : (
<Icon.IoChatbubbleOutline className="w-5 h-auto mr-1.5 opacity-80 shrink-0" />
)}
<span className="truncate grow">{chat.title || "SQL Chat"}</span>
<span className="ml-0.5 shrink-0 hidden group-hover:flex flex-row justify-end items-center space-x-1">
<Icon.FiEdit3
className="w-4 h-auto opacity-60 hover:opacity-80"
onClick={(e) => {
e.stopPropagation();
handleEditChatTitle(chat);
}}
/>
<Icon.IoClose
className="w-5 h-auto opacity-60 hover:opacity-80"
onClick={(e) => {
e.stopPropagation();
handleDeleteChat(chat);
}}
/>
</span>
</div>
))}
<button
className="w-full my-4 py-3 px-4 border rounded-lg flex flex-row justify-center items-center text-gray-500 hover:text-gray-700 hover:bg-gray-50"
onClick={handleCreateChat}
>
<Icon.AiOutlinePlus className="w-5 h-auto mr-1" />
New Chat
</button>
</div>
<div className="sticky bottom-0 w-full flex justify-center bg-gray-100 backdrop-blur bg-opacity-60 pb-6 py-2">
<a
href="https://discord.gg/6R3qb32h"
className="text-indigo-600 text-sm font-medium flex flex-row justify-center items-center hover:underline"
target="_blank"
>
<Icon.BsDiscord className="w-4 h-auto mr-1" />
Join Discord Channel
</a>
</div>
</div>
</div>
</aside>
{createPortal(
<CreateConnectionModal
show={state.showCreateConnectionModal}
connection={editConnectionModalContext}
close={() => toggleCreateConnectionModal(false)}
/>,
document.body
)}
{createPortal(<SettingModal show={state.showSettingModal} close={() => toggleSettingModal(false)} />, document.body)}
{state.showEditChatTitleModal &&
editChatTitleModalContext &&
createPortal(<EditChatTitleModal close={() => toggleEditChatTitleModal(false)} chat={editChatTitleModalContext} />, document.body)}
</>
);
};
export default ConnectionSidebar;

View File

@ -0,0 +1,266 @@
import { cloneDeep, head } from "lodash-es";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "react-hot-toast";
import { useConnectionStore } from "@/store";
import { Connection, Engine, ResponseObject } from "@/types";
import Icon from "./Icon";
import DataStorageBanner from "./DataStorageBanner";
import ActionConfirmModal from "./ActionConfirmModal";
interface Props {
show: boolean;
connection?: Connection;
close: () => void;
}
type SSLType = "none" | "ca-only" | "full";
const SSLTypeOptions = [
{
label: "None",
value: "none",
},
{
label: "CA Only",
value: "ca-only",
},
{
label: "Full",
value: "full",
},
];
const defaultConnection: Connection = {
id: "",
title: "",
engineType: Engine.MySQL,
host: "",
port: "",
username: "",
password: "",
};
const CreateConnectionModal = (props: Props) => {
const { show, connection: editConnection, close } = props;
const connectionStore = useConnectionStore();
const [connection, setConnection] = useState<Connection>(defaultConnection);
const [showDeleteConnectionModal, setShowDeleteConnectionModal] = useState(false);
const [sslType, setSSLType] = useState<SSLType>("none");
const [isRequesting, setIsRequesting] = useState(false);
const showDatabaseField = connection.engineType === Engine.PostgreSQL;
const isEditing = editConnection !== undefined;
const allowSave =
connection.host !== "" && connection.username !== "" && (connection.engineType === Engine.PostgreSQL ? connection.database : true);
useEffect(() => {
if (show) {
setConnection(isEditing ? editConnection : defaultConnection);
setIsRequesting(false);
setShowDeleteConnectionModal(false);
}
}, [show]);
const setPartialConnection = (state: Partial<Connection>) => {
setConnection({
...connection,
...state,
});
};
const handleCreateConnection = async () => {
if (isRequesting) {
return;
}
setIsRequesting(true);
const tempConnection = cloneDeep(connection);
if (!showDatabaseField) {
tempConnection.database = undefined;
}
try {
const response = await fetch("/api/connection/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection: tempConnection,
}),
});
const result = (await response.json()) as ResponseObject<boolean>;
if (result.message) {
toast.error(result.message);
return;
}
} catch (error) {
console.error(error);
toast.error("Failed to test connection");
} finally {
setIsRequesting(false);
}
try {
let connection: Connection;
if (isEditing) {
connectionStore.updateConnection(tempConnection.id, tempConnection);
connection = tempConnection;
} else {
connection = connectionStore.createConnection(tempConnection);
}
// Set the created connection as the current connection.
const databaseList = await connectionStore.getOrFetchDatabaseList(connection, true);
connectionStore.setCurrentConnectionCtx({
connection: connection,
database: head(databaseList),
});
} catch (error) {
console.error(error);
setIsRequesting(false);
toast.error("Failed to create connection");
return;
}
setIsRequesting(false);
close();
};
const handleDeleteConnection = () => {
connectionStore.clearConnection((item) => item.id !== connection.id);
if (connectionStore.currentConnectionCtx?.connection.id === connection.id) {
connectionStore.setCurrentConnectionCtx(undefined);
}
close();
};
return (
<>
<div className={`modal modal-middle ${show && "modal-open"}`}>
<div className="modal-box relative">
<h3 className="font-bold text-lg">{isEditing ? "Edit Connection" : "Create Connection"}</h3>
<button className="btn btn-sm btn-circle absolute right-4 top-4" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4">
<DataStorageBanner className="rounded-lg bg-white border py-2 !justify-start" alwaysShow={true} />
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Database Type</label>
<select
className="select select-bordered w-full"
value={connection.engineType}
onChange={(e) => setPartialConnection({ engineType: e.target.value as Engine })}
>
<option value={Engine.MySQL}>MySQL</option>
<option value={Engine.PostgreSQL}>PostgreSQL</option>
</select>
</div>
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Host</label>
<input
type="text"
placeholder="Connect host"
className="input input-bordered w-full"
value={connection.host}
onChange={(e) => setPartialConnection({ host: e.target.value })}
/>
</div>
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input
type="text"
placeholder="Connect port"
className="input input-bordered w-full"
value={connection.port}
onChange={(e) => setPartialConnection({ port: e.target.value })}
/>
</div>
{showDatabaseField && (
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Database Name</label>
<input
type="text"
placeholder="Connect database"
className="input input-bordered w-full"
value={connection.database}
onChange={(e) => setPartialConnection({ database: e.target.value })}
/>
</div>
)}
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input
type="text"
placeholder="Connect username"
className="input input-bordered w-full"
value={connection.username}
onChange={(e) => setPartialConnection({ username: e.target.value })}
/>
</div>
<div className="w-full flex flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="text"
placeholder="Connect password"
className="input input-bordered w-full"
value={connection.password}
onChange={(e) => setPartialConnection({ password: e.target.value })}
/>
</div>
{/* TODO: implement SSL textarea */}
<div className="hidden w-full flex-col">
<label className="block text-sm font-medium text-gray-700 mb-1">SSL</label>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{SSLTypeOptions.map((option) => (
<label key={option.value} className="w-auto flex flex-row justify-start items-center cursor-pointer mr-2 mb-2">
<input
type="radio"
className="radio w-4 h-4 mr-1"
value={option.value}
checked={sslType === option.value}
onChange={(e) => setSSLType(e.target.value as SSLType)}
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
</div>
<div className="modal-action w-full flex flex-row justify-between items-center space-x-2">
<div>
{isEditing && (
<button className="btn btn-ghost" onClick={() => setShowDeleteConnectionModal(true)}>
Delete
</button>
)}
</div>
<div className="space-x-2 flex flex-row justify-center">
<button className="btn btn-outline" onClick={close}>
Close
</button>
<button className="btn" disabled={isRequesting || !allowSave} onClick={handleCreateConnection}>
{isRequesting && <Icon.BiLoaderAlt className="w-4 h-auto animate-spin mr-1" />}
Save
</button>
</div>
</div>
</div>
</div>
{showDeleteConnectionModal &&
createPortal(
<ActionConfirmModal
title="Delete Connection"
content="Are you sure you want to delete this connection?"
confirmButtonStyle="btn-error"
close={() => setShowDeleteConnectionModal(false)}
confirm={() => handleDeleteConnection()}
/>,
document.body
)}
</>
);
};
export default CreateConnectionModal;

View File

@ -0,0 +1,33 @@
import { useLocalStorage } from "react-use";
import Icon from "./Icon";
interface Props {
className?: string;
alwaysShow?: boolean;
}
const DataStorageBanner = (props: Props) => {
const { className, alwaysShow } = props;
const [hideBanner, setHideBanner] = useLocalStorage("hide-local-storage-banner", false);
const show = alwaysShow || !hideBanner;
return (
<div
className={`${!show && "!hidden"} ${
className || ""
} relative w-full flex flex-row justify-start sm:justify-center items-center px-4 py-1 bg-gray-100`}
>
<span className="text-sm leading-6 pr-4">
<Icon.IoInformationCircleOutline className="inline-block h-5 w-auto -mt-0.5 mr-0.5 opacity-80" />
Connection settings are stored in your local browser
</span>
{!alwaysShow && (
<button className="absolute right-2 sm:right-4 opacity-60 hover:opacity-100" onClick={() => setHideBanner(true)}>
<Icon.BiX className="w-6 h-auto" />
</button>
)}
</div>
);
};
export default DataStorageBanner;

View File

@ -0,0 +1,60 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useChatStore } from "@/store";
import { Chat } from "@/types";
import Icon from "./Icon";
interface Props {
chat: Chat;
close: () => void;
}
const EditMessageTitleModal = (props: Props) => {
const { close, chat } = props;
const chatStore = useChatStore();
const [title, setTitle] = useState(chat.title);
const allowSave = title !== "";
const handleSaveEdit = () => {
const formatedTitle = title.trim();
if (formatedTitle === "") {
return;
}
chatStore.updateChat(chat.id, {
title: formatedTitle,
});
toast.success("Chat title updated");
close();
};
return (
<div className="modal modal-middle modal-open">
<div className="modal-box relative">
<h3 className="font-bold text-lg">Edit chat title</h3>
<button className="btn btn-sm btn-circle absolute right-4 top-4" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4">
<input
type="text"
placeholder="Chat title"
className="input input-bordered w-full"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="modal-action">
<button className="btn btn-outline" onClick={close}>
Close
</button>
<button className="btn" disabled={!allowSave} onClick={handleSaveEdit}>
Save
</button>
</div>
</div>
</div>
);
};
export default EditMessageTitleModal;

View File

@ -0,0 +1,82 @@
import { useChatStore, useConnectionStore, useMessageStore, useUserStore } from "@/store";
import { CreatorRole } from "@/types";
import { generateUUID } from "@/utils";
import Icon from "./Icon";
// examples are used to show some examples to the user.
const examples = ["Give me an example schema about employee", "How to create a view in MySQL?"];
interface Props {
className?: string;
sendMessage: () => Promise<void>;
}
const EmptyView = (props: Props) => {
const { className, sendMessage } = props;
const connectionStore = useConnectionStore();
const chatStore = useChatStore();
const userStore = useUserStore();
const messageStore = useMessageStore();
const handleExampleClick = async (content: string) => {
let chat = chatStore.currentChat;
if (!chat) {
const currentConnectionCtx = connectionStore.currentConnectionCtx;
if (!currentConnectionCtx) {
chat = chatStore.createChat();
} else {
chat = chatStore.createChat(currentConnectionCtx.connection.id, currentConnectionCtx.database?.name);
}
}
messageStore.addMessage({
id: generateUUID(),
chatId: chat.id,
creatorId: userStore.currentUser.id,
creatorRole: CreatorRole.User,
createdAt: Date.now(),
content: content,
isGenerated: true,
});
await sendMessage();
};
return (
<div className={`${className || ""} w-full h-full flex flex-col justify-start items-center`}>
<div className="w-96 max-w-full font-medium leading-loose mb-8">
<img src="/chat-logo-and-text.webp" alt="sql-chat-logo" />
</div>
<div className="w-full grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="w-full flex flex-col justify-start items-center">
<Icon.BsSun className="w-8 h-auto opacity-80" />
<span className="mt-2 mb-4">Examples</span>
{examples.map((example) => (
<div
key={example}
className="w-full rounded-lg px-4 py-3 text-sm mb-4 cursor-pointer bg-gray-50 hover:bg-gray-100"
onClick={() => handleExampleClick(example)}
>
{`"${example}"`}
</div>
))}
</div>
<div className="w-full flex flex-col justify-start items-center">
<Icon.BsLightning className="w-8 h-auto opacity-80" />
<span className="mt-2 mb-4">Capabilities</span>
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">Remembers what user said earlier in the conversation</div>
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">Allows user to provide follow-up corrections</div>
</div>
<div className="w-full hidden sm:flex flex-col justify-start items-center">
<Icon.BsEmojiNeutral className="w-8 h-auto opacity-80" />
<span className="mt-2 mb-4">Limitations</span>
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">May occasionally generate incorrect information</div>
<div className="w-full bg-gray-50 rounded-lg px-4 py-3 text-sm mb-4">
May occasionally produce harmful instructions or biased content
</div>
</div>
</div>
</div>
);
};
export default EmptyView;

View File

@ -0,0 +1,21 @@
import { Engine } from "@/types";
import Icon from "./Icon";
interface Props {
className: string;
engine: Engine;
}
const EngineIcon = (props: Props) => {
const { className, engine } = props;
if (engine === Engine.MySQL) {
return <Icon.DiMysql className={className} />;
} else if (engine === Engine.PostgreSQL) {
return <Icon.DiPostgresql className={className} />;
} else {
return <Icon.DiDatabase className={className} />;
}
};
export default EngineIcon;

View File

@ -0,0 +1,56 @@
import axios from "axios";
import { useEffect, useState } from "react";
import Icon from "./Icon";
interface Props {
className?: string;
}
const GitHubStarBadge = (props: Props) => {
const { className } = props;
const [stars, setStars] = useState(0);
const [isRequesting, setIsRequesting] = useState(true);
useEffect(() => {
const getRepoStarCount = async () => {
let starCount = 0;
try {
const { data } = await axios.get(`https://api.github.com/repos/sqlchat/sqlchat`, {
headers: {
Accept: "application/vnd.github.v3.star+json",
Authorization: "",
},
});
starCount = data.stargazers_count as number;
} catch (error) {
// do nth
}
setStars(starCount);
setIsRequesting(false);
};
getRepoStarCount();
}, []);
return (
<a
className={`${
className || ""
} border rounded flex flex-row justify-start items-center text-black text-xs bg-white shadow-inner overflow-clip hover:opacity-80`}
href="https://github.com/sqlchat/sqlchat"
target="_blank"
aria-label="Star SQL Chat on GitHub"
>
<span className="pr-1 pl-1.5 py-0.5 h-full flex flex-row justify-center items-center bg-gray-100 border-r font-medium">
<Icon.IoLogoGithub className="w-4 h-auto mr-0.5" />
<span className="mt-px">Star</span>
</span>
<div className="h-full block px-2 mt-px font-medium">
{isRequesting ? <Icon.BiLoaderAlt className="w-3 h-auto animate-spin opacity-70" /> : stars}
</div>
</a>
);
};
export default GitHubStarBadge;

21
src/components/Icon.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as Ai from "react-icons/ai";
import * as Bi from "react-icons/bi";
import * as Bs from "react-icons/bs";
import * as Di from "react-icons/di";
import * as Fi from "react-icons/fi";
import * as Io from "react-icons/io";
import * as Io5 from "react-icons/io5";
const Icon = {
...Ai,
...Bi,
...Bs,
...Di,
...Fi,
...Io,
...Io5,
};
// Icon is a collection of all icons from react-icons.
// See https://react-icons.github.io/react-icons/ for more details.
export default Icon;

View File

@ -0,0 +1,25 @@
import { ThreeDots } from "react-loader-spinner";
import { useConnectionStore } from "@/store";
import EngineIcon from "./EngineIcon";
const MessageLoader = () => {
const connectionStore = useConnectionStore();
const connection = connectionStore.currentConnectionCtx?.connection;
return (
<div className={`w-full max-w-full flex flex-row justify-start items-start my-4 pr-8 sm:pr-24`}>
<div className="flex justify-center items-center mr-2 shrink-0">
{connection ? (
<EngineIcon className="w-10 h-auto p-1 border rounded-full" engine={connection.engineType} />
) : (
<img className="w-10 h-auto p-1" src="/chat-logo-bot.webp" alt="" />
)}
</div>
<div className="mt-0.5 w-12 bg-gray-100 px-4 py-2 rounded-lg">
<ThreeDots wrapperClass="opacity-80" width="24" height="24" color="" />
</div>
</div>
);
};
export default MessageLoader;

View File

@ -0,0 +1,143 @@
import { Drawer } from "@mui/material";
import { head } from "lodash-es";
import { useEffect, useState } from "react";
import DataTable from "react-data-table-component";
import { toast } from "react-hot-toast";
import TextareaAutosize from "react-textarea-autosize";
import { useQueryStore } from "@/store";
import { ResponseObject } from "@/types";
import Icon from "./Icon";
import EngineIcon from "./EngineIcon";
type RawQueryResult = {
[key: string]: any;
};
const QueryDrawer = () => {
const queryStore = useQueryStore();
const [rawResults, setRawResults] = useState<RawQueryResult[]>([]);
const context = queryStore.context;
const [statement, setStatement] = useState<string>(context?.statement || "");
const [isLoading, setIsLoading] = useState(true);
const columns = Object.keys(head(rawResults) || {}).map((key) => {
return {
name: key,
sortable: true,
selector: (row: RawQueryResult) => row[key],
};
});
useEffect(() => {
if (!queryStore.showDrawer) {
return;
}
setStatement(context?.statement || "");
executeStatement(context?.statement || "");
}, [context, queryStore.showDrawer]);
const executeStatement = async (statement: string) => {
if (!statement) {
toast.error("Please enter a statement.");
return;
}
if (!context) {
toast.error("No execution context found.");
setIsLoading(false);
setRawResults([]);
return;
}
setIsLoading(true);
setRawResults([]);
const { connection, database } = context;
try {
const response = await fetch("/api/connection/execute", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection,
db: database?.name,
statement,
}),
});
const result = (await response.json()) as ResponseObject<RawQueryResult[]>;
if (result.message) {
toast.error(result.message);
} else {
setRawResults(result.data);
}
} catch (error) {
console.error(error);
toast.error("Failed to execute statement");
} finally {
setIsLoading(false);
}
};
const close = () => queryStore.toggleDrawer(false);
return (
<Drawer open={queryStore.showDrawer} anchor="right" className="w-full" onClose={close}>
<div className="w-screen sm:w-[calc(60vw)] lg:w-[calc(50vw)] 2xl:w-[calc(40vw)] max-w-full flex flex-col justify-start items-start p-4">
<button className="btn btn-sm btn-circle" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<h3 className="font-bold text-2xl mt-4">Execute query</h3>
{!context ? (
<div className="w-full flex flex-col justify-center items-center py-6 pt-10">
<Icon.BiSad className="w-7 h-auto opacity-70" />
<span className="text-sm font-mono text-gray-500 mt-2">No connection selected.</span>
</div>
) : (
<>
<div className="w-full flex flex-row justify-start items-center mt-4">
<span className="opacity-70">Connection: </span>
<EngineIcon className="w-6 h-auto" engine={context.connection.engineType} />
<span>{context.database?.name}</span>
</div>
<div className="w-full h-auto mt-4 px-2 flex flex-row justify-between items-end border rounded-lg overflow-clip">
<TextareaAutosize
className="w-full h-full outline-none border-none bg-transparent leading-6 pl-2 py-2 resize-none hide-scrollbar text-sm font-mono break-all"
value={statement}
rows={1}
minRows={1}
maxRows={5}
placeholder="Enter your SQL statement here..."
onChange={(e) => setStatement(e.target.value)}
/>
<button
className="w-8 p-1 -translate-y-1 cursor-pointer rounded-md hover:shadow hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => executeStatement(statement)}
>
<Icon.IoPlay className="w-full h-auto text-indigo-600" />
</button>
</div>
<div className="w-full flex flex-col justify-start items-start mt-4">
{isLoading ? (
<div className="w-full flex flex-col justify-center items-center py-6 pt-10">
<Icon.BiLoaderAlt className="w-7 h-auto opacity-70 animate-spin" />
<span className="text-sm font-mono text-gray-500 mt-2">Executing</span>
</div>
) : rawResults.length === 0 ? (
<div className="w-full flex flex-col justify-center items-center py-6 pt-10">
<Icon.BsBox2 className="w-7 h-auto opacity-70" />
<span className="text-sm font-mono text-gray-500 mt-2">No data return.</span>
</div>
) : (
<div className="w-full">
<DataTable className="w-full border !rounded-lg" columns={columns} data={rawResults} fixedHeader pagination responsive />
</div>
)}
</div>
</>
)}
</div>
</Drawer>
);
};
export default QueryDrawer;

View File

@ -0,0 +1,46 @@
import Icon from "./Icon";
import WeChatQRCodeView from "./WeChatQRCodeView";
import ClearDataButton from "./ClearDataButton";
interface Props {
show: boolean;
close: () => void;
}
const SettingModal = (props: Props) => {
const { show, close } = props;
return (
<div className={`modal modal-middle ${show && "modal-open"}`}>
<div className="modal-box relative">
<h3 className="font-bold text-lg">Setting</h3>
<button className="btn btn-sm btn-circle absolute right-4 top-4" onClick={close}>
<Icon.IoMdClose className="w-5 h-auto" />
</button>
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4">
<div className="w-full flex flex-row justify-start items-start flex-wrap">
<a
href="https://discord.gg/6R3qb32h"
className="w-auto px-4 py-2 rounded-full bg-indigo-600 text-white text-sm font-medium flex flex-row justify-center items-center mr-2 mb-2 hover:underline hover:shadow"
target="_blank"
>
<Icon.BsDiscord className="w-4 h-auto mr-1" />
Join Discord Channel
</a>
<WeChatQRCodeView />
</div>
<h3>Danger Zone</h3>
<div className="w-full border border-red-200 p-4 rounded-lg">
<div className="w-full flex flex-row justify-between items-center gap-2">
<span>Clear all data</span>
<ClearDataButton />
</div>
</div>
</div>
</div>
</div>
);
};
export default SettingModal;

View File

@ -0,0 +1,33 @@
import { Popover } from "@mui/material";
import { useState } from "react";
import Icon from "./Icon";
const WeChatQRCodeView = () => {
const [wechatAnchorEl, setWeChatAnchorEl] = useState<HTMLElement | null>(null);
const openWeChatQrCodePopover = Boolean(wechatAnchorEl);
return (
<>
<div
className="w-auto px-4 py-2 mr-2 mb-2 rounded-full cursor-pointer bg-green-600 text-white text-sm font-medium flex flex-row justify-center items-center hover:shadow"
onClick={(e) => setWeChatAnchorEl(e.currentTarget)}
>
<Icon.BsWechat className="w-4 h-auto mr-1" />
Join WeChat Group
</div>
<Popover
open={openWeChatQrCodePopover}
anchorEl={wechatAnchorEl}
onClose={() => setWeChatAnchorEl(null)}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<img className="w-64 h-auto" src="/wechat-qrcode.webp" alt="wechat qrcode" />
</Popover>
</>
);
};
export default WeChatQRCodeView;