| <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 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 { |
| deleteFolderById, |
| updateFolderIsExpandedById, |
| updateFolderNameById, |
| updateFolderParentIdById |
| } from '$lib/apis/folders'; |
| import { toast } from 'svelte-sonner'; |
| import { getChatsByFolderId, updateChatFolderIdById } from '$lib/apis/chats'; |
| import ChatItem from './ChatItem.svelte'; |
| import FolderMenu from './Folders/FolderMenu.svelte'; |
| import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; |
| |
| export let open = false; |
| |
| export let folders; |
| export let folderId; |
| |
| export let className = ''; |
| |
| export let parentDragged = false; |
| |
| let folderElement; |
| |
| let edit = false; |
| |
| let draggedOver = false; |
| let dragged = false; |
| |
| 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'); |
| const data = JSON.parse(dataTransfer); |
| console.log(data); |
| |
| const { type, id } = 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; |
| |
| // Move the chat |
| const res = await updateChatFolderIdById(localStorage.token, id, folderId).catch( |
| (error) => { |
| toast.error(error); |
| return null; |
| } |
| ); |
| |
| if (res) { |
| dispatch('update'); |
| } |
| } |
| } |
| } |
| } |
| |
| 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(() => { |
| open = folders[folderId].is_expanded; |
| 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); |
| } |
| }); |
| |
| 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).catch((error) => { |
| toast.error(error); |
| return null; |
| }); |
| |
| if (res) { |
| toast.success($i18n.t('Folder deleted successfully')); |
| dispatch('update'); |
| } |
| }; |
| |
| const nameUpdateHandler = async () => { |
| if (name === '') { |
| toast.error($i18n.t('Folder name cannot be empty')); |
| return; |
| } |
| |
| if (name === folders[folderId].name) { |
| edit = false; |
| return; |
| } |
| |
| const currentName = folders[folderId].name; |
| |
| name = name.trim(); |
| folders[folderId].name = name; |
| |
| const res = await updateFolderNameById(localStorage.token, folderId, name).catch((error) => { |
| toast.error(error); |
| |
| folders[folderId].name = currentName; |
| return null; |
| }); |
| |
| if (res) { |
| folders[folderId].name = name; |
| toast.success($i18n.t('Folder name updated successfully')); |
| dispatch('update'); |
| } |
| }; |
| |
| const isExpandedUpdateHandler = async () => { |
| const res = await updateFolderIsExpandedById(localStorage.token, folderId, open).catch( |
| (error) => { |
| toast.error(error); |
| return null; |
| } |
| ); |
| }; |
| |
| let isExpandedUpdateTimeout; |
| |
| const isExpandedUpdateDebounceHandler = (open) => { |
| clearTimeout(isExpandedUpdateTimeout); |
| isExpandedUpdateTimeout = setTimeout(() => { |
| isExpandedUpdateHandler(); |
| }, 500); |
| }; |
| |
| $: isExpandedUpdateDebounceHandler(open); |
| |
| const editHandler = async () => { |
| console.log('Edit'); |
| await tick(); |
| name = folders[folderId].name; |
| edit = true; |
| |
| await tick(); |
| |
| // focus on the input |
| setTimeout(() => { |
| const input = document.getElementById(`folder-${folderId}-input`); |
| input.focus(); |
| }, 100); |
| }; |
| |
| 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`); |
| }; |
| </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"> |
| {@html DOMPurify.sanitize( |
| $i18n.t('This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.', { |
| NAME: folders[folderId].name |
| }) |
| )} |
| </div> |
| </DeleteConfirmDialog> |
| |
| {#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-sm bg-[hsla(260,85%,65%,0.1)] 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" |
| hide={(folders[folderId]?.childrenIds ?? []).length === 0 && |
| (folders[folderId].items?.chats ?? []).length === 0} |
| on:change={(e) => { |
| dispatch('open', e.detail); |
| }} |
| > |
| <!-- svelte-ignore a11y-no-static-element-interactions --> |
| <div class="w-full group"> |
| <button |
| id="folder-{folderId}-button" |
| class="relative w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition" |
| on:dblclick={() => { |
| editHandler(); |
| }} |
| > |
| <div class="text-gray-300 dark:text-gray-600"> |
| {#if open} |
| <ChevronDown className=" size-3" strokeWidth="2.5" /> |
| {:else} |
| <ChevronRight className=" size-3" strokeWidth="2.5" /> |
| {/if} |
| </div> |
| |
| <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={() => { |
| nameUpdateHandler(); |
| 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') { |
| edit = false; |
| } |
| }} |
| class="w-full h-full bg-transparent text-gray-500 dark:text-gray-500 outline-none" |
| /> |
| {: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" |
| on:pointerup={(e) => { |
| e.stopPropagation(); |
| }} |
| > |
| <FolderMenu |
| on:rename={() => { |
| editHandler(); |
| }} |
| on:delete={() => { |
| showDeleteConfirm = true; |
| }} |
| on:export={() => { |
| exportHandler(); |
| }} |
| > |
| <button class="p-0.5 dark:hover:bg-gray-850 rounded-lg touch-auto" on:click={(e) => {}}> |
| <EllipsisHorizontal className="size-4" strokeWidth="2.5" /> |
| </button> |
| </FolderMenu> |
| </button> |
| </button> |
| </div> |
| |
| <div slot="content" class="w-full"> |
| {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (folders[folderId].items?.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 |
| {folders} |
| folderId={childFolder.id} |
| parentDragged={dragged} |
| on:import={(e) => { |
| dispatch('import', e.detail); |
| }} |
| on:update={(e) => { |
| dispatch('update', e.detail); |
| }} |
| on:change={(e) => { |
| dispatch('change', e.detail); |
| }} |
| /> |
| {/each} |
| {/if} |
| |
| {#if folders[folderId].items?.chats} |
| {#each folders[folderId].items.chats as chat (chat.id)} |
| <ChatItem |
| id={chat.id} |
| title={chat.title} |
| on:change={(e) => { |
| dispatch('change', e.detail); |
| }} |
| /> |
| {/each} |
| {/if} |
| </div> |
| {/if} |
| </div> |
| </Collapsible> |
| </div> |
| |