Spaces:
Running
Running
<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]; | |
// Check if the file is an audio file and transcribe/convert it to text file | |
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; | |
} | |
// Remove temporary item 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); | |
} | |
}; | |
// Helper function to check if a path contains hidden folders | |
const hasHiddenFolder = (path) => { | |
return path.split('/').some((part) => part.startsWith('.')); | |
}; | |
// Modern browsers implementation using File System Access API | |
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); | |
} | |
} | |
} | |
} | |
// Recursive function to process directories excluding hidden files and folders | |
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.'); | |
} | |
}; | |
// Firefox fallback implementation using traditional file input | |
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(); | |
} | |
} | |
// Clean up | |
document.body.removeChild(input); | |
resolve(); | |
} catch (error) { | |
reject(error); | |
} | |
}; | |
input.onerror = (error) => { | |
document.body.removeChild(input); | |
reject(error); | |
}; | |
// Trigger file picker | |
input.click(); | |
}); | |
}; | |
// Error handler | |
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); | |
} | |
}; | |
// Helper function to maintain file paths within zip | |
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); | |
} | |
} | |
} | |
}); | |
// Start observing the container's size changes | |
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> | |