ChatItem.svelte 10.0 KB

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