RecursiveFolder.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. <script>
  2. import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
  3. const i18n = getContext('i18n');
  4. const dispatch = createEventDispatcher();
  5. import DOMPurify from 'dompurify';
  6. import fileSaver from 'file-saver';
  7. const { saveAs } = fileSaver;
  8. import { toast } from 'svelte-sonner';
  9. import { chatId, mobile, selectedFolder, showSidebar } from '$lib/stores';
  10. import {
  11. deleteFolderById,
  12. updateFolderIsExpandedById,
  13. updateFolderById,
  14. updateFolderParentIdById
  15. } from '$lib/apis/folders';
  16. import {
  17. getChatById,
  18. getChatsByFolderId,
  19. importChat,
  20. updateChatFolderIdById
  21. } from '$lib/apis/chats';
  22. import ChevronDown from '../../icons/ChevronDown.svelte';
  23. import ChevronRight from '../../icons/ChevronRight.svelte';
  24. import Collapsible from '../../common/Collapsible.svelte';
  25. import DragGhost from '$lib/components/common/DragGhost.svelte';
  26. import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
  27. import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
  28. import ChatItem from './ChatItem.svelte';
  29. import FolderMenu from './Folders/FolderMenu.svelte';
  30. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  31. import FolderModal from './Folders/FolderModal.svelte';
  32. import { goto } from '$app/navigation';
  33. import Emoji from '$lib/components/common/Emoji.svelte';
  34. export let open = false;
  35. export let folders;
  36. export let folderId;
  37. export let shiftKey = false;
  38. export let className = '';
  39. export let parentDragged = false;
  40. export let onDelete = (e) => {};
  41. let folderElement;
  42. let showFolderModal = false;
  43. let edit = false;
  44. let draggedOver = false;
  45. let dragged = false;
  46. let clickTimer = null;
  47. let name = '';
  48. const onDragOver = (e) => {
  49. e.preventDefault();
  50. e.stopPropagation();
  51. if (dragged || parentDragged) {
  52. return;
  53. }
  54. draggedOver = true;
  55. };
  56. const onDrop = async (e) => {
  57. e.preventDefault();
  58. e.stopPropagation();
  59. if (dragged || parentDragged) {
  60. return;
  61. }
  62. if (folderElement.contains(e.target)) {
  63. console.log('Dropped on the Button');
  64. if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
  65. // Iterate over all items in the DataTransferItemList use functional programming
  66. for (const item of Array.from(e.dataTransfer.items)) {
  67. // If dropped items aren't files, reject them
  68. if (item.kind === 'file') {
  69. const file = item.getAsFile();
  70. if (file && file.type === 'application/json') {
  71. console.log('Dropped file is a JSON file!');
  72. // Read the JSON file with FileReader
  73. const reader = new FileReader();
  74. reader.onload = async function (event) {
  75. try {
  76. const fileContent = JSON.parse(event.target.result);
  77. open = true;
  78. dispatch('import', {
  79. folderId: folderId,
  80. items: fileContent
  81. });
  82. } catch (error) {
  83. console.error('Error parsing JSON file:', error);
  84. }
  85. };
  86. // Start reading the file
  87. reader.readAsText(file);
  88. } else {
  89. console.error('Only JSON file types are supported.');
  90. }
  91. console.log(file);
  92. } else {
  93. // Handle the drag-and-drop data for folders or chats (same as before)
  94. const dataTransfer = e.dataTransfer.getData('text/plain');
  95. try {
  96. const data = JSON.parse(dataTransfer);
  97. console.log(data);
  98. const { type, id, item } = data;
  99. if (type === 'folder') {
  100. open = true;
  101. if (id === folderId) {
  102. return;
  103. }
  104. // Move the folder
  105. const res = await updateFolderParentIdById(localStorage.token, id, folderId).catch(
  106. (error) => {
  107. toast.error(`${error}`);
  108. return null;
  109. }
  110. );
  111. if (res) {
  112. dispatch('update');
  113. }
  114. } else if (type === 'chat') {
  115. open = true;
  116. let chat = await getChatById(localStorage.token, id).catch((error) => {
  117. return null;
  118. });
  119. if (!chat && item) {
  120. chat = await importChat(
  121. localStorage.token,
  122. item.chat,
  123. item?.meta ?? {},
  124. false,
  125. null,
  126. item?.created_at ?? null,
  127. item?.updated_at ?? null
  128. ).catch((error) => {
  129. toast.error(`${error}`);
  130. return null;
  131. });
  132. }
  133. // Move the chat
  134. const res = await updateChatFolderIdById(
  135. localStorage.token,
  136. chat.id,
  137. folderId
  138. ).catch((error) => {
  139. toast.error(`${error}`);
  140. return null;
  141. });
  142. if (res) {
  143. dispatch('update');
  144. }
  145. }
  146. } catch (error) {
  147. console.log('Error parsing dataTransfer:', error);
  148. }
  149. }
  150. }
  151. }
  152. draggedOver = false;
  153. }
  154. };
  155. const onDragLeave = (e) => {
  156. e.preventDefault();
  157. if (dragged || parentDragged) {
  158. return;
  159. }
  160. draggedOver = false;
  161. };
  162. const dragImage = new Image();
  163. dragImage.src =
  164. 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
  165. let x;
  166. let y;
  167. const onDragStart = (event) => {
  168. event.stopPropagation();
  169. event.dataTransfer.setDragImage(dragImage, 0, 0);
  170. // Set the data to be transferred
  171. event.dataTransfer.setData(
  172. 'text/plain',
  173. JSON.stringify({
  174. type: 'folder',
  175. id: folderId
  176. })
  177. );
  178. dragged = true;
  179. folderElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
  180. };
  181. const onDrag = (event) => {
  182. event.stopPropagation();
  183. x = event.clientX;
  184. y = event.clientY;
  185. };
  186. const onDragEnd = (event) => {
  187. event.stopPropagation();
  188. folderElement.style.opacity = '1'; // Reset visual cue after drag
  189. dragged = false;
  190. };
  191. onMount(async () => {
  192. open = folders[folderId].is_expanded;
  193. if (folderElement) {
  194. folderElement.addEventListener('dragover', onDragOver);
  195. folderElement.addEventListener('drop', onDrop);
  196. folderElement.addEventListener('dragleave', onDragLeave);
  197. // Event listener for when dragging starts
  198. folderElement.addEventListener('dragstart', onDragStart);
  199. // Event listener for when dragging occurs (optional)
  200. folderElement.addEventListener('drag', onDrag);
  201. // Event listener for when dragging ends
  202. folderElement.addEventListener('dragend', onDragEnd);
  203. }
  204. if (folders[folderId]?.new) {
  205. delete folders[folderId].new;
  206. await tick();
  207. renameHandler();
  208. }
  209. });
  210. onDestroy(() => {
  211. if (folderElement) {
  212. folderElement.addEventListener('dragover', onDragOver);
  213. folderElement.removeEventListener('drop', onDrop);
  214. folderElement.removeEventListener('dragleave', onDragLeave);
  215. folderElement.removeEventListener('dragstart', onDragStart);
  216. folderElement.removeEventListener('drag', onDrag);
  217. folderElement.removeEventListener('dragend', onDragEnd);
  218. }
  219. });
  220. let showDeleteConfirm = false;
  221. const deleteHandler = async () => {
  222. const res = await deleteFolderById(localStorage.token, folderId).catch((error) => {
  223. toast.error(`${error}`);
  224. return null;
  225. });
  226. if (res) {
  227. toast.success($i18n.t('Folder deleted successfully'));
  228. onDelete(folderId);
  229. }
  230. };
  231. const updateHandler = async ({ name, meta, data }) => {
  232. if (name === '') {
  233. toast.error($i18n.t('Folder name cannot be empty.'));
  234. return;
  235. }
  236. const currentName = folders[folderId].name;
  237. name = name.trim();
  238. folders[folderId].name = name;
  239. const res = await updateFolderById(localStorage.token, folderId, {
  240. name,
  241. ...(meta ? { meta } : {}),
  242. ...(data ? { data } : {})
  243. }).catch((error) => {
  244. toast.error(`${error}`);
  245. folders[folderId].name = currentName;
  246. return null;
  247. });
  248. if (res) {
  249. folders[folderId].name = name;
  250. if (data) {
  251. folders[folderId].data = data;
  252. }
  253. // toast.success($i18n.t('Folder name updated successfully'));
  254. toast.success($i18n.t('Folder updated successfully'));
  255. if ($selectedFolder?.id === folderId) {
  256. selectedFolder.set(folders[folderId]);
  257. }
  258. dispatch('update');
  259. }
  260. };
  261. const isExpandedUpdateHandler = async () => {
  262. const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch(
  263. (error) => {
  264. toast.error(`${error}`);
  265. return null;
  266. }
  267. );
  268. };
  269. let isExpandedUpdateTimeout;
  270. const isExpandedUpdateDebounceHandler = () => {
  271. clearTimeout(isExpandedUpdateTimeout);
  272. isExpandedUpdateTimeout = setTimeout(() => {
  273. isExpandedUpdateHandler();
  274. }, 500);
  275. };
  276. $: if (open) {
  277. isExpandedUpdateDebounceHandler();
  278. }
  279. const renameHandler = async () => {
  280. console.log('Edit');
  281. await tick();
  282. name = folders[folderId].name;
  283. edit = true;
  284. await tick();
  285. await tick();
  286. const input = document.getElementById(`folder-${folderId}-input`);
  287. if (input) {
  288. input.focus();
  289. input.select();
  290. }
  291. };
  292. const exportHandler = async () => {
  293. const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => {
  294. toast.error(`${error}`);
  295. return null;
  296. });
  297. if (!chats) {
  298. return;
  299. }
  300. const blob = new Blob([JSON.stringify(chats)], {
  301. type: 'application/json'
  302. });
  303. saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`);
  304. };
  305. </script>
  306. <DeleteConfirmDialog
  307. bind:show={showDeleteConfirm}
  308. title={$i18n.t('Delete folder?')}
  309. on:confirm={() => {
  310. deleteHandler();
  311. }}
  312. >
  313. <div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
  314. {@html DOMPurify.sanitize(
  315. $i18n.t('This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.', {
  316. NAME: folders[folderId].name
  317. })
  318. )}
  319. </div>
  320. </DeleteConfirmDialog>
  321. <FolderModal
  322. bind:show={showFolderModal}
  323. edit={true}
  324. folder={folders[folderId]}
  325. onSubmit={updateHandler}
  326. />
  327. {#if dragged && x && y}
  328. <DragGhost {x} {y}>
  329. <div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
  330. <div class="flex items-center gap-1">
  331. <FolderOpen className="size-3.5" strokeWidth="2" />
  332. <div class=" text-xs text-white line-clamp-1">
  333. {folders[folderId].name}
  334. </div>
  335. </div>
  336. </div>
  337. </DragGhost>
  338. {/if}
  339. <div bind:this={folderElement} class="relative {className}" draggable="true">
  340. {#if draggedOver}
  341. <div
  342. class="absolute top-0 left-0 w-full h-full rounded-xs bg-gray-100/50 dark:bg-gray-700/20 bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
  343. ></div>
  344. {/if}
  345. <Collapsible
  346. bind:open
  347. className="w-full"
  348. buttonClassName="w-full"
  349. hide={(folders[folderId]?.childrenIds ?? []).length === 0 &&
  350. (folders[folderId].items?.chats ?? []).length === 0}
  351. onChange={(state) => {
  352. dispatch('open', state);
  353. }}
  354. >
  355. <!-- svelte-ignore a11y-no-static-element-interactions -->
  356. <div class="w-full group">
  357. <button
  358. id="folder-{folderId}-button"
  359. class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
  360. folderId
  361. ? 'bg-gray-100 dark:bg-gray-900 selected'
  362. : ''}"
  363. on:dblclick={(e) => {
  364. if (clickTimer) {
  365. clearTimeout(clickTimer); // cancel the single-click action
  366. clickTimer = null;
  367. }
  368. renameHandler();
  369. }}
  370. on:click={async (e) => {
  371. (e) => e.stopPropagation();
  372. if (clickTimer) {
  373. clearTimeout(clickTimer);
  374. clickTimer = null;
  375. }
  376. clickTimer = setTimeout(async () => {
  377. await goto('/');
  378. selectedFolder.set(folders[folderId]);
  379. if ($mobile) {
  380. showSidebar.set(!$showSidebar);
  381. }
  382. clickTimer = null;
  383. }, 100); // 100ms delay (typical double-click threshold)
  384. }}
  385. on:pointerup={(e) => {
  386. e.stopPropagation();
  387. }}
  388. >
  389. <button
  390. class="text-gray-500 dark:text-gray-500 transition-all p-1 hover:bg-gray-200 dark:hover:bg-gray-850 rounded-lg"
  391. on:click={(e) => {
  392. e.stopPropagation();
  393. open = !open;
  394. }}
  395. >
  396. {#if folders[folderId]?.meta?.icon}
  397. <div class="flex group-hover:hidden transition-all">
  398. <Emoji className="size-3.5" shortCode={folders[folderId].meta.icon} />
  399. </div>
  400. <div class="hidden group-hover:flex transition-all p-[1px]">
  401. {#if open}
  402. <ChevronDown className=" size-3" strokeWidth="2.5" />
  403. {:else}
  404. <ChevronRight className=" size-3" strokeWidth="2.5" />
  405. {/if}
  406. </div>
  407. {:else}
  408. <div class="p-[1px]">
  409. {#if open}
  410. <ChevronDown className=" size-3" strokeWidth="2.5" />
  411. {:else}
  412. <ChevronRight className=" size-3" strokeWidth="2.5" />
  413. {/if}
  414. </div>
  415. {/if}
  416. </button>
  417. <div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1">
  418. {#if edit}
  419. <input
  420. id="folder-{folderId}-input"
  421. type="text"
  422. bind:value={name}
  423. on:blur={() => {
  424. console.log('Blur');
  425. updateHandler({ name });
  426. edit = false;
  427. }}
  428. on:click={(e) => {
  429. // Prevent accidental collapse toggling when clicking inside input
  430. e.stopPropagation();
  431. }}
  432. on:mousedown={(e) => {
  433. // Prevent accidental collapse toggling when clicking inside input
  434. e.stopPropagation();
  435. }}
  436. on:keydown={(e) => {
  437. if (e.key === 'Enter') {
  438. updateHandler({ name });
  439. edit = false;
  440. }
  441. }}
  442. class="w-full h-full bg-transparent outline-hidden"
  443. />
  444. {:else}
  445. {folders[folderId].name}
  446. {/if}
  447. </div>
  448. <button
  449. class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
  450. >
  451. <FolderMenu
  452. onEdit={() => {
  453. showFolderModal = true;
  454. }}
  455. onDelete={() => {
  456. showDeleteConfirm = true;
  457. }}
  458. onExport={() => {
  459. exportHandler();
  460. }}
  461. >
  462. <div class="p-1 dark:hover:bg-gray-850 rounded-lg touch-auto">
  463. <EllipsisHorizontal className="size-4" strokeWidth="2.5" />
  464. </div>
  465. </FolderMenu>
  466. </button>
  467. </button>
  468. </div>
  469. <div slot="content" class="w-full">
  470. {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.chats ?? []).length > 0}
  471. <div
  472. class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
  473. >
  474. {#if folders[folderId]?.childrenIds}
  475. {@const children = folders[folderId]?.childrenIds
  476. .map((id) => folders[id])
  477. .sort((a, b) =>
  478. a.name.localeCompare(b.name, undefined, {
  479. numeric: true,
  480. sensitivity: 'base'
  481. })
  482. )}
  483. {#each children as childFolder (`${folderId}-${childFolder.id}`)}
  484. <svelte:self
  485. {folders}
  486. folderId={childFolder.id}
  487. {shiftKey}
  488. parentDragged={dragged}
  489. {onDelete}
  490. on:import={(e) => {
  491. dispatch('import', e.detail);
  492. }}
  493. on:update={(e) => {
  494. dispatch('update', e.detail);
  495. }}
  496. on:change={(e) => {
  497. dispatch('change', e.detail);
  498. }}
  499. />
  500. {/each}
  501. {/if}
  502. {#if folders[folderId].items?.chats}
  503. {#each folders[folderId].items.chats as chat (chat.id)}
  504. <ChatItem
  505. id={chat.id}
  506. title={chat.title}
  507. {shiftKey}
  508. on:change={(e) => {
  509. dispatch('change', e.detail);
  510. }}
  511. />
  512. {/each}
  513. {/if}
  514. </div>
  515. {/if}
  516. </div>
  517. </Collapsible>
  518. </div>