sidebar.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { useEffect, useRef, useMemo } from "react";
  2. import styles from "@/app/components/home/home.module.scss";
  3. import { IconButton } from "@/app/components/button";
  4. import SettingsIcon from "@/app/icons/settings.svg";
  5. import GithubIcon from "@/app/icons/github.svg";
  6. import ChatGptIcon from "@/app/icons/chatgpt.svg";
  7. import AddIcon from "@/app/icons/add.svg";
  8. import CloseIcon from "@/app/icons/close.svg";
  9. import DeleteIcon from "@/app/icons/delete.svg";
  10. import MaskIcon from "@/app/icons/mask.svg";
  11. import PluginIcon from "@/app/icons/plugin.svg";
  12. import DragIcon from "@/app/icons/drag.svg";
  13. import Locale from "@/app/locales";
  14. import { useAppConfig, useChatStore } from "@/app/store";
  15. import {
  16. DEFAULT_SIDEBAR_WIDTH,
  17. MAX_SIDEBAR_WIDTH,
  18. MIN_SIDEBAR_WIDTH,
  19. NARROW_SIDEBAR_WIDTH,
  20. Path,
  21. REPO_URL,
  22. } from "@/app/constant";
  23. import { Link, useNavigate } from "react-router-dom";
  24. import { isIOS, useMobileScreen } from "@/app/utils";
  25. import dynamic from "next/dynamic";
  26. import { showConfirm, showToast } from "@/app/components/ui-lib";
  27. const ChatList = dynamic(
  28. async () => (await import("@/app/components/chat-list")).ChatList,
  29. {
  30. loading: () => null,
  31. },
  32. );
  33. function useHotKey() {
  34. const chatStore = useChatStore();
  35. useEffect(() => {
  36. const onKeyDown = (e: KeyboardEvent) => {
  37. if (e.altKey || e.ctrlKey) {
  38. if (e.key === "ArrowUp") {
  39. chatStore.nextSession(-1);
  40. } else if (e.key === "ArrowDown") {
  41. chatStore.nextSession(1);
  42. }
  43. }
  44. };
  45. window.addEventListener("keydown", onKeyDown);
  46. return () => window.removeEventListener("keydown", onKeyDown);
  47. });
  48. }
  49. function useDragSideBar() {
  50. const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
  51. const config = useAppConfig();
  52. const startX = useRef(0);
  53. const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  54. const lastUpdateTime = useRef(Date.now());
  55. const toggleSideBar = () => {
  56. config.update((config) => {
  57. if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
  58. config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
  59. } else {
  60. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  61. }
  62. });
  63. };
  64. const onDragStart = (e: MouseEvent) => {
  65. // Remembers the initial width each time the mouse is pressed
  66. startX.current = e.clientX;
  67. startDragWidth.current = config.sidebarWidth;
  68. const dragStartTime = Date.now();
  69. const handleDragMove = (e: MouseEvent) => {
  70. if (Date.now() < lastUpdateTime.current + 20) {
  71. return;
  72. }
  73. lastUpdateTime.current = Date.now();
  74. const d = e.clientX - startX.current;
  75. const nextWidth = limit(startDragWidth.current + d);
  76. config.update((config) => {
  77. if (nextWidth < MIN_SIDEBAR_WIDTH) {
  78. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  79. } else {
  80. config.sidebarWidth = nextWidth;
  81. }
  82. });
  83. };
  84. const handleDragEnd = () => {
  85. // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
  86. window.removeEventListener("pointermove", handleDragMove);
  87. window.removeEventListener("pointerup", handleDragEnd);
  88. // if user click the drag icon, should toggle the sidebar
  89. const shouldFireClick = Date.now() - dragStartTime < 300;
  90. if (shouldFireClick) {
  91. toggleSideBar();
  92. }
  93. };
  94. window.addEventListener("pointermove", handleDragMove);
  95. window.addEventListener("pointerup", handleDragEnd);
  96. };
  97. const isMobileScreen = useMobileScreen();
  98. const shouldNarrow =
  99. !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
  100. useEffect(() => {
  101. const barWidth = shouldNarrow
  102. ? NARROW_SIDEBAR_WIDTH
  103. : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  104. const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
  105. document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
  106. }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
  107. return {
  108. onDragStart,
  109. shouldNarrow,
  110. };
  111. }
  112. export function SideBar(props: { className?: string }) {
  113. const chatStore = useChatStore();
  114. // drag side bar
  115. const { onDragStart, shouldNarrow } = useDragSideBar();
  116. const navigate = useNavigate();
  117. const config = useAppConfig();
  118. const isMobileScreen = useMobileScreen();
  119. const isIOSMobile = useMemo(
  120. () => isIOS() && isMobileScreen,
  121. [isMobileScreen],
  122. );
  123. useHotKey();
  124. return (
  125. <div
  126. className={`${styles.sidebar} ${props.className} ${
  127. shouldNarrow && styles["narrow-sidebar"]
  128. }`}
  129. style={{
  130. // #3016 disable transition on ios mobile screen
  131. transition: isMobileScreen && isIOSMobile ? "none" : undefined,
  132. }}
  133. >
  134. <div className={styles["sidebar-header"]} data-tauri-drag-region>
  135. <div className={styles["sidebar-title"]} data-tauri-drag-region>
  136. NextChat
  137. </div>
  138. <div className={styles["sidebar-sub-title"]}>
  139. Build your own AI assistant.
  140. </div>
  141. <div className={styles["sidebar-logo"] + " no-dark"}>
  142. <ChatGptIcon />
  143. </div>
  144. </div>
  145. <div className={styles["sidebar-header-bar"]}>
  146. <IconButton
  147. icon={<MaskIcon />}
  148. text={shouldNarrow ? undefined : Locale.Mask.Name}
  149. className={styles["sidebar-bar-button"]}
  150. onClick={() => {
  151. if (config.dontShowMaskSplashScreen !== true) {
  152. navigate(Path.NewChat, { state: { fromHome: true } });
  153. } else {
  154. navigate(Path.Masks, { state: { fromHome: true } });
  155. }
  156. }}
  157. shadow
  158. />
  159. <IconButton
  160. icon={<PluginIcon />}
  161. text={shouldNarrow ? undefined : Locale.Plugin.Name}
  162. className={styles["sidebar-bar-button"]}
  163. onClick={() => showToast(Locale.WIP)}
  164. shadow
  165. />
  166. </div>
  167. <div
  168. className={styles["sidebar-body"]}
  169. onClick={(e) => {
  170. if (e.target === e.currentTarget) {
  171. navigate(Path.Home);
  172. }
  173. }}
  174. >
  175. <ChatList narrow={shouldNarrow} />
  176. </div>
  177. <div className={styles["sidebar-tail"]}>
  178. <div className={styles["sidebar-actions"]}>
  179. <div className={styles["sidebar-action"] + " " + styles.mobile}>
  180. <IconButton
  181. icon={<DeleteIcon />}
  182. onClick={async () => {
  183. if (await showConfirm(Locale.Home.DeleteChat)) {
  184. chatStore.deleteSession(chatStore.currentSessionIndex);
  185. }
  186. }}
  187. />
  188. </div>
  189. <div className={styles["sidebar-action"]}>
  190. <Link to={Path.Settings}>
  191. <IconButton icon={<SettingsIcon />} shadow />
  192. </Link>
  193. </div>
  194. <div className={styles["sidebar-action"]}>
  195. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  196. <IconButton icon={<GithubIcon />} shadow />
  197. </a>
  198. </div>
  199. </div>
  200. <div>
  201. <IconButton
  202. icon={<AddIcon />}
  203. text={shouldNarrow ? undefined : Locale.Home.NewChat}
  204. onClick={() => {
  205. if (config.dontShowMaskSplashScreen) {
  206. chatStore.newSession();
  207. navigate(Path.Chat);
  208. } else {
  209. navigate(Path.NewChat);
  210. }
  211. }}
  212. shadow
  213. />
  214. </div>
  215. </div>
  216. <div
  217. className={styles["sidebar-drag"]}
  218. onPointerDown={(e) => onDragStart(e as any)}
  219. >
  220. <DragIcon />
  221. </div>
  222. </div>
  223. );
  224. }