mirror of
https://github.com/open-webui/open-webui
synced 2025-06-26 18:26:48 +00:00
feat: folder ui
This commit is contained in:
198
src/lib/apis/folders/index.ts
Normal file
198
src/lib/apis/folders/index.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export const createNewFolder = async (token: string, name: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFolders = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getFolderById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateFolderNameById = async (token: string, id: string, name: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
type FolderItems = {
|
||||
chat_ids: string[];
|
||||
file_ids: string[];
|
||||
folder_ids: string[];
|
||||
};
|
||||
|
||||
export const updateFolderItemsById = async (token: string, id: string, items: FolderItems) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update/items`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
items: items
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteFolderById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.log(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -24,7 +24,7 @@
|
||||
bind:this={popupElement}
|
||||
class="fixed top-0 left-0 w-screen h-[100dvh] z-50 touch-none pointer-events-none"
|
||||
>
|
||||
<div class=" absolute text-white z-[99999]" style="top: {y}px; left: {x}px;">
|
||||
<div class=" absolute text-white z-[99999]" style="top: {y + 10}px; left: {x + 10}px;">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
export let name = '';
|
||||
export let collapsible = true;
|
||||
|
||||
export let className = '';
|
||||
|
||||
let folderElement;
|
||||
|
||||
let dragged = false;
|
||||
let draggedOver = false;
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
dragged = true;
|
||||
draggedOver = true;
|
||||
};
|
||||
|
||||
const onDrop = (e) => {
|
||||
@@ -29,19 +31,23 @@
|
||||
if (folderElement.contains(e.target)) {
|
||||
console.log('Dropped on the Button');
|
||||
|
||||
// get data from the drag event
|
||||
const dataTransfer = e.dataTransfer.getData('text/plain');
|
||||
const data = JSON.parse(dataTransfer);
|
||||
console.log(data);
|
||||
dispatch('drop', data);
|
||||
try {
|
||||
// get data from the drag event
|
||||
const dataTransfer = e.dataTransfer.getData('text/plain');
|
||||
const data = JSON.parse(dataTransfer);
|
||||
console.log(data);
|
||||
dispatch('drop', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
dragged = false;
|
||||
draggedOver = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
dragged = false;
|
||||
draggedOver = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -57,10 +63,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={folderElement} class="relative">
|
||||
{#if dragged}
|
||||
<div bind:this={folderElement} class="relative {className}">
|
||||
{#if draggedOver}
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full rounded-sm bg-gray-200 bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
|
||||
class="absolute top-0 left-0 w-full h-full rounded-sm bg-[hsla(258,88%,66%,0.1)] bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
@@ -74,7 +80,7 @@
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="mx-2 w-full">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="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"
|
||||
>
|
||||
@@ -92,7 +98,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div slot="content" class=" pl-2">
|
||||
<div slot="content" class="w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
19
src/lib/components/icons/Document.svelte
Normal file
19
src/lib/components/icons/Document.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={strokeWidth}
|
||||
stroke="currentColor"
|
||||
class={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -43,6 +43,8 @@
|
||||
import Folder from '../common/Folder.svelte';
|
||||
import Plus from '../icons/Plus.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import { createNewFolder, getFolders } from '$lib/apis/folders';
|
||||
import Folders from './Sidebar/Folders.svelte';
|
||||
|
||||
const BREAKPOINT = 768;
|
||||
|
||||
@@ -65,6 +67,55 @@
|
||||
let chatListLoading = false;
|
||||
let allChatsLoaded = false;
|
||||
|
||||
let folders = {};
|
||||
|
||||
const initFolders = async () => {
|
||||
const folderList = await getFolders(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
for (const folder of folderList) {
|
||||
folders[folder.id] = { ...(folders[folder.id] ? folders[folder.id] : {}), ...folder };
|
||||
|
||||
if (folders[folder.id].parent_id) {
|
||||
folders[folders[folder.id].parent_id].childrenIds = folders[folders[folder.id].parent_id]
|
||||
.childrenIds
|
||||
? [...folders[folders[folder.id].parent_id].childrenIds, folder.id]
|
||||
: [folder.id];
|
||||
|
||||
folders[folders[folder.id].parent_id].childrenIds.sort((a, b) => {
|
||||
return folders[b].updated_at - folders[a].updated_at;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async (name = 'Untitled') => {
|
||||
if (name === '') {
|
||||
toast.error($i18n.t('Folder name cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.toLowerCase() in folders) {
|
||||
// If a folder with the same name already exists, append a number to the name
|
||||
let i = 1;
|
||||
while (name.toLowerCase() + ` ${i}` in folders) {
|
||||
i++;
|
||||
}
|
||||
name = name + ` ${i}`;
|
||||
}
|
||||
|
||||
const res = await createNewFolder(localStorage.token, name).catch((error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
await initFolders();
|
||||
}
|
||||
};
|
||||
|
||||
const initChatList = async () => {
|
||||
// Reset pagination variables
|
||||
tags.set(await getAllTags(localStorage.token));
|
||||
@@ -280,6 +331,7 @@
|
||||
localStorage.sidebar = value;
|
||||
});
|
||||
|
||||
await initFolders();
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
await initChatList();
|
||||
|
||||
@@ -491,7 +543,12 @@
|
||||
|
||||
<div class="absolute z-40 right-4 top-1">
|
||||
<Tooltip content={$i18n.t('New folder')}>
|
||||
<button class="p-1 rounded-lg hover:bg-white/5 transition">
|
||||
<button
|
||||
class="p-1 rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 transition"
|
||||
on:click={() => {
|
||||
createFolder();
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -514,33 +571,40 @@
|
||||
{/if}
|
||||
|
||||
{#if !search && $pinnedChats.length > 0}
|
||||
<div class=" flex flex-col space-y-1">
|
||||
<div class="flex flex-col space-y-1 rounded-xl">
|
||||
<Folder
|
||||
className="px-2"
|
||||
bind:open={showPinnedChat}
|
||||
on:change={(e) => {
|
||||
localStorage.setItem('showPinnedChat', e.detail);
|
||||
console.log(e.detail);
|
||||
}}
|
||||
on:drop={async (e) => {
|
||||
const { id } = e.detail;
|
||||
const { type, id } = e.detail;
|
||||
|
||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||
if (type === 'chat') {
|
||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||
|
||||
if (!status) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||
if (!status) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||
|
||||
if (res) {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
if (res) {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
name={$i18n.t('Pinned')}
|
||||
>
|
||||
<div class="pl-2 mt-0.5 flex flex-col overflow-y-auto scrollbar-hidden">
|
||||
<div
|
||||
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
|
||||
>
|
||||
{#each $pinnedChats as chat, idx}
|
||||
<ChatItem
|
||||
{chat}
|
||||
className=""
|
||||
id={chat.id}
|
||||
title={chat.title}
|
||||
{shiftKey}
|
||||
selected={selectedChatId === chat.id}
|
||||
on:select={() => {
|
||||
@@ -557,6 +621,10 @@
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
}}
|
||||
on:change={async () => {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
}}
|
||||
on:tag={(e) => {
|
||||
const { type, name } = e.detail;
|
||||
tagEventHandler(type, name, chat.id);
|
||||
@@ -568,20 +636,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if folders}
|
||||
<div class=" flex flex-col">
|
||||
<Folders {folders} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 flex flex-col space-y-1 overflow-y-auto scrollbar-hidden">
|
||||
<Folder
|
||||
collapsible={false}
|
||||
on:drop={async (e) => {
|
||||
const { id } = e.detail;
|
||||
const { type, id } = e.detail;
|
||||
|
||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||
if (type === 'chat') {
|
||||
const status = await getChatPinnedStatusById(localStorage.token, id);
|
||||
|
||||
if (status) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||
if (status) {
|
||||
const res = await toggleChatPinnedStatusById(localStorage.token, id);
|
||||
|
||||
if (res) {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
if (res) {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -619,7 +695,8 @@
|
||||
{/if}
|
||||
|
||||
<ChatItem
|
||||
{chat}
|
||||
id={chat.id}
|
||||
title={chat.title}
|
||||
{shiftKey}
|
||||
selected={selectedChatId === chat.id}
|
||||
on:select={() => {
|
||||
@@ -636,6 +713,10 @@
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
}}
|
||||
on:change={async () => {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
initChatList();
|
||||
}}
|
||||
on:tag={(e) => {
|
||||
const { type, name } = e.detail;
|
||||
tagEventHandler(type, name, chat.id);
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||||
import DragGhost from '$lib/components/common/DragGhost.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Document from '$lib/components/icons/Document.svelte';
|
||||
|
||||
export let className = 'pr-2';
|
||||
|
||||
export let id;
|
||||
export let title;
|
||||
|
||||
export let chat;
|
||||
export let selected = false;
|
||||
export let shiftKey = false;
|
||||
|
||||
@@ -43,7 +50,7 @@
|
||||
let showShareChatModal = false;
|
||||
let confirmEdit = false;
|
||||
|
||||
let chatTitle = chat.title;
|
||||
let chatTitle = title;
|
||||
|
||||
const editChatTitle = async (id, title) => {
|
||||
if (title === '') {
|
||||
@@ -93,7 +100,7 @@
|
||||
|
||||
let itemElement;
|
||||
|
||||
let drag = false;
|
||||
let dragged = false;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
@@ -108,11 +115,12 @@
|
||||
event.dataTransfer.setData(
|
||||
'text/plain',
|
||||
JSON.stringify({
|
||||
id: chat.id
|
||||
type: 'chat',
|
||||
id: id
|
||||
})
|
||||
);
|
||||
|
||||
drag = true;
|
||||
dragged = true;
|
||||
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
|
||||
};
|
||||
|
||||
@@ -123,7 +131,7 @@
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
itemElement.style.opacity = '1'; // Reset visual cue after drag
|
||||
drag = false;
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
@@ -146,24 +154,26 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={chat.id} />
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
|
||||
|
||||
{#if drag && x && y}
|
||||
{#if dragged && x && y}
|
||||
<DragGhost {x} {y}>
|
||||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-44">
|
||||
<div>
|
||||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-40">
|
||||
<div class="flex items-center gap-1">
|
||||
<Document className="size-4" strokeWidth="2" />
|
||||
<div class=" text-xs text-white line-clamp-1">
|
||||
{chat.title}
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DragGhost>
|
||||
{/if}
|
||||
|
||||
<div bind:this={itemElement} class=" w-full pr-2 relative group" draggable="true">
|
||||
<div bind:this={itemElement} class=" w-full {className} relative group" draggable="true">
|
||||
{#if confirmEdit}
|
||||
<div
|
||||
class=" w-full flex justify-between rounded-xl px-2.5 py-2 {chat.id === $chatId || confirmEdit
|
||||
class=" w-full flex justify-between rounded-lg px-[11px] py-[7px] {id === $chatId ||
|
||||
confirmEdit
|
||||
? 'bg-gray-200 dark:bg-gray-900'
|
||||
: selected
|
||||
? 'bg-gray-100 dark:bg-gray-950'
|
||||
@@ -177,12 +187,13 @@
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
class=" w-full flex justify-between rounded-lg px-2.5 py-2 {chat.id === $chatId || confirmEdit
|
||||
class=" w-full flex justify-between rounded-lg px-[11px] py-[7px] {id === $chatId ||
|
||||
confirmEdit
|
||||
? 'bg-gray-200 dark:bg-gray-900'
|
||||
: selected
|
||||
? 'bg-gray-100 dark:bg-gray-950'
|
||||
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
||||
href="/c/{chat.id}"
|
||||
href="/c/{id}"
|
||||
on:click={() => {
|
||||
dispatch('select');
|
||||
|
||||
@@ -191,7 +202,7 @@
|
||||
}
|
||||
}}
|
||||
on:dblclick={() => {
|
||||
chatTitle = chat.title;
|
||||
chatTitle = title;
|
||||
confirmEdit = true;
|
||||
}}
|
||||
on:mouseenter={(e) => {
|
||||
@@ -205,7 +216,7 @@
|
||||
>
|
||||
<div class=" flex self-center flex-1 w-full">
|
||||
<div class=" text-left self-center overflow-hidden w-full h-[20px]">
|
||||
{chat.title}
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -214,12 +225,14 @@
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="
|
||||
{chat.id === $chatId || confirmEdit
|
||||
{id === $chatId || confirmEdit
|
||||
? 'from-gray-200 dark:from-gray-900'
|
||||
: selected
|
||||
? 'from-gray-100 dark:from-gray-950'
|
||||
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
|
||||
absolute right-[10px] top-[6px] py-1 pr-2 pl-5 bg-gradient-to-l from-80%
|
||||
absolute {className === 'pr-2'
|
||||
? 'right-[8px]'
|
||||
: 'right-0'} top-[5px] py-1 pr-0.5 mr-2 pl-5 bg-gradient-to-l from-80%
|
||||
|
||||
to-transparent"
|
||||
on:mouseenter={(e) => {
|
||||
@@ -230,28 +243,19 @@
|
||||
}}
|
||||
>
|
||||
{#if confirmEdit}
|
||||
<div class="flex self-center space-x-1.5 z-10">
|
||||
<div
|
||||
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
|
||||
>
|
||||
<Tooltip content={$i18n.t('Confirm')}>
|
||||
<button
|
||||
class=" self-center dark:hover:text-white transition"
|
||||
on:click={() => {
|
||||
editChatTitle(chat.id, chatTitle);
|
||||
editChatTitle(id, chatTitle);
|
||||
confirmEdit = false;
|
||||
chatTitle = '';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Check className=" size-3.5" strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -263,16 +267,7 @@
|
||||
chatTitle = '';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
<XMark strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -282,7 +277,7 @@
|
||||
<button
|
||||
class=" self-center dark:hover:text-white transition"
|
||||
on:click={() => {
|
||||
archiveChatHandler(chat.id);
|
||||
archiveChatHandler(id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
@@ -305,18 +300,18 @@
|
||||
{:else}
|
||||
<div class="flex self-center space-x-1 z-10">
|
||||
<ChatMenu
|
||||
chatId={chat.id}
|
||||
chatId={id}
|
||||
cloneChatHandler={() => {
|
||||
cloneChatHandler(chat.id);
|
||||
cloneChatHandler(id);
|
||||
}}
|
||||
shareHandler={() => {
|
||||
showShareChatModal = true;
|
||||
}}
|
||||
archiveChatHandler={() => {
|
||||
archiveChatHandler(chat.id);
|
||||
archiveChatHandler(id);
|
||||
}}
|
||||
renameHandler={() => {
|
||||
chatTitle = chat.title;
|
||||
chatTitle = title;
|
||||
|
||||
confirmEdit = true;
|
||||
}}
|
||||
@@ -327,7 +322,7 @@
|
||||
dispatch('unselect');
|
||||
}}
|
||||
on:change={async () => {
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
dispatch('change');
|
||||
}}
|
||||
on:tag={(e) => {
|
||||
dispatch('tag', e.detail);
|
||||
@@ -353,7 +348,7 @@
|
||||
</button>
|
||||
</ChatMenu>
|
||||
|
||||
{#if chat.id === $chatId}
|
||||
{#if id === $chatId}
|
||||
<!-- Shortcut support using "delete-chat-button" id -->
|
||||
<button
|
||||
id="delete-chat-button"
|
||||
|
||||
14
src/lib/components/layout/Sidebar/Folders.svelte
Normal file
14
src/lib/components/layout/Sidebar/Folders.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import RecursiveFolder from './RecursiveFolder.svelte';
|
||||
export let folders = {};
|
||||
|
||||
let folderList = [];
|
||||
// Get the list of folders that have no parent, sorted by name alphabetically
|
||||
$: folderList = Object.keys(folders)
|
||||
.filter((key) => folders[key].parent_id === null)
|
||||
.sort((a, b) => folders[a].name.localeCompare(folders[b].name));
|
||||
</script>
|
||||
|
||||
{#each folderList as folderId (folderId)}
|
||||
<RecursiveFolder className="px-2" {folders} {folderId} />
|
||||
{/each}
|
||||
220
src/lib/components/layout/Sidebar/RecursiveFolder.svelte
Normal file
220
src/lib/components/layout/Sidebar/RecursiveFolder.svelte
Normal file
@@ -0,0 +1,220 @@
|
||||
<script>
|
||||
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
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';
|
||||
|
||||
export let open = true;
|
||||
|
||||
export let folders;
|
||||
export let folderId;
|
||||
|
||||
export let className;
|
||||
|
||||
let folderElement;
|
||||
|
||||
let edit = false;
|
||||
|
||||
let draggedOver = false;
|
||||
let dragged = false;
|
||||
|
||||
let name = '';
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
draggedOver = true;
|
||||
};
|
||||
|
||||
const onDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (folderElement.contains(e.target)) {
|
||||
console.log('Dropped on the Button');
|
||||
|
||||
try {
|
||||
// get data from the drag event
|
||||
const dataTransfer = e.dataTransfer.getData('text/plain');
|
||||
const data = JSON.parse(dataTransfer);
|
||||
console.log(data);
|
||||
dispatch('drop', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
draggedOver = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
draggedOver = false;
|
||||
};
|
||||
|
||||
const dragImage = new Image();
|
||||
dragImage.src =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
let x;
|
||||
let y;
|
||||
|
||||
const onDragStart = (event) => {
|
||||
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'; // Optional: Visual cue to show it's being dragged
|
||||
};
|
||||
|
||||
const onDrag = (event) => {
|
||||
x = event.clientX;
|
||||
y = event.clientY;
|
||||
};
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
folderElement.style.opacity = '1'; // Reset visual cue after drag
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if dragged && x && y}
|
||||
<DragGhost {x} {y}>
|
||||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg 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(258,88%,66%,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"
|
||||
on:change={(e) => {
|
||||
dispatch('open', e.detail);
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="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={() => {
|
||||
name = folders[folderId].name;
|
||||
edit = true;
|
||||
|
||||
// focus on the input
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`folder-${folderId}-input`);
|
||||
input.focus();
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{#if edit}
|
||||
<input
|
||||
id="folder-{folderId}-input"
|
||||
type="text"
|
||||
bind:value={folders[folderId].name}
|
||||
on:input={(e) => {
|
||||
folders[folderId].name = e.target.value;
|
||||
}}
|
||||
on:blur={() => {
|
||||
edit = false;
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div slot="content" class="w-full">
|
||||
{#if folders[folderId].childrenIds || folders[folderId].items?.chat_ids}
|
||||
<div
|
||||
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s dark:border-gray-850"
|
||||
>
|
||||
{#if folders[folderId]?.childrenIds}
|
||||
{#each folders[folderId]?.childrenIds as folderId (folderId)}
|
||||
<svelte:self {folders} {folderId} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if folders[folderId].items?.chat_ids}
|
||||
{#each folder.items.chat_ids as chatId (chatId)}
|
||||
{chatId}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
Reference in New Issue
Block a user