feat: folder ui

This commit is contained in:
Timothy J. Baek 2024-10-16 21:05:03 -07:00
parent ede71740d2
commit a942c30ca8
13 changed files with 917 additions and 87 deletions

View File

@ -9,6 +9,7 @@ from open_webui.apps.webui.models.models import Models
from open_webui.apps.webui.routers import (
auths,
chats,
folders,
configs,
files,
functions,
@ -110,6 +111,7 @@ app.include_router(configs.router, prefix="/configs", tags=["configs"])
app.include_router(auths.router, prefix="/auths", tags=["auths"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(chats.router, prefix="/chats", tags=["chats"])
app.include_router(folders.router, prefix="/folders", tags=["folders"])
app.include_router(models.router, prefix="/models", tags=["models"])
app.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])

View File

@ -33,6 +33,7 @@ class Chat(Base):
pinned = Column(Boolean, default=False, nullable=True)
meta = Column(JSON, server_default="{}")
folder_id = Column(Text, nullable=True)
class ChatModel(BaseModel):
@ -51,6 +52,7 @@ class ChatModel(BaseModel):
pinned: Optional[bool] = False
meta: dict = {}
folder_id: Optional[str] = None
####################
@ -512,6 +514,29 @@ class ChatTable:
# Validate and return chats
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str
) -> list[ChatModel]:
with get_db() as db:
all_chats = (
db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id).all()
)
return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_folder_id_by_id_and_user_id(
self, id: str, user_id: str, folder_id: str
) -> Optional[ChatModel]:
try:
with get_db() as db:
chat = db.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
db.commit()
db.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]:
with get_db() as db:
chat = db.get(Chat, id)

View File

@ -22,7 +22,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class FolderItems(BaseModel):
chat_ids: Optional[list[str]] = None
file_ids: Optional[list[str]] = None
folder_ids: Optional[list[str]] = None
model_config = ConfigDict(extra="allow")
@ -52,6 +51,21 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class FolderForm(BaseModel):
name: str
model_config = ConfigDict(extra="allow")
class FolderItemsUpdateForm(BaseModel):
items: FolderItems
model_config = ConfigDict(extra="allow")
class FolderTable:
def insert_new_folder(self, name: str, user_id: str) -> Optional[FolderModel]:
with get_db() as db:
@ -96,7 +110,59 @@ class FolderTable:
for folder in db.query(Folder).filter_by(user_id=user_id).all()
]
def update_folder_by_name_and_user_id(
def get_folders_by_parent_id_and_user_id(self, parent_id: str, user_id: str):
with get_db() as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.all()
]
def update_folder_parent_id_by_id_and_user_id(
self,
id: str,
user_id: str,
parent_id: str,
) -> Optional[FolderModel]:
try:
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
folder.parent_id = parent_id
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
return
def update_folder_name_by_name_and_user_id(
self, name: str, user_id: str, new_name: str
) -> Optional[FolderModel]:
try:
id = name.lower()
new_id = new_name.lower()
with get_db() as db:
# Check if new folder name already exists
folder = db.query(Folder).filter_by(id=new_id, user_id=user_id).first()
if folder:
return None
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
folder.id = new_id
folder.name = new_name
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
return
def update_folder_items_by_name_and_user_id(
self, name: str, user_id: str, items: FolderItems
) -> Optional[FolderModel]:
try:

View File

@ -0,0 +1,197 @@
import logging
import os
import shutil
import uuid
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
import mimetypes
from open_webui.apps.webui.models.folders import (
FolderForm,
FolderItemsUpdateForm,
FolderModel,
Folders,
)
from open_webui.apps.webui.models.chats import Chats
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.utils import get_admin_user, get_verified_user
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# Get Folders
############################
@router.get("/", response_model=list[FolderModel])
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
return folders
############################
# Create Folder
############################
@router.post("/")
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_name_and_user_id(form_data.name, user.id)
if folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.insert_new_folder(form_data.name, user.id)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error creating folder: {form_data.name}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error creating folder"),
)
############################
# Get Folders By Id
############################
@router.get("/{id}", response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
if folder:
return folder
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Name By Id
############################
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderForm, user=Depends(get_verified_user)
):
new_id = form_data.name.lower()
folder = Folders.get_folder_by_name_and_user_id(new_id, user.id)
if folder:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
if folder:
try:
folder = Folders.update_folder_name_by_name_and_user_id(
id, user.id, form_data.name
)
# Update children folders parent_id
children_folders = Folders.get_folders_by_parent_id_and_user_id(id, user.id)
for child in children_folders:
Folders.update_folder_parent_id_by_id_and_user_id(
child.id, user.id, folder.id
)
# Update children items parent_id
chats = Chats.get_chats_by_folder_id_and_user_id(id, user.id)
for chat in chats:
Chats.update_chat_folder_id_by_id_and_user_id(
chat.id, user.id, folder.id
)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Update Folder Items By Id
############################
@router.post("/{id}/update/items")
async def update_folder_items_by_id(
id: str, form_data: FolderItemsUpdateForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
if folder:
try:
folder = Folders.update_folder_by_name_and_user_id(
id, user.id, form_data.items
)
return folder
except Exception as e:
log.exception(e)
log.error(f"Error updating folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error updating folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# Delete Folder By Id
############################
@router.delete("/{id}")
async def delete_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_name_and_user_id(id, user.id)
if folder:
try:
result = Folders.delete_folder_by_name_and_user_id(id, user.id)
return result
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)

View File

@ -34,9 +34,16 @@ def upgrade():
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", "user_id")
sa.PrimaryKeyConstraint("id", "user_id"),
)
op.add_column(
"chat",
sa.Column("folder_id", sa.Text(), nullable=True),
)
def downgrade():
op.drop_column("chat", "folder_id")
op.drop_table("folder")

View 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;
};

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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);

View File

@ -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"

View 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}

View 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>