|
<script lang="ts"> |
|
import Fuse from 'fuse.js'; |
|
import { toast } from 'svelte-sonner'; |
|
import { v4 as uuidv4 } from 'uuid'; |
|
import { PaneGroup, Pane, PaneResizer } from 'paneforge'; |
|
|
|
import { onMount, getContext, onDestroy, tick } from 'svelte'; |
|
const i18n = getContext('i18n'); |
|
|
|
import { goto } from '$app/navigation'; |
|
import { page } from '$app/stores'; |
|
import { mobile, showSidebar, knowledge as _knowledge } from '$lib/stores'; |
|
|
|
import { updateFileDataContentById, uploadFile } from '$lib/apis/files'; |
|
import { |
|
addFileToKnowledgeById, |
|
getKnowledgeById, |
|
getKnowledgeItems, |
|
removeFileFromKnowledgeById, |
|
resetKnowledgeById, |
|
updateFileFromKnowledgeById, |
|
updateKnowledgeById |
|
} from '$lib/apis/knowledge'; |
|
|
|
import { transcribeAudio } from '$lib/apis/audio'; |
|
import { blobToFile } from '$lib/utils'; |
|
import { processFile } from '$lib/apis/retrieval'; |
|
|
|
import Spinner from '$lib/components/common/Spinner.svelte'; |
|
import Files from './Collection/Files.svelte'; |
|
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte'; |
|
|
|
import AddContentMenu from './Collection/AddContentMenu.svelte'; |
|
import AddTextContentModal from './Collection/AddTextContentModal.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 ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; |
|
import MenuLines from '$lib/components/icons/MenuLines.svelte'; |
|
|
|
let largeScreen = true; |
|
|
|
let pane; |
|
let showSidepanel = true; |
|
let minSize = 0; |
|
|
|
type Knowledge = { |
|
id: string; |
|
name: string; |
|
description: string; |
|
data: { |
|
file_ids: string[]; |
|
}; |
|
files: any[]; |
|
}; |
|
|
|
let id = null; |
|
let knowledge: Knowledge | null = null; |
|
let query = ''; |
|
|
|
let showAddTextContentModal = false; |
|
let showSyncConfirmModal = false; |
|
|
|
let inputFiles = null; |
|
|
|
let filteredItems = []; |
|
$: if (knowledge) { |
|
fuse = new Fuse(knowledge.files, { |
|
keys: ['meta.name', 'meta.description'] |
|
}); |
|
} |
|
|
|
$: if (fuse) { |
|
filteredItems = query |
|
? fuse.search(query).map((e) => { |
|
return e.item; |
|
}) |
|
: (knowledge?.files ?? []); |
|
} |
|
|
|
let selectedFile = null; |
|
let selectedFileId = null; |
|
|
|
$: if (selectedFileId) { |
|
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId); |
|
if (file) { |
|
file.data = file.data ?? { content: '' }; |
|
selectedFile = file; |
|
} else { |
|
selectedFile = null; |
|
} |
|
} else { |
|
selectedFile = null; |
|
} |
|
|
|
let fuse = null; |
|
let debounceTimeout = null; |
|
let mediaQuery; |
|
let dragged = false; |
|
|
|
const createFileFromText = (name, content) => { |
|
const blob = new Blob([content], { type: 'text/plain' }); |
|
const file = blobToFile(blob, `${name}.txt`); |
|
|
|
console.log(file); |
|
return file; |
|
}; |
|
|
|
const uploadFileHandler = async (file) => { |
|
console.log(file); |
|
|
|
const tempItemId = uuidv4(); |
|
const fileItem = { |
|
type: 'file', |
|
file: '', |
|
id: null, |
|
url: '', |
|
name: file.name, |
|
size: file.size, |
|
status: 'uploading', |
|
error: '', |
|
itemId: tempItemId |
|
}; |
|
|
|
if (fileItem.size == 0) { |
|
toast.error($i18n.t('You cannot upload an empty file.')); |
|
return null; |
|
} |
|
|
|
knowledge.files = [...(knowledge.files ?? []), fileItem]; |
|
|
|
|
|
if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) { |
|
const res = await transcribeAudio(localStorage.token, file).catch((error) => { |
|
toast.error(error); |
|
return null; |
|
}); |
|
|
|
if (res) { |
|
console.log(res); |
|
const blob = new Blob([res.text], { type: 'text/plain' }); |
|
file = blobToFile(blob, `${file.name}.txt`); |
|
} |
|
} |
|
|
|
try { |
|
const uploadedFile = await uploadFile(localStorage.token, file).catch((e) => { |
|
toast.error(e); |
|
return null; |
|
}); |
|
|
|
if (uploadedFile) { |
|
console.log(uploadedFile); |
|
knowledge.files = knowledge.files.map((item) => { |
|
if (item.itemId === tempItemId) { |
|
item.id = uploadedFile.id; |
|
} |
|
|
|
|
|
delete item.itemId; |
|
return item; |
|
}); |
|
await addFileHandler(uploadedFile.id); |
|
} else { |
|
toast.error($i18n.t('Failed to upload file.')); |
|
} |
|
} catch (e) { |
|
toast.error(e); |
|
} |
|
}; |
|
|
|
const uploadDirectoryHandler = async () => { |
|
// Check if File System Access API is supported |
|
const isFileSystemAccessSupported = 'showDirectoryPicker' in window; |
|
|
|
try { |
|
if (isFileSystemAccessSupported) { |
|
// Modern browsers (Chrome, Edge) implementation |
|
await handleModernBrowserUpload(); |
|
} else { |
|
// Firefox fallback |
|
await handleFirefoxUpload(); |
|
} |
|
} catch (error) { |
|
handleUploadError(error); |
|
} |
|
}; |
|
|
|
|
|
const hasHiddenFolder = (path) => { |
|
return path.split('/').some((part) => part.startsWith('.')); |
|
}; |
|
|
|
|
|
const handleModernBrowserUpload = async () => { |
|
const dirHandle = await window.showDirectoryPicker(); |
|
let totalFiles = 0; |
|
let uploadedFiles = 0; |
|
|
|
// Function to update the UI with the progress |
|
const updateProgress = () => { |
|
const percentage = (uploadedFiles / totalFiles) * 100; |
|
toast.info(`Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`); |
|
}; |
|
|
|
// Recursive function to count all files excluding hidden ones |
|
async function countFiles(dirHandle) { |
|
for await (const entry of dirHandle.values()) { |
|
// Skip hidden files and directories |
|
if (entry.name.startsWith('.')) continue; |
|
|
|
if (entry.kind === 'file') { |
|
totalFiles++; |
|
} else if (entry.kind === 'directory') { |
|
// Only process non-hidden directories |
|
if (!entry.name.startsWith('.')) { |
|
await countFiles(entry); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
async function processDirectory(dirHandle, path = '') { |
|
for await (const entry of dirHandle.values()) { |
|
// Skip hidden files and directories |
|
if (entry.name.startsWith('.')) continue; |
|
|
|
const entryPath = path ? `${path}/${entry.name}` : entry.name; |
|
|
|
// Skip if the path contains any hidden folders |
|
if (hasHiddenFolder(entryPath)) continue; |
|
|
|
if (entry.kind === 'file') { |
|
const file = await entry.getFile(); |
|
const fileWithPath = new File([file], entryPath, { type: file.type }); |
|
|
|
await uploadFileHandler(fileWithPath); |
|
uploadedFiles++; |
|
updateProgress(); |
|
} else if (entry.kind === 'directory') { |
|
// Only process non-hidden directories |
|
if (!entry.name.startsWith('.')) { |
|
await processDirectory(entry, entryPath); |
|
} |
|
} |
|
} |
|
} |
|
|
|
await countFiles(dirHandle); |
|
updateProgress(); |
|
|
|
if (totalFiles > 0) { |
|
await processDirectory(dirHandle); |
|
} else { |
|
console.log('No files to upload.'); |
|
} |
|
}; |
|
|
|
|
|
const handleFirefoxUpload = async () => { |
|
return new Promise((resolve, reject) => { |
|
// Create hidden file input |
|
const input = document.createElement('input'); |
|
input.type = 'file'; |
|
input.webkitdirectory = true; |
|
input.directory = true; |
|
input.multiple = true; |
|
input.style.display = 'none'; |
|
|
|
// Add input to DOM temporarily |
|
document.body.appendChild(input); |
|
|
|
input.onchange = async () => { |
|
try { |
|
const files = Array.from(input.files) |
|
// Filter out files from hidden folders |
|
.filter((file) => !hasHiddenFolder(file.webkitRelativePath)); |
|
|
|
let totalFiles = files.length; |
|
let uploadedFiles = 0; |
|
|
|
// Function to update the UI with the progress |
|
const updateProgress = () => { |
|
const percentage = (uploadedFiles / totalFiles) * 100; |
|
toast.info( |
|
`Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)` |
|
); |
|
}; |
|
|
|
updateProgress(); |
|
|
|
// Process all files |
|
for (const file of files) { |
|
// Skip hidden files (additional check) |
|
if (!file.name.startsWith('.')) { |
|
const relativePath = file.webkitRelativePath || file.name; |
|
const fileWithPath = new File([file], relativePath, { type: file.type }); |
|
|
|
await uploadFileHandler(fileWithPath); |
|
uploadedFiles++; |
|
updateProgress(); |
|
} |
|
} |
|
|
|
|
|
document.body.removeChild(input); |
|
resolve(); |
|
} catch (error) { |
|
reject(error); |
|
} |
|
}; |
|
|
|
input.onerror = (error) => { |
|
document.body.removeChild(input); |
|
reject(error); |
|
}; |
|
|
|
|
|
input.click(); |
|
}); |
|
}; |
|
|
|
|
|
const handleUploadError = (error) => { |
|
if (error.name === 'AbortError') { |
|
toast.info('Directory selection was cancelled'); |
|
} else { |
|
toast.error('Error accessing directory'); |
|
console.error('Directory access error:', error); |
|
} |
|
}; |
|
|
|
|
|
const syncDirectoryHandler = async () => { |
|
if ((knowledge?.files ?? []).length > 0) { |
|
const res = await resetKnowledgeById(localStorage.token, id).catch((e) => { |
|
toast.error(e); |
|
}); |
|
|
|
if (res) { |
|
knowledge = res; |
|
toast.success($i18n.t('Knowledge reset successfully.')); |
|
|
|
// Upload directory |
|
uploadDirectoryHandler(); |
|
} |
|
} else { |
|
uploadDirectoryHandler(); |
|
} |
|
}; |
|
|
|
const addFileHandler = async (fileId) => { |
|
const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch( |
|
(e) => { |
|
toast.error(e); |
|
return null; |
|
} |
|
); |
|
|
|
if (updatedKnowledge) { |
|
knowledge = updatedKnowledge; |
|
toast.success($i18n.t('File added successfully.')); |
|
} else { |
|
toast.error($i18n.t('Failed to add file.')); |
|
knowledge.files = knowledge.files.filter((file) => file.id !== fileId); |
|
} |
|
}; |
|
|
|
const deleteFileHandler = async (fileId) => { |
|
const updatedKnowledge = await removeFileFromKnowledgeById( |
|
localStorage.token, |
|
id, |
|
fileId |
|
).catch((e) => { |
|
toast.error(e); |
|
}); |
|
|
|
if (updatedKnowledge) { |
|
knowledge = updatedKnowledge; |
|
toast.success($i18n.t('File removed successfully.')); |
|
} |
|
}; |
|
|
|
const updateFileContentHandler = async () => { |
|
const fileId = selectedFile.id; |
|
const content = selectedFile.data.content; |
|
|
|
const res = updateFileDataContentById(localStorage.token, fileId, content).catch((e) => { |
|
toast.error(e); |
|
}); |
|
|
|
const updatedKnowledge = await updateFileFromKnowledgeById( |
|
localStorage.token, |
|
id, |
|
fileId |
|
).catch((e) => { |
|
toast.error(e); |
|
}); |
|
|
|
if (res && updatedKnowledge) { |
|
knowledge = updatedKnowledge; |
|
toast.success($i18n.t('File content updated successfully.')); |
|
} |
|
}; |
|
|
|
const changeDebounceHandler = () => { |
|
console.log('debounce'); |
|
if (debounceTimeout) { |
|
clearTimeout(debounceTimeout); |
|
} |
|
|
|
debounceTimeout = setTimeout(async () => { |
|
if (knowledge.name.trim() === '' || knowledge.description.trim() === '') { |
|
toast.error($i18n.t('Please fill in all fields.')); |
|
return; |
|
} |
|
|
|
const res = await updateKnowledgeById(localStorage.token, id, { |
|
name: knowledge.name, |
|
description: knowledge.description |
|
}).catch((e) => { |
|
toast.error(e); |
|
}); |
|
|
|
if (res) { |
|
toast.success($i18n.t('Knowledge updated successfully')); |
|
_knowledge.set(await getKnowledgeItems(localStorage.token)); |
|
} |
|
}, 1000); |
|
}; |
|
|
|
const handleMediaQuery = async (e) => { |
|
if (e.matches) { |
|
largeScreen = true; |
|
} else { |
|
largeScreen = false; |
|
} |
|
}; |
|
|
|
const onDragOver = (e) => { |
|
e.preventDefault(); |
|
dragged = true; |
|
}; |
|
|
|
const onDragLeave = () => { |
|
dragged = false; |
|
}; |
|
|
|
const onDrop = async (e) => { |
|
e.preventDefault(); |
|
dragged = false; |
|
|
|
if (e.dataTransfer?.files) { |
|
const inputFiles = e.dataTransfer?.files; |
|
|
|
if (inputFiles && inputFiles.length > 0) { |
|
for (const file of inputFiles) { |
|
await uploadFileHandler(file); |
|
} |
|
} else { |
|
toast.error($i18n.t(`File not found.`)); |
|
} |
|
} |
|
}; |
|
|
|
onMount(async () => { |
|
// listen to resize 1024px |
|
mediaQuery = window.matchMedia('(min-width: 1024px)'); |
|
|
|
mediaQuery.addEventListener('change', handleMediaQuery); |
|
handleMediaQuery(mediaQuery); |
|
|
|
// Select the container element you want to observe |
|
const container = document.getElementById('collection-container'); |
|
|
|
// initialize the minSize based on the container width |
|
minSize = !largeScreen ? 100 : Math.floor((300 / container.clientWidth) * 100); |
|
|
|
// Create a new ResizeObserver instance |
|
const resizeObserver = new ResizeObserver((entries) => { |
|
for (let entry of entries) { |
|
const width = entry.contentRect.width; |
|
// calculate the percentage of 300 |
|
const percentage = (300 / width) * 100; |
|
// set the minSize to the percentage, must be an integer |
|
minSize = !largeScreen ? 100 : Math.floor(percentage); |
|
|
|
if (showSidepanel) { |
|
if (pane && pane.isExpanded() && pane.getSize() < minSize) { |
|
pane.resize(minSize); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
|
|
resizeObserver.observe(container); |
|
|
|
if (pane) { |
|
pane.expand(); |
|
} |
|
|
|
id = $page.params.id; |
|
|
|
const res = await getKnowledgeById(localStorage.token, id).catch((e) => { |
|
toast.error(e); |
|
return null; |
|
}); |
|
|
|
if (res) { |
|
knowledge = res; |
|
} else { |
|
goto('/workspace/knowledge'); |
|
} |
|
|
|
const dropZone = document.querySelector('body'); |
|
dropZone?.addEventListener('dragover', onDragOver); |
|
dropZone?.addEventListener('drop', onDrop); |
|
dropZone?.addEventListener('dragleave', onDragLeave); |
|
}); |
|
|
|
onDestroy(() => { |
|
mediaQuery?.removeEventListener('change', handleMediaQuery); |
|
const dropZone = document.querySelector('body'); |
|
dropZone?.removeEventListener('dragover', onDragOver); |
|
dropZone?.removeEventListener('drop', onDrop); |
|
dropZone?.removeEventListener('dragleave', onDragLeave); |
|
}); |
|
</script> |
|
|
|
{#if dragged} |
|
<div |
|
class="fixed {$showSidebar |
|
? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]' |
|
: 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none" |
|
id="dropzone" |
|
role="region" |
|
aria-label="Drag and Drop Container" |
|
> |
|
<div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center"> |
|
<div class="m-auto pt-64 flex flex-col justify-center"> |
|
<div class="max-w-md"> |
|
<AddFilesPlaceholder> |
|
<div class=" mt-2 text-center text-sm dark:text-gray-200 w-full"> |
|
Drop any files here to add to my documents |
|
</div> |
|
</AddFilesPlaceholder> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<SyncConfirmDialog |
|
bind:show={showSyncConfirmModal} |
|
message={$i18n.t( |
|
'This will reset the knowledge base and sync all files. Do you wish to continue?' |
|
)} |
|
on:confirm={() => { |
|
syncDirectoryHandler(); |
|
}} |
|
/> |
|
|
|
<AddTextContentModal |
|
bind:show={showAddTextContentModal} |
|
on:submit={(e) => { |
|
const file = createFileFromText(e.detail.name, e.detail.content); |
|
uploadFileHandler(file); |
|
}} |
|
/> |
|
|
|
<input |
|
id="files-input" |
|
bind:files={inputFiles} |
|
type="file" |
|
multiple |
|
hidden |
|
on:change={async () => { |
|
if (inputFiles && inputFiles.length > 0) { |
|
for (const file of inputFiles) { |
|
await uploadFileHandler(file); |
|
} |
|
|
|
inputFiles = null; |
|
const fileInputElement = document.getElementById('files-input'); |
|
|
|
if (fileInputElement) { |
|
fileInputElement.value = ''; |
|
} |
|
} else { |
|
toast.error($i18n.t(`File not found.`)); |
|
} |
|
}} |
|
/> |
|
|
|
<div class="flex flex-col w-full h-full max-h-[100dvh]" id="collection-container"> |
|
{#if id && knowledge} |
|
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5"> |
|
<PaneGroup direction="horizontal"> |
|
<Pane |
|
bind:pane |
|
defaultSize={minSize} |
|
collapsible={true} |
|
maxSize={50} |
|
{minSize} |
|
class="h-full" |
|
onExpand={() => { |
|
showSidepanel = true; |
|
}} |
|
onCollapse={() => { |
|
showSidepanel = false; |
|
}} |
|
> |
|
<div |
|
class="{largeScreen ? 'flex-shrink-0' : '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 py-1"> |
|
<div class=" self-center ml-1 mr-3"> |
|
<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="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" |
|
clip-rule="evenodd" |
|
/> |
|
</svg> |
|
</div> |
|
<input |
|
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent" |
|
bind:value={query} |
|
placeholder={$i18n.t('Search Collection')} |
|
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 |
|
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="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
</Pane> |
|
|
|
{#if largeScreen} |
|
<PaneResizer class="relative flex w-2 items-center justify-center bg-background group"> |
|
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm"> |
|
<EllipsisVertical className="size-4 invisible group-hover:visible" /> |
|
</div> |
|
</PaneResizer> |
|
<Pane> |
|
<div class="flex-1 flex justify-start h-full max-h-full"> |
|
{#if selectedFile} |
|
<div class=" flex flex-col w-full h-full max-h-full ml-2.5"> |
|
<div class="flex-shrink-0 mb-2 flex items-center"> |
|
{#if !showSidepanel} |
|
<div class="-translate-x-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={() => { |
|
pane.expand(); |
|
}} |
|
> |
|
<ChevronLeft strokeWidth="2.5" /> |
|
</button> |
|
</div> |
|
{/if} |
|
|
|
<div class=" flex-1 text-2xl font-medium"> |
|
<a |
|
class="hover:text-gray-500 hover:dark:text-gray-100 hover:underline flex-grow line-clamp-1" |
|
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'} |
|
target="_blank" |
|
> |
|
{selectedFile?.meta?.name} |
|
</a> |
|
</div> |
|
|
|
<div> |
|
<button |
|
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" |
|
on:click={() => { |
|
updateFileContentHandler(); |
|
}} |
|
> |
|
{$i18n.t('Save')} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div |
|
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-none overflow-y-auto scrollbar-hidden" |
|
> |
|
{#key selectedFile.id} |
|
<RichTextInput |
|
className="input-prose-sm" |
|
bind:value={selectedFile.data.content} |
|
placeholder={$i18n.t('Add content here')} |
|
/> |
|
{/key} |
|
</div> |
|
</div> |
|
{:else} |
|
<div class="m-auto pb-32"> |
|
<div> |
|
<div class=" flex w-full mt-1 mb-3.5"> |
|
<div class="flex-1"> |
|
<div class="flex items-center justify-between w-full px-0.5 mb-1"> |
|
<div class="w-full"> |
|
<input |
|
type="text" |
|
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none" |
|
bind:value={knowledge.name} |
|
on:input={() => { |
|
changeDebounceHandler(); |
|
}} |
|
/> |
|
</div> |
|
</div> |
|
|
|
<div class="flex w-full px-1"> |
|
<input |
|
type="text" |
|
class="text-center w-full text-gray-500 bg-transparent outline-none" |
|
bind:value={knowledge.description} |
|
on:input={() => { |
|
changeDebounceHandler(); |
|
}} |
|
/> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full"> |
|
{$i18n.t('Select a file to view or drag and drop a file to upload')} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
</Pane> |
|
{:else if !largeScreen && selectedFileId !== null} |
|
<Drawer |
|
className="h-full" |
|
show={selectedFileId !== null} |
|
on:close={() => { |
|
selectedFileId = null; |
|
}} |
|
> |
|
<div class="flex flex-col justify-start h-full max-h-full p-2"> |
|
<div class=" flex flex-col w-full h-full max-h-full"> |
|
<div class="flex-shrink-0 mt-1 mb-2 flex items-center"> |
|
<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; |
|
}} |
|
> |
|
<ChevronLeft strokeWidth="2.5" /> |
|
</button> |
|
</div> |
|
<div class=" flex-1 text-xl line-clamp-1"> |
|
{selectedFile?.meta?.name} |
|
</div> |
|
|
|
<div> |
|
<button |
|
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" |
|
on:click={() => { |
|
updateFileContentHandler(); |
|
}} |
|
> |
|
{$i18n.t('Save')} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div |
|
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" |
|
> |
|
{#key selectedFile.id} |
|
<RichTextInput |
|
className="input-prose-sm" |
|
bind:value={selectedFile.data.content} |
|
placeholder={$i18n.t('Add content here')} |
|
/> |
|
{/key} |
|
</div> |
|
</div> |
|
</div> |
|
</Drawer> |
|
{/if} |
|
</PaneGroup> |
|
</div> |
|
{:else} |
|
<Spinner /> |
|
{/if} |
|
</div> |
|
|