mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
feat/enh: kb file pagination
This commit is contained in:
parent
7b0b16ebbd
commit
94a8439105
9 changed files with 602 additions and 319 deletions
|
|
@ -238,6 +238,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.hash = hash
|
file.hash = hash
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
|
|
@ -249,6 +250,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.data = {**(file.data if file.data else {}), **data}
|
file.data = {**(file.data if file.data else {}), **data}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -260,6 +262,7 @@ class FilesTable:
|
||||||
try:
|
try:
|
||||||
file = db.query(File).filter_by(id=id).first()
|
file = db.query(File).filter_by(id=id).first()
|
||||||
file.meta = {**(file.meta if file.meta else {}), **meta}
|
file.meta = {**(file.meta if file.meta else {}), **meta}
|
||||||
|
file.updated_at = int(time.time())
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileModel.model_validate(file)
|
return FileModel.model_validate(file)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ import uuid
|
||||||
from open_webui.internal.db import Base, get_db
|
from open_webui.internal.db import Base, get_db
|
||||||
from open_webui.env import SRC_LOG_LEVELS
|
from open_webui.env import SRC_LOG_LEVELS
|
||||||
|
|
||||||
from open_webui.models.files import File, FileModel, FileMetadataResponse
|
from open_webui.models.files import (
|
||||||
|
File,
|
||||||
|
FileModel,
|
||||||
|
FileMetadataResponse,
|
||||||
|
FileModelResponse,
|
||||||
|
)
|
||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import Users, UserResponse
|
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
@ -21,6 +26,7 @@ from sqlalchemy import (
|
||||||
Text,
|
Text,
|
||||||
JSON,
|
JSON,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
or_,
|
||||||
)
|
)
|
||||||
|
|
||||||
from open_webui.utils.access_control import has_access
|
from open_webui.utils.access_control import has_access
|
||||||
|
|
@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel):
|
||||||
access_control: Optional[dict] = None
|
access_control: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileUserResponse(FileModelResponse):
|
||||||
|
user: Optional[UserResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFileListResponse(BaseModel):
|
||||||
|
items: list[FileUserResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeTable:
|
class KnowledgeTable:
|
||||||
def insert_new_knowledge(
|
def insert_new_knowledge(
|
||||||
self, user_id: str, form_data: KnowledgeForm
|
self, user_id: str, form_data: KnowledgeForm
|
||||||
|
|
@ -232,6 +247,88 @@ class KnowledgeTable:
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def search_files_by_id(
|
||||||
|
self,
|
||||||
|
knowledge_id: str,
|
||||||
|
user_id: str,
|
||||||
|
filter: dict,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 30,
|
||||||
|
) -> KnowledgeFileListResponse:
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
query = (
|
||||||
|
db.query(File, User)
|
||||||
|
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||||
|
.outerjoin(User, User.id == KnowledgeFile.user_id)
|
||||||
|
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
query_key = filter.get("query")
|
||||||
|
if query_key:
|
||||||
|
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
|
||||||
|
|
||||||
|
view_option = filter.get("view_option")
|
||||||
|
if view_option == "created":
|
||||||
|
query = query.filter(KnowledgeFile.user_id == user_id)
|
||||||
|
elif view_option == "shared":
|
||||||
|
query = query.filter(KnowledgeFile.user_id != user_id)
|
||||||
|
|
||||||
|
order_by = filter.get("order_by")
|
||||||
|
direction = filter.get("direction")
|
||||||
|
|
||||||
|
if order_by == "name":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.filename.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.filename.desc())
|
||||||
|
elif order_by == "created_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.created_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.created_at.desc())
|
||||||
|
elif order_by == "updated_at":
|
||||||
|
if direction == "asc":
|
||||||
|
query = query.order_by(File.updated_at.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = query.order_by(File.updated_at.desc())
|
||||||
|
|
||||||
|
# Count BEFORE pagination
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
if skip:
|
||||||
|
query = query.offset(skip)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
items = query.all()
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for file, user in items:
|
||||||
|
files.append(
|
||||||
|
FileUserResponse(
|
||||||
|
**FileModel.model_validate(file).model_dump(),
|
||||||
|
user=(
|
||||||
|
UserResponse(
|
||||||
|
**UserModel.model_validate(user).model_dump()
|
||||||
|
)
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return KnowledgeFileListResponse(items=files, total=total)
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return KnowledgeFileListResponse(items=[], total=0)
|
||||||
|
|
||||||
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
|
||||||
try:
|
try:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
|
|
||||||
|
|
@ -302,9 +302,6 @@ class NoteTable:
|
||||||
else:
|
else:
|
||||||
query = query.order_by(Note.updated_at.desc())
|
query = query.order_by(Note.updated_at.desc())
|
||||||
|
|
||||||
for key, value in filter.items():
|
|
||||||
query = query.filter(getattr(Note, key).ilike(f"%{value}%"))
|
|
||||||
|
|
||||||
# Count BEFORE pagination
|
# Count BEFORE pagination
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
|
||||||
|
|
||||||
|
|
||||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||||
|
|
||||||
from open_webui.models.chats import Chats
|
from open_webui.models.chats import Chats
|
||||||
from open_webui.models.groups import Groups, GroupMember
|
from open_webui.models.groups import Groups, GroupMember
|
||||||
from open_webui.models.channels import ChannelMember
|
from open_webui.models.channels import ChannelMember
|
||||||
|
|
||||||
|
|
||||||
from open_webui.utils.misc import throttle
|
from open_webui.utils.misc import throttle
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from open_webui.models.knowledge import (
|
from open_webui.models.knowledge import (
|
||||||
|
KnowledgeFileListResponse,
|
||||||
Knowledges,
|
Knowledges,
|
||||||
KnowledgeForm,
|
KnowledgeForm,
|
||||||
KnowledgeResponse,
|
KnowledgeResponse,
|
||||||
|
|
@ -264,6 +265,59 @@ async def update_knowledge_by_id(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
############################
|
||||||
|
# GetKnowledgeFilesById
|
||||||
|
############################
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
|
||||||
|
async def get_knowledge_files_by_id(
|
||||||
|
id: str,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
view_option: Optional[str] = None,
|
||||||
|
order_by: Optional[str] = None,
|
||||||
|
direction: Optional[str] = None,
|
||||||
|
page: Optional[int] = 1,
|
||||||
|
user=Depends(get_verified_user),
|
||||||
|
):
|
||||||
|
|
||||||
|
knowledge = Knowledges.get_knowledge_by_id(id=id)
|
||||||
|
if not knowledge:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
user.role == "admin"
|
||||||
|
or knowledge.user_id == user.id
|
||||||
|
or has_access(user.id, "read", knowledge.access_control)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = max(page, 1)
|
||||||
|
|
||||||
|
limit = 30
|
||||||
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
|
filter = {}
|
||||||
|
if query:
|
||||||
|
filter["query"] = query
|
||||||
|
if view_option:
|
||||||
|
filter["view_option"] = view_option
|
||||||
|
if order_by:
|
||||||
|
filter["order_by"] = order_by
|
||||||
|
if direction:
|
||||||
|
filter["direction"] = direction
|
||||||
|
|
||||||
|
return Knowledges.search_files_by_id(
|
||||||
|
id, user.id, filter=filter, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# AddFileToKnowledge
|
# AddFileToKnowledge
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const searchKnowledgeFilesById = async (
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
query?: string | null = null,
|
||||||
|
viewOption?: string | null = null,
|
||||||
|
orderBy?: string | null = null,
|
||||||
|
direction?: string | null = null,
|
||||||
|
page: number = 1
|
||||||
|
) => {
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (query) searchParams.append('query', query);
|
||||||
|
if (viewOption) searchParams.append('view_option', viewOption);
|
||||||
|
if (orderBy) searchParams.append('order_by', orderBy);
|
||||||
|
if (direction) searchParams.append('direction', direction);
|
||||||
|
searchParams.append('page', page.toString());
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
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.error(err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
type KnowledgeUpdateForm = {
|
type KnowledgeUpdateForm = {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
removeFileFromKnowledgeById,
|
removeFileFromKnowledgeById,
|
||||||
resetKnowledgeById,
|
resetKnowledgeById,
|
||||||
updateFileFromKnowledgeById,
|
updateFileFromKnowledgeById,
|
||||||
updateKnowledgeById
|
updateKnowledgeById,
|
||||||
|
searchKnowledgeFilesById
|
||||||
} from '$lib/apis/knowledge';
|
} from '$lib/apis/knowledge';
|
||||||
import { blobToFile } from '$lib/utils';
|
import { blobToFile } from '$lib/utils';
|
||||||
|
|
||||||
|
|
@ -43,22 +44,25 @@
|
||||||
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
|
||||||
|
|
||||||
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
|
||||||
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
|
|
||||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
|
||||||
import Drawer from '$lib/components/common/Drawer.svelte';
|
import Drawer from '$lib/components/common/Drawer.svelte';
|
||||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||||
import Search from '$lib/components/icons/Search.svelte';
|
import Search from '$lib/components/icons/Search.svelte';
|
||||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
|
||||||
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
|
||||||
|
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
|
||||||
|
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||||
|
|
||||||
let largeScreen = true;
|
let largeScreen = true;
|
||||||
|
|
||||||
let pane;
|
let pane;
|
||||||
let showSidepanel = true;
|
let showSidepanel = true;
|
||||||
let minSize = 0;
|
|
||||||
|
|
||||||
|
let showAddTextContentModal = false;
|
||||||
|
let showSyncConfirmModal = false;
|
||||||
|
let showAccessControlModal = false;
|
||||||
|
|
||||||
|
let minSize = 0;
|
||||||
type Knowledge = {
|
type Knowledge = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -71,52 +75,88 @@
|
||||||
|
|
||||||
let id = null;
|
let id = null;
|
||||||
let knowledge: Knowledge | null = null;
|
let knowledge: Knowledge | null = null;
|
||||||
let query = '';
|
|
||||||
|
|
||||||
let showAddTextContentModal = false;
|
let selectedFileId = null;
|
||||||
let showSyncConfirmModal = false;
|
let selectedFile = null;
|
||||||
let showAccessControlModal = false;
|
let selectedFileContent = '';
|
||||||
|
|
||||||
let inputFiles = null;
|
let inputFiles = null;
|
||||||
|
|
||||||
let filteredItems = [];
|
let query = '';
|
||||||
$: if (knowledge && knowledge.files) {
|
let viewOption = null;
|
||||||
fuse = new Fuse(knowledge.files, {
|
let sortKey = null;
|
||||||
keys: ['meta.name', 'meta.description']
|
let direction = null;
|
||||||
});
|
|
||||||
|
let currentPage = 1;
|
||||||
|
let fileItems = null;
|
||||||
|
let fileItemsTotal = null;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
currentPage = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
reset();
|
||||||
|
await getItemsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (
|
||||||
|
knowledge !== null &&
|
||||||
|
query !== undefined &&
|
||||||
|
viewOption !== undefined &&
|
||||||
|
sortKey !== undefined &&
|
||||||
|
direction !== undefined &&
|
||||||
|
currentPage !== undefined
|
||||||
|
) {
|
||||||
|
getItemsPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (fuse) {
|
$: if (
|
||||||
filteredItems = query
|
query !== undefined &&
|
||||||
? fuse.search(query).map((e) => {
|
viewOption !== undefined &&
|
||||||
return e.item;
|
sortKey !== undefined &&
|
||||||
})
|
direction !== undefined
|
||||||
: (knowledge?.files ?? []);
|
) {
|
||||||
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedFile = null;
|
const getItemsPage = async () => {
|
||||||
let selectedFileId = null;
|
if (knowledge === null) return;
|
||||||
let selectedFileContent = '';
|
|
||||||
|
|
||||||
// Add cache object
|
fileItems = null;
|
||||||
let fileContentCache = new Map();
|
fileItemsTotal = null;
|
||||||
|
|
||||||
$: if (selectedFileId) {
|
if (sortKey === null) {
|
||||||
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
|
direction = null;
|
||||||
if (file) {
|
|
||||||
fileSelectHandler(file);
|
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
selectedFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fuse = null;
|
const res = await searchKnowledgeFilesById(
|
||||||
let debounceTimeout = null;
|
localStorage.token,
|
||||||
let mediaQuery;
|
knowledge.id,
|
||||||
let dragged = false;
|
query,
|
||||||
let isSaving = false;
|
viewOption,
|
||||||
|
sortKey,
|
||||||
|
direction,
|
||||||
|
currentPage
|
||||||
|
).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
fileItems = res.items;
|
||||||
|
fileItemsTotal = res.total;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileSelectHandler = async (file) => {
|
||||||
|
try {
|
||||||
|
selectedFile = file;
|
||||||
|
selectedFileContent = selectedFile?.data?.content || '';
|
||||||
|
} catch (e) {
|
||||||
|
toast.error($i18n.t('Failed to load file content.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createFileFromText = (name, content) => {
|
const createFileFromText = (name, content) => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
|
@ -163,8 +203,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
knowledge.files = [...(knowledge.files ?? []), fileItem];
|
fileItems = [...(fileItems ?? []), fileItem];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If the file is an audio file, provide the language for STT.
|
// If the file is an audio file, provide the language for STT.
|
||||||
let metadata = null;
|
let metadata = null;
|
||||||
|
|
@ -184,7 +223,7 @@
|
||||||
|
|
||||||
if (uploadedFile) {
|
if (uploadedFile) {
|
||||||
console.log(uploadedFile);
|
console.log(uploadedFile);
|
||||||
knowledge.files = knowledge.files.map((item) => {
|
fileItems = fileItems.map((item) => {
|
||||||
if (item.itemId === tempItemId) {
|
if (item.itemId === tempItemId) {
|
||||||
item.id = uploadedFile.id;
|
item.id = uploadedFile.id;
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +236,7 @@
|
||||||
if (uploadedFile.error) {
|
if (uploadedFile.error) {
|
||||||
console.warn('File upload warning:', uploadedFile.error);
|
console.warn('File upload warning:', uploadedFile.error);
|
||||||
toast.warning(uploadedFile.error);
|
toast.warning(uploadedFile.error);
|
||||||
knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id);
|
fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
|
||||||
} else {
|
} else {
|
||||||
await addFileHandler(uploadedFile.id);
|
await addFileHandler(uploadedFile.id);
|
||||||
}
|
}
|
||||||
|
|
@ -413,7 +452,7 @@
|
||||||
toast.success($i18n.t('File added successfully.'));
|
toast.success($i18n.t('File added successfully.'));
|
||||||
} else {
|
} else {
|
||||||
toast.error($i18n.t('Failed to add file.'));
|
toast.error($i18n.t('Failed to add file.'));
|
||||||
knowledge.files = knowledge.files.filter((file) => file.id !== fileId);
|
fileItems = fileItems.filter((file) => file.id !== fileId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -436,32 +475,38 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let debounceTimeout = null;
|
||||||
|
let mediaQuery;
|
||||||
|
|
||||||
|
let dragged = false;
|
||||||
|
let isSaving = false;
|
||||||
|
|
||||||
const updateFileContentHandler = async () => {
|
const updateFileContentHandler = async () => {
|
||||||
if (isSaving) {
|
if (isSaving) {
|
||||||
console.log('Save operation already in progress, skipping...');
|
console.log('Save operation already in progress, skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileId = selectedFile.id;
|
const res = await updateFileDataContentById(
|
||||||
const content = selectedFileContent;
|
|
||||||
// Clear the cache for this file since we're updating it
|
|
||||||
fileContentCache.delete(fileId);
|
|
||||||
const res = await updateFileDataContentById(localStorage.token, fileId, content).catch(
|
|
||||||
(e) => {
|
|
||||||
toast.error(`${e}`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const updatedKnowledge = await updateFileFromKnowledgeById(
|
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
id,
|
selectedFile.id,
|
||||||
fileId
|
selectedFileContent
|
||||||
).catch((e) => {
|
).catch((e) => {
|
||||||
toast.error(`${e}`);
|
toast.error(`${e}`);
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
if (res && updatedKnowledge) {
|
|
||||||
knowledge = updatedKnowledge;
|
if (res) {
|
||||||
toast.success($i18n.t('File content updated successfully.'));
|
toast.success($i18n.t('File content updated successfully.'));
|
||||||
|
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
selectedFileContent = '';
|
||||||
|
|
||||||
|
await init();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSaving = false;
|
isSaving = false;
|
||||||
|
|
@ -504,29 +549,6 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileSelectHandler = async (file) => {
|
|
||||||
try {
|
|
||||||
selectedFile = file;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if (fileContentCache.has(file.id)) {
|
|
||||||
selectedFileContent = fileContentCache.get(file.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getFileById(localStorage.token, file.id);
|
|
||||||
if (response) {
|
|
||||||
selectedFileContent = response.data.content;
|
|
||||||
// Cache the content
|
|
||||||
fileContentCache.set(file.id, response.data.content);
|
|
||||||
} else {
|
|
||||||
toast.error($i18n.t('No content found in file.'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error($i18n.t('Failed to load file content.'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOver = (e) => {
|
const onDragOver = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -705,32 +727,42 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col w-full h-full translate-y-1" id="collection-container">
|
<div class="flex flex-col w-full h-full min-h-full" id="collection-container">
|
||||||
{#if id && knowledge}
|
{#if id && knowledge}
|
||||||
<AccessControlModal
|
<AccessControlModal
|
||||||
bind:show={showAccessControlModal}
|
bind:show={showAccessControlModal}
|
||||||
bind:accessControl={knowledge.access_control}
|
bind:accessControl={knowledge.access_control}
|
||||||
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
||||||
sharePu={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
changeDebounceHandler();
|
changeDebounceHandler();
|
||||||
}}
|
}}
|
||||||
accessRoles={['read', 'write']}
|
accessRoles={['read', 'write']}
|
||||||
/>
|
/>
|
||||||
<div class="w-full mb-2.5">
|
<div class="w-full px-2">
|
||||||
<div class=" flex w-full">
|
<div class=" flex w-full">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center justify-between w-full px-0.5 mb-1">
|
<div class="flex items-center justify-between w-full">
|
||||||
<div class="w-full">
|
<div class="w-full flex justify-between items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="text-left w-full font-medium text-2xl font-primary bg-transparent outline-hidden"
|
class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1"
|
||||||
bind:value={knowledge.name}
|
bind:value={knowledge.name}
|
||||||
placeholder={$i18n.t('Knowledge Name')}
|
placeholder={$i18n.t('Knowledge Name')}
|
||||||
on:input={() => {
|
on:input={() => {
|
||||||
changeDebounceHandler();
|
changeDebounceHandler();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="shrink-0 mr-2.5">
|
||||||
|
{#if (knowledge?.files ?? []).length}
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{$i18n.t('{{count}} files', {
|
||||||
|
count: (knowledge?.files ?? []).length
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="self-center shrink-0">
|
<div class="self-center shrink-0">
|
||||||
|
|
@ -750,7 +782,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full px-1">
|
<div class="flex w-full">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
|
||||||
|
|
@ -765,204 +797,205 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3">
|
<div
|
||||||
{#if largeScreen}
|
class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
|
||||||
<div class="flex-1 flex justify-start w-full h-full max-h-full">
|
>
|
||||||
{#if selectedFile}
|
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||||
<div class=" flex flex-col w-full">
|
<div class="flex flex-1 items-center">
|
||||||
<div class="shrink-0 mb-2 flex items-center">
|
<div class=" self-center ml-1 mr-3">
|
||||||
{#if !showSidepanel}
|
<Search className="size-3.5" />
|
||||||
<div class="-translate-x-2">
|
</div>
|
||||||
<button
|
<input
|
||||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||||
on:click={() => {
|
bind:value={query}
|
||||||
pane.expand();
|
placeholder={`${$i18n.t('Search Collection')}`}
|
||||||
}}
|
on:focus={() => {
|
||||||
>
|
selectedFileId = null;
|
||||||
<ChevronLeft strokeWidth="2.5" />
|
}}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class=" flex-1 text-xl font-medium">
|
<div>
|
||||||
<a
|
<AddContentMenu
|
||||||
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1"
|
on:upload={(e) => {
|
||||||
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
|
if (e.detail.type === 'directory') {
|
||||||
target="_blank"
|
uploadDirectoryHandler();
|
||||||
>
|
} else if (e.detail.type === 'text') {
|
||||||
{decodeString(selectedFile?.meta?.name)}
|
showAddTextContentModal = true;
|
||||||
</a>
|
} else {
|
||||||
</div>
|
document.getElementById('files-input').click();
|
||||||
|
}
|
||||||
<div>
|
}}
|
||||||
<button
|
on:sync={(e) => {
|
||||||
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
showSyncConfirmModal = true;
|
||||||
disabled={isSaving}
|
}}
|
||||||
on:click={() => {
|
/>
|
||||||
updateFileContentHandler();
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{$i18n.t('Save')}
|
|
||||||
{#if isSaving}
|
|
||||||
<div class="ml-2 self-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden"
|
|
||||||
>
|
|
||||||
{#key selectedFile.id}
|
|
||||||
<textarea
|
|
||||||
class="w-full h-full outline-none resize-none"
|
|
||||||
bind:value={selectedFileContent}
|
|
||||||
placeholder={$i18n.t('Add content here')}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full flex w-full">
|
|
||||||
<div class="m-auto text-xs text-center text-gray-200 dark:text-gray-700">
|
|
||||||
{$i18n.t('Drag and drop a file to upload or select a file to view')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if !largeScreen && selectedFileId !== null}
|
</div>
|
||||||
<Drawer
|
|
||||||
className="h-full"
|
<div class="px-3 flex justify-between">
|
||||||
show={selectedFileId !== null}
|
<div
|
||||||
onClose={() => {
|
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||||
selectedFileId = null;
|
on:wheel={(e) => {
|
||||||
|
if (e.deltaY !== 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-start h-full max-h-full p-2">
|
<div
|
||||||
<div class=" flex flex-col w-full h-full max-h-full">
|
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||||
<div class="shrink-0 mt-1 mb-2 flex items-center">
|
>
|
||||||
<div class="mr-2">
|
<DropdownOptions
|
||||||
<button
|
align="start"
|
||||||
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
|
||||||
on:click={() => {
|
bind:value={viewOption}
|
||||||
selectedFileId = null;
|
items={[
|
||||||
}}
|
{ value: null, label: $i18n.t('All') },
|
||||||
>
|
{ value: 'created', label: $i18n.t('Created by you') },
|
||||||
<ChevronLeft strokeWidth="2.5" />
|
{ value: 'shared', label: $i18n.t('Shared with you') }
|
||||||
</button>
|
]}
|
||||||
</div>
|
onChange={(value) => {
|
||||||
<div class=" flex-1 text-xl line-clamp-1">
|
if (value) {
|
||||||
{selectedFile?.meta?.name}
|
localStorage.workspaceViewOption = value;
|
||||||
</div>
|
} else {
|
||||||
|
delete localStorage.workspaceViewOption;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<DropdownOptions
|
||||||
<button
|
align="start"
|
||||||
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
bind:value={sortKey}
|
||||||
disabled={isSaving}
|
placeholder={$i18n.t('Sort')}
|
||||||
on:click={() => {
|
items={[
|
||||||
updateFileContentHandler();
|
{ value: 'name', label: $i18n.t('Name') },
|
||||||
}}
|
{ value: 'created_at', label: $i18n.t('Created At') },
|
||||||
>
|
{ value: 'updated_at', label: $i18n.t('Updated At') }
|
||||||
{$i18n.t('Save')}
|
]}
|
||||||
{#if isSaving}
|
/>
|
||||||
<div class="ml-2 self-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
{#if sortKey}
|
||||||
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden"
|
<DropdownOptions
|
||||||
>
|
align="start"
|
||||||
{#key selectedFile.id}
|
bind:value={direction}
|
||||||
<textarea
|
items={[
|
||||||
class="w-full h-full outline-none resize-none"
|
{ value: 'asc', label: $i18n.t('Asc') },
|
||||||
bind:value={selectedFileContent}
|
{ value: null, label: $i18n.t('Desc') }
|
||||||
placeholder={$i18n.t('Add content here')}
|
]}
|
||||||
/>
|
/>
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="{largeScreen ? 'shrink-0 w-72 max-w-72' : 'flex-1'}
|
|
||||||
flex
|
|
||||||
py-2
|
|
||||||
rounded-2xl
|
|
||||||
border
|
|
||||||
border-gray-50
|
|
||||||
h-full
|
|
||||||
dark:border-gray-850"
|
|
||||||
>
|
|
||||||
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
|
||||||
<div class="w-full h-full flex flex-col">
|
|
||||||
<div class=" px-3">
|
|
||||||
<div class="flex mb-0.5">
|
|
||||||
<div class=" self-center ml-1 mr-3">
|
|
||||||
<Search />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
|
|
||||||
on:focus={() => {
|
|
||||||
selectedFileId = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<AddContentMenu
|
|
||||||
on:upload={(e) => {
|
|
||||||
if (e.detail.type === 'directory') {
|
|
||||||
uploadDirectoryHandler();
|
|
||||||
} else if (e.detail.type === 'text') {
|
|
||||||
showAddTextContentModal = true;
|
|
||||||
} else {
|
|
||||||
document.getElementById('files-input').click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:sync={(e) => {
|
|
||||||
showSyncConfirmModal = true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredItems.length > 0}
|
|
||||||
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
|
||||||
<Files
|
|
||||||
small
|
|
||||||
files={filteredItems}
|
|
||||||
{selectedFileId}
|
|
||||||
on:click={(e) => {
|
|
||||||
selectedFileId = selectedFileId === e.detail ? null : e.detail;
|
|
||||||
}}
|
|
||||||
on:delete={(e) => {
|
|
||||||
console.log(e.detail);
|
|
||||||
|
|
||||||
selectedFileId = null;
|
|
||||||
deleteFileHandler(e.detail);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
|
||||||
<div>
|
|
||||||
{$i18n.t('No content found')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if fileItems !== null && fileItemsTotal !== null}
|
||||||
|
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
|
||||||
|
<div class="flex-1 flex">
|
||||||
|
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
|
||||||
|
<div class="w-full h-full flex flex-col min-h-full">
|
||||||
|
{#if fileItems.length > 0}
|
||||||
|
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
|
||||||
|
<Files
|
||||||
|
files={fileItems}
|
||||||
|
{selectedFileId}
|
||||||
|
onClick={(fileId) => {
|
||||||
|
selectedFileId = fileId;
|
||||||
|
|
||||||
|
if (fileItems) {
|
||||||
|
const file = fileItems.find((file) => file.id === selectedFileId);
|
||||||
|
if (file) {
|
||||||
|
fileSelectHandler(file);
|
||||||
|
} else {
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelete={(fileId) => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
|
||||||
|
deleteFileHandler(fileId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if fileItemsTotal > 30}
|
||||||
|
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
|
||||||
|
<div>
|
||||||
|
{$i18n.t('No content found')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedFileId !== null}
|
||||||
|
<Drawer
|
||||||
|
className="h-full"
|
||||||
|
show={selectedFileId !== null}
|
||||||
|
onClose={() => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-start h-full max-h-full">
|
||||||
|
<div class=" flex flex-col w-full h-full max-h-full">
|
||||||
|
<div class="shrink-0 flex items-center p-2">
|
||||||
|
<div class="mr-2">
|
||||||
|
<button
|
||||||
|
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||||
|
on:click={() => {
|
||||||
|
selectedFileId = null;
|
||||||
|
selectedFile = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft strokeWidth="2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class=" flex-1 text-lg line-clamp-1">
|
||||||
|
{selectedFile?.meta?.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={isSaving}
|
||||||
|
on:click={() => {
|
||||||
|
updateFileContentHandler();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{$i18n.t('Save')}
|
||||||
|
{#if isSaving}
|
||||||
|
<div class="ml-2 self-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key selectedFile.id}
|
||||||
|
<textarea
|
||||||
|
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
|
||||||
|
bind:value={selectedFileContent}
|
||||||
|
placeholder={$i18n.t('Add content here')}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="my-10">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,14 @@
|
||||||
|
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
class="w-full max-w-44 rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
transition={flyAndScale}
|
transition={flyAndScale}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'files' });
|
dispatch('upload', { type: 'files' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'directory' });
|
dispatch('upload', { type: 'directory' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('sync', { type: 'directory' });
|
dispatch('sync', { type: 'directory' });
|
||||||
}}
|
}}
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
dispatch('upload', { type: 'text' });
|
dispatch('upload', { type: 'text' });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,94 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import dayjs from '$lib/dayjs';
|
||||||
const dispatch = createEventDispatcher();
|
import duration from 'dayjs/plugin/duration';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
import FileItem from '$lib/components/common/FileItem.svelte';
|
dayjs.extend(duration);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
import { capitalizeFirstLetter } from '$lib/utils';
|
||||||
|
|
||||||
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
|
||||||
|
import XMark from '$lib/components/icons/XMark.svelte';
|
||||||
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||||
|
|
||||||
export let selectedFileId = null;
|
export let selectedFileId = null;
|
||||||
export let files = [];
|
export let files = [];
|
||||||
|
|
||||||
export let small = false;
|
export let onClick = (fileId) => {};
|
||||||
|
export let onDelete = (fileId) => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" max-h-full flex flex-col w-full">
|
<div class=" max-h-full flex flex-col w-full gap-[0.5px]">
|
||||||
{#each files as file}
|
{#each files as file (file?.id ?? file?.tempId)}
|
||||||
<div class="mt-1 px-2">
|
<div
|
||||||
<FileItem
|
class=" flex cursor-pointer w-full px-1.5 py-0.5 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
|
||||||
className="w-full"
|
? ''
|
||||||
colorClassName="{selectedFileId === file.id
|
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
|
||||||
? ' bg-gray-50 dark:bg-gray-850'
|
>
|
||||||
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
<button
|
||||||
{small}
|
class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
|
||||||
item={file}
|
type="button"
|
||||||
name={file?.name ?? file?.meta?.name}
|
on:click={async () => {
|
||||||
type="file"
|
console.log(file);
|
||||||
size={file?.size ?? file?.meta?.size ?? ''}
|
onClick(file?.id ?? file?.tempId);
|
||||||
loading={file.status === 'uploading'}
|
|
||||||
dismissible
|
|
||||||
on:click={() => {
|
|
||||||
if (file.status === 'uploading') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch('click', file.id);
|
|
||||||
}}
|
}}
|
||||||
on:dismiss={() => {
|
>
|
||||||
if (file.status === 'uploading') {
|
<div class="">
|
||||||
return;
|
<div class="flex gap-2 items-center line-clamp-1">
|
||||||
}
|
<div class="shrink-0">
|
||||||
|
{#if file?.status !== 'uploading'}
|
||||||
|
<DocumentPage className="size-3" />
|
||||||
|
{:else}
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
dispatch('delete', file.id);
|
<div class="line-clamp-1">
|
||||||
}}
|
{file?.name ?? file?.meta?.name}
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<Tooltip content={dayjs(file.updated_at * 1000).format('LLLL')}>
|
||||||
|
<div>
|
||||||
|
{dayjs(file.updated_at * 1000).fromNow()}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={file?.user?.email ?? $i18n.t('Deleted User')}
|
||||||
|
className="flex shrink-0"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="shrink-0 text-gray-500">
|
||||||
|
{$i18n.t('By {{name}}', {
|
||||||
|
name: capitalizeFirstLetter(
|
||||||
|
file?.user?.name ?? file?.user?.email ?? $i18n.t('Deleted User')
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Tooltip content={$i18n.t('Delete')}>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||||
|
type="button"
|
||||||
|
on:click={() => {
|
||||||
|
onDelete(file?.id ?? file?.tempId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XMark />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue