mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-29 06:32:52 +00:00
refac
This commit is contained in:
parent
562b361e7d
commit
2a86805648
6 changed files with 560 additions and 447 deletions
|
|
@ -511,6 +511,15 @@ async def list_files(
|
|||
############################
|
||||
|
||||
|
||||
async def _search_files(filename: str, content: bool, skip: int, limit: int, user_id: str | None, db: AsyncSession):
|
||||
files = await Files.search_files(user_id=user_id, filename=filename, skip=skip, limit=limit, db=db)
|
||||
if not content:
|
||||
for file in files:
|
||||
if file.data and 'content' in file.data:
|
||||
del file.data['content']
|
||||
return files
|
||||
|
||||
|
||||
@router.get('/search', response_model=list[FileModelResponse])
|
||||
async def search_files(
|
||||
filename: str = Query(
|
||||
|
|
@ -523,36 +532,28 @@ async def search_files(
|
|||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Search for files by filename with support for wildcard patterns.
|
||||
Uses SQL-based filtering with pagination for better performance.
|
||||
"""
|
||||
# Determine user_id: null for admin with bypass (search all), user.id otherwise
|
||||
user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
|
||||
|
||||
# Use optimized database query with pagination
|
||||
files = await Files.search_files(
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
files = await _search_files(filename, content, skip, limit, user_id, db)
|
||||
if not files:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No files found matching the pattern.',
|
||||
)
|
||||
|
||||
if not content:
|
||||
for file in files:
|
||||
if file.data and 'content' in file.data:
|
||||
del file.data['content']
|
||||
|
||||
return files
|
||||
|
||||
|
||||
@router.get('/user/search', response_model=list[FileModelResponse])
|
||||
async def search_user_files(
|
||||
filename: str = Query('*'),
|
||||
content: bool = Query(False),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await _search_files(filename, content, skip, limit, user.id, db)
|
||||
|
||||
|
||||
############################
|
||||
# Count Files
|
||||
############################
|
||||
|
|
@ -567,6 +568,14 @@ async def count_files(
|
|||
return await Files.count_files_by_user_id(user_id=user_id, db=db)
|
||||
|
||||
|
||||
@router.get('/user/count', response_model=int)
|
||||
async def count_user_files(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await Files.count_files_by_user_id(user_id=user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# Delete All Files
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -179,73 +179,56 @@ export const getFiles = async (token: string = '', content: boolean = false) =>
|
|||
return res;
|
||||
};
|
||||
|
||||
export const searchFiles = async (
|
||||
const searchFilesByPath = async (
|
||||
path: string,
|
||||
token: string,
|
||||
filename: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
content: boolean
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
filename,
|
||||
skip: String(skip),
|
||||
limit: String(limit),
|
||||
content: String(content)
|
||||
});
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}?${searchParams}`, {
|
||||
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw error.detail ?? error;
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const searchFiles = (
|
||||
token: string,
|
||||
filename: string = '*',
|
||||
skip: number = 0,
|
||||
limit: number = 50,
|
||||
content: boolean = false
|
||||
) => {
|
||||
let error = null;
|
||||
) => searchFilesByPath('search', token, filename, skip, limit, content);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('filename', filename);
|
||||
searchParams.append('skip', String(skip));
|
||||
searchParams.append('limit', String(limit));
|
||||
searchParams.append('content', String(content));
|
||||
export const searchUserFiles = (
|
||||
token: string,
|
||||
filename: string = '*',
|
||||
skip: number = 0,
|
||||
limit: number = 50,
|
||||
content: boolean = false
|
||||
) => searchFilesByPath('user/search', token, filename, skip, limit, content);
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/search?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return [];
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
const getFileCountByPath = async (path: string, token: string) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/${path}`, {
|
||||
headers: { Accept: 'application/json', authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const getFileCount = async (token: string = '') => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/files/count`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
export const getFileCount = (token: string = '') => getFileCountByPath('count', token);
|
||||
export const getUserFileCount = (token: string = '') => getFileCountByPath('user/count', token);
|
||||
|
||||
export const getFileById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
|
@ -388,7 +371,7 @@ export const deleteFileById = async (token: string, id: string) => {
|
|||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
error = err.detail ?? err;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@
|
|||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleOpen();
|
||||
if (show) closeDropdown();
|
||||
else openDropdown(true);
|
||||
}
|
||||
}
|
||||
node.addEventListener('click', handleClick);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@
|
|||
(event.key === 'Escape' ||
|
||||
($settings?.keyboardShortcuts !== false &&
|
||||
matchKeybinding(event) === Shortcut.CLOSE_MODAL)) &&
|
||||
!(
|
||||
event.target instanceof Element && event.target.closest('[role="menu"], [role="listbox"]')
|
||||
) &&
|
||||
isTopModal()
|
||||
) {
|
||||
requestClose();
|
||||
|
|
@ -60,14 +63,16 @@
|
|||
return modals.length && modals[modals.length - 1] === modalElement;
|
||||
};
|
||||
|
||||
let handleOutsidePointerDown: ((event: PointerEvent) => void) | undefined;
|
||||
let handleOutsideInteraction: ((event: Event) => void) | undefined;
|
||||
let handleModalFocusIn: (() => void) | undefined;
|
||||
const release = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
focusTrap?.deactivate();
|
||||
focusTrap = null;
|
||||
if (handleOutsidePointerDown)
|
||||
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
|
||||
if (handleOutsideInteraction) {
|
||||
document.removeEventListener('pointerdown', handleOutsideInteraction, true);
|
||||
window.removeEventListener('focusin', handleOutsideInteraction, true);
|
||||
}
|
||||
if (handleModalFocusIn) modalElement?.removeEventListener('focusin', handleModalFocusIn);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (previousOverflow !== null) {
|
||||
|
|
@ -90,13 +95,15 @@
|
|||
});
|
||||
focusTrap.activate();
|
||||
|
||||
// Auto-pause focus trap when interacting with portaled content (e.g. Dropdown)
|
||||
handleOutsidePointerDown = (e) => {
|
||||
// Portaled menus need keyboard focus as well as pointer interaction.
|
||||
handleOutsideInteraction = (e) => {
|
||||
if (
|
||||
focusTrap &&
|
||||
modalElement &&
|
||||
e.target instanceof Node &&
|
||||
!modalElement.contains(e.target)
|
||||
!modalElement.contains(e.target) &&
|
||||
(e.type === 'pointerdown' ||
|
||||
(e.target instanceof Element && e.target.closest('[role="menu"], [role="listbox"]')))
|
||||
) {
|
||||
focusTrap.pause();
|
||||
}
|
||||
|
|
@ -106,7 +113,8 @@
|
|||
focusTrap.unpause();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handleOutsidePointerDown, true);
|
||||
document.addEventListener('pointerdown', handleOutsideInteraction, true);
|
||||
window.addEventListener('focusin', handleOutsideInteraction, true);
|
||||
modalElement.addEventListener('focusin', handleModalFocusIn);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount, onDestroy } from 'svelte';
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { searchFiles, deleteFileById, getFileCount } from '$lib/apis/files';
|
||||
import {
|
||||
searchUserFiles,
|
||||
deleteFileById,
|
||||
getUserFileCount,
|
||||
getFileById,
|
||||
getFileBlobById
|
||||
} from '$lib/apis/files';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
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 Checkbox from '$lib/components/common/Checkbox.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import FileItemModal from '$lib/components/common/FileItemModal.svelte';
|
||||
import FileTypeIcon from '$lib/components/chat/FileNav/FileTypeIcon.svelte';
|
||||
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
export let show = false;
|
||||
|
||||
let files: any[] | null = null;
|
||||
|
|
@ -25,390 +35,474 @@
|
|||
let query = '';
|
||||
let orderBy = 'created_at';
|
||||
let direction = 'desc';
|
||||
|
||||
let page = 0;
|
||||
let allFilesLoaded = false;
|
||||
let filesLoading = false;
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let loadError = false;
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
let generation = 0;
|
||||
|
||||
let selectedFileId: string | null = null;
|
||||
let selectedIds = new Set<string>();
|
||||
let selectionStatus: 'idle' | 'loading' | 'all' | 'error' = 'idle';
|
||||
let deleteIds: string[] = [];
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
let operation: 'delete' | 'download' | null = null;
|
||||
let progress = { done: 0, total: 0, failed: 0 };
|
||||
let stopRequested = 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;
|
||||
|
||||
if (!query) {
|
||||
fileCount = await getFileCount(localStorage.token);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle 404 or other errors - show empty state instead of spinner
|
||||
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) {
|
||||
// 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;
|
||||
if (fileCount !== null) fileCount--;
|
||||
} 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;
|
||||
};
|
||||
|
||||
// Debounce query changes
|
||||
$: 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);
|
||||
};
|
||||
$: busy = selectionStatus === 'loading' || !!operation || showDeleteConfirmDialog;
|
||||
$: loadedSelected = (files ?? []).filter((file) => selectedIds.has(file.id)).length;
|
||||
$: allLoadedSelected = !!files?.length && loadedSelected === files.length;
|
||||
$: sortedFiles = [...(files ?? [])].sort((a, b) => {
|
||||
const result =
|
||||
orderBy === 'filename'
|
||||
? (a.filename ?? '').localeCompare(b.filename ?? '')
|
||||
: (a.created_at ?? 0) - (b.created_at ?? 0);
|
||||
return direction === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
const loadFiles = async (more = false) => {
|
||||
if (!show || (more && (busy || filesLoading || allFilesLoaded || loadError))) return;
|
||||
const request = generation;
|
||||
const nextPage = more ? page + 1 : 0;
|
||||
filesLoading = true;
|
||||
loadError = false;
|
||||
try {
|
||||
const result = await searchUserFiles(
|
||||
localStorage.token,
|
||||
query ? `*${query}*` : '*',
|
||||
nextPage * PAGE_SIZE,
|
||||
PAGE_SIZE
|
||||
);
|
||||
if (request !== generation) return;
|
||||
files = more ? [...(files ?? []), ...result] : result;
|
||||
page = nextPage;
|
||||
allFilesLoaded = result.length < PAGE_SIZE;
|
||||
if (!more) {
|
||||
const count = await getUserFileCount(localStorage.token);
|
||||
if (request === generation) fileCount = count;
|
||||
}
|
||||
} catch {
|
||||
if (request !== generation) return;
|
||||
files ??= [];
|
||||
loadError = true;
|
||||
} finally {
|
||||
if (request === generation) filesLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetSearch = (visible: boolean, _query: string) => {
|
||||
++generation;
|
||||
selectedIds = new Set();
|
||||
selectionStatus = 'idle';
|
||||
files = null;
|
||||
fileCount = null;
|
||||
filesLoading = false;
|
||||
clearTimeout(searchTimer);
|
||||
if (visible) searchTimer = setTimeout(() => loadFiles(), 300);
|
||||
};
|
||||
$: resetSearch(show, query);
|
||||
|
||||
const toggleSelection = (ids: string[], selected: boolean) => {
|
||||
if (busy) return;
|
||||
for (const id of ids) {
|
||||
if (selected) selectedIds.add(id);
|
||||
else selectedIds.delete(id);
|
||||
}
|
||||
selectedIds = new Set(selectedIds);
|
||||
selectionStatus = 'idle';
|
||||
};
|
||||
|
||||
const selectAllMatching = async () => {
|
||||
if (busy) return;
|
||||
const request = generation;
|
||||
const pattern = query ? `*${query}*` : '*';
|
||||
const ids = new Set<string>();
|
||||
selectionStatus = 'loading';
|
||||
try {
|
||||
for (let skip = 0; ; skip += PAGE_SIZE) {
|
||||
const result = await searchUserFiles(localStorage.token, pattern, skip, PAGE_SIZE);
|
||||
if (request !== generation) return;
|
||||
for (const file of result) ids.add(file.id);
|
||||
if (result.length < PAGE_SIZE) break;
|
||||
}
|
||||
selectedIds = ids;
|
||||
selectionStatus = 'all';
|
||||
} catch {
|
||||
if (request === generation) selectionStatus = 'error';
|
||||
}
|
||||
};
|
||||
|
||||
const requestDelete = (ids: string[]) => {
|
||||
if (busy || !ids.length) return;
|
||||
deleteIds = ids;
|
||||
showDeleteConfirmDialog = true;
|
||||
};
|
||||
|
||||
const deleteHandler = async () => {
|
||||
if (operation || !deleteIds.length) return;
|
||||
operation = 'delete';
|
||||
stopRequested = false;
|
||||
progress = { done: 0, total: deleteIds.length, failed: 0 };
|
||||
++generation;
|
||||
clearTimeout(searchTimer);
|
||||
for (let offset = 0; offset < deleteIds.length && !stopRequested; offset += 3) {
|
||||
await Promise.all(
|
||||
deleteIds.slice(offset, offset + 3).map(async (id) => {
|
||||
try {
|
||||
await deleteFileById(localStorage.token, id);
|
||||
selectedIds.delete(id);
|
||||
files = files?.filter((file) => file.id !== id) ?? null;
|
||||
} catch {
|
||||
progress.failed++;
|
||||
selectedIds.add(id);
|
||||
} finally {
|
||||
progress.done++;
|
||||
selectedIds = new Set(selectedIds);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
const message = $i18n.t(
|
||||
'{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.',
|
||||
{
|
||||
deleted: progress.done - progress.failed,
|
||||
failed: progress.failed,
|
||||
remaining: progress.total - progress.done
|
||||
}
|
||||
);
|
||||
if (progress.failed) toast.error(message);
|
||||
else toast.success(message);
|
||||
selectionStatus = 'idle';
|
||||
await loadFiles();
|
||||
operation = null;
|
||||
};
|
||||
|
||||
const downloadHandler = async (ids: string[]) => {
|
||||
if (busy || !ids.length) return;
|
||||
operation = 'download';
|
||||
stopRequested = false;
|
||||
progress = { done: 0, total: ids.length, failed: 0 };
|
||||
try {
|
||||
// One archive avoids browser prompts for hundreds of separate downloads.
|
||||
const zip = ids.length > 1 ? new (await import('jszip')).default() : null;
|
||||
let output: Blob | undefined;
|
||||
let filename = '';
|
||||
for (const id of ids) {
|
||||
if (stopRequested) return;
|
||||
const file =
|
||||
files?.find((file) => file.id === id) ?? (await getFileById(localStorage.token, id));
|
||||
if (!file) throw new Error($i18n.t('Could not load files.'));
|
||||
const blob = await getFileBlobById(localStorage.token, id, { attachment: true });
|
||||
filename = (file.filename || 'file').replace(/[\\/]/g, '_');
|
||||
if (filename === '.' || filename === '..') filename = 'file';
|
||||
if (zip) {
|
||||
let name = filename;
|
||||
let suffix = 1;
|
||||
while (zip.file(name)) name = `${suffix++}-${filename}`;
|
||||
zip.file(name, await blob.arrayBuffer());
|
||||
} else output = blob;
|
||||
progress.done++;
|
||||
}
|
||||
if (stopRequested) return;
|
||||
if (zip) output = await zip.generateAsync({ type: 'blob' });
|
||||
if (stopRequested || !output) return;
|
||||
const url = URL.createObjectURL(output);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = zip ? 'files.zip' : filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
operation = null;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
show = false;
|
||||
stopRequested = true;
|
||||
++generation;
|
||||
clearTimeout(searchTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={() => {
|
||||
if (selectedFileId) {
|
||||
deleteHandler(selectedFileId);
|
||||
selectedFileId = null;
|
||||
}
|
||||
}}
|
||||
title={$i18n.t('Delete {{count}} files?', { count: deleteIds.length })}
|
||||
message={$i18n.t('This cannot be undone. References to these files may stop working.')}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={deleteHandler}
|
||||
/>
|
||||
|
||||
<FileItemModal bind:show={showFileItemModal} item={selectedFile} edit={false} />
|
||||
|
||||
<Modal size="xl" bind:show>
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium self-center">
|
||||
<div>{$i18n.t('Files')}</div>
|
||||
{#if query && files}
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-500">
|
||||
{files.length}
|
||||
</div>
|
||||
{:else if fileCount !== null}
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-500">
|
||||
{fileCount}
|
||||
</div>
|
||||
{/if}
|
||||
<Modal
|
||||
size="lg"
|
||||
bind:show
|
||||
onRequestClose={() => {
|
||||
if (!operation) show = false;
|
||||
}}
|
||||
ariaLabel={$i18n.t('Files')}
|
||||
>
|
||||
<div class="px-4 pb-3 pt-3 text-gray-700 dark:text-gray-200">
|
||||
<div class="mb-2 flex h-6 items-center gap-2">
|
||||
<div class="flex flex-1 items-center gap-1.5 text-sm font-medium">
|
||||
{$i18n.t('Files')}
|
||||
{#if fileCount !== null}<span class="text-xs font-normal tabular-nums text-gray-400"
|
||||
>{fileCount}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<Dropdown closeOnSelect align="end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Selection actions')}
|
||||
disabled={busy || !selectedIds.size}
|
||||
class="flex h-6 items-center gap-1 rounded-lg px-1.5 text-xs text-gray-500 transition hover:text-gray-900 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
>
|
||||
{$i18n.t('Actions')}<Icon name="chevron-down" size={11} />
|
||||
</button>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-36" style="border: 0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !selectedIds.size}
|
||||
on:click={() => downloadHandler([...selectedIds])}
|
||||
><ArrowDownTray className="size-3.5" />{$i18n.t('Download')}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !selectedIds.size}
|
||||
on:click={() => requestDelete([...selectedIds])}
|
||||
><GarbageBin className="size-3.5" />{$i18n.t('Delete')}</button
|
||||
>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Close')}
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
disabled={!!operation}
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 disabled:opacity-40 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
}}><XMark className="size-3.5" /></button
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<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}
|
||||
placeholder={$i18n.t('Search Files')}
|
||||
maxlength="500"
|
||||
/>
|
||||
<div class="mb-1 flex h-7 items-center gap-2 px-1 pb-1">
|
||||
{#if operation}
|
||||
<Spinner className="size-3" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-gray-400" role="status"
|
||||
>{$i18n.t(
|
||||
operation === 'download'
|
||||
? 'Preparing {{processed}} of {{total}}'
|
||||
: 'Processed {{processed}} of {{total}}',
|
||||
{ processed: progress.done, total: progress.total }
|
||||
)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-900 disabled:opacity-40 dark:hover:text-gray-100"
|
||||
disabled={stopRequested}
|
||||
on:click={() => {
|
||||
stopRequested = true;
|
||||
}}>{stopRequested ? $i18n.t('Stopping...') : $i18n.t('Stop')}</button
|
||||
>
|
||||
{:else}
|
||||
<Icon name="search" size={13} class="shrink-0 text-gray-400" />
|
||||
<input
|
||||
class="min-w-0 flex-1 bg-transparent py-1 text-xs outline-hidden placeholder:text-gray-400"
|
||||
bind:value={query}
|
||||
disabled={showDeleteConfirmDialog}
|
||||
placeholder={$i18n.t('Search Files')}
|
||||
aria-label={$i18n.t('Search Files')}
|
||||
maxlength="500"
|
||||
/>
|
||||
{#if query}<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Clear search')}
|
||||
disabled={showDeleteConfirmDialog}
|
||||
class="flex size-5 shrink-0 items-center justify-center text-gray-400 hover:text-gray-600"
|
||||
on:click={() => {
|
||||
query = '';
|
||||
}}><XMark className="size-3" /></button
|
||||
>{/if}
|
||||
{#if selectedIds.size}
|
||||
<span
|
||||
role="status"
|
||||
class="shrink-0 text-[0.6875rem] tabular-nums text-gray-500 dark:text-gray-400"
|
||||
>{$i18n.t('{{count}} selected', { count: selectedIds.size })}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Clear selection')}
|
||||
title={$i18n.t('Clear selection')}
|
||||
disabled={busy}
|
||||
class="flex size-5 shrink-0 items-center justify-center text-gray-400 hover:text-gray-600 disabled:opacity-40 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
selectedIds = new Set();
|
||||
selectionStatus = 'idle';
|
||||
}}><XMark className="size-3" /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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"
|
||||
aria-label={$i18n.t('Clear search')}
|
||||
on:click={() => {
|
||||
query = '';
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectionStatus === 'loading'}
|
||||
<div class="flex h-6 items-center gap-1.5 px-1 text-[0.6875rem] text-gray-500" role="status">
|
||||
<Spinner className="size-3" /><span>{$i18n.t('Selecting all matching files...')}</span>
|
||||
</div>
|
||||
{:else if selectionStatus === 'error'}
|
||||
<div class="flex items-center gap-2 px-1 py-1 text-[0.6875rem] text-gray-500" role="alert">
|
||||
<span
|
||||
>{$i18n.t('Could not select all matching files. Your selection has not changed.')}</span
|
||||
><button disabled={busy} class="shrink-0 underline" on:click={selectAllMatching}
|
||||
>{$i18n.t('Retry')}</button
|
||||
>
|
||||
</div>
|
||||
{:else if allLoadedSelected && !allFilesLoaded && selectionStatus !== 'all' && !operation}
|
||||
<button
|
||||
class="h-6 px-1 text-[0.6875rem] text-gray-500 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
disabled={busy}
|
||||
on:click={selectAllMatching}>{$i18n.t('Select all matching files')}</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<!-- 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-normal 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/70 dark:hover:bg-gray-850/50 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">
|
||||
<Tooltip
|
||||
content={dayjs(file.created_at * 1000)
|
||||
.locale($i18n.language)
|
||||
.format('MMM D, YYYY h:mm A')}
|
||||
className="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs"
|
||||
>
|
||||
{dayjs(file.created_at * 1000)
|
||||
.locale($i18n.language)
|
||||
.format('MMM D, YYYY')}
|
||||
</Tooltip>
|
||||
|
||||
<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}
|
||||
{#if files !== null}
|
||||
{#if files.length}
|
||||
<div
|
||||
class="grid h-7 grid-cols-[1.75rem_minmax(0,1fr)_3.5rem_1.75rem] items-center gap-x-2 text-[0.6875rem] text-gray-400 sm:grid-cols-[1.75rem_minmax(0,1fr)_4rem_7rem_1.75rem]"
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
state={allLoadedSelected ? 'checked' : 'unchecked'}
|
||||
indeterminate={loadedSelected > 0 && !allLoadedSelected}
|
||||
disabled={busy}
|
||||
ariaLabel={$i18n.t('Select loaded files')}
|
||||
on:change={() =>
|
||||
toggleSelection(
|
||||
(files ?? []).map((file) => file.id),
|
||||
!allLoadedSelected
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy}
|
||||
class="flex items-center gap-1 text-left hover:text-gray-700 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
direction = orderBy === 'filename' && direction === 'asc' ? 'desc' : 'asc';
|
||||
orderBy = 'filename';
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Filename')}{#if orderBy === 'filename'}<Icon
|
||||
name={direction === 'asc' ? 'chevron-up' : 'chevron-down'}
|
||||
size={10}
|
||||
/>{/if}
|
||||
</button>
|
||||
<span class="text-right">{$i18n.t('Size')}</span>
|
||||
<button
|
||||
disabled={busy}
|
||||
class="hidden items-center justify-end gap-1 hover:text-gray-700 dark:hover:text-gray-200 sm:flex"
|
||||
on:click={() => {
|
||||
direction = orderBy === 'created_at' && direction === 'asc' ? 'desc' : 'asc';
|
||||
orderBy = 'created_at';
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Created at')}{#if orderBy === 'created_at'}<Icon
|
||||
name={direction === 'asc' ? 'chevron-up' : 'chevron-down'}
|
||||
size={10}
|
||||
/>{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="max-h-[min(28rem,calc(100dvh-12rem))] overflow-y-auto">
|
||||
{#if !files.length && !loadError}<div
|
||||
class="flex min-h-20 items-center justify-center text-xs text-gray-400"
|
||||
>
|
||||
{$i18n.t('No files found')}
|
||||
</div>{/if}
|
||||
{#each sortedFiles as file (file.id)}
|
||||
<div
|
||||
class="group grid h-7 grid-cols-[1.75rem_minmax(0,1fr)_3.5rem_1.75rem] items-center gap-x-2 rounded-xl text-xs transition-colors hover:bg-gray-50/40 dark:hover:bg-white/4 sm:grid-cols-[1.75rem_minmax(0,1fr)_4rem_7rem_1.75rem]"
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
state={selectedIds.has(file.id) ? 'checked' : 'unchecked'}
|
||||
disabled={busy}
|
||||
ariaLabel={$i18n.t('Select {{filename}}', { filename: file.filename })}
|
||||
on:change={() => toggleSelection([file.id], !selectedIds.has(file.id))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-full min-w-0 items-center gap-1.5 text-left"
|
||||
title={file.filename}
|
||||
disabled={!!operation}
|
||||
on:click={() => {
|
||||
selectedFile = {
|
||||
id: file.id,
|
||||
name: file.filename,
|
||||
type: 'file',
|
||||
size: file.meta?.size,
|
||||
meta: file.meta
|
||||
};
|
||||
showFileItemModal = true;
|
||||
}}
|
||||
>
|
||||
<FileTypeIcon name={file.filename} size={14} /><span class="truncate"
|
||||
>{file.filename}</span
|
||||
>
|
||||
</button>
|
||||
<span class="truncate text-right text-[0.6875rem] tabular-nums text-gray-400"
|
||||
>{formatFileSize(file.meta?.size ?? 0)}</span
|
||||
>
|
||||
<span
|
||||
class="hidden text-right text-[0.6875rem] tabular-nums text-gray-400 sm:block"
|
||||
title={dayjs(file.created_at * 1000)
|
||||
.locale($i18n.language)
|
||||
.format('MMM D, YYYY h:mm A')}
|
||||
>{dayjs(file.created_at * 1000)
|
||||
.locale($i18n.language)
|
||||
.format('MMM D, YYYY')}</span
|
||||
>
|
||||
<Dropdown closeOnSelect align="end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Actions for {{filename}}', { filename: file.filename })}
|
||||
disabled={busy}
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 disabled:opacity-40 dark:hover:text-gray-200"
|
||||
><Icon name="three-dots" size={14} /></button
|
||||
>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-36" style="border: 0">
|
||||
<button type="button" disabled={busy} on:click={() => downloadHandler([file.id])}
|
||||
><ArrowDownTray className="size-3.5" />{$i18n.t('Download')}</button
|
||||
>
|
||||
<button type="button" disabled={busy} on:click={() => requestDelete([file.id])}
|
||||
><GarbageBin className="size-3.5" />{$i18n.t('Delete')}</button
|
||||
>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex justify-center items-center min-h-20">
|
||||
<Spinner className="size-5" />
|
||||
{/each}
|
||||
{#if loadError}
|
||||
<div role="alert" class="py-2 text-center text-xs text-gray-500">
|
||||
{$i18n.t('Could not load files.')}
|
||||
<button disabled={busy} class="underline" on:click={() => loadFiles()}
|
||||
>{$i18n.t('Retry')}</button
|
||||
>
|
||||
</div>
|
||||
{:else if !allFilesLoaded && !busy}
|
||||
<Loader on:visible={() => loadFiles(true)}
|
||||
><div class="flex justify-center py-2"><Spinner className="size-3" /></div></Loader
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-20 items-center justify-center"><Spinner className="size-4" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -4087,5 +4087,23 @@
|
|||
"YouTube": "",
|
||||
"YouTube video player": "",
|
||||
"Zoom in": "",
|
||||
"Zoom out": ""
|
||||
"Zoom out": "",
|
||||
"Delete {{count}} files?_one": "Delete {{count}} file?",
|
||||
"Delete {{count}} files?_other": "Delete {{count}} files?",
|
||||
"This cannot be undone. References to these files may stop working.": "This cannot be undone. References to these files may stop working.",
|
||||
"{{count}} selected": "{{count}} selected",
|
||||
"Clear selection": "Clear selection",
|
||||
"Select all matching files": "Select all matching files",
|
||||
"Selecting all matching files...": "Selecting all matching files...",
|
||||
"Could not select all matching files. Your selection has not changed.": "Could not select all matching files. Your selection has not changed.",
|
||||
"Select loaded files": "Select loaded files",
|
||||
"Select {{filename}}": "Select {{filename}}",
|
||||
"Delete {{filename}}": "Delete {{filename}}",
|
||||
"Could not load files.": "Could not load files.",
|
||||
"Processed {{processed}} of {{total}}": "Processed {{processed}} of {{total}}",
|
||||
"Stopping...": "Stopping...",
|
||||
"{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.": "{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.",
|
||||
"Selection actions": "Selection actions",
|
||||
"Actions for {{filename}}": "Actions for {{filename}}",
|
||||
"Preparing {{processed}} of {{total}}": "Preparing {{processed}} of {{total}}"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue