forked from XiaoMo/ChatGPT-Next-Web
feat: use toast instead of alert
This commit is contained in:
parent
2badfbd619
commit
4af8c26d02
@ -10,7 +10,6 @@
|
||||
min-width: 600px;
|
||||
min-height: 480px;
|
||||
max-width: 900px;
|
||||
max-height: 720px;
|
||||
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
@ -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)} />
|
||||
)}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 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>
|
||||
</div>
|
||||
</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>
|
||||
</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} />);
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ const cn = {
|
||||
},
|
||||
Home: {
|
||||
NewChat: '新的聊天',
|
||||
DeleteChat: '确认删除选中的对话?',
|
||||
},
|
||||
Settings: {
|
||||
Title: '设置',
|
||||
|
@ -27,6 +27,7 @@ const en: LocaleType = {
|
||||
},
|
||||
Home: {
|
||||
NewChat: 'New Chat',
|
||||
DeleteChat: 'Confirm to delete the selected conversation?',
|
||||
},
|
||||
Settings: {
|
||||
Title: 'Settings',
|
||||
|
41
app/utils.ts
41
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);
|
||||
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();
|
||||
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user