ChatItem.svelte 9.3 KB

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