sidebar.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
  2. import styles from "./home.module.scss";
  3. import { IconButton } from "./button";
  4. import SettingsIcon from "../icons/settings.svg";
  5. import GithubIcon from "../icons/github.svg";
  6. import ChatGptIcon from "../icons/chatgpt.svg";
  7. import AddIcon from "../icons/add.svg";
  8. import DeleteIcon from "../icons/delete.svg";
  9. import MaskIcon from "../icons/mask.svg";
  10. import DragIcon from "../icons/drag.svg";
  11. import DiscoveryIcon from "../icons/discovery.svg";
  12. import Locale from "../locales";
  13. import { useAppConfig, useChatStore } from "../store";
  14. import {
  15. DEFAULT_SIDEBAR_WIDTH,
  16. MAX_SIDEBAR_WIDTH,
  17. MIN_SIDEBAR_WIDTH,
  18. NARROW_SIDEBAR_WIDTH,
  19. Path,
  20. REPO_URL,
  21. } from "../constant";
  22. import { Link, useNavigate } from "react-router-dom";
  23. import { isIOS, useMobileScreen } from "../utils";
  24. import dynamic from "next/dynamic";
  25. import { showConfirm, Selector } from "./ui-lib";
  26. import clsx from "clsx";
  27. const DISCOVERY = [
  28. { name: Locale.Plugin.Name, path: Path.Plugins },
  29. { name: "Stable Diffusion", path: Path.Sd },
  30. { name: Locale.SearchChat.Page.Title, path: Path.SearchChat },
  31. ];
  32. const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
  33. loading: () => null,
  34. });
  35. export function useHotKey() {
  36. const chatStore = useChatStore();
  37. useEffect(() => {
  38. const onKeyDown = (e: KeyboardEvent) => {
  39. if (e.altKey || e.ctrlKey) {
  40. if (e.key === "ArrowUp") {
  41. chatStore.nextSession(-1);
  42. } else if (e.key === "ArrowDown") {
  43. chatStore.nextSession(1);
  44. }
  45. }
  46. };
  47. window.addEventListener("keydown", onKeyDown);
  48. return () => window.removeEventListener("keydown", onKeyDown);
  49. });
  50. }
  51. export function useDragSideBar() {
  52. const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
  53. const config = useAppConfig();
  54. const startX = useRef(0);
  55. const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  56. const lastUpdateTime = useRef(Date.now());
  57. const toggleSideBar = () => {
  58. config.update((config) => {
  59. if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
  60. config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
  61. } else {
  62. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  63. }
  64. });
  65. };
  66. const onDragStart = (e: MouseEvent) => {
  67. // Remembers the initial width each time the mouse is pressed
  68. startX.current = e.clientX;
  69. startDragWidth.current = config.sidebarWidth;
  70. const dragStartTime = Date.now();
  71. const handleDragMove = (e: MouseEvent) => {
  72. if (Date.now() < lastUpdateTime.current + 20) {
  73. return;
  74. }
  75. lastUpdateTime.current = Date.now();
  76. const d = e.clientX - startX.current;
  77. const nextWidth = limit(startDragWidth.current + d);
  78. config.update((config) => {
  79. if (nextWidth < MIN_SIDEBAR_WIDTH) {
  80. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  81. } else {
  82. config.sidebarWidth = nextWidth;
  83. }
  84. });
  85. };
  86. const handleDragEnd = () => {
  87. // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
  88. window.removeEventListener("pointermove", handleDragMove);
  89. window.removeEventListener("pointerup", handleDragEnd);
  90. // if user click the drag icon, should toggle the sidebar
  91. const shouldFireClick = Date.now() - dragStartTime < 300;
  92. if (shouldFireClick) {
  93. toggleSideBar();
  94. }
  95. };
  96. window.addEventListener("pointermove", handleDragMove);
  97. window.addEventListener("pointerup", handleDragEnd);
  98. };
  99. const isMobileScreen = useMobileScreen();
  100. const shouldNarrow =
  101. !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
  102. useEffect(() => {
  103. const barWidth = shouldNarrow
  104. ? NARROW_SIDEBAR_WIDTH
  105. : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  106. const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
  107. document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
  108. }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
  109. return {
  110. onDragStart,
  111. shouldNarrow,
  112. };
  113. }
  114. export function SideBarContainer(props: {
  115. children: React.ReactNode;
  116. onDragStart: (e: MouseEvent) => void;
  117. shouldNarrow: boolean;
  118. className?: string;
  119. }) {
  120. const isMobileScreen = useMobileScreen();
  121. const isIOSMobile = useMemo(
  122. () => isIOS() && isMobileScreen,
  123. [isMobileScreen],
  124. );
  125. const { children, className, onDragStart, shouldNarrow } = props;
  126. return (
  127. <div
  128. className={clsx(styles.sidebar, className, {
  129. [styles["narrow-sidebar"]]: shouldNarrow,
  130. })}
  131. style={{
  132. // #3016 disable transition on ios mobile screen
  133. transition: isMobileScreen && isIOSMobile ? "none" : undefined,
  134. }}
  135. >
  136. {children}
  137. <div
  138. className={styles["sidebar-drag"]}
  139. onPointerDown={(e) => onDragStart(e as any)}
  140. >
  141. <DragIcon />
  142. </div>
  143. </div>
  144. );
  145. }
  146. export function SideBarHeader(props: {
  147. title?: string | React.ReactNode;
  148. subTitle?: string | React.ReactNode;
  149. logo?: React.ReactNode;
  150. children?: React.ReactNode;
  151. shouldNarrow?: boolean;
  152. }) {
  153. const { title, subTitle, logo, children, shouldNarrow } = props;
  154. return (
  155. <Fragment>
  156. <div
  157. className={clsx(styles["sidebar-header"], {
  158. [styles["sidebar-header-narrow"]]: shouldNarrow,
  159. })}
  160. data-tauri-drag-region
  161. >
  162. <div className={styles["sidebar-title-container"]}>
  163. <div className={styles["sidebar-title"]} data-tauri-drag-region>
  164. {title}
  165. </div>
  166. <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
  167. </div>
  168. <div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div>
  169. </div>
  170. {children}
  171. </Fragment>
  172. );
  173. }
  174. export function SideBarBody(props: {
  175. children: React.ReactNode;
  176. onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
  177. }) {
  178. const { onClick, children } = props;
  179. return (
  180. <div className={styles["sidebar-body"]} onClick={onClick}>
  181. {children}
  182. </div>
  183. );
  184. }
  185. export function SideBarTail(props: {
  186. primaryAction?: React.ReactNode;
  187. secondaryAction?: React.ReactNode;
  188. }) {
  189. const { primaryAction, secondaryAction } = props;
  190. return (
  191. <div className={styles["sidebar-tail"]}>
  192. <div className={styles["sidebar-actions"]}>{primaryAction}</div>
  193. <div className={styles["sidebar-actions"]}>{secondaryAction}</div>
  194. </div>
  195. );
  196. }
  197. export function SideBar(props: { className?: string }) {
  198. useHotKey();
  199. const { onDragStart, shouldNarrow } = useDragSideBar();
  200. const [showDiscoverySelector, setshowDiscoverySelector] = useState(false);
  201. const navigate = useNavigate();
  202. const config = useAppConfig();
  203. const chatStore = useChatStore();
  204. return (
  205. <SideBarContainer
  206. onDragStart={onDragStart}
  207. shouldNarrow={shouldNarrow}
  208. {...props}
  209. >
  210. <SideBarHeader
  211. title="NextChat"
  212. subTitle="Build your own AI assistant."
  213. logo={<ChatGptIcon />}
  214. shouldNarrow={shouldNarrow}
  215. >
  216. <div className={styles["sidebar-header-bar"]}>
  217. <IconButton
  218. icon={<MaskIcon />}
  219. text={shouldNarrow ? undefined : Locale.Mask.Name}
  220. className={styles["sidebar-bar-button"]}
  221. onClick={() => {
  222. if (config.dontShowMaskSplashScreen !== true) {
  223. navigate(Path.NewChat, { state: { fromHome: true } });
  224. } else {
  225. navigate(Path.Masks, { state: { fromHome: true } });
  226. }
  227. }}
  228. shadow
  229. />
  230. <IconButton
  231. icon={<DiscoveryIcon />}
  232. text={shouldNarrow ? undefined : Locale.Discovery.Name}
  233. className={styles["sidebar-bar-button"]}
  234. onClick={() => setshowDiscoverySelector(true)}
  235. shadow
  236. />
  237. </div>
  238. {showDiscoverySelector && (
  239. <Selector
  240. items={[
  241. ...DISCOVERY.map((item) => {
  242. return {
  243. title: item.name,
  244. value: item.path,
  245. };
  246. }),
  247. ]}
  248. onClose={() => setshowDiscoverySelector(false)}
  249. onSelection={(s) => {
  250. navigate(s[0], { state: { fromHome: true } });
  251. }}
  252. />
  253. )}
  254. </SideBarHeader>
  255. <SideBarBody
  256. onClick={(e) => {
  257. if (e.target === e.currentTarget) {
  258. navigate(Path.Home);
  259. }
  260. }}
  261. >
  262. <ChatList narrow={shouldNarrow} />
  263. </SideBarBody>
  264. <SideBarTail
  265. primaryAction={
  266. <>
  267. <div className={clsx(styles["sidebar-action"], styles.mobile)}>
  268. <IconButton
  269. icon={<DeleteIcon />}
  270. onClick={async () => {
  271. if (await showConfirm(Locale.Home.DeleteChat)) {
  272. chatStore.deleteSession(chatStore.currentSessionIndex);
  273. }
  274. }}
  275. />
  276. </div>
  277. <div className={styles["sidebar-action"]}>
  278. <Link to={Path.Settings}>
  279. <IconButton
  280. aria={Locale.Settings.Title}
  281. icon={<SettingsIcon />}
  282. shadow
  283. />
  284. </Link>
  285. </div>
  286. <div className={styles["sidebar-action"]}>
  287. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  288. <IconButton
  289. aria={Locale.Export.MessageFromChatGPT}
  290. icon={<GithubIcon />}
  291. shadow
  292. />
  293. </a>
  294. </div>
  295. </>
  296. }
  297. secondaryAction={
  298. <IconButton
  299. icon={<AddIcon />}
  300. text={shouldNarrow ? undefined : Locale.Home.NewChat}
  301. onClick={() => {
  302. if (config.dontShowMaskSplashScreen) {
  303. chatStore.newSession();
  304. navigate(Path.Chat);
  305. } else {
  306. navigate(Path.NewChat);
  307. }
  308. }}
  309. shadow
  310. />
  311. }
  312. />
  313. </SideBarContainer>
  314. );
  315. }