981579c255
* refactor(sync): kind-agnostic replica primitives Extract dict-only sync into shared primitives (registry, pull/apply orchestrator, persist env, schema allowlist) so other kinds can plug in. Companion changes: per-replica Storage Manager grouping, useReplicaPull boot-race recovery, manifest=null reconciliation on every boot pull, copyFile takes explicit srcBase + dstBase, settled- event helpers, lenient webDownload Content-Length (R2/S3 signed URLs commonly omit it), and generic "File" transfer toast copy any replica kind can share. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sync): cross-device custom font sync Plug the font replica adapter into the kind-agnostic primitives: font store gains replica wiring, custom font import publishes the replica row + queues a binary upload, and bootstrap registers the font adapter and download-complete handler. Includes legacy flat-path migration so pre-existing fonts sync without re-import, full @font-face activation on auto-download (load + mount the rule, mirroring manual import), and a fix to createCustomFont so contentId / bundleDir / byteSize survive the trip through addFont — otherwise import-time publish silently no-oped on missing contentId. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
588 lines
22 KiB
TypeScript
588 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import clsx from 'clsx';
|
||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useEnv } from '@/context/EnvContext';
|
||
import { useThemeStore } from '@/store/themeStore';
|
||
import { useLibraryStore } from '@/store/libraryStore';
|
||
import { useTranslation } from '@/hooks/useTranslation';
|
||
import { useLibrary } from '@/hooks/useLibrary';
|
||
import {
|
||
listFiles,
|
||
getStorageStats,
|
||
purgeFiles,
|
||
type FileRecord,
|
||
type StorageStats,
|
||
type ListFilesParams,
|
||
} from '@/libs/storage';
|
||
import { eventDispatcher } from '@/utils/event';
|
||
import { debounce } from '@/utils/debounce';
|
||
import Spinner from '@/components/Spinner';
|
||
import Alert from '@/components/Alert';
|
||
|
||
const StorageManager = () => {
|
||
const _ = useTranslation();
|
||
const { appService } = useEnv();
|
||
const { libraryLoaded } = useLibrary();
|
||
const { safeAreaInsets } = useThemeStore();
|
||
const [loading, setLoading] = useState(false);
|
||
const [filesLoaded, setFilesLoaded] = useState(false);
|
||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||
const [stats, setStats] = useState<StorageStats | null>(null);
|
||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchInput, setSearchInput] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [sortBy, setSortBy] = useState<ListFilesParams['sortBy']>('created_at');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||
const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
|
||
|
||
const loadFiles = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: ListFilesParams = {
|
||
page: currentPage,
|
||
pageSize: 20,
|
||
sortBy,
|
||
sortOrder,
|
||
};
|
||
|
||
if (searchQuery.trim()) {
|
||
params.search = searchQuery.trim();
|
||
}
|
||
|
||
const response = await listFiles(params);
|
||
setFiles(response.files);
|
||
setTotalPages(response.totalPages);
|
||
setFilesLoaded(true);
|
||
} catch (error) {
|
||
console.error('Failed to load files:', error);
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'info',
|
||
message: _('Failed to load files'),
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [currentPage, sortBy, sortOrder, searchQuery, _]);
|
||
|
||
const loadStats = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const statsData = await getStorageStats();
|
||
setStats(statsData);
|
||
} catch (error) {
|
||
console.error('Failed to load stats:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadFiles();
|
||
loadStats();
|
||
}, [loadFiles, loadStats]);
|
||
|
||
// Group files by their natural row identity:
|
||
// - book_hash for book uploads
|
||
// - replica_kind:replica_id for replica binaries (each dictionary,
|
||
// font, etc. clusters under its own row)
|
||
// - 'no-group' as a final catch-all
|
||
const groupKeyFor = (file: FileRecord): string => {
|
||
if (file.book_hash) return file.book_hash;
|
||
if (file.replica_id) return `replica:${file.replica_kind ?? '?'}:${file.replica_id}`;
|
||
return 'no-group';
|
||
};
|
||
|
||
const groupedFiles = React.useMemo(() => {
|
||
const groups = new Map<string, FileRecord[]>();
|
||
files.forEach((file) => {
|
||
const key = groupKeyFor(file);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key)!.push(file);
|
||
});
|
||
return groups;
|
||
}, [files]);
|
||
|
||
const getFileName = (fileKey: string): string => {
|
||
const parts = fileKey.split('/');
|
||
return parts[parts.length - 1] || fileKey;
|
||
};
|
||
|
||
const isCoverFile = (file: FileRecord): boolean => {
|
||
return getFileName(file.file_key).toLowerCase() === 'cover.png';
|
||
};
|
||
|
||
// Pick the file whose name labels the group:
|
||
// - book groups: first non-cover file (existing behavior).
|
||
// - replica groups: the largest file. The primary binary
|
||
// (dictionary .mdx, font .ttf, etc.) dominates the bundle by
|
||
// size, so labelling by it surfaces the natural identifier
|
||
// rather than a tiny sidecar like dacihai.css.
|
||
const getMainBookFile = (groupFiles: FileRecord[]): FileRecord | null => {
|
||
if (groupFiles.length === 0) return null;
|
||
const isReplicaGroup = !groupFiles[0]!.book_hash && !!groupFiles[0]!.replica_id;
|
||
if (isReplicaGroup) {
|
||
return groupFiles.reduce((a, b) => (b.file_size > a.file_size ? b : a));
|
||
}
|
||
return groupFiles.find((f) => !isCoverFile(f)) || groupFiles[0] || null;
|
||
};
|
||
|
||
// Get all files for a book including covers
|
||
const getAllBookFiles = (bookFiles: FileRecord[]): FileRecord[] => {
|
||
return bookFiles;
|
||
};
|
||
|
||
const toggleBookExpansion = (bookHash: string) => {
|
||
const newExpanded = new Set(expandedBooks);
|
||
if (newExpanded.has(bookHash)) {
|
||
newExpanded.delete(bookHash);
|
||
} else {
|
||
newExpanded.add(bookHash);
|
||
}
|
||
setExpandedBooks(newExpanded);
|
||
};
|
||
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedFiles(new Set(files.map((f) => f.file_key)));
|
||
} else {
|
||
setSelectedFiles(new Set());
|
||
}
|
||
};
|
||
|
||
const handleSelectBook = (bookFiles: FileRecord[], checked: boolean) => {
|
||
const newSelected = new Set(selectedFiles);
|
||
const allBookFiles = getAllBookFiles(bookFiles);
|
||
|
||
allBookFiles.forEach((file) => {
|
||
if (checked) {
|
||
newSelected.add(file.file_key);
|
||
} else {
|
||
newSelected.delete(file.file_key);
|
||
}
|
||
});
|
||
|
||
setSelectedFiles(newSelected);
|
||
};
|
||
|
||
const handleSelectFile = (fileKey: string, checked: boolean) => {
|
||
const newSelected = new Set(selectedFiles);
|
||
if (checked) {
|
||
newSelected.add(fileKey);
|
||
} else {
|
||
newSelected.delete(fileKey);
|
||
}
|
||
setSelectedFiles(newSelected);
|
||
};
|
||
|
||
const isBookSelected = (bookFiles: FileRecord[]): boolean => {
|
||
const allBookFiles = getAllBookFiles(bookFiles);
|
||
return allBookFiles.length > 0 && allBookFiles.every((f) => selectedFiles.has(f.file_key));
|
||
};
|
||
|
||
const isBookPartiallySelected = (bookFiles: FileRecord[]): boolean => {
|
||
const allBookFiles = getAllBookFiles(bookFiles);
|
||
const selectedCount = allBookFiles.filter((f) => selectedFiles.has(f.file_key)).length;
|
||
return selectedCount > 0 && selectedCount < allBookFiles.length;
|
||
};
|
||
|
||
const handleDeleteSelected = async () => {
|
||
if (selectedFiles.size === 0) return;
|
||
if (!libraryLoaded || !appService) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const fileKeys = Array.from(selectedFiles);
|
||
const fileRecords = files.filter((f) => selectedFiles.has(f.file_key));
|
||
const selectedBookHashes = new Set(
|
||
fileRecords.map((f) => f.book_hash).filter((hash): hash is string => !!hash),
|
||
);
|
||
|
||
const result = await purgeFiles(fileKeys, true);
|
||
|
||
const { library, setLibrary } = useLibraryStore.getState();
|
||
library
|
||
.filter((book) => selectedBookHashes.has(book.hash))
|
||
.forEach((book) => {
|
||
book.uploadedAt = null;
|
||
book.updatedAt = Date.now();
|
||
});
|
||
setLibrary(library);
|
||
appService.saveLibraryBooks(library);
|
||
|
||
if (result.deletedCount > 0) {
|
||
await loadFiles();
|
||
await loadStats();
|
||
setSelectedFiles(new Set());
|
||
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'info',
|
||
message: _('Deleted {{count}} file(s)', { count: result.deletedCount }),
|
||
});
|
||
|
||
if (result.failedCount > 0) {
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'info',
|
||
message: _('Failed to delete {{count}} file(s)', { count: result.failedCount }),
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to delete files:', error);
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'info',
|
||
message: _('Failed to delete files'),
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
setShowConfirmDelete(false);
|
||
}
|
||
};
|
||
|
||
const handleSearchChange = useMemo(
|
||
() =>
|
||
debounce((value: string) => {
|
||
setSearchQuery(value);
|
||
setCurrentPage(1);
|
||
}, 1000),
|
||
[setSearchQuery, setCurrentPage],
|
||
);
|
||
|
||
useEffect(() => {
|
||
handleSearchChange(searchInput);
|
||
}, [searchInput, handleSearchChange]);
|
||
|
||
const formatFileSize = (bytes: number): string => {
|
||
if (bytes === 0) return '0 B';
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1e6) return `${Math.round((bytes / 1024) * 10) / 10} KB`;
|
||
const inGB = bytes > 1e9;
|
||
const value = bytes / 1024 / 1024 / (inGB ? 1024 : 1);
|
||
return `${Math.round(value * 10) / 10} ${inGB ? 'GB' : 'MB'}`;
|
||
};
|
||
|
||
const formatDate = (dateString: string): string => {
|
||
return new Date(dateString).toLocaleDateString(undefined, {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
});
|
||
};
|
||
|
||
const getBookTotalSize = (bookFiles: FileRecord[]): number => {
|
||
return bookFiles.reduce((sum, file) => sum + file.file_size, 0);
|
||
};
|
||
|
||
const isAllSelected = files.length > 0 && selectedFiles.size === files.length;
|
||
|
||
return (
|
||
<div className='flex flex-col gap-6'>
|
||
{/* Stats Section */}
|
||
{stats ? (
|
||
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
|
||
<h3 className='text-base-content mb-4 text-lg font-semibold'>
|
||
{_('Cloud Storage Usage')}
|
||
</h3>
|
||
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
|
||
<div>
|
||
<div className='text-base-content/60 text-sm'>{_('Total Files')}</div>
|
||
<div className='text-base-content text-xl font-semibold'>{stats.totalFiles}</div>
|
||
</div>
|
||
<div>
|
||
<div className='text-base-content/60 text-sm'>{_('Total Size')}</div>
|
||
<div className='text-base-content text-xl font-semibold'>
|
||
{formatFileSize(stats.totalSize)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className='text-base-content/60 text-sm'>{_('Quota')}</div>
|
||
<div className='text-base-content text-xl font-semibold'>
|
||
{formatFileSize(stats.quota)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className='text-base-content/60 text-sm'>{_('Used')}</div>
|
||
<div className='text-base-content text-xl font-semibold'>
|
||
{stats.usagePercentage}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className='bg-base-300 mt-4 h-2 w-full overflow-hidden rounded-full'>
|
||
<div
|
||
className='bg-primary h-full transition-all'
|
||
style={{ width: `${Math.min(stats.usagePercentage, 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className='bg-base-100 border-base-300 rounded-lg border p-4'>
|
||
<div className='skeleton mb-4 h-6 w-32'></div>
|
||
<div className='grid grid-cols-2 gap-4 sm:grid-cols-4'>
|
||
<div className='skeleton h-16 w-full'></div>
|
||
<div className='skeleton h-16 w-full'></div>
|
||
<div className='skeleton h-16 w-full'></div>
|
||
<div className='skeleton h-16 w-full'></div>
|
||
</div>
|
||
<div className='skeleton mt-4 h-2 w-full rounded-full'></div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Files Section */}
|
||
<div className='bg-base-100 border-base-300 rounded-lg border'>
|
||
<div className='border-base-300 flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between'>
|
||
<div className='hidden items-center justify-center sm:flex'>
|
||
<h3 className='text-base-content text-lg font-semibold'>{_('Files')}</h3>
|
||
</div>
|
||
|
||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||
<input
|
||
type='text'
|
||
placeholder={_('Search files...')}
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
className='input input-bordered input-sm w-full sm:w-64'
|
||
disabled={loading}
|
||
/>
|
||
|
||
<select
|
||
value={`${sortBy}-${sortOrder}`}
|
||
onChange={(e) => {
|
||
const [newSortBy, newSortOrder] = e.target.value.split('-');
|
||
setSortBy(newSortBy as ListFilesParams['sortBy']);
|
||
setSortOrder(newSortOrder as 'asc' | 'desc');
|
||
}}
|
||
disabled={loading}
|
||
className='select select-bordered select-sm'
|
||
>
|
||
<option value='created_at-desc'>{_('Newest First')}</option>
|
||
<option value='created_at-asc'>{_('Oldest First')}</option>
|
||
<option value='file_size-desc'>{_('Largest First')}</option>
|
||
<option value='file_size-asc'>{_('Smallest First')}</option>
|
||
<option value='file_key-asc'>{_('Name A-Z')}</option>
|
||
<option value='file_key-desc'>{_('Name Z-A')}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions Bar */}
|
||
<div className='bg-base-200 border-base-300 flex items-center justify-between border-b p-4'>
|
||
<span className='text-base-content text-sm'>
|
||
{_('{{count}} selected', { count: selectedFiles.size })}
|
||
</span>
|
||
<button
|
||
onClick={() => setShowConfirmDelete(true)}
|
||
className='btn btn-error btn-sm'
|
||
disabled={loading || selectedFiles.size === 0}
|
||
>
|
||
{_('Delete Selected')}
|
||
</button>
|
||
</div>
|
||
|
||
{loading && <Spinner loading />}
|
||
|
||
{/* Files List - Grouped by Book */}
|
||
<div className='w-full'>
|
||
<table className='table-sm table w-full [&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1'>
|
||
<thead className='h-10'>
|
||
<tr>
|
||
<th className='w-12'>
|
||
<div className='flex items-center'>
|
||
<input
|
||
type='checkbox'
|
||
checked={isAllSelected}
|
||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||
className='checkbox checkbox-sm'
|
||
disabled={!filesLoaded || loading}
|
||
/>
|
||
</div>
|
||
</th>
|
||
<th className='!ps-0'>{_('File Name')}</th>
|
||
<th className='hidden sm:table-cell'>{_('Size')}</th>
|
||
<th className='hidden sm:table-cell'>{_('Created')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{!filesLoaded ? (
|
||
<>
|
||
{[...Array(5)].map((_, i) => (
|
||
<tr key={i}>
|
||
<td className='min-w-16'>
|
||
<div className='skeleton h-5 w-5'></div>
|
||
</td>
|
||
<td className='max-w-0 !ps-0 sm:w-[80%]'>
|
||
<div className='flex flex-col gap-2'>
|
||
<div className='skeleton h-4 w-3/4'></div>
|
||
<div className='skeleton h-3 w-1/2 sm:hidden'></div>
|
||
</div>
|
||
</td>
|
||
<td className='hidden sm:table-cell'>
|
||
<div className='skeleton h-4 w-16'></div>
|
||
</td>
|
||
<td className='hidden sm:table-cell'>
|
||
<div className='skeleton h-4 w-20'></div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</>
|
||
) : groupedFiles.size === 0 ? (
|
||
<tr>
|
||
<td colSpan={4} className='text-center'>
|
||
<div className='text-base-content/60 py-8'>
|
||
{searchQuery ? _('No files found') : _('No files uploaded yet')}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
Array.from(groupedFiles.entries()).map(([bookHash, bookFiles]) => {
|
||
const mainFile = getMainBookFile(bookFiles);
|
||
const isExpanded = expandedBooks.has(bookHash);
|
||
const hasMultipleFiles = bookFiles.length > 1;
|
||
const bookSelected = isBookSelected(bookFiles);
|
||
const bookPartiallySelected = isBookPartiallySelected(bookFiles);
|
||
|
||
if (!mainFile) return null;
|
||
|
||
return (
|
||
<React.Fragment key={bookHash}>
|
||
{/* Main book row */}
|
||
<tr className='hover'>
|
||
<td>
|
||
<div className='flex items-center gap-1'>
|
||
<input
|
||
type='checkbox'
|
||
checked={bookSelected}
|
||
ref={(el) => {
|
||
if (el) el.indeterminate = bookPartiallySelected;
|
||
}}
|
||
onChange={(e) => handleSelectBook(bookFiles, e.target.checked)}
|
||
disabled={loading}
|
||
className='checkbox checkbox-sm'
|
||
/>
|
||
{hasMultipleFiles && (
|
||
<button
|
||
onClick={() => toggleBookExpansion(bookHash)}
|
||
className='btn btn-ghost btn-xs'
|
||
>
|
||
{isExpanded ? '−' : '+'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className='max-w-0 !ps-0 sm:w-[80%]'>
|
||
<div className='flex flex-col'>
|
||
<div className='flex items-center gap-2'>
|
||
<span className='text-base-content block max-w-full truncate font-medium'>
|
||
{getFileName(mainFile.file_key)}
|
||
</span>
|
||
{hasMultipleFiles && (
|
||
<span className='text-base-content/60 flex-shrink-0 whitespace-nowrap text-xs'>
|
||
({bookFiles.length} {_('files')})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className='text-base-content/60 text-xs sm:hidden'>
|
||
{formatFileSize(getBookTotalSize(bookFiles))} ·{' '}
|
||
{formatDate(mainFile.created_at)}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className='hidden whitespace-nowrap sm:table-cell'>
|
||
{formatFileSize(getBookTotalSize(bookFiles))}
|
||
</td>
|
||
<td className='hidden whitespace-nowrap sm:table-cell'>
|
||
{formatDate(mainFile.created_at)}
|
||
</td>
|
||
</tr>
|
||
|
||
{/* Expanded files (excluding covers unless expanded) */}
|
||
{isExpanded &&
|
||
bookFiles.map((file) => (
|
||
<tr key={file.file_key} className='hover bg-base-200/50'>
|
||
<td>
|
||
<div className='pl-4'>
|
||
<input
|
||
type='checkbox'
|
||
checked={selectedFiles.has(file.file_key)}
|
||
onChange={(e) =>
|
||
handleSelectFile(file.file_key, e.target.checked)
|
||
}
|
||
disabled={loading}
|
||
className='checkbox checkbox-sm'
|
||
/>
|
||
</div>
|
||
</td>
|
||
<td className='max-w-0 !ps-0 sm:w-[80%]'>
|
||
<div className='flex flex-col'>
|
||
<span className='text-base-content/80 text-xs'>
|
||
{getFileName(file.file_key)}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className='hidden sm:table-cell'>
|
||
{formatFileSize(file.file_size)}
|
||
</td>
|
||
<td className='hidden sm:table-cell'>{formatDate(file.created_at)}</td>
|
||
</tr>
|
||
))}
|
||
</React.Fragment>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className='border-base-300 flex items-center justify-between border-t p-4'>
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||
disabled={currentPage === 1}
|
||
className='btn btn-sm'
|
||
>
|
||
{_('Previous')}
|
||
</button>
|
||
<span className='text-base-content text-sm'>
|
||
{_('Page {{current}} of {{total}}', { current: currentPage, total: totalPages })}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className='btn btn-sm'
|
||
>
|
||
{_('Next')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Confirm Delete Modal */}
|
||
{showConfirmDelete && (
|
||
<div
|
||
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
||
style={{
|
||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||
}}
|
||
>
|
||
<Alert
|
||
title={_('Confirm Deletion')}
|
||
message={_('Are you sure to delete {{count}} selected file(s)?', {
|
||
count: selectedFiles.size,
|
||
})}
|
||
onCancel={() => {
|
||
setShowConfirmDelete(false);
|
||
}}
|
||
onConfirm={() => {
|
||
handleDeleteSelected();
|
||
setShowConfirmDelete(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StorageManager;
|