| | <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') { |
| | nameUpdateHandler(); |
| | 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> |
| | |