Merge pull request #959 from Yidadaa/0420-mask

refactor: close #643 use react router
This commit is contained in:
Yifei Zhang 2023-04-21 02:54:56 +08:00 committed by GitHub
commit a62bca442e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 338 additions and 187 deletions

View File

@ -10,6 +10,8 @@ import {
import { useChatStore } from "../store";
import Locale from "../locales";
import { Link, useNavigate } from "react-router-dom";
import { Path } from "../constant";
export function ChatItem(props: {
onClick?: () => void;
@ -20,6 +22,7 @@ export function ChatItem(props: {
selected: boolean;
id: number;
index: number;
narrow?: boolean;
}) {
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
@ -33,13 +36,20 @@ export function ChatItem(props: {
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<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-date"]}>{props.time}</div>
</div>
{props.narrow ? (
<div className={styles["chat-item-narrow"]}>{props.count}</div>
) : (
<>
<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-date"]}>{props.time}</div>
</div>
</>
)}
<div className={styles["chat-item-delete"]} onClick={props.onDelete}>
<DeleteIcon />
</div>
@ -49,7 +59,7 @@ export function ChatItem(props: {
);
}
export function ChatList() {
export function ChatList(props: { narrow?: boolean }) {
const [sessions, selectedIndex, selectSession, removeSession, moveSession] =
useChatStore((state) => [
state.sessions,
@ -59,6 +69,7 @@ export function ChatList() {
state.moveSession,
]);
const chatStore = useChatStore();
const navigate = useNavigate();
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
@ -94,8 +105,16 @@ export function ChatList() {
id={item.id}
index={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() => chatStore.deleteSession(i)}
onClick={() => {
navigate(Path.Chat);
selectSession(i);
}}
onDelete={() => {
if (!props.narrow || confirm(Locale.Home.DeleteChat)) {
chatStore.deleteSession(i);
}
}}
narrow={props.narrow}
/>
))}
{provided.placeholder}

View File

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

View File

@ -50,7 +50,7 @@
flex-direction: column;
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
position: relative;
transition: width ease 0.1s;
transition: width ease 0.05s;
}
.sidebar-drag {
@ -126,11 +126,13 @@
.sidebar-title {
font-size: 20px;
font-weight: bold;
animation: slide-in ease 0.3s;
}
.sidebar-sub-title {
font-size: 12px;
font-weight: 400px;
animation: slide-in ease 0.3s;
}
.sidebar-body {
@ -171,6 +173,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
animation: slide-in ease 0.3s;
}
.chat-item-delete {
@ -197,6 +200,7 @@
color: rgb(166, 166, 166);
font-size: 12px;
margin-top: 8px;
animation: slide-in ease 0.3s;
}
.chat-item-count,
@ -206,6 +210,69 @@
white-space: nowrap;
}
.narrow-sidebar {
.sidebar-title,
.sidebar-sub-title {
display: none;
}
.sidebar-logo {
position: relative;
display: flex;
justify-content: center;
}
.chat-item {
padding: 0;
min-height: 50px;
display: flex;
justify-content: center;
align-items: center;
transition: all ease 0.3s;
&:hover {
.chat-item-narrow {
transform: scale(0.7) translateX(-50%);
}
}
}
.chat-item-narrow {
font-weight: bolder;
font-size: 24px;
line-height: 0;
font-weight: lighter;
color: var(--black);
transform: translateX(0);
transition: all ease 0.3s;
opacity: 0.1;
padding: 4px;
}
.chat-item-delete {
top: 15px;
}
.chat-item:hover > .chat-item-delete {
opacity: 0.5;
right: 5px;
}
.sidebar-tail {
flex-direction: column;
align-items: center;
.sidebar-actions {
flex-direction: column;
align-items: center;
.sidebar-action {
margin-right: 0;
margin-bottom: 15px;
}
}
}
}
.sidebar-tail {
display: flex;
justify-content: space-between;

View File

@ -2,32 +2,31 @@
require("../polyfill");
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { IconButton } from "./button";
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 AddIcon from "../icons/add.svg";
import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store";
import { getCSSVar, useMobileScreen } from "../utils";
import Locale from "../locales";
import { Chat } from "./chat";
import dynamic from "next/dynamic";
import { REPO_URL } from "../constant";
import { Path } from "../constant";
import { ErrorBoundary } from "./error";
import {
HashRouter as Router,
Routes,
Route,
useLocation,
} from "react-router-dom";
export function Loading(props: { noLogo?: boolean }) {
return (
<div className={styles["loading-content"]}>
<div className={styles["loading-content"] + " no-dark"}>
{!props.noLogo && <BotIcon />}
<LoadingIcon />
</div>
@ -38,11 +37,11 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
const SideBar = dynamic(async () => (await import("./sidebar")).SideBar, {
loading: () => <Loading noLogo />,
});
function useSwitchTheme() {
export function useSwitchTheme() {
const config = useChatStore((state) => state.config);
useEffect(() => {
@ -73,50 +72,6 @@ function useSwitchTheme() {
}, [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 [hasHydrated, setHasHydrated] = useState<boolean>(false);
@ -127,130 +82,58 @@ const useHasHydrated = () => {
return hasHydrated;
};
function _Home() {
const [createNewSession, currentIndex, removeSession] = useChatStore(
(state) => [
state.newSession,
state.currentSessionIndex,
state.removeSession,
],
);
const chatStore = useChatStore();
const loading = !useHasHydrated();
const [showSideBar, setShowSideBar] = useState(true);
// setting
const [openSettings, setOpenSettings] = useState(false);
function WideScreen() {
const config = useChatStore((state) => state.config);
// drag side bar
const { onDragMouseDown } = useDragSideBar();
const isMobileScreen = useMobileScreen();
useSwitchTheme();
if (loading) {
return <Loading />;
}
return (
<div
className={`${
config.tightBorder && !isMobileScreen
? styles["tight-container"]
: styles.container
config.tightBorder ? styles["tight-container"] : styles.container
}`}
>
<div
className={styles.sidebar + ` ${showSideBar && styles["sidebar-show"]}`}
>
<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>
<SideBar />
<div className={styles["window-content"]}>
{openSettings ? (
<Settings
closeSettings={() => {
setOpenSettings(false);
setShowSideBar(true);
}}
/>
) : (
<Chat
key="chat"
showSideBar={() => setShowSideBar(true)}
sideBarShowing={showSideBar}
/>
)}
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</div>
);
}
function MobileScreen() {
const location = useLocation();
const isHome = location.pathname === Path.Home;
return (
<div className={styles.container}>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<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>
);
}
export function Home() {
const isMobileScreen = useMobileScreen();
useSwitchTheme();
if (!useHasHydrated()) {
return <Loading />;
}
return (
<ErrorBoundary>
<_Home></_Home>
<Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
</ErrorBoundary>
);
}

View File

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

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

@ -0,0 +1,146 @@
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 {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
NARROW_SIDEBAR_WIDTH,
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(MAX_SIDEBAR_WIDTH, 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 + 50) {
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();
const shouldNarrow =
!isMobileScreen && chatStore.config.sidebarWidth < MIN_SIDEBAR_WIDTH;
useEffect(() => {
const barWidth = shouldNarrow
? NARROW_SIDEBAR_WIDTH
: limit(chatStore.config.sidebarWidth ?? 300);
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [chatStore.config.sidebarWidth, isMobileScreen, shouldNarrow]);
return {
onDragMouseDown,
shouldNarrow,
};
}
export function SideBar(props: { className?: string }) {
const chatStore = useChatStore();
// drag side bar
const { onDragMouseDown, shouldNarrow } = useDragSideBar();
const navigate = useNavigate();
return (
<div
className={`${styles.sidebar} ${props.className} ${
shouldNarrow && styles["narrow-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={(e) => {
if (e.target === e.currentTarget) {
navigate(Path.Home);
}
}}
>
<ChatList narrow={shouldNarrow} />
</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={shouldNarrow ? undefined : Locale.Home.NewChat}
onClick={() => {
chatStore.newSession();
}}
shadow
/>
</div>
</div>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</div>
);
}

View File

@ -6,3 +6,13 @@ 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_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export enum Path {
Home = "/",
Chat = "/chat",
Settings = "/settings",
}
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const NARROW_SIDEBAR_WIDTH = 100;

View File

@ -49,7 +49,7 @@ export function isIOS() {
}
export function useMobileScreen() {
const [isMobileScreen_, setIsMobileScreen] = useState(false);
const [isMobileScreen_, setIsMobileScreen] = useState(isMobileScreen());
useEffect(() => {
const onResize = () => {
setIsMobileScreen(isMobileScreen());
@ -66,6 +66,9 @@ export function useMobileScreen() {
}
export function isMobileScreen() {
if (typeof window === "undefined") {
return false;
}
return window.innerWidth <= 600;
}

View File

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

View File

@ -1189,6 +1189,11 @@
tiny-glob "^0.2.9"
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":
version "1.2.0"
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"
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:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"