refactor: close #643 use react router

This commit is contained in:
Yidadaa 2023-04-21 01:12:39 +08:00
parent ee0f847827
commit 693dcf12d6
9 changed files with 234 additions and 174 deletions

View File

@ -10,6 +10,8 @@ import {
import { useChatStore } from "../store"; import { useChatStore } from "../store";
import Locale from "../locales"; import Locale from "../locales";
import { Link, useNavigate } from "react-router-dom";
import { Path } from "../constant";
export function ChatItem(props: { export function ChatItem(props: {
onClick?: () => void; onClick?: () => void;
@ -59,6 +61,7 @@ export function ChatList() {
state.moveSession, state.moveSession,
]); ]);
const chatStore = useChatStore(); const chatStore = useChatStore();
const navigate = useNavigate();
const onDragEnd: OnDragEndResponder = (result) => { const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result; const { destination, source } = result;
@ -94,7 +97,10 @@ export function ChatList() {
id={item.id} id={item.id}
index={i} index={i}
selected={i === selectedIndex} selected={i === selectedIndex}
onClick={() => selectSession(i)} onClick={() => {
navigate(Path.Chat);
selectSession(i);
}}
onDelete={() => chatStore.deleteSession(i)} onDelete={() => chatStore.deleteSession(i)}
/> />
))} ))}

View File

