KnowledgeBase.svelte 24 KB

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