mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-09-29 02:54:58 +08:00
refactor: move codes into src folder
This commit is contained in:
43
src/components/ActionConfirmModal.tsx
Normal file
43
src/components/ActionConfirmModal.tsx
Normal 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;
|
47
src/components/ChatView/Header.tsx
Normal file
47
src/components/ChatView/Header.tsx
Normal 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;
|
98
src/components/ChatView/MessageTextarea.tsx
Normal file
98
src/components/ChatView/MessageTextarea.tsx
Normal 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;
|
132
src/components/ChatView/MessageView.tsx
Normal file
132
src/components/ChatView/MessageView.tsx
Normal 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;
|
209
src/components/ChatView/index.tsx
Normal file
209
src/components/ChatView/index.tsx
Normal 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;
|
20
src/components/ClearDataButton.tsx
Normal file
20
src/components/ClearDataButton.tsx
Normal 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;
|
43
src/components/ClearDataConfirmModal.tsx
Normal file
43
src/components/ClearDataConfirmModal.tsx
Normal 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;
|
74
src/components/CodeBlock.tsx
Normal file
74
src/components/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
};
|
274
src/components/ConnectionSidebar.tsx
Normal file
274
src/components/ConnectionSidebar.tsx
Normal 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;
|
266
src/components/CreateConnectionModal.tsx
Normal file
266
src/components/CreateConnectionModal.tsx
Normal 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;
|
33
src/components/DataStorageBanner.tsx
Normal file
33
src/components/DataStorageBanner.tsx
Normal 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;
|
60
src/components/EditChatTitleModal.tsx
Normal file
60
src/components/EditChatTitleModal.tsx
Normal 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;
|
82
src/components/EmptyView.tsx
Normal file
82
src/components/EmptyView.tsx
Normal 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;
|
21
src/components/EngineIcon.tsx
Normal file
21
src/components/EngineIcon.tsx
Normal 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;
|
56
src/components/GitHubStarBadge.tsx
Normal file
56
src/components/GitHubStarBadge.tsx
Normal 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
21
src/components/Icon.tsx
Normal 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;
|
25
src/components/MessageLoader.tsx
Normal file
25
src/components/MessageLoader.tsx
Normal 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;
|
143
src/components/QueryDrawer.tsx
Normal file
143
src/components/QueryDrawer.tsx
Normal 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;
|
46
src/components/SettingModal.tsx
Normal file
46
src/components/SettingModal.tsx
Normal 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;
|
33
src/components/WeChatQRCodeView.tsx
Normal file
33
src/components/WeChatQRCodeView.tsx
Normal 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;
|
Reference in New Issue
Block a user