Collection.svelte 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  1. <script lang="ts">
  2. import Fuse from 'fuse.js';
  3. import { toast } from 'svelte-sonner';
  4. import { v4 as uuidv4 } from 'uuid';
  5. import { PaneGroup, Pane, PaneResizer } from 'paneforge';
  6. import { onMount, getContext, onDestroy, tick } from 'svelte';
  7. const i18n = getContext('i18n');
  8. import { goto } from '$app/navigation';
  9. import { page } from '$app/stores';
  10. import { mobile, showSidebar, knowledge as _knowledge } from '$lib/stores';
  11. import { updateFileDataContentById, uploadFile } from '$lib/apis/files';
  12. import {
  13. addFileToKnowledgeById,
  14. getKnowledgeById,
  15. getKnowledgeItems,
  16. removeFileFromKnowledgeById,
  17. resetKnowledgeById,
  18. updateFileFromKnowledgeById,
  19. updateKnowledgeById
  20. } from '$lib/apis/knowledge';
  21. import { transcribeAudio } from '$lib/apis/audio';
  22. import { blobToFile } from '$lib/utils';
  23. import { processFile } from '$lib/apis/retrieval';
  24. import Spinner from '$lib/components/common/Spinner.svelte';
  25. import Files from './Collection/Files.svelte';
  26. import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
  27. import AddContentMenu from './Collection/AddContentMenu.svelte';
  28. import AddTextContentModal from './Collection/AddTextContentModal.svelte';
  29. import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
  30. import RichTextInput from '$lib/components/common/RichTextInput.svelte';
  31. import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
  32. import Drawer from '$lib/components/common/Drawer.svelte';
  33. import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
  34. import MenuLines from '$lib/components/icons/MenuLines.svelte';
  35. let largeScreen = true;
  36. let pane;
  37. let showSidepanel = true;
  38. let minSize = 0;
  39. type Knowledge = {
  40. id: string;
  41. name: string;
  42. description: string;
  43. data: {
  44. file_ids: string[];
  45. };
  46. files: any[];
  47. };
  48. let id = null;
  49. let knowledge: Knowledge | null = null;
  50. let query = '';
  51. let showAddTextContentModal = false;
  52. let showSyncConfirmModal = false;
  53. let inputFiles = null;
  54. let filteredItems = [];
  55. $: if (knowledge) {
  56. fuse = new Fuse(knowledge.files, {
  57. keys: ['meta.name', 'meta.description']
  58. });
  59. }
  60. $: if (fuse) {
  61. filteredItems = query
  62. ? fuse.search(query).map((e) => {
  63. return e.item;
  64. })
  65. : (knowledge?.files ?? []);
  66. }
  67. let selectedFile = null;
  68. let selectedFileId = null;
  69. $: if (selectedFileId) {
  70. const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
  71. if (file) {
  72. file.data = file.data ?? { content: '' };
  73. selectedFile = file;
  74. } else {
  75. selectedFile = null;
  76. }
  77. } else {
  78. selectedFile = null;
  79. }
  80. let fuse = null;
  81. let debounceTimeout = null;
  82. let mediaQuery;
  83. let dragged = false;
  84. const createFileFromText = (name, content) => {
  85. const blob = new Blob([content], { type: 'text/plain' });
  86. const file = blobToFile(blob, `${name}.txt`);
  87. console.log(file);
  88. return file;
  89. };
  90. const uploadFileHandler = async (file) => {
  91. console.log(file);
  92. const tempItemId = uuidv4();
  93. const fileItem = {
  94. type: 'file',
  95. file: '',
  96. id: null,
  97. url: '',
  98. name: file.name,
  99. size: file.size,
  100. status: 'uploading',
  101. error: '',
  102. itemId: tempItemId
  103. };
  104. knowledge.files = [...(knowledge.files ?? []), fileItem];
  105. // Check if the file is an audio file and transcribe/convert it to text file
  106. if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
  107. const res = await transcribeAudio(localStorage.token, file).catch((error) => {
  108. toast.error(error);
  109. return null;
  110. });
  111. if (res) {
  112. console.log(res);
  113. const blob = new Blob([res.text], { type: 'text/plain' });
  114. file = blobToFile(blob, `${file.name}.txt`);
  115. }
  116. }
  117. try {
  118. const uploadedFile = await uploadFile(localStorage.token, file).catch((e) => {
  119. toast.error(e);
  120. return null;
  121. });
  122. if (uploadedFile) {
  123. console.log(uploadedFile);
  124. knowledge.files = knowledge.files.map((item) => {
  125. if (item.itemId === tempItemId) {
  126. item.id = uploadedFile.id;
  127. }
  128. // Remove temporary item id
  129. delete item.itemId;
  130. return item;
  131. });
  132. await addFileHandler(uploadedFile.id);
  133. } else {
  134. toast.error($i18n.t('Failed to upload file.'));
  135. }
  136. } catch (e) {
  137. toast.error(e);
  138. }
  139. };
  140. const uploadDirectoryHandler = async () => {
  141. // Check if File System Access API is supported
  142. const isFileSystemAccessSupported = 'showDirectoryPicker' in window;
  143. try {
  144. if (isFileSystemAccessSupported) {
  145. // Modern browsers (Chrome, Edge) implementation
  146. await handleModernBrowserUpload();
  147. } else {
  148. // Firefox fallback
  149. await handleFirefoxUpload();
  150. }
  151. } catch (error) {
  152. handleUploadError(error);
  153. }
  154. };
  155. // Helper function to check if a path contains hidden folders
  156. const hasHiddenFolder = (path) => {
  157. return path.split('/').some((part) => part.startsWith('.'));
  158. };
  159. // Modern browsers implementation using File System Access API
  160. const handleModernBrowserUpload = async () => {
  161. const dirHandle = await window.showDirectoryPicker();
  162. let totalFiles = 0;
  163. let uploadedFiles = 0;
  164. // Function to update the UI with the progress
  165. const updateProgress = () => {
  166. const percentage = (uploadedFiles / totalFiles) * 100;
  167. toast.info(`Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`);
  168. };
  169. // Recursive function to count all files excluding hidden ones
  170. async function countFiles(dirHandle) {
  171. for await (const entry of dirHandle.values()) {
  172. // Skip hidden files and directories
  173. if (entry.name.startsWith('.')) continue;
  174. if (entry.kind === 'file') {
  175. totalFiles++;
  176. } else if (entry.kind === 'directory') {
  177. // Only process non-hidden directories
  178. if (!entry.name.startsWith('.')) {
  179. await countFiles(entry);
  180. }
  181. }
  182. }
  183. }
  184. // Recursive function to process directories excluding hidden files and folders
  185. async function processDirectory(dirHandle, path = '') {
  186. for await (const entry of dirHandle.values()) {
  187. // Skip hidden files and directories
  188. if (entry.name.startsWith('.')) continue;
  189. const entryPath = path ? `${path}/${entry.name}` : entry.name;
  190. // Skip if the path contains any hidden folders
  191. if (hasHiddenFolder(entryPath)) continue;
  192. if (entry.kind === 'file') {
  193. const file = await entry.getFile();
  194. const fileWithPath = new File([file], entryPath, { type: file.type });
  195. await uploadFileHandler(fileWithPath);
  196. uploadedFiles++;
  197. updateProgress();
  198. } else if (entry.kind === 'directory') {
  199. // Only process non-hidden directories
  200. if (!entry.name.startsWith('.')) {
  201. await processDirectory(entry, entryPath);
  202. }
  203. }
  204. }
  205. }
  206. await countFiles(dirHandle);
  207. updateProgress();
  208. if (totalFiles > 0) {
  209. await processDirectory(dirHandle);
  210. } else {
  211. console.log('No files to upload.');
  212. }
  213. };
  214. // Firefox fallback implementation using traditional file input
  215. const handleFirefoxUpload = async () => {
  216. return new Promise((resolve, reject) => {
  217. // Create hidden file input
  218. const input = document.createElement('input');
  219. input.type = 'file';
  220. input.webkitdirectory = true;
  221. input.directory = true;
  222. input.multiple = true;
  223. input.style.display = 'none';
  224. // Add input to DOM temporarily
  225. document.body.appendChild(input);
  226. input.onchange = async () => {
  227. try {
  228. const files = Array.from(input.files)
  229. // Filter out files from hidden folders
  230. .filter((file) => !hasHiddenFolder(file.webkitRelativePath));
  231. let totalFiles = files.length;
  232. let uploadedFiles = 0;
  233. // Function to update the UI with the progress
  234. const updateProgress = () => {
  235. const percentage = (uploadedFiles / totalFiles) * 100;
  236. toast.info(
  237. `Upload Progress: ${uploadedFiles}/${totalFiles} (${percentage.toFixed(2)}%)`
  238. );
  239. };
  240. updateProgress();
  241. // Process all files
  242. for (const file of files) {
  243. // Skip hidden files (additional check)
  244. if (!file.name.startsWith('.')) {
  245. const relativePath = file.webkitRelativePath || file.name;
  246. const fileWithPath = new File([file], relativePath, { type: file.type });
  247. await uploadFileHandler(fileWithPath);
  248. uploadedFiles++;
  249. updateProgress();
  250. }
  251. }
  252. // Clean up
  253. document.body.removeChild(input);
  254. resolve();
  255. } catch (error) {
  256. reject(error);
  257. }
  258. };
  259. input.onerror = (error) => {
  260. document.body.removeChild(input);
  261. reject(error);
  262. };
  263. // Trigger file picker
  264. input.click();
  265. });
  266. };
  267. // Error handler
  268. const handleUploadError = (error) => {
  269. if (error.name === 'AbortError') {
  270. toast.info('Directory selection was cancelled');
  271. } else {
  272. toast.error('Error accessing directory');
  273. console.error('Directory access error:', error);
  274. }
  275. };
  276. // Helper function to maintain file paths within zip
  277. const syncDirectoryHandler = async () => {
  278. if ((knowledge?.files ?? []).length > 0) {
  279. const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
  280. toast.error(e);
  281. });
  282. if (res) {
  283. knowledge = res;
  284. toast.success($i18n.t('Knowledge reset successfully.'));
  285. // Upload directory
  286. uploadDirectoryHandler();
  287. }
  288. } else {
  289. uploadDirectoryHandler();
  290. }
  291. };
  292. const addFileHandler = async (fileId) => {
  293. const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch(
  294. (e) => {
  295. toast.error(e);
  296. return null;
  297. }
  298. );
  299. if (updatedKnowledge) {
  300. knowledge = updatedKnowledge;
  301. toast.success($i18n.t('File added successfully.'));
  302. } else {
  303. toast.error($i18n.t('Failed to add file.'));
  304. knowledge.files = knowledge.files.filter((file) => file.id !== fileId);
  305. }
  306. };
  307. const deleteFileHandler = async (fileId) => {
  308. const updatedKnowledge = await removeFileFromKnowledgeById(
  309. localStorage.token,
  310. id,
  311. fileId
  312. ).catch((e) => {
  313. toast.error(e);
  314. });
  315. if (updatedKnowledge) {
  316. knowledge = updatedKnowledge;
  317. toast.success($i18n.t('File removed successfully.'));
  318. }
  319. };
  320. const updateFileContentHandler = async () => {
  321. const fileId = selectedFile.id;
  322. const content = selectedFile.data.content;
  323. const res = updateFileDataContentById(localStorage.token, fileId, content).catch((e) => {
  324. toast.error(e);
  325. });
  326. const updatedKnowledge = await updateFileFromKnowledgeById(
  327. localStorage.token,
  328. id,
  329. fileId
  330. ).catch((e) => {
  331. toast.error(e);
  332. });
  333. if (res && updatedKnowledge) {
  334. knowledge = updatedKnowledge;
  335. toast.success($i18n.t('File content updated successfully.'));
  336. }
  337. };
  338. const changeDebounceHandler = () => {
  339. console.log('debounce');
  340. if (debounceTimeout) {
  341. clearTimeout(debounceTimeout);
  342. }
  343. debounceTimeout = setTimeout(async () => {
  344. if (knowledge.name.trim() === '' || knowledge.description.trim() === '') {
  345. toast.error($i18n.t('Please fill in all fields.'));
  346. return;
  347. }
  348. const res = await updateKnowledgeById(localStorage.token, id, {
  349. name: knowledge.name,
  350. description: knowledge.description
  351. }).catch((e) => {
  352. toast.error(e);
  353. });
  354. if (res) {
  355. toast.success($i18n.t('Knowledge updated successfully'));
  356. _knowledge.set(await getKnowledgeItems(localStorage.token));
  357. }
  358. }, 1000);
  359. };
  360. const handleMediaQuery = async (e) => {
  361. if (e.matches) {
  362. largeScreen = true;
  363. } else {
  364. largeScreen = false;
  365. }
  366. };
  367. const onDragOver = (e) => {
  368. e.preventDefault();
  369. dragged = true;
  370. };
  371. const onDragLeave = () => {
  372. dragged = false;
  373. };
  374. const onDrop = async (e) => {
  375. e.preventDefault();
  376. dragged = false;
  377. if (e.dataTransfer?.files) {
  378. const inputFiles = e.dataTransfer?.files;
  379. if (inputFiles && inputFiles.length > 0) {
  380. for (const file of inputFiles) {
  381. await uploadFileHandler(file);
  382. }
  383. } else {
  384. toast.error($i18n.t(`File not found.`));
  385. }
  386. }
  387. };
  388. onMount(async () => {
  389. // listen to resize 1024px
  390. mediaQuery = window.matchMedia('(min-width: 1024px)');
  391. mediaQuery.addEventListener('change', handleMediaQuery);
  392. handleMediaQuery(mediaQuery);
  393. // Select the container element you want to observe
  394. const container = document.getElementById('collection-container');
  395. // initialize the minSize based on the container width
  396. minSize = !largeScreen ? 100 : Math.floor((300 / container.clientWidth) * 100);
  397. // Create a new ResizeObserver instance
  398. const resizeObserver = new ResizeObserver((entries) => {
  399. for (let entry of entries) {
  400. const width = entry.contentRect.width;
  401. // calculate the percentage of 300
  402. const percentage = (300 / width) * 100;
  403. // set the minSize to the percentage, must be an integer
  404. minSize = !largeScreen ? 100 : Math.floor(percentage);
  405. if (showSidepanel) {
  406. if (pane && pane.isExpanded() && pane.getSize() < minSize) {
  407. pane.resize(minSize);
  408. }
  409. }
  410. }
  411. });
  412. // Start observing the container's size changes
  413. resizeObserver.observe(container);
  414. if (pane) {
  415. pane.expand();
  416. }
  417. id = $page.params.id;
  418. const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
  419. toast.error(e);
  420. return null;
  421. });
  422. if (res) {
  423. knowledge = res;
  424. } else {
  425. goto('/workspace/knowledge');
  426. }
  427. const dropZone = document.querySelector('body');
  428. dropZone?.addEventListener('dragover', onDragOver);
  429. dropZone?.addEventListener('drop', onDrop);
  430. dropZone?.addEventListener('dragleave', onDragLeave);
  431. });
  432. onDestroy(() => {
  433. mediaQuery?.removeEventListener('change', handleMediaQuery);
  434. const dropZone = document.querySelector('body');
  435. dropZone?.removeEventListener('dragover', onDragOver);
  436. dropZone?.removeEventListener('drop', onDrop);
  437. dropZone?.removeEventListener('dragleave', onDragLeave);
  438. });
  439. </script>
  440. {#if dragged}
  441. <div
  442. class="fixed {$showSidebar
  443. ? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
  444. : 'left-0'} w-full h-full flex z-50 touch-none pointer-events-none"
  445. id="dropzone"
  446. role="region"
  447. aria-label="Drag and Drop Container"
  448. >
  449. <div class="absolute w-full h-full backdrop-blur bg-gray-800/40 flex justify-center">
  450. <div class="m-auto pt-64 flex flex-col justify-center">
  451. <div class="max-w-md">
  452. <AddFilesPlaceholder>
  453. <div class=" mt-2 text-center text-sm dark:text-gray-200 w-full">
  454. Drop any files here to add to my documents
  455. </div>
  456. </AddFilesPlaceholder>
  457. </div>
  458. </div>
  459. </div>
  460. </div>
  461. {/if}
  462. <SyncConfirmDialog
  463. bind:show={showSyncConfirmModal}
  464. message={$i18n.t(
  465. 'This will reset the knowledge base and sync all files. Do you wish to continue?'
  466. )}
  467. on:confirm={() => {
  468. syncDirectoryHandler();
  469. }}
  470. />
  471. <AddTextContentModal
  472. bind:show={showAddTextContentModal}
  473. on:submit={(e) => {
  474. const file = createFileFromText(e.detail.name, e.detail.content);
  475. uploadFileHandler(file);
  476. }}
  477. />
  478. <input
  479. id="files-input"
  480. bind:files={inputFiles}
  481. type="file"
  482. multiple
  483. hidden
  484. on:change={async () => {
  485. if (inputFiles && inputFiles.length > 0) {
  486. for (const file of inputFiles) {
  487. await uploadFileHandler(file);
  488. }
  489. inputFiles = null;
  490. const fileInputElement = document.getElementById('files-input');
  491. if (fileInputElement) {
  492. fileInputElement.value = '';
  493. }
  494. } else {
  495. toast.error($i18n.t(`File not found.`));
  496. }
  497. }}
  498. />
  499. <div class="flex flex-col w-full h-full max-h-[100dvh]" id="collection-container">
  500. {#if id && knowledge}
  501. <div class="flex flex-row flex-1 h-full max-h-full pb-2.5">
  502. <PaneGroup direction="horizontal">
  503. <Pane
  504. bind:pane
  505. defaultSize={minSize}
  506. collapsible={true}
  507. maxSize={50}
  508. {minSize}
  509. class="h-full"
  510. onExpand={() => {
  511. showSidepanel = true;
  512. }}
  513. onCollapse={() => {
  514. showSidepanel = false;
  515. }}
  516. >
  517. <div
  518. class="{largeScreen ? 'flex-shrink-0' : 'flex-1'}
  519. flex
  520. py-2
  521. rounded-2xl
  522. border
  523. border-gray-50
  524. h-full
  525. dark:border-gray-850"
  526. >
  527. <div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
  528. <div class="w-full h-full flex flex-col">
  529. <div class=" px-3">
  530. <div class="flex py-1">
  531. <div class=" self-center ml-1 mr-3">
  532. <svg
  533. xmlns="http://www.w3.org/2000/svg"
  534. viewBox="0 0 20 20"
  535. fill="currentColor"
  536. class="w-4 h-4"
  537. >
  538. <path
  539. fill-rule="evenodd"
  540. 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"
  541. clip-rule="evenodd"
  542. />
  543. </svg>
  544. </div>
  545. <input
  546. class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
  547. bind:value={query}
  548. placeholder={$i18n.t('Search Collection')}
  549. on:focus={() => {
  550. selectedFileId = null;
  551. }}
  552. />
  553. <div>
  554. <AddContentMenu
  555. on:upload={(e) => {
  556. if (e.detail.type === 'directory') {
  557. uploadDirectoryHandler();
  558. } else if (e.detail.type === 'text') {
  559. showAddTextContentModal = true;
  560. } else {
  561. document.getElementById('files-input').click();
  562. }
  563. }}
  564. on:sync={(e) => {
  565. showSyncConfirmModal = true;
  566. }}
  567. />
  568. </div>
  569. </div>
  570. </div>
  571. {#if filteredItems.length > 0}
  572. <div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
  573. <Files
  574. files={filteredItems}
  575. {selectedFileId}
  576. on:click={(e) => {
  577. selectedFileId = selectedFileId === e.detail ? null : e.detail;
  578. }}
  579. on:delete={(e) => {
  580. console.log(e.detail);
  581. selectedFileId = null;
  582. deleteFileHandler(e.detail);
  583. }}
  584. />
  585. </div>
  586. {:else}
  587. <div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
  588. {/if}
  589. </div>
  590. </div>
  591. </div>
  592. </Pane>
  593. {#if largeScreen}
  594. <PaneResizer class="relative flex w-2 items-center justify-center bg-background group">
  595. <div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
  596. <EllipsisVertical className="size-4 invisible group-hover:visible" />
  597. </div>
  598. </PaneResizer>
  599. <Pane>
  600. <div class="flex-1 flex justify-start h-full max-h-full">
  601. {#if selectedFile}
  602. <div class=" flex flex-col w-full h-full max-h-full ml-2.5">
  603. <div class="flex-shrink-0 mb-2 flex items-center">
  604. {#if !showSidepanel}
  605. <div class="-translate-x-2">
  606. <button
  607. 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"
  608. on:click={() => {
  609. pane.expand();
  610. }}
  611. >
  612. <ChevronLeft strokeWidth="2.5" />
  613. </button>
  614. </div>
  615. {/if}
  616. <div class=" flex-1 text-2xl font-medium">
  617. <a
  618. class="hover:text-gray-500 hover:dark:text-gray-100 hover:underline flex-grow line-clamp-1"
  619. href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
  620. target="_blank"
  621. >
  622. {selectedFile?.meta?.name}
  623. </a>
  624. </div>
  625. <div>
  626. <button
  627. 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"
  628. on:click={() => {
  629. updateFileContentHandler();
  630. }}
  631. >
  632. {$i18n.t('Save')}
  633. </button>
  634. </div>
  635. </div>
  636. <div
  637. class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-none overflow-y-auto scrollbar-hidden"
  638. >
  639. {#key selectedFile.id}
  640. <RichTextInput
  641. className="input-prose-sm"
  642. bind:value={selectedFile.data.content}
  643. placeholder={$i18n.t('Add content here')}
  644. />
  645. {/key}
  646. </div>
  647. </div>
  648. {:else}
  649. <div class="m-auto pb-32">
  650. <div>
  651. <div class=" flex w-full mt-1 mb-3.5">
  652. <div class="flex-1">
  653. <div class="flex items-center justify-between w-full px-0.5 mb-1">
  654. <div class="w-full">
  655. <input
  656. type="text"
  657. class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
  658. bind:value={knowledge.name}
  659. on:input={() => {
  660. changeDebounceHandler();
  661. }}
  662. />
  663. </div>
  664. </div>
  665. <div class="flex w-full px-1">
  666. <input
  667. type="text"
  668. class="text-center w-full text-gray-500 bg-transparent outline-none"
  669. bind:value={knowledge.description}
  670. on:input={() => {
  671. changeDebounceHandler();
  672. }}
  673. />
  674. </div>
  675. </div>
  676. </div>
  677. </div>
  678. <div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
  679. {$i18n.t('Select a file to view or drag and drop a file to upload')}
  680. </div>
  681. </div>
  682. {/if}
  683. </div>
  684. </Pane>
  685. {:else if !largeScreen && selectedFileId !== null}
  686. <Drawer
  687. className="h-full"
  688. show={selectedFileId !== null}
  689. on:close={() => {
  690. selectedFileId = null;
  691. }}
  692. >
  693. <div class="flex flex-col justify-start h-full max-h-full p-2">
  694. <div class=" flex flex-col w-full h-full max-h-full">
  695. <div class="flex-shrink-0 mt-1 mb-2 flex items-center">
  696. <div class="mr-2">
  697. <button
  698. 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"
  699. on:click={() => {
  700. selectedFileId = null;
  701. }}
  702. >
  703. <ChevronLeft strokeWidth="2.5" />
  704. </button>
  705. </div>
  706. <div class=" flex-1 text-xl line-clamp-1">
  707. {selectedFile?.meta?.name}
  708. </div>
  709. <div>
  710. <button
  711. 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"
  712. on:click={() => {
  713. updateFileContentHandler();
  714. }}
  715. >
  716. {$i18n.t('Save')}
  717. </button>
  718. </div>
  719. </div>
  720. <div
  721. 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"
  722. >
  723. {#key selectedFile.id}
  724. <RichTextInput
  725. className="input-prose-sm"
  726. bind:value={selectedFile.data.content}
  727. placeholder={$i18n.t('Add content here')}
  728. />
  729. {/key}
  730. </div>
  731. </div>
  732. </div>
  733. </Drawer>
  734. {/if}
  735. </PaneGroup>
  736. </div>
  737. {:else}
  738. <Spinner />
  739. {/if}
  740. </div>