diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx index f0139205b..626336afd 100644 --- a/app/components/chat-list.tsx +++ b/app/components/chat-list.tsx @@ -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 ( @@ -33,13 +36,20 @@ export function ChatItem(props: { {...provided.draggableProps} {...provided.dragHandleProps} > -
{props.title}
-
-
- {Locale.ChatItem.ChatItemCount(props.count)} -
-
{props.time}
-
+ {props.narrow ? ( +
{props.count}
+ ) : ( + <> +
{props.title}
+
+
+ {Locale.ChatItem.ChatItemCount(props.count)} +
+
{props.time}
+
+ + )} +
@@ -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} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index c6bc61ebf..bab422983 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -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={} bordered title={Locale.Chat.Actions.ChatList} - onClick={props?.showSideBar} + onClick={() => navigate(Path.Home)} />
@@ -830,7 +830,7 @@ export function Chat(props: { setAutoScroll(false); setTimeout(() => setPromptHints([]), 500); }} - autoFocus={!props?.sideBarShowing} + autoFocus rows={inputRows} /> .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; diff --git a/app/components/home.tsx b/app/components/home.tsx index ef30243c4..123be03a9 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -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 ( -
+
{!props.noLogo && }
@@ -38,11 +37,11 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , }); -const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { +const SideBar = dynamic(async () => (await import("./sidebar")).SideBar, { loading: () => , }); -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(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 ; - } - return (
-
-
-
ChatGPT Next
-
- Build your own AI assistant. -
-
- -
-
- -
{ - setOpenSettings(false); - setShowSideBar(false); - }} - > - -
- -
-
-
- } - onClick={chatStore.deleteSession} - /> -
-
- } - onClick={() => { - setOpenSettings(true); - setShowSideBar(false); - }} - shadow - /> -
- -
-
- } - text={Locale.Home.NewChat} - onClick={() => { - createNewSession(); - setShowSideBar(false); - }} - shadow - /> -
-
- -
onDragMouseDown(e as any)} - >
-
+
- {openSettings ? ( - { - setOpenSettings(false); - setShowSideBar(true); - }} - /> - ) : ( - setShowSideBar(true)} - sideBarShowing={showSideBar} - /> - )} + + } /> + } /> + } /> + +
+
+ ); +} + +function MobileScreen() { + const location = useLocation(); + const isHome = location.pathname === Path.Home; + + return ( +
+ + +
+ + + } /> + } /> +
); } export function Home() { + const isMobileScreen = useMobileScreen(); + useSwitchTheme(); + + if (!useHasHydrated()) { + return ; + } + return ( - <_Home> + {isMobileScreen ? : } ); } diff --git a/app/components/settings.tsx b/app/components/settings.tsx index d81b5b358..021484927 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -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) { ); } -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 }) {
} - onClick={props.closeSettings} + onClick={() => navigate(Path.Home)} bordered title={Locale.Settings.Actions.Close} /> diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 000000000..71e75f8ab --- /dev/null +++ b/app/components/sidebar.tsx @@ -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 ( +
+
+
ChatGPT Next
+
+ Build your own AI assistant. +
+
+ +
+
+ +
{ + if (e.target === e.currentTarget) { + navigate(Path.Home); + } + }} + > + +
+ +
+
+
+ } + onClick={chatStore.deleteSession} + /> +
+
+ + } shadow /> + +
+ +
+
+ } + text={shouldNarrow ? undefined : Locale.Home.NewChat} + onClick={() => { + chatStore.newSession(); + }} + shadow + /> +
+
+ +
onDragMouseDown(e as any)} + >
+
+ ); +} diff --git a/app/constant.ts b/app/constant.ts index 6f08ad756..43ae4cc68 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -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; diff --git a/app/utils.ts b/app/utils.ts index 9d6e90622..dfec8d3e9 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -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; } diff --git a/package.json b/package.json index 19047ad11..785005519 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 342ea4a44..b7d9f8309 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"