Browse Source

feat: use toast instead of alert

Yifei Zhang 2 years ago
parent
commit
4af8c26d02

+ 0 - 1
app/components/home.module.scss

@@ -10,7 +10,6 @@
   min-width: 600px;
   min-height: 480px;
   max-width: 900px;
-  max-height: 720px;
 
   display: flex;
   overflow: hidden;

+ 131 - 73
app/components/home.tsx

@@ -22,31 +22,31 @@ import DownloadIcon from "../icons/download.svg";
 
 import { Message, SubmitKey, useChatStore, ChatSession } from "../store";
 import { showModal } from "./ui-lib";
-import { copyToClipboard, downloadAs, isIOS } from "../utils";
-import Locale from '../locales'
+import { copyToClipboard, downloadAs, isIOS, selectOrCopy } from "../utils";
+import Locale from "../locales";
 
 import dynamic from "next/dynamic";
 
-export function Loading(props: {
-  noLogo?: boolean
-}) {
-  return <div className={styles['loading-content']}>
-    {!props.noLogo && <BotIcon />}
-    <LoadingIcon />
-  </div>
+export function Loading(props: { noLogo?: boolean }) {
+  return (
+    <div className={styles["loading-content"]}>
+      {!props.noLogo && <BotIcon />}
+      <LoadingIcon />
+    </div>
+  );
 }
 
-const Markdown = dynamic(async () => (await import('./markdown')).Markdown, {
-  loading: () => <LoadingIcon />
-})
+const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
+  loading: () => <LoadingIcon />,
+});
 
