390 lines
11 KiB
Svelte
390 lines
11 KiB
Svelte
<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 searchDebounceTimeout: any;
|
|
|
|
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;
|
|
|
|
if (searchDebounceTimeout) {
|
|
clearTimeout(searchDebounceTimeout);
|
|
}
|
|
|
|
page = 0;
|
|
files = null;
|
|
allFilesLoaded = false;
|
|
|
|
const doSearch = async () => {
|
|
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) {
|
|
// Handle 404 or other errors - show empty state instead of spinner
|
|
files = [];
|
|
allFilesLoaded = true;
|
|
}
|
|
};
|
|
|
|
if (query === '') {
|
|
await doSearch();
|
|
} else {
|
|
searchDebounceTimeout = setTimeout(doSearch, 500);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
// Handle errors silently for load more
|
|
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.'));
|
|
// Remove from local array instead of re-fetching to allow rapid deletion
|
|
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) {
|
|
searchHandler();
|
|
}
|
|
|
|
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 () => {
|
|
window.removeEventListener('keydown', onKeyDown);
|
|
window.removeEventListener('keyup', onKeyUp);
|
|
window.removeEventListener('blur', onBlur);
|
|
};
|
|
});
|
|
</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">
|
|
<!-- Search -->
|
|
<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}
|
|
on:input={() => searchHandler()}
|
|
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 = '';
|
|
searchHandler();
|
|
}}
|
|
>
|
|
<XMark className="size-3" strokeWidth="2" />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Files List -->
|
|
<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>
|