ChatItem.svelte 11 KB

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