-const Settings = dynamic(async () => (await import('./settings')).Settings, {
-  loading: () => <Loading noLogo />
-})
+const Settings = dynamic(async () => (await import("./settings")).Settings, {
+  loading: () => <Loading noLogo />,
+});
 
 const Emoji = dynamic(async () => (await import("emoji-picker-react")).Emoji, {
-  loading: () => <LoadingIcon />
-})
+  loading: () => <LoadingIcon />,
+});
 
 export function Avatar(props: { role: Message["role"] }) {
   const config = useChatStore((state) => state.config);
@@ -72,13 +72,16 @@ export function ChatItem(props: {
 }) {
   return (
     <div
-      className={`${styles["chat-item"]} ${props.selected && styles["chat-item-selected"]
-        }`}
+      className={`${styles["chat-item"]} ${
+        props.selected && styles["chat-item-selected"]
+      }`}
       onClick={props.onClick}
     >
       <div className={styles["chat-item-title"]}>{props.title}</div>
       <div className={styles["chat-item-info"]}>
-        <div className={styles["chat-item-count"]}>{Locale.ChatItem.ChatItemCount(props.count)}</div>
+        <div className={styles["chat-item-count"]}>
+          {Locale.ChatItem.ChatItemCount(props.count)}
+        </div>
         <div className={styles["chat-item-date"]}>{props.time}</div>
       </div>
       <div className={styles["chat-item-delete"]} onClick={props.onDelete}>
@@ -163,34 +166,34 @@ export function Chat(props: { showSideBar?: () => void }) {
     .concat(
       isLoading
         ? [
-          {
-            role: "assistant",
-            content: "……",
-            date: new Date().toLocaleString(),
-            preview: true,
-          },
-        ]
+            {
+              role: "assistant",
+              content: "……",
+              date: new Date().toLocaleString(),
+              preview: true,
+            },
+          ]
         : []
     )
     .concat(
       userInput.length > 0
         ? [
-          {
-            role: "user",
-            content: userInput,
-            date: new Date().toLocaleString(),
-            preview: true,
-          },
-        ]
+            {
+              role: "user",
+              content: userInput,
+              date: new Date().toLocaleString(),
+              preview: true,
+            },
+          ]
         : []
     );
 
   useEffect(() => {
-    const dom = latestMessageRef.current
+    const dom = latestMessageRef.current;
     if (dom && !isIOS()) {
       dom.scrollIntoView({
         behavior: "smooth",
-        block: "end"
+        block: "end",
       });
     }
   });
@@ -198,8 +201,13 @@ export function Chat(props: { showSideBar?: () => void }) {
   return (
     <div className={styles.chat} key={session.id}>
       <div className={styles["window-header"]}>
-        <div className={styles["window-header-title"]} onClick={props?.showSideBar}>
-          <div className={styles["window-header-main-title"]}>{session.topic}</div>
+        <div
+          className={styles["window-header-title"]}
+          onClick={props?.showSideBar}
+        >
+          <div className={styles["window-header-main-title"]}>
+            {session.topic}
+          </div>
           <div className={styles["window-header-sub-title"]}>
             {Locale.Chat.SubTitle(session.messages.length)}
           </div>
@@ -219,7 +227,7 @@ export function Chat(props: { showSideBar?: () => void }) {
               bordered
               title={Locale.Chat.Actions.CompressedHistory}
               onClick={() => {
-                showMemoryPrompt(session)
+                showMemoryPrompt(session);
               }}
             />
           </div>
@@ -229,7 +237,7 @@ export function Chat(props: { showSideBar?: () => void }) {
               bordered
               title={Locale.Chat.Actions.Export}
               onClick={() => {
-                exportMessages(session.messages, session.topic)
+                exportMessages(session.messages, session.topic);
               }}
             />
           </div>
@@ -252,14 +260,23 @@ export function Chat(props: { showSideBar?: () => void }) {
                   <Avatar role={message.role} />
                 </div>
                 {(message.preview || message.streaming) && (
-                  <div className={styles["chat-message-status"]}>{Locale.Chat.Typing}</div>
+                  <div className={styles["chat-message-status"]}>
+                    {Locale.Chat.Typing}
+                  </div>
                 )}
                 <div className={styles["chat-message-item"]}>
                   {(message.preview || message.content.length === 0) &&
-                    !isUser ? (
+                  !isUser ? (
                     <LoadingIcon />
                   ) : (
-                    <div className="markdown-body">
+                    <div
+                      className="markdown-body"
+                      onContextMenu={(e) => {
+                        if (selectOrCopy(e.currentTarget, message.content)) {
+                          e.preventDefault()
+                        }
+                      }}
+                    >
                       <Markdown content={message.content} />
                     </div>
                   )}
@@ -317,33 +334,71 @@ function useSwitchTheme() {
 }
 
 function exportMessages(messages: Message[], topic: string) {
-  const mdText = `# ${topic}\n\n` + messages.map(m => {
-    return m.role === 'user' ? `## ${m.content}` : m.content.trim()
-  }).join('\n\n')
-  const filename = `${topic}.md`
+  const mdText =
+    `# ${topic}\n\n` +
+    messages
+      .map((m) => {
+        return m.role === "user" ? `## ${m.content}` : m.content.trim();
+      })
+      .join("\n\n");
+  const filename = `${topic}.md`;
 
   showModal({
-    title: Locale.Export.Title, children: <div className="markdown-body">
-      <pre className={styles['export-content']}>{mdText}</pre>
-    </div>, actions: [
-      <IconButton key="copy" icon={<CopyIcon />} bordered text={Locale.Export.Copy} onClick={() => copyToClipboard(mdText)} />,
-      <IconButton key="download" icon={<DownloadIcon />} bordered text={Locale.Export.Download} onClick={() => downloadAs(mdText, filename)} />
-    ]
-  })
+    title: Locale.Export.Title,
+    children: (
+      <div className="markdown-body">
+        <pre className={styles["export-content"]}>{mdText}</pre>
+      </div>
+    ),
+    actions: [
+      <IconButton
+        key="copy"
+        icon={<CopyIcon />}
+        bordered
+        text={Locale.Export.Copy}
+        onClick={() => copyToClipboard(mdText)}
+      />,
+      <IconButton
+        key="download"
+        icon={<DownloadIcon />}
+        bordered
+        text={Locale.Export.Download}
+        onClick={() => downloadAs(mdText, filename)}
+      />,
+    ],
+  });
 }
 
 function showMemoryPrompt(session: ChatSession) {
   showModal({
-    title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`, children: <div className="markdown-body">
-      <pre className={styles['export-content']}>{session.memoryPrompt || Locale.Memory.EmptyContent}</pre>
-    </div>, actions: [
-      <IconButton key="copy" icon={<CopyIcon />} bordered text={Locale.Memory.Copy} onClick={() => copyToClipboard(session.memoryPrompt)} />,
-    ]
-  })
+    title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
+    children: (
+      <div className="markdown-body">
+        <pre className={styles["export-content"]}>
+          {session.memoryPrompt || Locale.Memory.EmptyContent}
+        </pre>
+      </div>
+    ),
+    actions: [
+      <IconButton
+        key="copy"
+        icon={<CopyIcon />}
+        bordered
+        text={Locale.Memory.Copy}
+        onClick={() => copyToClipboard(session.memoryPrompt)}
+      />,
+    ],
+  });
 }
 
 export function Home() {
-  const [createNewSession, currentIndex, removeSession] = useChatStore((state) => [state.newSession, state.currentSessionIndex, state.removeSession]);
+  const [createNewSession, currentIndex, removeSession] = useChatStore(
+    (state) => [
+      state.newSession,
+      state.currentSessionIndex,
+      state.removeSession,
+    ]
+  );
   const loading = !useChatStore?.persist?.hasHydrated();
   const [showSideBar, setShowSideBar] = useState(true);
 
@@ -359,8 +414,9 @@ export function Home() {
 
   return (
     <div
-      className={`${config.tightBorder ? styles["tight-container"] : styles.container
-        }`}
+      className={`${
+        config.tightBorder ? styles["tight-container"] : styles.container
+      }`}
     >
       <div
         className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
@@ -378,8 +434,8 @@ export function Home() {
         <div
           className={styles["sidebar-body"]}
           onClick={() => {
-            setOpenSettings(false)
-            setShowSideBar(false)
+            setOpenSettings(false);
+            setShowSideBar(false);
           }}
         >
           <ChatList />
@@ -391,8 +447,8 @@ export function Home() {
               <IconButton
                 icon={<CloseIcon />}
                 onClick={() => {
-                  if (confirm('删除选中的对话?')) {
-                    removeSession(currentIndex)
+                  if (confirm(Locale.Home.DeleteChat)) {
+                    removeSession(currentIndex);
                   }
                 }}
               />
@@ -401,8 +457,8 @@ export function Home() {
               <IconButton
                 icon={<SettingsIcon />}
                 onClick={() => {
-                  setOpenSettings(true)
-                  setShowSideBar(false)
+                  setOpenSettings(true);
+                  setShowSideBar(false);
                 }}
               />
             </div>
@@ -424,10 +480,12 @@ export function Home() {
 
       <div className={styles["window-content"]}>
         {openSettings ? (
-          <Settings closeSettings={() => {
-            setOpenSettings(false)
-            setShowSideBar(true)
-          }} />
+          <Settings
+            closeSettings={() => {
+              setOpenSettings(false);
+              setShowSideBar(true);
+            }}
+          />
         ) : (
           <Chat key="chat" showSideBar={() => setShowSideBar(true)} />
         )}

+ 39 - 1
app/components/ui-lib.module.scss

@@ -63,6 +63,7 @@
   background-color: var(--white);
   border-radius: 12px;
   width: 50vw;
+  animation: slide-in ease 0.3s;
 
   --modal-padding: 20px;
 
@@ -111,6 +112,43 @@
   }
 }
 
+.show {
+  opacity: 1;
+  transition: all ease 0.3s;
+  transform: translateY(0);
+  position: fixed;
+  left: 0;
+  bottom: 0;
+  animation: slide-in ease 0.6s;
+  z-index: 99999;
+}
+
+.hide {
+  opacity: 0;
+  transition: all ease 0.3s;
+  transform: translateY(20px);
+}
+
+.toast-container {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  width: 100vw;
+  display: flex;
+  justify-content: center;
+
+  .toast-content {
+    font-size: 14px;
+    background-color: var(--white);
+    box-shadow: var(--card-shadow);
+    border: var(--border-in-light);
+    color: var(--black);
+    padding: 10px 30px;
+    border-radius: 50px;
+    margin-bottom: 20px;
+  }
+}
+
 @media only screen and (max-width: 600px) {
   .modal-container {
     width: 90vw;
@@ -119,4 +157,4 @@
       max-height: 50vh;
     }
   }
-}
+}

+ 81 - 29
app/components/ui-lib.tsx

@@ -1,7 +1,7 @@
 import styles from "./ui-lib.module.scss";
 import LoadingIcon from "../icons/three-dots.svg";
 import CloseIcon from "../icons/close.svg";
-import { createRoot } from 'react-dom/client'
+import { createRoot } from "react-dom/client";
 
 export function Popover(props: {
   children: JSX.Element;
@@ -41,50 +41,102 @@ export function List(props: { children: JSX.Element[] }) {
 }
 
 export function Loading() {
-  return <div style={{
-    height: "100vh",
-    width: "100vw",
-    display: "flex",
-    alignItems: "center",
-    justifyContent: "center"
-  }}><LoadingIcon /></div>
+  return (
+    <div
+      style={{
+        height: "100vh",
+        width: "100vw",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+      }}
+    >
+      <LoadingIcon />
+    </div>
+  );
 }
 
 interface ModalProps {
-  title: string,
-  children?: JSX.Element,
-  actions?: JSX.Element[],
-  onClose?: () => void,
+  title: string;
+  children?: JSX.Element;
+  actions?: JSX.Element[];
+  onClose?: () => void;
 }
 export function Modal(props: ModalProps) {
-  return <div className={styles['modal-container']}>
-    <div className={styles['modal-header']}>
-      <div className={styles['modal-title']}>{props.title}</div>
+  return (
+    <div className={styles["modal-container"]}>
+      <div className={styles["modal-header"]}>
+        <div className={styles["modal-title"]}>{props.title}</div>
 
-      <div className={styles['modal-close-btn']} onClick={props.onClose}>
-        <CloseIcon />
+        <div className={styles["modal-close-btn"]} onClick={props.onClose}>
+          <CloseIcon />
+        </div>
       </div>
-    </div>
 
-    <div className={styles['modal-content']}>{props.children}</div>
+      <div className={styles["modal-content"]}>{props.children}</div>
 
-    <div className={styles['modal-footer']}>
-      <div className={styles['modal-actions']}>
-        {props.actions?.map((action, i) => <div key={i} className={styles['modal-action']}>{action}</div>)}
+      <div className={styles["modal-footer"]}>
+        <div className={styles["modal-actions"]}>
+          {props.actions?.map((action, i) => (
+            <div key={i} className={styles["modal-action"]}>
+              {action}
+            </div>
+          ))}
+        </div>
       </div>
     </div>
-  </div>
+  );
 }
 
 export function showModal(props: ModalProps) {
-  const div = document.createElement('div')
+  const div = document.createElement("div");
   div.className = "modal-mask";
-  document.body.appendChild(div)
+  document.body.appendChild(div);
 
-  const root = createRoot(div)
-  root.render(<Modal {...props} onClose={() => {
+  const root = createRoot(div);
+  const closeModal = () => {
     props.onClose?.();
     root.unmount();
     div.remove();
-  }}></Modal>)
-}
+  };
+
+  div.onclick = (e) => {
+    if (e.target === div) {
+      closeModal();
+    }
+  };
+
+  root.render(<Modal {...props} onClose={closeModal}></Modal>);
+}
+
+export type ToastProps = { content: string };
+
+export function Toast(props: ToastProps) {
+  return (
+    <div className={styles["toast-container"]}>
+      <div className={styles["toast-content"]}>{props.content}</div>
+    </div>
+  );
+}
+
+export function showToast(content: string, delay = 3000) {
+  const div = document.createElement("div");
+  div.className = styles.show;
+  document.body.appendChild(div);
+
+  const root = createRoot(div);
+  const close = () => {
+    div.classList.add(styles.hide);
+
+    setTimeout(() => {
+      root.unmount();
+      div.remove();
+    }, 300);
+  };
+
+  setTimeout(() => {
+    close();
+  }, delay);
+
+  root.render(<Toast content={content} />);
+}

+ 1 - 0
app/locales/cn.ts

@@ -26,6 +26,7 @@ const cn = {
     },
     Home: {
         NewChat: '新的聊天',
+        DeleteChat: '确认删除选中的对话?',
     },
     Settings: {
         Title: '设置',

+ 1 - 0
app/locales/en.ts

@@ -27,6 +27,7 @@ const en: LocaleType = {
     },
     Home: {
         NewChat: 'New Chat',
+        DeleteChat: 'Confirm to delete the selected conversation?',
     },
     Settings: {
         Title: 'Settings',

+ 31 - 12
app/utils.ts

@@ -1,4 +1,5 @@
-import Locale from './locales'
+import { showToast } from "./components/ui-lib";
+import Locale from "./locales";
 
 export function trimTopic(topic: string) {
   const s = topic.split("");
@@ -13,19 +14,25 @@ export function trimTopic(topic: string) {
 }
 
 export function copyToClipboard(text: string) {
-  navigator.clipboard.writeText(text).then(res => {
-    alert(Locale.Copy.Success)
-  }).catch(err => {
-    alert(Locale.Copy.Failed)
-  })
+  navigator.clipboard
+    .writeText(text)
+    .then((res) => {
+      showToast(Locale.Copy.Success);
+    })
+    .catch((err) => {
+      showToast(Locale.Copy.Failed);
+    });
 }
 
 export function downloadAs(text: string, filename: string) {
-  const element = document.createElement('a');
-  element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
-  element.setAttribute('download', filename);
-
-  element.style.display = 'none';
+  const element = document.createElement("a");
+  element.setAttribute(
+    "href",
+    "data:text/plain;charset=utf-8," + encodeURIComponent(text)
+  );
+  element.setAttribute("download", filename);
+
+  element.style.display = "none";
   document.body.appendChild(element);
 
   element.click();
@@ -36,4 +43,16 @@ export function downloadAs(text: string, filename: string) {
 export function isIOS() {
   const userAgent = navigator.userAgent.toLowerCase();
   return /iphone|ipad|ipod/.test(userAgent);
-}
+}
+
+export function selectOrCopy(el: HTMLElement, content: string) {
+  const currentSelection = window.getSelection();
+
+  if (currentSelection?.type === "Range") {
+    return false;
+  }
+
+  copyToClipboard(content);
+
+  return true;
+}