| | <script lang="ts"> |
| | import { toast } from 'svelte-sonner'; |
| | import { getContext, onMount, onDestroy } from 'svelte'; |
| | import type { Writable } from 'svelte/store'; |
| | import dayjs from 'dayjs'; |
| |
|
| | import { searchFiles, deleteFileById } from '$lib/apis/files'; |
| | import Modal from '$lib/components/common/Modal.svelte'; |
| | import Spinner from '$lib/components/common/Spinner.svelte'; |
| | import Tooltip from '$lib/components/common/Tooltip.svelte'; |
| | import Loader from '$lib/components/common/Loader.svelte'; |
| | import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; |
| | import XMark from '$lib/components/icons/XMark.svelte'; |
| | import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; |
| | import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; |
| | import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; |
| | import FileItemModal from '$lib/components/common/FileItemModal.svelte'; |
| |
|
| | const i18n: Writable<any> = getContext('i18n'); |
| |
|
| | export let show = false; |
| |
|
| | let files: any[] | null = null; |
| | let query = ''; |
| | let orderBy = 'created_at'; |
| | let direction = 'desc'; |
| |
|
| | let page = 0; |
| | let allFilesLoaded = false; |
| | let filesLoading = false; |
| | let searchDebounceTimer: ReturnType<typeof setTimeout>; |
| |
|
| | let selectedFileId: string | null = null; |
| | let showDeleteConfirmDialog = false; |
| |
|
| | let selectedFile: any = null; |
| | let showFileItemModal = false; |
| |
|
| | let shiftKey = false; |
| |
|
| | const PAGE_SIZE = 50; |
| |
|
| | const formatFileSize = (bytes: number): string => { |
| | if (bytes === 0) return '0 B'; |
| | const k = 1024; |
| | const sizes = ['B', 'KB', 'MB', 'GB']; |
| | const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| | return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; |
| | }; |
| |
|
| | const setSortKey = (key: string) => { |
| | if (orderBy === key) { |
| | direction = direction === 'asc' ? 'desc' : 'asc'; |
| | } else { |
| | orderBy = key; |
| | direction = 'asc'; |
| | } |
| | searchHandler(); |
| | }; |
| |
|
| | const searchHandler = async () => { |
| | if (!show) return; |
| |
|
| | page = 0; |
| | files = null; |
| | allFilesLoaded = false; |
| |
|
| | try { |
| | const pattern = query ? `*${query}*` : '*'; |
| | const newFiles = await searchFiles(localStorage.token, pattern, 0, PAGE_SIZE); |
| | files = sortFiles(newFiles); |
| | allFilesLoaded = newFiles.length < PAGE_SIZE; |
| | } catch (error) { |
| | |
| | files = []; |
| | allFilesLoaded = true; |
| | } |
| | }; |
| |
|
| | const loadMoreFiles = async () => { |
| | if (filesLoading || allFilesLoaded) return; |
| |
|
| | filesLoading = true; |
| | page += 1; |
| |
|
| | try { |
| | const pattern = query ? `*${query}*` : '*'; |
| | const newFiles = await searchFiles(localStorage.token, pattern, page * PAGE_SIZE, PAGE_SIZE); |
| |
|
| | allFilesLoaded = newFiles.length < PAGE_SIZE; |
| |
|
| | if (newFiles.length > 0) { |
| | files = sortFiles([...(files || []), ...newFiles]); |
| | } |
| | } catch (error) { |
| | |
| | allFilesLoaded = true; |
| | } |
| |
|
| | filesLoading = false; |
| | }; |
| |
|
| | const sortFiles = (fileList: any[]): any[] => { |
| | return fileList.sort((a, b) => { |
| | let aVal = a[orderBy] ?? 0; |
| | let bVal = b[orderBy] ?? 0; |
| |
|
| | if (orderBy === 'filename') { |
| | aVal = a.filename?.toLowerCase() ?? ''; |
| | bVal = b.filename?.toLowerCase() ?? ''; |
| | } |
| |
|
| | if (direction === 'asc') { |
| | return aVal > bVal ? 1 : -1; |
| | } else { |
| | return aVal < bVal ? 1 : -1; |
| | } |
| | }); |
| | }; |
| |
|
| | const deleteHandler = async (fileId: string) => { |
| | try { |
| | await deleteFileById(localStorage.token, fileId); |
| | toast.success($i18n.t('File deleted successfully.')); |
| | |
| | files = files?.filter((f) => f.id !== fileId) ?? null; |
| | } catch (error) { |
| | toast.error(`${error}`); |
| | } |
| | }; |
| |
|
| | const openFileViewer = (file: any) => { |
| | selectedFile = { |
| | id: file.id, |
| | name: file.filename, |
| | type: 'file', |
| | size: file.meta?.size, |
| | meta: file.meta |
| | }; |
| | showFileItemModal = true; |
| | }; |
| |
|
| | |
| | $: if (show && query !== undefined) { |
| | clearTimeout(searchDebounceTimer); |
| | searchDebounceTimer = setTimeout(() => { |
| | searchHandler(); |
| | }, 300); |
| | } |
| |
|
| | onMount(() => { |
| | const onKeyDown = (event: KeyboardEvent) => { |
| | if (event.key === 'Shift') { |
| | shiftKey = true; |
| | } |
| | }; |
| |
|
| | const onKeyUp = (event: KeyboardEvent) => { |
| | if (event.key === 'Shift') { |
| | shiftKey = false; |
| | } |
| | }; |
| |
|
| | const onBlur = () => { |
| | shiftKey = false; |
| | }; |
| |
|
| | window.addEventListener('keydown', onKeyDown); |
| | window.addEventListener('keyup', onKeyUp); |
| | window.addEventListener('blur', onBlur); |
| |
|
| | return () => { |
| | clearTimeout(searchDebounceTimer); |
| | window.removeEventListener('keydown', onKeyDown); |
| | window.removeEventListener('keyup', onKeyUp); |
| | window.removeEventListener('blur', onBlur); |
| | }; |
| | }); |
| |
|
| | onDestroy(() => { |
| | clearTimeout(searchDebounceTimer); |
| | }); |
| | </script> |
| |
|
| | <ConfirmDialog |
| | bind:show={showDeleteConfirmDialog} |
| | on:confirm={() => { |
| | if (selectedFileId) { |
| | deleteHandler(selectedFileId); |
| | selectedFileId = null; |
| | } |
| | }} |
| | /> |
| |
|
| | <FileItemModal bind:show={showFileItemModal} item={selectedFile} edit={false} /> |
| |
|
| | <Modal size="xl" bind:show> |
| | <div> |
| | <div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-1"> |
| | <div class="text-lg font-medium self-center">{$i18n.t('Files')}</div> |
| | <button |
| | class="self-center" |
| | on:click={() => { |
| | show = false; |
| | }} |
| | > |
| | <svg |
| | xmlns="http://www.w3.org/2000/svg" |
| | viewBox="0 0 20 20" |
| | fill="currentColor" |
| | class="w-5 h-5" |
| | > |
| | <path |
| | fill-rule="evenodd" |
| | d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" |
| | clip-rule="evenodd" |
| | /> |
| | </svg> |
| | </button> |
| | </div> |
| | |
| | <div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200"> |
| | |
| | <div class="flex w-full space-x-2 mb-0.5"> |
| | <div class="flex flex-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-hidden bg-transparent" |
| | bind:value={query} |
| | placeholder={$i18n.t('Search Files')} |
| | maxlength="500" |
| | /> |
| | |
| | {#if query} |
| | <div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent"> |
| | <button |
| | class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition" |
| | on:click={() => { |
| | query = ''; |
| | }} |
| | > |
| | <XMark className="size-3" strokeWidth="2" /> |
| | </button> |
| | </div> |
| | {/if} |
| | </div> |
| | </div> |
| | |
| | |
| | <div class="flex flex-col w-full"> |
| | {#if files !== null} |
| | <div class="w-full"> |
| | {#if files.length > 0} |
| | <div class="flex text-xs font-medium mb-1.5"> |
| | <button |
| | class="px-1.5 py-1 cursor-pointer select-none basis-3/5" |
| | on:click={() => setSortKey('filename')} |
| | > |
| | <div class="flex gap-1.5 items-center"> |
| | {$i18n.t('Filename')} |
| | {#if orderBy === 'filename'} |
| | <span class="font-normal"> |
| | {#if direction === 'asc'} |
| | <ChevronUp className="size-2" /> |
| | {:else} |
| | <ChevronDown className="size-2" /> |
| | {/if} |
| | </span> |
| | {:else} |
| | <span class="invisible"> |
| | <ChevronUp className="size-2" /> |
| | </span> |
| | {/if} |
| | </div> |
| | </button> |
| | <button |
| | class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end" |
| | on:click={() => setSortKey('created_at')} |
| | > |
| | <div class="flex gap-1.5 items-center"> |
| | {$i18n.t('Created at')} |
| | {#if orderBy === 'created_at'} |
| | <span class="font-normal"> |
| | {#if direction === 'asc'} |
| | <ChevronUp className="size-2" /> |
| | {:else} |
| | <ChevronDown className="size-2" /> |
| | {/if} |
| | </span> |
| | {:else} |
| | <span class="invisible"> |
| | <ChevronUp className="size-2" /> |
| | </span> |
| | {/if} |
| | </div> |
| | </button> |
| | </div> |
| | {/if} |
| | |
| | <div class="text-left text-sm w-full mb-3 max-h-[32rem] overflow-y-scroll"> |
| | {#if files.length === 0} |
| | <div |
| | class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center" |
| | > |
| | {$i18n.t('No files found')} |
| | </div> |
| | {/if} |
| | |
| | {#each files as file (file.id)} |
| | <div |
| | class="w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 cursor-pointer" |
| | on:click={() => openFileViewer(file)} |
| | > |
| | <div class="basis-3/5 min-w-0"> |
| | <div class="text-ellipsis line-clamp-1">{file.filename}</div> |
| | <div class="text-xs text-gray-500"> |
| | {formatFileSize(file.meta?.size ?? 0)} |
| | </div> |
| | </div> |
| | |
| | <div class="basis-2/5 flex items-center justify-end"> |
| | <div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs"> |
| | {dayjs(file.created_at * 1000).format('MMM D, YYYY')} |
| | </div> |
| | |
| | <div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300"> |
| | <Tooltip content={shiftKey ? $i18n.t('Delete File') : $i18n.t('Delete File')}> |
| | <button |
| | class="self-center w-fit px-1 text-sm rounded-xl {shiftKey |
| | ? 'text-red-500' |
| | : ''}" |
| | on:click|stopPropagation={() => { |
| | if (shiftKey) { |
| | deleteHandler(file.id); |
| | } else { |
| | selectedFileId = file.id; |
| | showDeleteConfirmDialog = true; |
| | } |
| | }} |
| | > |
| | <GarbageBin class="size-4" strokeWidth="1.5" /> |
| | </button> |
| | </Tooltip> |
| | </div> |
| | </div> |
| | </div> |
| | {/each} |
| | |
| | {#if !allFilesLoaded} |
| | <Loader |
| | on:visible={() => { |
| | if (!filesLoading) { |
| | loadMoreFiles(); |
| | } |
| | }} |
| | > |
| | <div |
| | class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2" |
| | > |
| | <Spinner className="size-4" /> |
| | <div>{$i18n.t('Loading...')}</div> |
| | </div> |
| | </Loader> |
| | {/if} |
| | </div> |
| | </div> |
| | {:else} |
| | <div class="w-full h-full flex justify-center items-center min-h-20"> |
| | <Spinner className="size-5" /> |
| | </div> |
| | {/if} |
| | </div> |
| | </div> |
| | </div> |
| | </Modal> |
| |
|