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-width: 600px;
min-height: 480px; min-height: 480px;
max-width: 900px; max-width: 900px;
max-height: 720px;
display: flex; display: flex;
overflow: hidden; overflow: hidden;

View File

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

View File

@ -63,6 +63,7 @@
background-color: var(--white); background-color: var(--white);
border-radius: 12px; border-radius: 12px;
width: 50vw; width: 50vw;
animation: slide-in ease 0.3s;
--modal-padding: 20px; --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) { @media only screen and (max-width: 600px) {
.modal-container { .modal-container {
width: 90vw; width: 90vw;

View File

@ -1,7 +1,7 @@
import styles from "./ui-lib.module.scss"; import styles from "./ui-lib.module.scss";
import LoadingIcon from "../icons/three-dots.svg"; import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg"; import CloseIcon from "../icons/close.svg";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
export function Popover(props: { export function Popover(props: {
children: JSX.Element; children: JSX.Element;
@ -41,50 +41,102 @@ export function List(props: { children: JSX.Element[] }) {
} }
export function Loading() { export function Loading() {
return <div style={{ return (
<div
style={{
height: "100vh", height: "100vh",
width: "100vw", width: "100vw",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center" justifyContent: "center",
}}><LoadingIcon /></div> }}
>
<LoadingIcon />
</div>
);
} }
interface ModalProps { interface ModalProps {
title: string, title: string;
children?: JSX.Element, children?: JSX.Element;
actions?: JSX.Element[], actions?: JSX.Element[];
onClose?: () => void, onClose?: () => void;
} }
export function Modal(props: ModalProps) { export function Modal(props: ModalProps) {
return <div className={styles['modal-container']}> return (
<div className={styles['modal-header']}> <div className={styles["modal-container"]}>
<div className={styles['modal-title']}>{props.title}</div> <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 /> <CloseIcon />
</div> </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-footer"]}>
<div className={styles['modal-actions']}> <div className={styles["modal-actions"]}>
{props.actions?.map((action, i) => <div key={i} className={styles['modal-action']}>{action}</div>)} {props.actions?.map((action, i) => (
<div key={i} className={styles["modal-action"]}>
{action}
</div>
))}
</div> </div>
</div> </div>
</div> </div>
);
} }
export function showModal(props: ModalProps) { export function showModal(props: ModalProps) {
const div = document.createElement('div') const div = document.createElement("div");
div.className = "modal-mask"; div.className = "modal-mask";
document.body.appendChild(div) document.body.appendChild(div);
const root = createRoot(div) const root = createRoot(div);
root.render(<Modal {...props} onClose={() => { const closeModal = () => {
props.onClose?.(); props.onClose?.();
root.unmount(); root.unmount();
div.remove(); 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: { Home: {
NewChat: '新的聊天', NewChat: '新的聊天',
DeleteChat: '确认删除选中的对话?',
}, },
Settings: { Settings: {
Title: '设置', Title: '设置',

View File

@ -27,6 +27,7 @@ const en: LocaleType = {
}, },
Home: { Home: {
NewChat: 'New Chat', NewChat: 'New Chat',
DeleteChat: 'Confirm to delete the selected conversation?',
}, },
Settings: { Settings: {
Title: '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) { export function trimTopic(topic: string) {
const s = topic.split(""); const s = topic.split("");
@ -13,19 +14,25 @@ export function trimTopic(topic: string) {
} }
export function copyToClipboard(text: string) { export function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(res => { navigator.clipboard
alert(Locale.Copy.Success) .writeText(text)
}).catch(err => { .then((res) => {
alert(Locale.Copy.Failed) showToast(Locale.Copy.Success);
}) })
.catch((err) => {
showToast(Locale.Copy.Failed);
});
} }
export function downloadAs(text: string, filename: string) { export function downloadAs(text: string, filename: string) {
const element = document.createElement('a'); const element = document.createElement("a");
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute(
element.setAttribute('download', filename); "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); document.body.appendChild(element);
element.click(); element.click();
@ -37,3 +44,15 @@ export function isIOS() {
const userAgent = navigator.userAgent.toLowerCase(); const userAgent = navigator.userAgent.toLowerCase();
return /iphone|ipad|ipod/.test(userAgent); 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;
}