@ -54,6 +54,8 @@ import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss"; import chatStyle from "./chat.module.scss";
import { Input, Modal, showModal } from "./ui-lib"; import { Input, Modal, showModal } from "./ui-lib";
import { useNavigate } from "react-router-dom";
import { Path } from "../constant";
const Markdown = dynamic( const Markdown = dynamic(
async () => memo((await import("./markdown")).Markdown), async () => memo((await import("./markdown")).Markdown),
@ -418,10 +420,7 @@ export function ChatActions(props: {
); );
} }
export function Chat(props: { export function Chat() {
showSideBar?: () => void;
sideBarShowing?: boolean;
}) {
type RenderMessage = Message & { preview?: boolean }; type RenderMessage = Message & { preview?: boolean };
const chatStore = useChatStore(); const chatStore = useChatStore();
@ -439,6 +438,7 @@ export function Chat(props: {
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom(); const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false); const [hitBottom, setHitBottom] = useState(false);
const isMobileScreen = useMobileScreen(); const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const onChatBodyScroll = (e: HTMLElement) => { const onChatBodyScroll = (e: HTMLElement) => {
const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20; const isTouchBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 20;
@ -641,7 +641,7 @@ export function Chat(props: {
// Auto focus // Auto focus
useEffect(() => { useEffect(() => {
if (props.sideBarShowing && isMobileScreen) return; if (isMobileScreen) return;
inputRef.current?.focus(); inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@ -666,7 +666,7 @@ export function Chat(props: {
icon={<ReturnIcon />} icon={<ReturnIcon />}
bordered bordered
title={Locale.Chat.Actions.ChatList} title={Locale.Chat.Actions.ChatList}
onClick={props?.showSideBar} onClick={() => navigate(Path.Home)}
/> />
</div> </div>
<div className={styles["window-action-button"]}> <div className={styles["window-action-button"]}>
@ -830,7 +830,7 @@ export function Chat(props: {
setAutoScroll(false); setAutoScroll(false);
setTimeout(() => setPromptHints([]), 500); setTimeout(() => setPromptHints([]), 500);
}} }}
autoFocus={!props?.sideBarShowing} autoFocus
rows={inputRows} rows={inputRows}
/> />
<IconButton <IconButton

View File

@ -2,32 +2,32 @@
require("../polyfill"); require("../polyfill");
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { IconButton } from "./button";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import SettingsIcon from "../icons/settings.svg";
import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import BotIcon from "../icons/bot.svg"; import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import LoadingIcon from "../icons/three-dots.svg"; import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store"; import { useChatStore } from "../store";
import { getCSSVar, useMobileScreen } from "../utils"; import { getCSSVar, useMobileScreen } from "../utils";
import Locale from "../locales";
import { Chat } from "./chat"; import { Chat } from "./chat";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { REPO_URL } from "../constant"; import { Path } from "../constant";
import { ErrorBoundary } from "./error"; import { ErrorBoundary } from "./error";
import {
HashRouter as Router,
Routes,
Route,
useNavigation,
useLocation,
} from "react-router-dom";
export function Loading(props: { noLogo?: boolean }) { export function Loading(props: { noLogo?: boolean }) {
return ( return (
<div className={styles["loading-content"]}> <div className={styles["loading-content"] + " no-dark"}>
{!props.noLogo && <BotIcon />} {!props.noLogo && <BotIcon />}
<LoadingIcon /> <LoadingIcon />
</div> </div>
@ -38,7 +38,7 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />, loading: () => <Loading noLogo />,
}); });
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { const SideBar = dynamic(async () => (await import("./sidebar")).SideBar, {
loading: () => <Loading noLogo />, loading: () => <Loading noLogo />,
}); });
@ -73,50 +73,6 @@ function useSwitchTheme() {
}, [config.theme]); }, [config.theme]);
} }
function useDragSideBar() {
const limit = (x: number) => Math.min(500, Math.max(220, x));
const chatStore = useChatStore();
const startX = useRef(0);
const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
const lastUpdateTime = useRef(Date.now());
const handleMouseMove = useRef((e: MouseEvent) => {
if (Date.now() < lastUpdateTime.current + 100) {
return;
}
lastUpdateTime.current = Date.now();
const d = e.clientX - startX.current;
const nextWidth = limit(startDragWidth.current + d);
chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
window.removeEventListener("mousemove", handleMouseMove.current);
window.removeEventListener("mouseup", handleMouseUp.current);
});
const onDragMouseDown = (e: MouseEvent) => {
startX.current = e.clientX;
window.addEventListener("mousemove", handleMouseMove.current);
window.addEventListener("mouseup", handleMouseUp.current);
};
const isMobileScreen = useMobileScreen();
useEffect(() => {
const sideBarWidth = isMobileScreen
? "100vw"
: `${limit(chatStore.config.sidebarWidth ?? 300)}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [chatStore.config.sidebarWidth, isMobileScreen]);
return {
onDragMouseDown,
};
}
const useHasHydrated = () => { const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false); const [hasHydrated, setHasHydrated] = useState<boolean>(false);
@ -127,130 +83,64 @@ const useHasHydrated = () => {
return hasHydrated; return hasHydrated;
}; };
function _Home() { function WideScreen() {
const [createNewSession, currentIndex, removeSession] = useChatStore(
(state) => [
state.newSession,
state.currentSessionIndex,
state.removeSession,
],
);
const chatStore = useChatStore();
const loading = !useHasHydrated();
const [showSideBar, setShowSideBar] = useState(true);
// setting // setting
const [openSettings, setOpenSettings] = useState(false);
const config = useChatStore((state) => state.config); const config = useChatStore((state) => state.config);
// drag side bar
const { onDragMouseDown } = useDragSideBar();
const isMobileScreen = useMobileScreen();
useSwitchTheme();
if (loading) {
return <Loading />;
}
return ( return (
<div <div
className={`${ className={`${
config.tightBorder && !isMobileScreen config.tightBorder ? styles["tight-container"] : styles.container
? styles["tight-container"]
: styles.container
}`} }`}
> >
<div <div className={styles.sidebar}>
className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`} <SideBar></SideBar>
>
<div className={styles["sidebar-header"]}>
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
<div className={styles["sidebar-sub-title"]}>
Build your own AI assistant.
</div>
<div className={styles["sidebar-logo"]}>
<ChatGptIcon />
</div>
</div>
<div
className={styles["sidebar-body"]}
onClick={() => {
setOpenSettings(false);
setShowSideBar(false);
}}
>
<ChatList />
</div>
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
onClick={chatStore.deleteSession}
/>
</div>
<div className={styles["sidebar-action"]}>
<IconButton
icon={<SettingsIcon />}
onClick={() => {
setOpenSettings(true);
setShowSideBar(false);
}}
shadow
/>
</div>
<div className={styles["sidebar-action"]}>
<a href={REPO_URL} target="_blank">
<IconButton icon={<GithubIcon />} shadow />
</a>
</div>
</div>
<div>
<IconButton
icon={<AddIcon />}
text={Locale.Home.NewChat}
onClick={() => {
createNewSession();
setShowSideBar(false);
}}
shadow
/>
</div>
</div>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</div> </div>
<div className={styles["window-content"]}> <div className={styles["window-content"]}>
{openSettings ? ( <Routes>
<Settings <Route path={Path.Home} element={<Chat />} />
closeSettings={() => { <Route path={Path.Chat} element={<Chat />} />
setOpenSettings(false); <Route path={Path.Settings} element={<Settings />} />
setShowSideBar(true); </Routes>
}} </div>
/> </div>
) : ( );
<Chat }
key="chat"
showSideBar={() => setShowSideBar(true)} function MobileScreen() {
sideBarShowing={showSideBar} const location = useLocation();
/> const isHome = location.pathname === Path.Home;
)}
return (
<div className={styles.container}>
<div className={`${styles.sidebar} ${isHome && styles["sidebar-show"]}`}>
<SideBar />
</div>
<div className={styles["window-content"]}>
<Routes>
<Route path={Path.Home} element={null} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div> </div>
</div> </div>
); );
} }
export function Home() { export function Home() {
useSwitchTheme();
const isMobileScreen = useMobileScreen();
if (!useHasHydrated()) {
return <Loading />;
}
return ( return (
<ErrorBoundary> <ErrorBoundary>
<_Home></_Home> <Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
</ErrorBoundary> </ErrorBoundary>
); );
} }

View File

@ -29,10 +29,11 @@ import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales"; import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { copyToClipboard, getEmojiUrl } from "../utils"; import { copyToClipboard, getEmojiUrl } from "../utils";
import Link from "next/link"; import Link from "next/link";
import { UPDATE_URL } from "../constant"; import { Path, UPDATE_URL } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt"; import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error"; import { ErrorBoundary } from "./error";
import { InputRange } from "./input-range"; import { InputRange } from "./input-range";
import { useNavigate } from "react-router-dom";
function UserPromptModal(props: { onClose?: () => void }) { function UserPromptModal(props: { onClose?: () => void }) {
const promptStore = usePromptStore(); const promptStore = usePromptStore();
@ -176,7 +177,8 @@ function PasswordInput(props: HTMLProps<HTMLInputElement>) {
); );
} }
export function Settings(props: { closeSettings: () => void }) { export function Settings() {
const navigate = useNavigate();
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [config, updateConfig, resetConfig, clearAllData, clearSessions] = const [config, updateConfig, resetConfig, clearAllData, clearSessions] =
useChatStore((state) => [ useChatStore((state) => [
@ -235,7 +237,7 @@ export function Settings(props: { closeSettings: () => void }) {
useEffect(() => { useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => { const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
props.closeSettings(); navigate(Path.Home);
} }
}; };
document.addEventListener("keydown", keydownEvent); document.addEventListener("keydown", keydownEvent);
@ -290,7 +292,7 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}> <div className={styles["window-action-button"]}>
<IconButton <IconButton
icon={<CloseIcon />} icon={<CloseIcon />}
onClick={props.closeSettings} onClick={() => navigate(Path.Home)}
bordered bordered
title={Locale.Settings.Actions.Close} title={Locale.Settings.Actions.Close}
/> />

135
app/components/sidebar.tsx Normal file
View File

@ -0,0 +1,135 @@
import { useState, useEffect, useRef } from "react";
import styles from "./home.module.scss";
import { IconButton } from "./button";
import SettingsIcon from "../icons/settings.svg";
import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg";
import Locale from "../locales";
import { useChatStore } from "../store";
import { Path, REPO_URL } from "../constant";
import { HashRouter as Router, Link, useNavigate } from "react-router-dom";
import { useMobileScreen } from "../utils";
import { ChatList } from "./chat-list";
function useDragSideBar() {
const limit = (x: number) => Math.min(500, Math.max(220, x));
const chatStore = useChatStore();
const startX = useRef(0);
const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
const lastUpdateTime = useRef(Date.now());
const handleMouseMove = useRef((e: MouseEvent) => {
if (Date.now() < lastUpdateTime.current + 100) {
return;
}
lastUpdateTime.current = Date.now();
const d = e.clientX - startX.current;
const nextWidth = limit(startDragWidth.current + d);
chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
window.removeEventListener("mousemove", handleMouseMove.current);
window.removeEventListener("mouseup", handleMouseUp.current);
});
const onDragMouseDown = (e: MouseEvent) => {
startX.current = e.clientX;
window.addEventListener("mousemove", handleMouseMove.current);
window.addEventListener("mouseup", handleMouseUp.current);
};
const isMobileScreen = useMobileScreen();
useEffect(() => {
const sideBarWidth = isMobileScreen
? "100vw"
: `${limit(chatStore.config.sidebarWidth ?? 300)}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [chatStore.config.sidebarWidth, isMobileScreen]);
return {
onDragMouseDown,
};
}
export function SideBar(props: { setShowSideBar?: (_: boolean) => void }) {
const chatStore = useChatStore();
// drag side bar
const { onDragMouseDown } = useDragSideBar();
const navigate = useNavigate();
const isMobileScreen = useMobileScreen();
return (
<>
<div className={styles["sidebar-header"]}>
<div className={styles["sidebar-title"]}>ChatGPT Next</div>
<div className={styles["sidebar-sub-title"]}>
Build your own AI assistant.
</div>
<div className={styles["sidebar-logo"]}>
<ChatGptIcon />
</div>
</div>
<div
className={styles["sidebar-body"]}
onClick={(e) => {
if (e.target === e.currentTarget) {
navigate(Path.Home);
}
props.setShowSideBar?.(false);
}}
>
<ChatList />
</div>
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton
icon={<CloseIcon />}
onClick={chatStore.deleteSession}
/>
</div>
<div className={styles["sidebar-action"]}>
<Link to={Path.Settings}>
<IconButton icon={<SettingsIcon />} shadow />
</Link>
</div>
<div className={styles["sidebar-action"]}>
<a href={REPO_URL} target="_blank">
<IconButton icon={<GithubIcon />} shadow />
</a>
</div>
</div>
<div>
<IconButton
icon={<AddIcon />}
text={Locale.Home.NewChat}
onClick={() => {
chatStore.newSession();
props.setShowSideBar?.(false);
}}
shadow
/>
</div>
</div>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</>
);
}

View File

@ -6,3 +6,9 @@ export const UPDATE_URL = `${REPO_URL}#keep-updated`;
export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`; export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/commits?per_page=1`;
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export enum Path {
Home = "/",
Chat = "/chat",
Settings = "/settings",
}

View File

@ -49,7 +49,7 @@ export function isIOS() {
} }
export function useMobileScreen() { export function useMobileScreen() {
const [isMobileScreen_, setIsMobileScreen] = useState(false); const [isMobileScreen_, setIsMobileScreen] = useState(isMobileScreen());
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
setIsMobileScreen(isMobileScreen()); setIsMobileScreen(isMobileScreen());

View File

@ -25,6 +25,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^8.0.5", "react-markdown": "^8.0.5",
"react-router-dom": "^6.10.0",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",
"rehype-katex": "^6.0.2", "rehype-katex": "^6.0.2",
"remark-breaks": "^3.0.2", "remark-breaks": "^3.0.2",

View File

@ -1189,6 +1189,11 @@
tiny-glob "^0.2.9" tiny-glob "^0.2.9"
tslib "^2.4.0" tslib "^2.4.0"
"@remix-run/router@1.5.0":
version "1.5.0"
resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.5.0.tgz#57618e57942a5f0131374a9fdb0167e25a117fdc"
integrity sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==
"@rushstack/eslint-patch@^1.1.3": "@rushstack/eslint-patch@^1.1.3":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
@ -4296,6 +4301,21 @@ react-redux@^8.0.4:
react-is "^18.0.0" react-is "^18.0.0"
use-sync-external-store "^1.0.0" use-sync-external-store "^1.0.0"
react-router-dom@^6.10.0:
version "6.10.0"
resolved "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.10.0.tgz#090ddc5c84dc41b583ce08468c4007c84245f61f"
integrity sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==
dependencies:
"@remix-run/router" "1.5.0"
react-router "6.10.0"
react-router@6.10.0:
version "6.10.0"
resolved "https://registry.npmmirror.com/react-router/-/react-router-6.10.0.tgz#230f824fde9dd0270781b5cb497912de32c0a971"
integrity sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==
dependencies:
"@remix-run/router" "1.5.0"
react@^18.2.0: react@^18.2.0:
version "18.2.0" version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"