| <script> |
| import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte'; |
| |
| const i18n = getContext('i18n'); |
| const dispatch = createEventDispatcher(); |
| |
| import DOMPurify from 'dompurify'; |
| import fileSaver from 'file-saver'; |
| const { saveAs } = fileSaver; |
| |
| import { goto } from '$app/navigation'; |
| import { toast } from 'svelte-sonner'; |
| |
| import { chatId, mobile, selectedFolder, showSidebar } from '$lib/stores'; |
| |
| import { |
| deleteFolderById, |
| updateFolderIsExpandedById, |
| updateFolderById, |
| updateFolderParentIdById, |
| getFolderById, |
| createNewFolder |
| } from '$lib/apis/folders'; |
| import { |
| getChatById, |
| getChatsByFolderId, |
| getChatListByFolderId, |
| updateChatFolderIdById, |
| importChats |
| } from '$lib/apis/chats'; |
| |
| import ChevronDown from '../../icons/ChevronDown.svelte'; |
| import ChevronRight from '../../icons/ChevronRight.svelte'; |
| import Collapsible from '../../common/Collapsible.svelte'; |
| import DragGhost from '$lib/components/common/DragGhost.svelte'; |
| |
| import FolderOpen from '$lib/components/icons/FolderOpen.svelte'; |
| import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; |
| |
| import ChatItem from './ChatItem.svelte'; |
| import FolderMenu from './Folders/FolderMenu.svelte'; |
| import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; |
| import FolderModal from './Folders/FolderModal.svelte'; |
| import Emoji from '$lib/components/common/Emoji.svelte'; |
| import Spinner from '$lib/components/common/Spinner.svelte'; |
| |
| export let folderRegistry = {}; |
| export let open = false; |
| |
| export let folders; |
| export let folderId; |
| export let shiftKey = false; |
| |
| export let className = ''; |
| |
| export let deleteFolderContents = true; |
| |
| export let parentDragged = false; |
| |
| export let onDelete = (e) => {}; |
| export let onItemMove = (e) => {}; |
| |
| let folderElement; |
| |
| let showFolderModal = false; |
| let edit = false; |
| |
| let showCreateSubFolderModal = false; |
| let createSubFolderParentId = null; |
| |
| let draggedOver = false; |
| let dragged = false; |
| |
| let clickTimer = null; |
| |
| let name = ''; |
| |
| const onDragOver = (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (dragged || parentDragged) { |
| return; |
| } |
| draggedOver = true; |
| }; |
| |
| const onDrop = async (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (dragged || parentDragged) { |
| return; |
| } |
| |
| if (folderElement.contains(e.target)) { |
| console.log('Dropped on the Button'); |
| |
| if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { |
| // Iterate over all items in the DataTransferItemList use functional programming |
| for (const item of Array.from(e.dataTransfer.items)) { |
| // If dropped items aren't files, reject them |
| if (item.kind === 'file') { |
| const file = item.getAsFile(); |
| if (file && file.type === 'application/json') { |
| console.log('Dropped file is a JSON file!'); |
| |
| // Read the JSON file with FileReader |
| const reader = new FileReader(); |
| reader.onload = async function (event) { |
| try { |
| const fileContent = JSON.parse(event.target.result); |
| open = true; |
| dispatch('import', { |
| folderId: folderId, |
| items: fileContent |
| }); |
| } catch (error) { |
| console.error('Error parsing JSON file:', error); |
| } |
| }; |
| |
| |
| reader.readAsText(file); |
| } else { |
| console.error('Only JSON file types are supported.'); |
| } |
| |
| console.log(file); |
| } else { |
| // Handle the drag-and-drop data for folders or chats (same as before) |
| const dataTransfer = e.dataTransfer.getData('text/plain'); |
| |
| try { |
| const data = JSON.parse(dataTransfer); |
| console.log(data); |
| |
| const { type, id, item } = data; |
| |
| if (type === 'folder') { |
| open = true; |
| if (id === folderId) { |
| return; |
| } |
| |
| const res = await updateFolderParentIdById(localStorage.token, id, folderId).catch( |
| (error) => { |
| toast.error(`${error}`); |
| return null; |
| } |
| ); |
| |
| if (res) { |
| dispatch('update'); |
| } |
| } else if (type === 'chat') { |
| open = true; |
| |
| let chat = await getChatById(localStorage.token, id).catch((error) => { |
| return null; |
| }); |
| if (!chat && item) { |
| chat = await importChats(localStorage.token, [ |
| { |
| chat: item.chat, |
| meta: item?.meta ?? {}, |
| pinned: false, |
| folder_id: null, |
| created_at: item?.created_at ?? null, |
| updated_at: item?.updated_at ?? null |
| } |
| ]).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| } |
| |
| // Move the chat |
| const res = await updateChatFolderIdById( |
| localStorage.token, |
| chat.id, |
| folderId |
| ).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| |
| onItemMove({ |
| originFolderId: chat.folder_id, |
| targetFolderId: folderId, |
| e |
| }); |
| |
| if (res) { |
| dispatch('update'); |
| } |
| } |
| } catch (error) { |
| console.log('Error parsing dataTransfer:', error); |
| } |
| } |
| } |
| } |
| |
| setFolderItems(); |
| draggedOver = false; |
| } |
| }; |
| |
| const onDragLeave = (e) => { |
| e.preventDefault(); |
| if (dragged || parentDragged) { |
| return; |
| } |
| |
| draggedOver = false; |
| }; |
| |
| const dragImage = new Image(); |
| dragImage.src = |
| 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| |
| let x; |
| let y; |
| |
| const onDragStart = (event) => { |
| event.stopPropagation(); |
| event.dataTransfer.setDragImage(dragImage, 0, 0); |
| |
| // Set the data to be transferred |
| event.dataTransfer.setData( |
| 'text/plain', |
| JSON.stringify({ |
| type: 'folder', |
| id: folderId |
| }) |
| ); |
| |
| dragged = true; |
| folderElement.style.opacity = '0.5'; |
| }; |
| |
| const onDrag = (event) => { |
| event.stopPropagation(); |
| |
| x = event.clientX; |
| y = event.clientY; |
| }; |
| |
| const onDragEnd = (event) => { |
| event.stopPropagation(); |
| |
| folderElement.style.opacity = '1'; // Reset visual cue after drag |
| dragged = false; |
| }; |
| |
| onMount(async () => { |
| open = folders[folderId].is_expanded; |
| folderRegistry[folderId] = { |
| setFolderItems: () => { |
| setFolderItems(); |
| } |
| }; |
| if (folderElement) { |
| folderElement.addEventListener('dragover', onDragOver); |
| folderElement.addEventListener('drop', onDrop); |
| folderElement.addEventListener('dragleave', onDragLeave); |
| |
| // Event listener for when dragging starts |
| folderElement.addEventListener('dragstart', onDragStart); |
| // Event listener for when dragging occurs (optional) |
| folderElement.addEventListener('drag', onDrag); |
| // Event listener for when dragging ends |
| folderElement.addEventListener('dragend', onDragEnd); |
| } |
| |
| if (folders[folderId]?.new) { |
| delete folders[folderId].new; |
| await tick(); |
| renameHandler(); |
| } |
| }); |
| |
| onDestroy(() => { |
| if (folderElement) { |
| folderElement.addEventListener('dragover', onDragOver); |
| folderElement.removeEventListener('drop', onDrop); |
| folderElement.removeEventListener('dragleave', onDragLeave); |
| |
| folderElement.removeEventListener('dragstart', onDragStart); |
| folderElement.removeEventListener('drag', onDrag); |
| folderElement.removeEventListener('dragend', onDragEnd); |
| } |
| }); |
| |
| let showDeleteConfirm = false; |
| |
| const deleteHandler = async () => { |
| const res = await deleteFolderById(localStorage.token, folderId, deleteFolderContents).catch( |
| (error) => { |
| toast.error(`${error}`); |
| return null; |
| } |
| ); |
| |
| if (res) { |
| toast.success($i18n.t('Folder deleted successfully')); |
| onDelete(folderId); |
| } |
| }; |
| |
| const updateHandler = async ({ name, meta, data }) => { |
| if (name === '') { |
| toast.error($i18n.t('Folder name cannot be empty.')); |
| return; |
| } |
| |
| const currentName = folders[folderId].name; |
| |
| name = name.trim(); |
| folders[folderId].name = name; |
| |
| const res = await updateFolderById(localStorage.token, folderId, { |
| name, |
| ...(meta ? { meta } : {}), |
| ...(data ? { data } : {}) |
| }).catch((error) => { |
| toast.error(`${error}`); |
| |
| folders[folderId].name = currentName; |
| return null; |
| }); |
| |
| if (res) { |
| folders[folderId].name = name; |
| if (data) { |
| folders[folderId].data = data; |
| } |
| |
| |
| toast.success($i18n.t('Folder updated successfully')); |
| |
| if ($selectedFolder?.id === folderId) { |
| const folder = await getFolderById(localStorage.token, folderId).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| |
| if (folder) { |
| await selectedFolder.set(folder); |
| } |
| } |
| dispatch('update'); |
| } |
| }; |
| |
| const isExpandedUpdateHandler = async () => { |
| const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch( |
| (error) => { |
| toast.error(`${error}`); |
| return null; |
| } |
| ); |
| }; |
| |
| let isExpandedUpdateTimeout; |
| |
| const isExpandedUpdateDebounceHandler = () => { |
| clearTimeout(isExpandedUpdateTimeout); |
| isExpandedUpdateTimeout = setTimeout(() => { |
| isExpandedUpdateHandler(); |
| }, 500); |
| }; |
| |
| let chats = null; |
| export const setFolderItems = async () => { |
| await tick(); |
| if (open) { |
| chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => { |
| toast.error(`${error}`); |
| return []; |
| }); |
| } else { |
| chats = null; |
| } |
| }; |
| |
| $: if (open) { |
| setFolderItems(); |
| } |
| |
| const renameHandler = async () => { |
| console.log('Edit'); |
| await tick(); |
| name = folders[folderId].name; |
| edit = true; |
| |
| await tick(); |
| await tick(); |
| |
| const input = document.getElementById(`folder-${folderId}-input`); |
| if (input) { |
| input.focus(); |
| input.select(); |
| } |
| }; |
| |
| const exportHandler = async () => { |
| const chats = await getChatsByFolderId(localStorage.token, folderId).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| if (!chats) { |
| return; |
| } |
| |
| const blob = new Blob([JSON.stringify(chats)], { |
| type: 'application/json' |
| }); |
| |
| saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`); |
| }; |
| |
| const createSubFolderHandler = async ({ name, meta, data, parent_id }) => { |
| if (name === '') { |
| toast.error($i18n.t('Folder name cannot be empty.')); |
| return; |
| } |
| |
| name = name.trim(); |
| |
| const res = await createNewFolder(localStorage.token, { |
| name, |
| data, |
| meta, |
| parent_id |
| }).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| |
| if (res) { |
| toast.success($i18n.t('Folder created successfully')); |
| dispatch('update'); |
| } |
| }; |
| </script> |
| |
| <DeleteConfirmDialog |
| bind:show={showDeleteConfirm} |
| title={$i18n.t('Delete folder?')} |
| on:confirm={() => { |
| deleteHandler(); |
| }} |
| > |
| <div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3 mb-2"> |
| {$i18n.t('This will delete <strong>{{NAME}{ |
| NAME: folders[folderId].name |
| } |
| |
| {$i18n.t(`Are you sure you want to delete "{{NAME}}"?`, { |
| NAME: folders[folderId].name |
| })} |
| </div> |
| |
| <div class="flex items-center gap-1.5"> |
| <input type="checkbox" bind:checked={deleteFolderContents} /> |
| |
| <div class="text-xs text-gray-500"> |
| {$i18n.t('Delete all contents inside this folder')} |
| </div> |
| </div> |
| </DeleteConfirmDialog> |
| |
| <FolderModal bind:show={showFolderModal} edit={true} {folderId} onSubmit={updateHandler} /> |
| |
| <FolderModal |
| bind:show={showCreateSubFolderModal} |
| parentId={createSubFolderParentId} |
| onSubmit={createSubFolderHandler} |
| /> |
| |
| {#if dragged && x && y} |
| <DragGhost {x} {y}> |
| <div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40"> |
| <div class="flex items-center gap-1"> |
| <FolderOpen className="size-3.5" strokeWidth="2" /> |
| <div class=" text-xs text-white line-clamp-1"> |
| {folders[folderId].name} |
| </div> |
| </div> |
| </div> |
| </DragGhost> |
| {/if} |
| |
| <div bind:this={folderElement} class="relative {className}" draggable="true"> |
| {#if draggedOver} |
| <div |
| 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" |
| ></div> |
| {/if} |
| |
| <Collapsible |
| bind:open |
| className="w-full" |
| buttonClassName="w-full" |
| onChange={(state) => { |
| dispatch('open', state); |
| }} |
| > |
| |
| <div class="w-full group"> |
| <div |
| id="folder-{folderId}-button" |
| 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 === |
| folderId |
| ? 'bg-gray-100 dark:bg-gray-900 selected' |
| : ''}" |
| on:dblclick={(e) => { |
| if (clickTimer) { |
| clearTimeout(clickTimer); // cancel the single-click action |
| clickTimer = null; |
| } |
| renameHandler(); |
| }} |
| on:click={async (e) => { |
| (e) => e.stopPropagation(); |
| if (clickTimer) { |
| clearTimeout(clickTimer); |
| clickTimer = null; |
| } |
| |
| clickTimer = setTimeout(async () => { |
| const folder = await getFolderById(localStorage.token, folderId).catch((error) => { |
| toast.error(`${error}`); |
| return null; |
| }); |
| |
| if (folder) { |
| await selectedFolder.set(folder); |
| } |
| |
| await goto('/'); |
| |
| if ($mobile) { |
| showSidebar.set(!$showSidebar); |
| } |
| clickTimer = null; |
| }, 100); // 100ms delay (typical double-click threshold) |
| }} |
| on:pointerup={(e) => { |
| e.stopPropagation(); |
| }} |
| > |
| <button |
| class="text-gray-500 dark:text-gray-500 transition-all p-1 hover:bg-gray-200 dark:hover:bg-gray-850 rounded-lg" |
| on:click={(e) => { |
| e.stopPropagation(); |
| e.stopImmediatePropagation(); |
| open = !open; |
| isExpandedUpdateDebounceHandler(); |
| }} |
| > |
| {#if folders[folderId]?.meta?.icon} |
| <div class="flex group-hover:hidden transition-all"> |
| <Emoji className="size-3.5" shortCode={folders[folderId].meta.icon} /> |
| </div> |
| |
| <div class="hidden group-hover:flex transition-all p-[1px]"> |
| {#if open} |
| <ChevronDown className=" size-3" strokeWidth="2.5" /> |
| {:else} |
| <ChevronRight className=" size-3" strokeWidth="2.5" /> |
| {/if} |
| </div> |
| {:else} |
| <div class="p-[1px]"> |
| {#if open} |
| <ChevronDown className=" size-3" strokeWidth="2.5" /> |
| {:else} |
| <ChevronRight className=" size-3" strokeWidth="2.5" /> |
| {/if} |
| </div> |
| {/if} |
| </button> |
| |
| <div class="translate-y-[0.5px] flex-1 justify-start text-start line-clamp-1"> |
| {#if edit} |
| <input |
| id="folder-{folderId}-input" |
| type="text" |
| bind:value={name} |
| on:blur={() => { |
| console.log('Blur'); |
| updateHandler({ name }); |
| edit = false; |
| }} |
| on:click={(e) => { |
| // Prevent accidental collapse toggling when clicking inside input |
| e.stopPropagation(); |
| }} |
| on:mousedown={(e) => { |
| // Prevent accidental collapse toggling when clicking inside input |
| e.stopPropagation(); |
| }} |
| on:keydown={(e) => { |
| if (e.key === 'Enter') { |
| updateHandler({ name }); |
| edit = false; |
| } |
| }} |
| class="w-full h-full bg-transparent outline-hidden" |
| /> |
| {:else} |
| {folders[folderId].name} |
| {/if} |
| </div> |
| |
| <button |
| class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300" |
| > |
| <FolderMenu |
| onEdit={() => { |
| showFolderModal = true; |
| }} |
| onDelete={() => { |
| showDeleteConfirm = true; |
| }} |
| onExport={() => { |
| exportHandler(); |
| }} |
| onCreateSubFolder={() => { |
| createSubFolderParentId = folderId; |
| showCreateSubFolderModal = true; |
| }} |
| > |
| <div class="p-1 dark:hover:bg-gray-850 rounded-lg touch-auto"> |
| <EllipsisHorizontal className="size-4" strokeWidth="2.5" /> |
| </div> |
| </FolderMenu> |
| </button> |
| </div> |
| </div> |
| |
| <div slot="content" class="w-full"> |
| {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0} |
| <div |
| class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900" |
| > |
| {#if folders[folderId]?.childrenIds} |
| {@const children = folders[folderId]?.childrenIds |
| .map((id) => folders[id]) |
| .sort((a, b) => |
| a.name.localeCompare(b.name, undefined, { |
| numeric: true, |
| sensitivity: 'base' |
| }) |
| )} |
| |
| {#each children as childFolder (`${folderId}-${childFolder.id}`)} |
| <svelte:self |
| bind:folderRegistry |
| {folders} |
| folderId={childFolder.id} |
| {shiftKey} |
| parentDragged={dragged} |
| {onItemMove} |
| {onDelete} |
| on:import={(e) => { |
| dispatch('import', e.detail); |
| }} |
| on:update={(e) => { |
| dispatch('update', e.detail); |
| }} |
| on:change={(e) => { |
| dispatch('change', e.detail); |
| }} |
| /> |
| {/each} |
| {/if} |
| |
| {#each chats ?? [] as chat (chat.id)} |
| <ChatItem |
| id={chat.id} |
| title={chat.title} |
| createdAt={chat.created_at} |
| updatedAt={chat.updated_at} |
| lastReadAt={chat.last_read_at} |
| {shiftKey} |
| on:change={(e) => { |
| dispatch('change', e.detail); |
| }} |
| /> |
| {/each} |
| </div> |
| {/if} |
| |
| {#if chats === null} |
| <div class="flex justify-center items-center p-2"> |
| <Spinner className="size-4 text-gray-500" /> |
| </div> |
| {/if} |
| </div> |
| </Collapsible> |
| </div> |
| |