ChatItem.svelte 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { goto, invalidate, invalidateAll } from '$app/navigation';
  4. import { onMount, getContext, createEventDispatcher, tick, onDestroy } from 'svelte';
  5. const i18n = getContext('i18n');
  6. const dispatch = createEventDispatcher();
  7. import {
  8. archiveChatById,
  9. cloneChatById,
  10. deleteChatById,
  11. getAllTags,
  12. getChatList,
  13. getChatListByTagName,
  14. getPinnedChatList,
  15. updateChatById
  16. } from '$lib/apis/chats';
  17. import {
  18. chatId,
  19. chatTitle as _chatTitle,
  20. chats,
  21. mobile,
  22. pinnedChats,
  23. showSidebar,
  24. currentChatPage,
  25. tags
  26. } from '$lib/stores';
  27. import ChatMenu from './ChatMenu.svelte';
  28. import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
  29. import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
  30. import Tooltip from '$lib/components/common/Tooltip.svelte';
  31. import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
  32. import DragGhost from '$lib/components/common/DragGhost.svelte';
  33. export let chat;
  34. export let selected = false;
  35. export let shiftKey = false;
  36. let mouseOver = false;
  37. let showShareChatModal = false;
  38. let confirmEdit = false;
  39. let chatTitle = chat.title;
  40. const editChatTitle = async (id, title) => {
  41. if (title === '') {
  42. toast.error($i18n.t('Title cannot be an empty string.'));
  43. } else {
  44. await updateChatById(localStorage.token, id, {
  45. title: title
  46. });
  47. if (id === $chatId) {
  48. _chatTitle.set(title);
  49. }
  50. currentChatPage.set(1);
  51. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  52. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  53. }
  54. };
  55. const cloneChatHandler = async (id) => {
  56. const res = await cloneChatById(localStorage.token, id).catch((error) => {
  57. toast.error(error);
  58. return null;
  59. });
  60. if (res) {
  61. goto(`/c/${res.id}`);
  62. currentChatPage.set(1);
  63. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  64. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  65. }
  66. };
  67. const archiveChatHandler = async (id) => {
  68. await archiveChatById(localStorage.token, id);
  69. tags.set(await getAllTags(localStorage.token));
  70. currentChatPage.set(1);
  71. await chats.set(await getChatList(localStorage.token, $currentChatPage));
  72. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  73. };
  74. const focusEdit = async (node: HTMLInputElement) => {
  75. node.focus();
  76. };
  77. let itemElement;
  78. let drag = false;
  79. let x = 0;
  80. let y = 0;
  81. const dragImage = new Image();
  82. dragImage.src =
  83. 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
  84. const onDragStart = (event) => {
  85. event.dataTransfer.setDragImage(dragImage, 0, 0);
  86. // Set the data to be transferred
  87. event.dataTransfer.setData(
  88. 'text/plain',
  89. JSON.stringify({
  90. id: chat.id
  91. })
  92. );
  93. drag = true;
  94. itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
  95. };
  96. const onDrag = (event) => {
  97. x = event.clientX;
  98. y = event.clientY;
  99. };
  100. const onDragEnd = (event) => {
  101. itemElement.style.opacity = '1'; // Reset visual cue after drag
  102. drag = false;
  103. };
  104. onMount(() => {
  105. if (itemElement) {
  106. // Event listener for when dragging starts
  107. itemElement.addEventListener('dragstart', onDragStart);
  108. // Event listener for when dragging occurs (optional)
  109. itemElement.addEventListener('drag', onDrag);
  110. // Event listener for when dragging ends
  111. itemElement.addEventListener('dragend', onDragEnd);
  112. }
  113. });
  114. onDestroy(() => {
  115. if (itemElement) {
  116. itemElement.removeEventListener('dragstart', onDragStart);
  117. itemElement.removeEventListener('drag', onDrag);
  118. itemElement.removeEventListener('dragend', onDragEnd);
  119. }
  120. });
  121. </script>
  122. <ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
  123. {#if drag && x && y}
  124. <DragGhost {x} {y}>
  125. <div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-44">
  126. <div>
  127. <div class=" text-xs text-white line-clamp-1">
  128. {chat.title}
  129. </div>
  130. </div>
  131. </div>
  132. </DragGhost>
  133. {/if}
  134. <div bind:this={itemElement} class=" w-full pr-2 relative group" draggable="true">
  135. {#if confirmEdit}
  136. <div
  137. class=" w-full flex justify-between rounded-xl px-2.5 py-2 {chat.id === $chatId || confirmEdit
  138. ? 'bg-gray-200 dark:bg-gray-900'
  139. : selected
  140. ? 'bg-gray-100 dark:bg-gray-950'
  141. : 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
  142. >
  143. <input
  144. use:focusEdit
  145. bind:value={chatTitle}
  146. class=" bg-transparent w-full outline-none mr-10"
  147. />
  148. </div>
  149. {:else}
  150. <a
  151. class=" w-full flex justify-between rounded-lg px-2.5 py-2 {chat.id === $chatId || confirmEdit
  152. ? 'bg-gray-200 dark:bg-gray-900'
  153. : selected
  154. ? 'bg-gray-100 dark:bg-gray-950'
  155. : ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
  156. href="/c/{chat.id}"
  157. on:click={() => {
  158. dispatch('select');
  159. if ($mobile) {
  160. showSidebar.set(false);
  161. }
  162. }}
  163. on:dblclick={() => {
  164. chatTitle = chat.title;
  165. confirmEdit = true;
  166. }}
  167. on:mouseenter={(e) => {
  168. mouseOver = true;
  169. }}
  170. on:mouseleave={(e) => {
  171. mouseOver = false;
  172. }}
  173. on:focus={(e) => {}}
  174. draggable="false"
  175. >
  176. <div class=" flex self-center flex-1 w-full">
  177. <div class=" text-left self-center overflow-hidden w-full h-[20px]">
  178. {chat.title}
  179. </div>
  180. </div>
  181. </a>
  182. {/if}
  183. <!-- svelte-ignore a11y-no-static-element-interactions -->
  184. <div
  185. class="
  186. {chat.id === $chatId || confirmEdit
  187. ? 'from-gray-200 dark:from-gray-900'
  188. : selected
  189. ? 'from-gray-100 dark:from-gray-950'
  190. : 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
  191. absolute right-[10px] top-[6px] py-1 pr-2 pl-5 bg-gradient-to-l from-80%
  192. to-transparent"
  193. on:mouseenter={(e) => {
  194. mouseOver = true;
  195. }}
  196. on:mouseleave={(e) => {
  197. mouseOver = false;
  198. }}
  199. >
  200. {#if confirmEdit}
  201. <div class="flex self-center space-x-1.5 z-10">
  202. <Tooltip content={$i18n.t('Confirm')}>
  203. <button
  204. class=" self-center dark:hover:text-white transition"
  205. on:click={() => {
  206. editChatTitle(chat.id, chatTitle);
  207. confirmEdit = false;
  208. chatTitle = '';
  209. }}
  210. >
  211. <svg
  212. xmlns="http://www.w3.org/2000/svg"
  213. viewBox="0 0 20 20"
  214. fill="currentColor"
  215. class="w-4 h-4"
  216. >
  217. <path
  218. fill-rule="evenodd"
  219. d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
  220. clip-rule="evenodd"
  221. />
  222. </svg>
  223. </button>
  224. </Tooltip>
  225. <Tooltip content={$i18n.t('Cancel')}>
  226. <button
  227. class=" self-center dark:hover:text-white transition"
  228. on:click={() => {
  229. confirmEdit = false;
  230. chatTitle = '';
  231. }}
  232. >
  233. <svg
  234. xmlns="http://www.w3.org/2000/svg"
  235. viewBox="0 0 20 20"
  236. fill="currentColor"
  237. class="w-4 h-4"
  238. >
  239. <path
  240. d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
  241. />
  242. </svg>
  243. </button>
  244. </Tooltip>
  245. </div>
  246. {:else if shiftKey && mouseOver}
  247. <div class=" flex items-center self-center space-x-1.5">
  248. <Tooltip content={$i18n.t('Archive')} className="flex items-center">
  249. <button
  250. class=" self-center dark:hover:text-white transition"
  251. on:click={() => {
  252. archiveChatHandler(chat.id);
  253. }}
  254. type="button"
  255. >
  256. <ArchiveBox className="size-4 translate-y-[0.5px]" strokeWidth="2" />
  257. </button>
  258. </Tooltip>
  259. <Tooltip content={$i18n.t('Delete')}>
  260. <button
  261. class=" self-center dark:hover:text-white transition"
  262. on:click={() => {
  263. dispatch('delete', 'shift');
  264. }}
  265. type="button"
  266. >
  267. <GarbageBin strokeWidth="2" />
  268. </button>
  269. </Tooltip>
  270. </div>
  271. {:else}
  272. <div class="flex self-center space-x-1 z-10">
  273. <ChatMenu
  274. chatId={chat.id}
  275. cloneChatHandler={() => {
  276. cloneChatHandler(chat.id);
  277. }}
  278. shareHandler={() => {
  279. showShareChatModal = true;
  280. }}
  281. archiveChatHandler={() => {
  282. archiveChatHandler(chat.id);
  283. }}
  284. renameHandler={() => {
  285. chatTitle = chat.title;
  286. confirmEdit = true;
  287. }}
  288. deleteHandler={() => {
  289. dispatch('delete');
  290. }}
  291. onClose={() => {
  292. dispatch('unselect');
  293. }}
  294. on:change={async () => {
  295. await pinnedChats.set(await getPinnedChatList(localStorage.token));
  296. }}
  297. on:tag={(e) => {
  298. dispatch('tag', e.detail);
  299. }}
  300. >
  301. <button
  302. aria-label="Chat Menu"
  303. class=" self-center dark:hover:text-white transition"
  304. on:click={() => {
  305. dispatch('select');
  306. }}
  307. >
  308. <svg
  309. xmlns="http://www.w3.org/2000/svg"
  310. viewBox="0 0 16 16"
  311. fill="currentColor"
  312. class="w-4 h-4"
  313. >
  314. <path
  315. d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
  316. />
  317. </svg>
  318. </button>
  319. </ChatMenu>
  320. {#if chat.id === $chatId}
  321. <!-- Shortcut support using "delete-chat-button" id -->
  322. <button
  323. id="delete-chat-button"
  324. class="hidden"
  325. on:click={() => {
  326. dispatch('delete');
  327. }}
  328. >
  329. <svg
  330. xmlns="http://www.w3.org/2000/svg"
  331. viewBox="0 0 16 16"
  332. fill="currentColor"
  333. class="w-4 h-4"
  334. >
  335. <path
  336. d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
  337. />
  338. </svg>
  339. </button>
  340. {/if}
  341. </div>
  342. {/if}
  343. </div>
  344. </div>