feat: use toast instead of alert

This commit is contained in:
Yifei Zhang 2023-03-21 14:56:27 +00:00
parent 2badfbd619
commit 4af8c26d02
7 changed files with 286 additions and 118 deletions

View File

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

View File

@ -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']}>
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}>
@ -186,11 +189,11 @@ export function Chat(props: { showSideBar?: () => void }) {
);
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 ? (
<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,7 +414,8 @@ export function Home() {
return (
<div
className={`${config.tightBorder ? styles["tight-container"] : styles.container
className={`${
config.tightBorder ? styles["tight-container"] : styles.container
}`}
>
<div
@ -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)} />
)}

View File

@ -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;

View File

@ -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={{
return (
<div
style={{
height: "100vh",
width: "100vw",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}><LoadingIcon /></div>
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}>
<div className={styles["modal-close-btn"]} onClick={props.onClose}>
<CloseIcon />
</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>
);
}
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} />);
}

View File

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

View File

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

View File

@ -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);
const element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text)
);
element.setAttribute("download", filename);
element.style.display = 'none';
element.style.display = "none";
document.body.appendChild(element);
element.click();
@ -37,3 +44,15 @@ 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;
}