diff --git a/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs b/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs index 2bbdc879..ef12f513 100644 --- a/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs +++ b/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs @@ -1,8 +1,8 @@ #nullable enable using System.ComponentModel.DataAnnotations; -using System.IO; using System.Collections.Generic; +using Volo.Abp.Content; namespace Kurs.Platform.FileManagement; @@ -32,12 +32,10 @@ public class UploadFileDto [Required] public string FileName { get; set; } = string.Empty; - // Either FileStream or FileContent can be used - public Stream? FileStream { get; set; } - - public byte[]? FileContent { get; set; } - public string? ParentId { get; set; } + + // ActivityModal pattern - Files array + public IRemoteStreamContent[]? Files { get; set; } } public class SearchFilesDto @@ -52,4 +50,20 @@ public class BulkDeleteDto { [Required] public List ItemIds { get; set; } = new(); +} + +public class CopyItemsDto +{ + [Required] + public List ItemIds { get; set; } = new(); + + public string? TargetFolderId { get; set; } +} + +public class MoveItemsDto +{ + [Required] + public List ItemIds { get; set; } = new(); + + public string? TargetFolderId { get; set; } } \ No newline at end of file diff --git a/api/src/Kurs.Platform.Application.Contracts/FileManagement/IFileManagementAppService.cs b/api/src/Kurs.Platform.Application.Contracts/FileManagement/IFileManagementAppService.cs index 74bfc42a..b4ee66b5 100644 --- a/api/src/Kurs.Platform.Application.Contracts/FileManagement/IFileManagementAppService.cs +++ b/api/src/Kurs.Platform.Application.Contracts/FileManagement/IFileManagementAppService.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Volo.Abp.Application.Services; @@ -31,4 +32,10 @@ public interface IFileManagementAppService : IApplicationService Task GetFolderPathAsync(); Task GetFolderPathByIdAsync(string folderId); + + Task BulkDeleteItemsAsync(BulkDeleteDto input); + + Task> CopyItemsAsync(CopyItemsDto input); + + Task> MoveItemsAsync(MoveItemsDto input); } \ No newline at end of file diff --git a/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs b/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs index a3114473..a9ff76a5 100644 --- a/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs +++ b/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs @@ -320,8 +320,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe }; } - public async Task UploadFileAsync(UploadFileDto input) + public async Task UploadFileAsync([FromForm] UploadFileDto input) { + // Debug logging + Logger.LogInformation("UploadFileAsync called with: FileName={FileName}, ParentId={ParentId}, FilesCount={FilesCount}", + input.FileName, input.ParentId, input.Files?.Length ?? 0); + ValidateFileName(input.FileName); // Decode parent ID if provided @@ -333,14 +337,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe var items = await GetFolderIndexAsync(decodedParentId); - if (items.Any(x => x.Name.Equals(input.FileName, StringComparison.OrdinalIgnoreCase))) - { - throw new UserFriendlyException("A file with this name already exists"); - } + // Generate unique filename if file already exists + var uniqueFileName = GetUniqueFileName(items, input.FileName); var filePath = string.IsNullOrEmpty(decodedParentId) - ? input.FileName - : $"{decodedParentId}/{input.FileName}"; + ? uniqueFileName + : $"{decodedParentId}/{uniqueFileName}"; var fileId = EncodePathAsId(filePath); @@ -362,32 +364,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe // Dizini oluştur Directory.CreateDirectory(fullCdnPath); - var fullFilePath = Path.Combine(fullCdnPath, input.FileName); + var fullFilePath = Path.Combine(fullCdnPath, uniqueFileName); long fileSize; - if (input.FileStream != null) + if (input.Files != null && input.Files.Length > 0) { - input.FileStream.Position = 0; + // İlk dosyayı kullan (tek dosya upload için) + var file = input.Files[0]; + using var stream = file.GetStream(); using var fileStream = File.Create(fullFilePath); - await input.FileStream.CopyToAsync(fileStream); - fileSize = input.FileStream.Length; - } - else if (input.FileContent != null) - { - await File.WriteAllBytesAsync(fullFilePath, input.FileContent); - fileSize = input.FileContent.Length; + await stream.CopyToAsync(fileStream); + fileSize = stream.Length; } else { - throw new UserFriendlyException("Either FileStream or FileContent must be provided"); + throw new UserFriendlyException("Files must be provided"); } - var fileInfo = new FileInfo(input.FileName); + var fileInfo = new FileInfo(uniqueFileName); var metadata = new FileMetadata { Id = fileId, - Name = input.FileName, + Name = uniqueFileName, Type = "file", Size = fileSize, Extension = fileInfo.Extension, @@ -631,6 +630,239 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } } + public async Task> CopyItemsAsync(CopyItemsDto input) + { + if (input.ItemIds == null || !input.ItemIds.Any()) + { + throw new UserFriendlyException("No items selected for copy"); + } + + var cdnBasePath = _configuration["App:CdnPath"]; + if (string.IsNullOrEmpty(cdnBasePath)) + { + throw new UserFriendlyException("CDN path is not configured"); + } + + var tenantId = _currentTenant.Id?.ToString() ?? "host"; + var basePath = Path.Combine(cdnBasePath, tenantId); + + string? targetPath = null; + if (!string.IsNullOrEmpty(input.TargetFolderId)) + { + targetPath = DecodeIdAsPath(input.TargetFolderId); + } + + var copiedItems = new List(); + var errors = new List(); + + foreach (var itemId in input.ItemIds) + { + try + { + var sourcePath = DecodeIdAsPath(itemId); + var sourceFullPath = Path.Combine(basePath, sourcePath); + + // Get source item name + var sourceItemName = Path.GetFileName(sourcePath); + + // Generate unique name if item already exists in target + var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; + var targetFullPath = Path.Combine(basePath, targetItemPath); + + var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName); + var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/'); + + if (Directory.Exists(sourceFullPath)) + { + // Copy directory recursively + CopyDirectory(sourceFullPath, uniqueTargetPath); + + var dirInfo = new DirectoryInfo(uniqueTargetPath); + copiedItems.Add(new FileItemDto + { + Id = EncodePathAsId(finalTargetPath), + Name = dirInfo.Name, + Type = "folder", + CreatedAt = dirInfo.CreationTime, + ModifiedAt = dirInfo.LastWriteTime, + Path = finalTargetPath, + ParentId = input.TargetFolderId ?? string.Empty, + IsReadOnly = false + }); + } + else if (File.Exists(sourceFullPath)) + { + // Copy file + var targetDir = Path.GetDirectoryName(uniqueTargetPath); + if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + File.Copy(sourceFullPath, uniqueTargetPath); + + var fileInfo = new FileInfo(uniqueTargetPath); + var extension = fileInfo.Extension; + + copiedItems.Add(new FileItemDto + { + Id = EncodePathAsId(finalTargetPath), + Name = fileInfo.Name, + Type = "file", + Size = fileInfo.Length, + Extension = extension, + MimeType = GetMimeType(extension), + CreatedAt = fileInfo.CreationTime, + ModifiedAt = fileInfo.LastWriteTime, + Path = finalTargetPath, + ParentId = input.TargetFolderId ?? string.Empty, + IsReadOnly = false + }); + } + else + { + errors.Add($"Source item not found: {itemId}"); + } + } + catch (Exception ex) + { + errors.Add($"Failed to copy {itemId}: {ex.Message}"); + } + } + + if (errors.Any()) + { + throw new UserFriendlyException($"Some items could not be copied: {string.Join(", ", errors)}"); + } + + return copiedItems; + } + + public async Task> MoveItemsAsync(MoveItemsDto input) + { + if (input.ItemIds == null || !input.ItemIds.Any()) + { + throw new UserFriendlyException("No items selected for move"); + } + + var cdnBasePath = _configuration["App:CdnPath"]; + if (string.IsNullOrEmpty(cdnBasePath)) + { + throw new UserFriendlyException("CDN path is not configured"); + } + + var tenantId = _currentTenant.Id?.ToString() ?? "host"; + var basePath = Path.Combine(cdnBasePath, tenantId); + + string? targetPath = null; + if (!string.IsNullOrEmpty(input.TargetFolderId)) + { + targetPath = DecodeIdAsPath(input.TargetFolderId); + } + + var movedItems = new List(); + var errors = new List(); + + foreach (var itemId in input.ItemIds) + { + try + { + // Check if this is a protected system folder + ValidateNotProtectedFolder(itemId, "move"); + + var sourcePath = DecodeIdAsPath(itemId); + var sourceFullPath = Path.Combine(basePath, sourcePath); + + // Get source item name + var sourceItemName = Path.GetFileName(sourcePath); + + // Generate target path + var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; + var targetFullPath = Path.Combine(basePath, targetItemPath); + + // Check if moving to same location + if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath)) + { + errors.Add($"Cannot move item to the same location: {sourceItemName}"); + continue; + } + + // Generate unique name if item already exists in target + var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName); + var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/'); + + if (Directory.Exists(sourceFullPath)) + { + // Move directory + var targetDir = Path.GetDirectoryName(uniqueTargetPath); + if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + Directory.Move(sourceFullPath, uniqueTargetPath); + + var dirInfo = new DirectoryInfo(uniqueTargetPath); + movedItems.Add(new FileItemDto + { + Id = EncodePathAsId(finalTargetPath), + Name = dirInfo.Name, + Type = "folder", + CreatedAt = dirInfo.CreationTime, + ModifiedAt = dirInfo.LastWriteTime, + Path = finalTargetPath, + ParentId = input.TargetFolderId ?? string.Empty, + IsReadOnly = false + }); + } + else if (File.Exists(sourceFullPath)) + { + // Move file + var targetDir = Path.GetDirectoryName(uniqueTargetPath); + if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir)) + { + Directory.CreateDirectory(targetDir); + } + + File.Move(sourceFullPath, uniqueTargetPath); + + var fileInfo = new FileInfo(uniqueTargetPath); + var extension = fileInfo.Extension; + + movedItems.Add(new FileItemDto + { + Id = EncodePathAsId(finalTargetPath), + Name = fileInfo.Name, + Type = "file", + Size = fileInfo.Length, + Extension = extension, + MimeType = GetMimeType(extension), + CreatedAt = fileInfo.CreationTime, + ModifiedAt = fileInfo.LastWriteTime, + Path = finalTargetPath, + ParentId = input.TargetFolderId ?? string.Empty, + IsReadOnly = false + }); + } + else + { + errors.Add($"Source item not found: {itemId}"); + } + } + catch (Exception ex) + { + errors.Add($"Failed to move {itemId}: {ex.Message}"); + } + } + + if (errors.Any()) + { + throw new UserFriendlyException($"Some items could not be moved: {string.Join(", ", errors)}"); + } + + return movedItems; + } + public async Task DownloadFileAsync(string id) { var cdnBasePath = _configuration["App:CdnPath"]; @@ -691,6 +923,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe #region Private Helper Methods + private string GetUniqueFileName(List existingItems, string originalFileName) + { + // Check if file name is already unique + if (!existingItems.Any(x => x.Name.Equals(originalFileName, StringComparison.OrdinalIgnoreCase))) + { + return originalFileName; + } + + var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName); + var extension = Path.GetExtension(originalFileName); + + var counter = 1; + string uniqueName; + + do + { + uniqueName = $"{nameWithoutExt} ({counter}){extension}"; + counter++; + } while (existingItems.Any(x => x.Name.Equals(uniqueName, StringComparison.OrdinalIgnoreCase))); + + return uniqueName; + } + private async Task FindItemMetadataAsync(string id) { // This is not efficient, but IBlobContainer doesn't have built-in search @@ -823,5 +1078,54 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe return new FolderPathDto { Path = pathItems }; } + private string GetUniqueItemPath(string targetPath, string originalName) + { + if (!File.Exists(targetPath) && !Directory.Exists(targetPath)) + { + return targetPath; + } + + var directory = Path.GetDirectoryName(targetPath) ?? ""; + var nameWithoutExt = Path.GetFileNameWithoutExtension(originalName); + var extension = Path.GetExtension(originalName); + + var counter = 1; + string newPath; + + do + { + var newName = $"{nameWithoutExt} ({counter}){extension}"; + newPath = Path.Combine(directory, newName); + counter++; + } while (File.Exists(newPath) || Directory.Exists(newPath)); + + return newPath; + } + + private void CopyDirectory(string sourceDir, string targetDir) + { + if (!Directory.Exists(sourceDir)) + throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}"); + + // Create target directory + Directory.CreateDirectory(targetDir); + + // Copy files + foreach (var file in Directory.GetFiles(sourceDir)) + { + var fileName = Path.GetFileName(file); + var destFile = Path.Combine(targetDir, fileName); + File.Copy(file, destFile, overwrite: false); + } + + // Copy subdirectories recursively + foreach (var subDir in Directory.GetDirectories(sourceDir)) + { + var subDirName = Path.GetFileName(subDir); + var destSubDir = Path.Combine(targetDir, subDirName); + CopyDirectory(subDir, destSubDir); + } + } + #endregion } \ No newline at end of file diff --git a/api/src/Kurs.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs b/api/src/Kurs.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs index 23fe7f68..f28f9961 100644 --- a/api/src/Kurs.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs +++ b/api/src/Kurs.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs @@ -10,6 +10,7 @@ using Kurs.Notifications.Application; using Kurs.Platform.Classrooms; using Kurs.Platform.EntityFrameworkCore; using Kurs.Platform.Extensions; +using Kurs.Platform.FileManagement; using Kurs.Platform.Identity; using Kurs.Platform.Localization; using Kurs.Settings; @@ -200,6 +201,7 @@ public class PlatformHttpApiHostModule : AbpModule options.ConventionalControllers.Create(typeof(NotificationApplicationModule).Assembly); options.ChangeControllerModelApiExplorerGroupName = false; options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(PlatformUpdateProfileDto)); + options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(UploadFileDto)); }); } diff --git a/ui/src/services/account.service.ts b/ui/src/services/account.service.ts index 90727b66..adcb0860 100644 --- a/ui/src/services/account.service.ts +++ b/ui/src/services/account.service.ts @@ -100,8 +100,8 @@ export const updateProfile = async (data: UpdateProfileDto) => { return await apiService.fetchData({ url: 'api/account/my-profile', method: 'put', - data, - headers: { 'Content-Type': 'multipart/form-data' }, + data: formData, + // Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder }) } catch (error) { if (error instanceof AxiosError) { diff --git a/ui/src/services/fileManagement.service.ts b/ui/src/services/fileManagement.service.ts index 9d0adf5c..92678c27 100644 --- a/ui/src/services/fileManagement.service.ts +++ b/ui/src/services/fileManagement.service.ts @@ -30,27 +30,44 @@ class FileManagementService { }) } - // Upload a file + // Upload a file (DTO pattern) async uploadFile(request: UploadFileRequest): Promise<{ data: FileItem }> { const formData = new FormData() - formData.append('fileName', request.file.name) + formData.append('fileName', request.fileName) if (request.parentId) { formData.append('parentId', request.parentId) } - // Send the actual file for FileContent property - formData.append('fileContent', request.file) + // ActivityModal pattern - Files array + request.files.forEach(file => { + formData.append('Files', file) + }) return ApiService.fetchData({ url: `/api/app/file-management/upload-file`, method: 'POST', data: formData as any, - headers: { - 'Content-Type': 'multipart/form-data', - }, + // Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder }) } + // Upload a file directly with FormData (ActivityModal pattern) + async uploadFileDirectly(formData: FormData): Promise<{ data: FileItem }> { + try { + console.log('Uploading file directly with FormData') + + // ABP convention-based routing: UploadFileAsync -> upload-file (Async suffix kaldırılır) + return await ApiService.fetchData({ + url: 'api/app/file-management/upload-file', + method: 'POST', + data: formData as any, + }) + } catch (error) { + console.error('File upload error:', error) + throw error + } + } + // Rename a file or folder async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> { return ApiService.fetchData({ @@ -126,11 +143,29 @@ class FileManagementService { // Bulk delete items async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> { return ApiService.fetchData({ - url: `/api/app/file-management/bulk-delete`, + url: `/api/app/file-management/bulk-delete-items`, method: 'POST', data: { itemIds }, }) } + + // Copy items to target folder + async copyItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> { + return ApiService.fetchData({ + url: `/api/app/file-management/copy-items`, + method: 'POST', + data: { itemIds, targetFolderId }, + }) + } + + // Move items to target folder + async moveItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> { + return ApiService.fetchData({ + url: `/api/app/file-management/move-items`, + method: 'POST', + data: { itemIds, targetFolderId }, + }) + } } export default new FileManagementService() \ No newline at end of file diff --git a/ui/src/types/fileManagement.ts b/ui/src/types/fileManagement.ts index 78fdfef9..24af6ef7 100644 --- a/ui/src/types/fileManagement.ts +++ b/ui/src/types/fileManagement.ts @@ -37,7 +37,8 @@ export interface DeleteItemRequest { } export interface UploadFileRequest { - file: File + fileName: string + files: File[] parentId?: string } diff --git a/ui/src/views/admin/files/FileManager.tsx b/ui/src/views/admin/files/FileManager.tsx index be4d8095..53ed914e 100644 --- a/ui/src/views/admin/files/FileManager.tsx +++ b/ui/src/views/admin/files/FileManager.tsx @@ -1,7 +1,22 @@ import { useState, useEffect, useCallback } from 'react' import { Helmet } from 'react-helmet' import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui' -import { FaFolder, FaCloudUploadAlt, FaSearch, FaTh, FaList, FaArrowUp, FaCheckSquare, FaSquare, FaTrash, FaCut, FaCopy } from 'react-icons/fa' +import { + FaFolder, + FaCloudUploadAlt, + FaSearch, + FaTh, + FaList, + FaArrowUp, + FaCheckSquare, + FaSquare, + FaTrash, + FaCut, + FaCopy, + FaEdit, + FaDownload, + FaPaste, +} from 'react-icons/fa' import Container from '@/components/shared/Container' import { useLocalization } from '@/utils/hooks/useLocalization' import fileManagementService from '@/services/fileManagement.service' @@ -62,16 +77,19 @@ const FileManager = () => { // Backend returns GetFilesDto which has Items property const items = response.data.items || [] // Manual protection for system folders - const protectedItems = items.map(item => { + const protectedItems = items.map((item) => { const isSystemFolder = ['avatar', 'import', 'activity'].includes(item.name.toLowerCase()) return { ...item, - isReadOnly: item.isReadOnly || isSystemFolder + isReadOnly: item.isReadOnly || isSystemFolder, } }) - + console.log('Fetched items:', protectedItems) - console.log('Protected folders check:', protectedItems.filter(item => item.isReadOnly)) + console.log( + 'Protected folders check:', + protectedItems.filter((item) => item.isReadOnly), + ) setItems(protectedItems) } catch (error) { console.error('Failed to fetch items:', error) @@ -161,6 +179,11 @@ const FileManager = () => { } const handleItemSelect = (item: FileItemType) => { + // Protected öğeler seçilemez + if (item.isReadOnly) { + return + } + setSelectedItems((prev) => { if (prev.includes(item.id)) { return prev.filter((id) => id !== item.id) @@ -170,7 +193,21 @@ const FileManager = () => { }) } - const handleItemDoubleClick = (item: FileItemType) => { + const handleItemDoubleClick = (item: FileItemType, event?: React.MouseEvent) => { + // Prevent text selection and other default behaviors + if (event) { + event.preventDefault() + event.stopPropagation() + } + + // Clear any text selection that might have occurred + if (window.getSelection) { + const selection = window.getSelection() + if (selection) { + selection.removeAllRanges() + } + } + if (item.type === 'folder') { setCurrentFolderId(item.id) setSelectedItems([]) @@ -182,10 +219,22 @@ const FileManager = () => { try { setUploading(true) for (const file of files) { - await fileManagementService.uploadFile({ - file, + // ActivityModal pattern'ini kullan - Files array ile FormData + const formData = new FormData() + formData.append('fileName', file.name) + formData.append('Files', file) // ActivityModal pattern - Files array + if (currentFolderId) { + formData.append('parentId', currentFolderId) + } + + console.log('FileManager uploading:', { + fileName: file.name, + fileSize: file.size, + fileType: file.type, parentId: currentFolderId, }) + + await fileManagementService.uploadFileDirectly(formData) } await fetchItems(currentFolderId) toast.push(Files uploaded successfully) @@ -239,16 +288,16 @@ const FileManager = () => { const handleDeleteItems = async () => { try { setDeleting(true) - + if (itemsToDelete.length === 1) { // Single item delete - use existing API await fileManagementService.deleteItem({ id: itemsToDelete[0].id }) } else { // Multiple items - use bulk delete API - const itemIds = itemsToDelete.map(item => item.id) + const itemIds = itemsToDelete.map((item) => item.id) await fileManagementService.bulkDeleteItems(itemIds) } - + await fetchItems(currentFolderId) setSelectedItems([]) toast.push(Items deleted successfully) @@ -303,13 +352,13 @@ const FileManager = () => { const checkClipboard = () => { setHasClipboardData(!!localStorage.getItem('fileManager_clipboard')) } - + // Check initially checkClipboard() - + // Check periodically (in case another tab changes it) const interval = setInterval(checkClipboard, 1000) - + return () => clearInterval(interval) }, []) @@ -364,7 +413,9 @@ const FileManager = () => { // Bulk operations const selectAllItems = () => { - setSelectedItems(filteredItems.map(item => item.id)) + // Sadece protected olmayan öğeleri seç + const selectableItems = filteredItems.filter((item) => !item.isReadOnly) + setSelectedItems(selectableItems.map((item) => item.id)) } const deselectAllItems = () => { @@ -372,120 +423,173 @@ const FileManager = () => { } const deleteSelectedItems = () => { - const itemsToDelete = filteredItems.filter(item => selectedItems.includes(item.id)) - const deletableItems = itemsToDelete.filter(item => !item.isReadOnly) - const protectedItems = itemsToDelete.filter(item => item.isReadOnly) - + const itemsToDelete = filteredItems.filter((item) => selectedItems.includes(item.id)) + const deletableItems = itemsToDelete.filter((item) => !item.isReadOnly) + const protectedItems = itemsToDelete.filter((item) => item.isReadOnly) + if (protectedItems.length > 0) { toast.push( - {protectedItems.length} protected system folder(s) cannot be deleted: {protectedItems.map(i => i.name).join(', ')} - + {protectedItems.length} protected system folder(s) cannot be deleted:{' '} + {protectedItems.map((i) => i.name).join(', ')} + , ) } - + if (deletableItems.length > 0) { openDeleteModal(deletableItems) // Remove protected items from selection - const deletableIds = deletableItems.map(item => item.id) - setSelectedItems(prev => prev.filter(id => deletableIds.includes(id))) + const deletableIds = deletableItems.map((item) => item.id) + setSelectedItems((prev) => prev.filter((id) => deletableIds.includes(id))) } } const copySelectedItems = () => { - const itemsToCopy = filteredItems.filter(item => selectedItems.includes(item.id)) - const copyableItems = itemsToCopy.filter(item => !item.isReadOnly) - const protectedItems = itemsToCopy.filter(item => item.isReadOnly) - + const itemsToCopy = filteredItems.filter((item) => selectedItems.includes(item.id)) + const copyableItems = itemsToCopy.filter((item) => !item.isReadOnly) + const protectedItems = itemsToCopy.filter((item) => item.isReadOnly) + if (protectedItems.length > 0) { toast.push( - {protectedItems.length} protected system folder(s) cannot be copied: {protectedItems.map(i => i.name).join(', ')} - + {protectedItems.length} protected system folder(s) cannot be copied:{' '} + {protectedItems.map((i) => i.name).join(', ')} + , ) } - + if (copyableItems.length > 0) { // Store in local storage or context for paste operation - localStorage.setItem('fileManager_clipboard', JSON.stringify({ - operation: 'copy', - items: copyableItems, - sourceFolder: currentFolderId - })) + localStorage.setItem( + 'fileManager_clipboard', + JSON.stringify({ + operation: 'copy', + items: copyableItems, + sourceFolder: currentFolderId, + }), + ) setHasClipboardData(true) toast.push( {copyableItems.length} item(s) copied to clipboard - + , ) } } const cutSelectedItems = () => { - const itemsToCut = filteredItems.filter(item => selectedItems.includes(item.id)) - const cuttableItems = itemsToCut.filter(item => !item.isReadOnly) - const protectedItems = itemsToCut.filter(item => item.isReadOnly) - + const itemsToCut = filteredItems.filter((item) => selectedItems.includes(item.id)) + const cuttableItems = itemsToCut.filter((item) => !item.isReadOnly) + const protectedItems = itemsToCut.filter((item) => item.isReadOnly) + if (protectedItems.length > 0) { toast.push( - {protectedItems.length} protected system folder(s) cannot be moved: {protectedItems.map(i => i.name).join(', ')} - + {protectedItems.length} protected system folder(s) cannot be moved:{' '} + {protectedItems.map((i) => i.name).join(', ')} + , ) } - + if (cuttableItems.length > 0) { // Store in local storage or context for paste operation - localStorage.setItem('fileManager_clipboard', JSON.stringify({ - operation: 'cut', - items: cuttableItems, - sourceFolder: currentFolderId - })) + localStorage.setItem( + 'fileManager_clipboard', + JSON.stringify({ + operation: 'cut', + items: cuttableItems, + sourceFolder: currentFolderId, + }), + ) setHasClipboardData(true) toast.push( {cuttableItems.length} item(s) cut to clipboard - + , ) } } - const pasteItems = () => { + const pasteItems = async () => { const clipboardData = localStorage.getItem('fileManager_clipboard') - if (clipboardData) { - try { - const clipboard = JSON.parse(clipboardData) - if (clipboard.operation === 'copy') { - toast.push( - - Copy functionality will be implemented soon - - ) - } else if (clipboard.operation === 'cut') { - toast.push( - - Move functionality will be implemented soon - - ) - } - } catch (error) { - toast.push( - - Invalid clipboard data - - ) - } - } else { + if (!clipboardData) { toast.push( No items in clipboard - + , + ) + return + } + + try { + const clipboard = JSON.parse(clipboardData) + const itemIds = clipboard.items.map((item: FileItemType) => item.id) + + if (clipboard.operation === 'copy') { + setLoading(true) + try { + await fileManagementService.copyItems(itemIds, currentFolderId) + await fetchItems(currentFolderId) + toast.push( + + {itemIds.length} item(s) copied successfully + , + ) + } catch (error) { + console.error('Copy failed:', error) + toast.push( + + Failed to copy items + , + ) + } finally { + setLoading(false) + } + } else if (clipboard.operation === 'cut') { + // Aynı klasörde move yapmaya çalışırsa engelleyelim + if (clipboard.sourceFolder === currentFolderId) { + toast.push( + + Cannot move items to the same folder + , + ) + return + } + + setLoading(true) + try { + await fileManagementService.moveItems(itemIds, currentFolderId) + await fetchItems(currentFolderId) + // Clipboard'ı temizle + localStorage.removeItem('fileManager_clipboard') + setHasClipboardData(false) + toast.push( + + {itemIds.length} item(s) moved successfully + , + ) + } catch (error) { + console.error('Move failed:', error) + toast.push( + + Failed to move items + , + ) + } finally { + setLoading(false) + } + } + } catch (error) { + toast.push( + + Invalid clipboard data + , ) } } return ( - + { > {/* Enhanced Unified Toolbar */} -
+
{/* Main Toolbar Row */} -
+
{/* Left Section - Primary Actions */}
{/* File Operations */} -
- - -
- - {/* Divider */} -
+ {/* Navigation */} + + {/* Clipboard Operations */} -
- -
+ - {/* Divider */} -
+ + + {/* Selection Actions */} -
- {filteredItems.length > 0 && ( - - )} - {selectedItems.length > 0 && ( - - )} -
- - {/* Navigation */} - {breadcrumbItems.length > 1 && ( - <> -
- - + {filteredItems.length > 0 && ( + )}
{/* Right Section - Search, Sort, View */} -
+
{/* Search */} -
+
setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))} prefix={} - className="w-48" + className="w-full sm:w-36 md:w-48" />
@@ -652,17 +819,17 @@ const FileManager = () => { setFilters((prev) => ({ ...prev, sortBy, sortOrder })) } }} - className="min-w-36" + className="min-w-32 sm:min-w-36 flex-shrink-0" /> {/* View Mode */} -
+
- - {/* Selection Status Bar - Show when items are selected */} - {selectedItems.length > 0 && ( -
-
-
- - {selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected - - - ({filteredItems.filter(item => selectedItems.includes(item.id)).length} of {filteredItems.length}) - -
-
- -
-
-
- )}
{/* Breadcrumb */} -
- +
+
+ +
{/* Files Grid/List */} @@ -724,12 +867,11 @@ const FileManager = () => { <> {/* List View Header */} {viewMode === 'list' && ( -
-
{/* Icon column */} -
İsim
-
Tür
-
Boyut
-
Değiştirilme
+
+
İsim
+
Tür
+
Boyut
+
Değiştirilme
{/* Actions column */}
)} @@ -737,52 +879,77 @@ const FileManager = () => {
- {filteredItems.length === 0 ? ( -
- -

- {filters.searchTerm ? 'No files match your search' : 'This folder is empty'} -

-
- ) : ( - filteredItems.map((item) => ( - { - // Klasör içinde yeni klasör oluşturmak için parent klasörü set et - setCurrentFolderId(parentItem.id) - setCreateFolderModalOpen(true) - }} - onRename={openRenameModal} - onMove={(item) => { - // Move işlevi henüz implement edilmedi - toast.push(Move özelliği yakında eklenecek) - }} - onDelete={(item) => openDeleteModal([item])} - onDownload={item.type === 'file' ? handleDownload : undefined} - onPreview={item.type === 'file' ? (item) => { - // Preview işlevi - resimler için modal açabiliriz - if (item.mimeType?.startsWith('image/')) { - // Resim preview modal'ı açılabilir - toast.push(Resim önizleme özelliği yakında eklenecek) - } else { - // Diğer dosya tipleri için download - handleDownload(item) + {filteredItems.length === 0 ? ( +
+ +

+ {filters.searchTerm ? 'No files match your search' : 'This folder is empty'} +

+
+ ) : ( + filteredItems.map((item) => ( + { + // Klasör içinde yeni klasör oluşturmak için parent klasörü set et + setCurrentFolderId(parentItem.id) + setCreateFolderModalOpen(true) + }} + onRename={openRenameModal} + onMove={(item) => { + // Move işlemi için öğeyi cut olarak clipboard'a koy + cutSelectedItems() + if (!selectedItems.includes(item.id)) { + setSelectedItems([item.id]) + localStorage.setItem( + 'fileManager_clipboard', + JSON.stringify({ + operation: 'cut', + items: [item], + sourceFolder: currentFolderId, + }), + ) + setHasClipboardData(true) + toast.push( + + Item ready to move. Navigate to target folder and paste. + , + ) + } + }} + onDelete={(item) => openDeleteModal([item])} + onDownload={item.type === 'file' ? handleDownload : undefined} + onPreview={ + item.type === 'file' + ? (item) => { + // Preview işlevi - resimler için modal açabiliriz + if (item.mimeType?.startsWith('image/')) { + // Resim preview modal'ı açılabilir + toast.push( + + Resim önizleme özelliği yakında eklenecek + , + ) + } else { + // Diğer dosya tipleri için download + handleDownload(item) + } + } + : undefined } - } : undefined} - /> - )) - )} + /> + )) + )}
)} diff --git a/ui/src/views/admin/files/components/FileItem.tsx b/ui/src/views/admin/files/components/FileItem.tsx index 231dc2ac..64e9fcd1 100644 --- a/ui/src/views/admin/files/components/FileItem.tsx +++ b/ui/src/views/admin/files/components/FileItem.tsx @@ -23,7 +23,7 @@ export interface FileItemProps { selected?: boolean viewMode?: 'grid' | 'list' onSelect?: (item: FileItemType) => void - onDoubleClick?: (item: FileItemType) => void + onDoubleClick?: (item: FileItemType, event?: React.MouseEvent) => void onCreateFolder?: (parentItem: FileItemType) => void onRename?: (item: FileItemType) => void onMove?: (item: FileItemType) => void @@ -35,10 +35,14 @@ export interface FileItemProps { } const getFileIcon = (item: FileItemType, large: boolean = false) => { - const iconSize = large ? "h-12 w-12" : "h-8 w-8" - + const iconSize = large ? 'h-12 w-12' : 'h-8 w-8' + if (item.type === 'folder') { - return + if (item.isReadOnly) { + return + } else { + return + } } const extension = item.extension?.toLowerCase() @@ -75,7 +79,7 @@ const formatFileSize = (bytes?: number): string => { const formatDate = (date?: string | Date): string => { if (!date) return '' - + try { const dateObj = typeof date === 'string' ? new Date(date) : date return dateObj.toLocaleDateString('tr-TR', { @@ -83,7 +87,7 @@ const formatDate = (date?: string | Date): string => { month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', }) } catch { return '' @@ -92,10 +96,10 @@ const formatDate = (date?: string | Date): string => { const getFileTypeLabel = (item: FileItemType): string => { if (item.type === 'folder') return 'Klasör' - + const extension = item.extension?.toLowerCase() const mimeType = item.mimeType?.toLowerCase() - + if (mimeType?.startsWith('image/')) return 'Resim' if (mimeType?.startsWith('video/')) return 'Video' if (mimeType?.startsWith('audio/')) return 'Ses' @@ -106,7 +110,7 @@ const getFileTypeLabel = (item: FileItemType): string => { if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || '')) return 'Arşiv' if (['txt', 'md'].includes(extension || '')) return 'Metin' if (['json', 'xml', 'css', 'js', 'ts', 'html'].includes(extension || '')) return 'Kod' - + return extension?.toUpperCase() || 'Dosya' } @@ -133,8 +137,8 @@ const FileItem = forwardRef((props, ref) => { onSelect?.(item) } - const handleDoubleClick = () => { - onDoubleClick?.(item) + const handleDoubleClick = (e: React.MouseEvent) => { + onDoubleClick?.(item, e) } const handleCheckboxChange = (e: React.ChangeEvent) => { @@ -216,8 +220,6 @@ const FileItem = forwardRef((props, ref) => { : []), ] - - const dropdownList = (
{actionMenuItems.map((menuItem) => ( @@ -225,16 +227,21 @@ const FileItem = forwardRef((props, ref) => { key={menuItem.key} className={classNames( 'flex items-center px-2 py-1 text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors', - menuItem.dangerous && 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20', + menuItem.dangerous && + 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20', )} onClick={() => { menuItem.onClick() setDropdownOpen(false) }} > - {menuItem.icon === 'HiFolderPlus' && } + {menuItem.icon === 'HiFolderPlus' && ( + + )} {menuItem.icon === 'HiEye' && } - {menuItem.icon === 'HiArrowDownTray' && } + {menuItem.icon === 'HiArrowDownTray' && ( + + )} {menuItem.icon === 'HiPencil' && } {menuItem.icon === 'HiArrowRightOnRectangle' && ( @@ -249,12 +256,12 @@ const FileItem = forwardRef((props, ref) => { // Resim preview komponenti const ImagePreview = ({ src, alt }: { src: string; alt: string }) => { const [imageError, setImageError] = useState(false) - + return (
{!imageError ? ( - {alt} setImageError(true)} @@ -273,7 +280,7 @@ const FileItem = forwardRef((props, ref) => {
((props, ref) => { onClick={handleClick} onDoubleClick={handleDoubleClick} > - {/* Checkbox */} -
- -
+ {/* File Name */} +
+ {/* Checkbox */} + {!item.isReadOnly ? ( + + ) : ( +
// Boş alan bırak + )} - {/* File Icon or Preview */} -
+ {/* File Icon or Preview */}
{item.type === 'file' && item.mimeType?.startsWith('image/') ? ( - + ) : (
{getFileIcon(item, false)}
)}
-
- {/* File Name */} -
-

+

{item.name}

{item.isReadOnly && ( - Protected + Protected + ! )}
- {/* File Type */} -
- - {getFileTypeLabel(item)} - + {/* File Type - Hidden on mobile */} +
+ {getFileTypeLabel(item)}
{/* File Size */} -
+
{item.type === 'file' && item.size ? ( {formatFileSize(item.size)} @@ -338,40 +349,15 @@ const FileItem = forwardRef((props, ref) => { )}
- {/* Modified Date */} -
+ {/* Modified Date - Hidden on mobile */} +
{formatDate(item.modifiedAt)}
- - {/* Action Menu */} -
- {!item.isReadOnly && ( - <> - - - {dropdownOpen && actionMenuItems.length > 0 && ( - <> -
setDropdownOpen(false)} - /> -
- {dropdownList} -
- - )} - - )} + + {/* Action Menu - Removed */} +
) @@ -382,7 +368,7 @@ const FileItem = forwardRef((props, ref) => {
((props, ref) => { onDoubleClick={handleDoubleClick} > {/* Checkbox */} -
- -
- - {/* Action Menu */} {!item.isReadOnly && ( -
- - - {dropdownOpen && actionMenuItems.length > 0 && ( - <> -
setDropdownOpen(false)} - /> -
- {dropdownList} -
- - )} +
+
)} + + {/* File/Folder Icon or Preview */}
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
- +
) : ( -
- {getFileIcon(item, true)} -
+
{getFileIcon(item, true)}
)}
{/* File/Folder Name and Details */}
-

+

{item.name}

- {item.isReadOnly && ( - - Protected - - )}
{/* File Size and Type */} {item.type === 'file' && (
-

- {getFileTypeLabel(item)} -

+

{getFileTypeLabel(item)}

{item.size && (

{formatFileSize(item.size)} diff --git a/ui/src/views/admin/files/components/FileModals.tsx b/ui/src/views/admin/files/components/FileModals.tsx index 50f74e1a..41d2f6f7 100644 --- a/ui/src/views/admin/files/components/FileModals.tsx +++ b/ui/src/views/admin/files/components/FileModals.tsx @@ -201,10 +201,9 @@ export const DeleteConfirmModal = forwardRef -

+

Delete Items

-
@@ -212,7 +211,7 @@ export const DeleteConfirmModal = forwardRef -
+
{folderCount > 0 && (

{folderCount} folder{folderCount > 1 ? 's' : ''} diff --git a/ui/src/views/admin/files/components/FileUploadModal.tsx b/ui/src/views/admin/files/components/FileUploadModal.tsx index 13f87a5a..8bbd6858 100644 --- a/ui/src/views/admin/files/components/FileUploadModal.tsx +++ b/ui/src/views/admin/files/components/FileUploadModal.tsx @@ -12,11 +12,13 @@ export interface FileUploadModalProps { className?: string } -interface UploadFileWithProgress extends File { +interface UploadFileWithProgress { id: string + file: File progress: number status: 'pending' | 'uploading' | 'completed' | 'error' error?: string + errorDetail?: string } const FileUploadModal = forwardRef((props, ref) => { @@ -31,7 +33,9 @@ const FileUploadModal = forwardRef((props, const formatFileSize = (bytes: number): string => { const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - if (bytes === 0) return '0 B' + + // Handle undefined, null, NaN values + if (!bytes || bytes === 0 || isNaN(bytes)) return '0 B' const i = Math.floor(Math.log(bytes) / Math.log(1024)) return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i] @@ -40,8 +44,8 @@ const FileUploadModal = forwardRef((props, const handleFilesSelect = useCallback((files: FileList | File[]) => { const fileArray = Array.from(files) const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({ - ...file, id: generateFileId(), + file: file, progress: 0, status: 'pending' as const, })) @@ -87,41 +91,121 @@ const FileUploadModal = forwardRef((props, setUploading(true) const filesToUpload = uploadFiles.filter((f) => f.status === 'pending') - try { - // Simulate upload progress for demo - replace with actual upload logic - for (const file of filesToUpload) { + // Upload files one by one + for (const fileData of filesToUpload) { + let progressInterval: NodeJS.Timeout | null = null + + try { + // Set status to uploading setUploadFiles((prev) => - prev.map((f) => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)), + prev.map((f) => (f.id === fileData.id ? { ...f, status: 'uploading' as const } : f)), ) - // Simulate progress - for (let progress = 0; progress <= 100; progress += 10) { - await new Promise((resolve) => setTimeout(resolve, 100)) - setUploadFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, progress } : f))) + // Simulate progress for visual feedback + progressInterval = setInterval(() => { + setUploadFiles((prev) => + prev.map((f) => { + if (f.id === fileData.id && f.progress < 90) { + return { ...f, progress: f.progress + 10 } + } + return f + }), + ) + }, 100) + + // Call the actual upload function for single file + await onUpload([fileData.file]) + + // Clear progress interval + if (progressInterval) { + clearInterval(progressInterval) + progressInterval = null } + // Mark as completed and remove from list after delay setUploadFiles((prev) => - prev.map((f) => (f.id === file.id ? { ...f, status: 'completed' as const } : f)), + prev.map((f) => + f.id === fileData.id ? { ...f, status: 'completed' as const, progress: 100 } : f, + ), + ) + + // Remove completed files from list after 2 seconds + setTimeout(() => { + setUploadFiles((prev) => prev.filter((f) => f.id !== fileData.id)) + }, 2000) + } catch (error: any) { + console.error('Upload failed for file:', fileData.file.name, error) + + // Clear progress interval in case of error + if (progressInterval) { + clearInterval(progressInterval) + progressInterval = null + } + + // Extract detailed error message from ABP response + let errorMessage = 'Upload failed' + let detailMessage = '' + + if (error?.response?.data?.error) { + const errorData = error.response.data.error + + // Ana hata mesajı + if (errorData.message) { + errorMessage = errorData.message + } + + // Detay mesajı - validationErrors veya details'den + if (errorData.details) { + detailMessage = errorData.details + } else if (errorData.validationErrors && errorData.validationErrors.length > 0) { + detailMessage = errorData.validationErrors[0].message || errorData.validationErrors[0] + } + + // Dosya boyutu kontrolü için özel mesaj + if (detailMessage.includes('Request body too large') || detailMessage.includes('max request body size')) { + const maxSizeMB = 30 // 30MB limit + errorMessage = 'File too large' + detailMessage = `File size exceeds the maximum allowed size of ${maxSizeMB}MB. Your file is ${(fileData.file.size / (1024 * 1024)).toFixed(1)}MB.` + } + + } else if (error?.message) { + errorMessage = error.message + } else if (typeof error === 'string') { + errorMessage = error + } + + // Mark as error with detailed message + setUploadFiles((prev) => + prev.map((f) => + f.id === fileData.id + ? { + ...f, + status: 'error' as const, + error: errorMessage, + errorDetail: detailMessage, + progress: 0, + } + : f, + ), ) } + } - // Call the actual upload function - await onUpload(filesToUpload) + setUploading(false) - // Close modal after successful upload - setTimeout(() => { - onClose() - setUploadFiles([]) - }, 1000) - } catch (error) { - console.error('Upload failed:', error) - setUploadFiles((prev) => - prev.map((f) => - f.status === 'uploading' ? { ...f, status: 'error' as const, error: 'Upload failed' } : f, - ), - ) - } finally { - setUploading(false) + // Check if all files are processed (completed or error) + const remainingFiles = uploadFiles.filter( + (f) => f.status === 'pending' || f.status === 'uploading', + ) + if (remainingFiles.length === 0) { + // If no pending files and no errors, close modal + const hasErrors = uploadFiles.some((f) => f.status === 'error') + if (!hasErrors) { + setTimeout(() => { + onClose() + setUploadFiles([]) + }, 2000) + } } } @@ -132,22 +216,31 @@ const FileUploadModal = forwardRef((props, } } + const clearCompletedFiles = () => { + setUploadFiles((prev) => prev.filter((f) => f.status !== 'completed')) + } + + const clearErrorFiles = () => { + setUploadFiles((prev) => prev.filter((f) => f.status !== 'error')) + } + const totalFiles = uploadFiles.length const completedFiles = uploadFiles.filter((f) => f.status === 'completed').length - const hasError = uploadFiles.some((f) => f.status === 'error') + const errorFiles = uploadFiles.filter((f) => f.status === 'error').length + const pendingFiles = uploadFiles.filter((f) => f.status === 'pending').length + const hasError = errorFiles > 0 return (

-
+

Upload Files

-
- {/* Upload Area */} +
((props,

Select one or more files to upload

-
{/* File List */} {uploadFiles.length > 0 && ( -
-

- Files to upload ({totalFiles}) -

-
- {uploadFiles.map((file) => ( -
-
-

- {file.name} +

+ {uploadFiles.map((file) => ( +
+
+ {/* File name and size in one line */} +
+

+ {file.file.name}

-

- {formatFileSize(file.size)} -

- - {/* Progress Bar */} - {file.status === 'uploading' && ( -
- -
- )} - - {/* Status Messages */} - {file.status === 'completed' && ( -

- Upload completed -

- )} - {file.status === 'error' && ( -

- {file.error || 'Upload failed'} -

- )} + + {formatFileSize(file.file.size)} +
- {file.status === 'pending' && ( -
- ))} -
+ + {(file.status === 'pending' || file.status === 'error') && ( +
+ ))}
)}
@@ -242,26 +334,37 @@ const FileUploadModal = forwardRef((props,
{uploading && ( - Uploading {completedFiles}/{totalFiles} files... + Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`} )} - {!uploading && completedFiles > 0 && !hasError && ( - All files uploaded successfully! + {!uploading && !hasError && pendingFiles > 0 && ( + Ready to upload {pendingFiles} file(s) + )} + {!uploading && !hasError && pendingFiles === 0 && totalFiles === 0 && ( + No files selected )} - {hasError && Some files failed to upload}
+ {hasError && !uploading && ( + + )}