mirror of
https://github.com/open-webui/open-webui
synced 2025-03-22 22:07:15 +00:00
feat: folder ui
This commit is contained in:
parent
ede71740d2
commit
a942c30ca8
@ -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"])
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
197
backend/open_webui/apps/webui/routers/folders.py
Normal file
197
backend/open_webui/apps/webui/routers/folders.py
Normal 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,
|
||||
)
|
@ -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")
|
||||
|
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>
|
Loading…
Reference in New Issue
Block a user