Dosya Yöneticisi
File exists, Toolbar ve style güncellemeleri
This commit is contained in:
parent
730430ac93
commit
697c7c1d65
11 changed files with 1103 additions and 512 deletions
|
|
@ -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<string> ItemIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CopyItemsDto
|
||||
{
|
||||
[Required]
|
||||
public List<string> ItemIds { get; set; } = new();
|
||||
|
||||
public string? TargetFolderId { get; set; }
|
||||
}
|
||||
|
||||
public class MoveItemsDto
|
||||
{
|
||||
[Required]
|
||||
public List<string> ItemIds { get; set; } = new();
|
||||
|
||||
public string? TargetFolderId { get; set; }
|
||||
}
|
||||
|
|
@ -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<FolderPathDto> GetFolderPathAsync();
|
||||
|
||||
Task<FolderPathDto> GetFolderPathByIdAsync(string folderId);
|
||||
|
||||
Task BulkDeleteItemsAsync(BulkDeleteDto input);
|
||||
|
||||
Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input);
|
||||
|
||||
Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input);
|
||||
}
|
||||
|
|
@ -320,8 +320,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<FileItemDto> UploadFileAsync(UploadFileDto input)
|
||||
public async Task<FileItemDto> 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<List<FileItemDto>> 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<FileItemDto>();
|
||||
var errors = new List<string>();
|
||||
|
||||
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<List<FileItemDto>> 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<FileItemDto>();
|
||||
var errors = new List<string>();
|
||||
|
||||
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<Stream> 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<FileMetadata> 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<FileMetadata?> 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
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<FileItem>({
|
||||
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<FileItem>({
|
||||
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<FileItem>({
|
||||
|
|
@ -126,11 +143,29 @@ class FileManagementService {
|
|||
// Bulk delete items
|
||||
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
|
||||
return ApiService.fetchData<any>({
|
||||
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<FileItem[]>({
|
||||
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<FileItem[]>({
|
||||
url: `/api/app/file-management/move-items`,
|
||||
method: 'POST',
|
||||
data: { itemIds, targetFolderId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new FileManagementService()
|
||||
|
|
@ -37,7 +37,8 @@ export interface DeleteItemRequest {
|
|||
}
|
||||
|
||||
export interface UploadFileRequest {
|
||||
file: File
|
||||
fileName: string
|
||||
files: File[]
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<Notification type="success">Files uploaded successfully</Notification>)
|
||||
|
|
@ -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(<Notification type="success">Items deleted successfully</Notification>)
|
||||
|
|
@ -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(
|
||||
<Notification title="Warning" type="warning">
|
||||
{protectedItems.length} protected system folder(s) cannot be deleted: {protectedItems.map(i => i.name).join(', ')}
|
||||
</Notification>
|
||||
{protectedItems.length} protected system folder(s) cannot be deleted:{' '}
|
||||
{protectedItems.map((i) => i.name).join(', ')}
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
<Notification title="Warning" type="warning">
|
||||
{protectedItems.length} protected system folder(s) cannot be copied: {protectedItems.map(i => i.name).join(', ')}
|
||||
</Notification>
|
||||
{protectedItems.length} protected system folder(s) cannot be copied:{' '}
|
||||
{protectedItems.map((i) => i.name).join(', ')}
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
<Notification title="Copied" type="success">
|
||||
{copyableItems.length} item(s) copied to clipboard
|
||||
</Notification>
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
<Notification title="Warning" type="warning">
|
||||
{protectedItems.length} protected system folder(s) cannot be moved: {protectedItems.map(i => i.name).join(', ')}
|
||||
</Notification>
|
||||
{protectedItems.length} protected system folder(s) cannot be moved:{' '}
|
||||
{protectedItems.map((i) => i.name).join(', ')}
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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(
|
||||
<Notification title="Cut" type="success">
|
||||
{cuttableItems.length} item(s) cut to clipboard
|
||||
</Notification>
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
<Notification title="Copy" type="info">
|
||||
Copy functionality will be implemented soon
|
||||
</Notification>
|
||||
)
|
||||
} else if (clipboard.operation === 'cut') {
|
||||
toast.push(
|
||||
<Notification title="Move" type="info">
|
||||
Move functionality will be implemented soon
|
||||
</Notification>
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.push(
|
||||
<Notification title="Error" type="danger">
|
||||
Invalid clipboard data
|
||||
</Notification>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!clipboardData) {
|
||||
toast.push(
|
||||
<Notification title="Clipboard Empty" type="info">
|
||||
No items in clipboard
|
||||
</Notification>
|
||||
</Notification>,
|
||||
)
|
||||
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(
|
||||
<Notification title="Success" type="success">
|
||||
{itemIds.length} item(s) copied successfully
|
||||
</Notification>,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error)
|
||||
toast.push(
|
||||
<Notification title="Error" type="danger">
|
||||
Failed to copy items
|
||||
</Notification>,
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (clipboard.operation === 'cut') {
|
||||
// Aynı klasörde move yapmaya çalışırsa engelleyelim
|
||||
if (clipboard.sourceFolder === currentFolderId) {
|
||||
toast.push(
|
||||
<Notification title="Warning" type="warning">
|
||||
Cannot move items to the same folder
|
||||
</Notification>,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await fileManagementService.moveItems(itemIds, currentFolderId)
|
||||
await fetchItems(currentFolderId)
|
||||
// Clipboard'ı temizle
|
||||
localStorage.removeItem('fileManager_clipboard')
|
||||
setHasClipboardData(false)
|
||||
toast.push(
|
||||
<Notification title="Success" type="success">
|
||||
{itemIds.length} item(s) moved successfully
|
||||
</Notification>,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Move failed:', error)
|
||||
toast.push(
|
||||
<Notification title="Error" type="danger">
|
||||
Failed to move items
|
||||
</Notification>,
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.push(
|
||||
<Notification title="Error" type="danger">
|
||||
Invalid clipboard data
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container className="px-3 sm:px-4 md:px-6">
|
||||
<Helmet
|
||||
titleTemplate="%s | Sözsoft Kurs Platform"
|
||||
title={translate('::' + 'App.Files')}
|
||||
|
|
@ -493,126 +597,189 @@ const FileManager = () => {
|
|||
></Helmet>
|
||||
|
||||
{/* Enhanced Unified Toolbar */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 mt-2">
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 sm:p-4 mb-4 mt-2">
|
||||
{/* Main Toolbar Row */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4">
|
||||
{/* Left Section - Primary Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
{/* File Operations */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="solid"
|
||||
icon={<FaCloudUploadAlt />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Upload Files
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<FaFolder />}
|
||||
onClick={() => setCreateFolderModalOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Create Folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
{/* Navigation */}
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaArrowUp />}
|
||||
onClick={goUpOneLevel}
|
||||
size="sm"
|
||||
disabled={breadcrumbItems.length <= 1}
|
||||
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||
title="Go up one level"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
icon={<FaCloudUploadAlt />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<span className="hidden sm:inline">Upload Files</span>
|
||||
<span className="sm:hidden">Upload</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<FaFolder />}
|
||||
onClick={() => setCreateFolderModalOpen(true)}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<span className="hidden sm:inline">Create Folder</span>
|
||||
<span className="sm:hidden">Create</span>
|
||||
</Button>
|
||||
|
||||
{/* Clipboard Operations */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaCopy />}
|
||||
onClick={copySelectedItems}
|
||||
disabled={selectedItems.length === 0}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 disabled:opacity-50"
|
||||
title="Copy selected items"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaCut />}
|
||||
onClick={cutSelectedItems}
|
||||
disabled={selectedItems.length === 0}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-orange-600 disabled:opacity-50"
|
||||
title="Cut selected items"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={pasteItems}
|
||||
disabled={!hasClipboardData}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-green-600 disabled:opacity-50"
|
||||
title="Paste items"
|
||||
>
|
||||
Paste
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaCopy />}
|
||||
onClick={copySelectedItems}
|
||||
disabled={selectedItems.length === 0}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 disabled:opacity-50 flex-shrink-0"
|
||||
title="Copy selected items"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaCut />}
|
||||
onClick={cutSelectedItems}
|
||||
disabled={selectedItems.length === 0}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-orange-600 disabled:opacity-50 flex-shrink-0"
|
||||
title="Cut selected items"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaPaste />}
|
||||
onClick={pasteItems}
|
||||
disabled={!hasClipboardData}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-green-600 disabled:opacity-50 flex-shrink-0"
|
||||
title="Paste items"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaEdit />}
|
||||
onClick={() => {
|
||||
if (selectedItems.length === 1) {
|
||||
const itemToRename = filteredItems.find((item) => item.id === selectedItems[0])
|
||||
if (itemToRename && !itemToRename.isReadOnly) {
|
||||
openRenameModal(itemToRename)
|
||||
} else {
|
||||
toast.push(
|
||||
<Notification title="Warning" type="warning">
|
||||
Protected system folders cannot be renamed
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={selectedItems.length !== 1}
|
||||
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||
title="Rename selected item"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaDownload />}
|
||||
onClick={() => {
|
||||
if (selectedItems.length === 1) {
|
||||
const itemToDownload = filteredItems.find((item) => item.id === selectedItems[0])
|
||||
if (itemToDownload && itemToDownload.type === 'file') {
|
||||
handleDownload(itemToDownload)
|
||||
} else {
|
||||
toast.push(
|
||||
<Notification title="Warning" type="warning">
|
||||
Only files can be downloaded
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
disabled={
|
||||
selectedItems.length !== 1 ||
|
||||
(() => {
|
||||
const selectedItem = filteredItems.find((item) => item.id === selectedItems[0])
|
||||
return selectedItem?.type !== 'file'
|
||||
})()
|
||||
}
|
||||
className="text-gray-600 hover:text-green-600 flex-shrink-0"
|
||||
title="Download selected file"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaTrash />}
|
||||
onClick={deleteSelectedItems}
|
||||
size="sm"
|
||||
disabled={selectedItems.length === 0}
|
||||
className="text-gray-600 hover:text-red-600 flex-shrink-0"
|
||||
title="Delete selected items"
|
||||
>
|
||||
<span>Delete {selectedItems.length > 0 && `(${selectedItems.length})`}</span>
|
||||
</Button>
|
||||
|
||||
{/* Selection Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{filteredItems.length > 0 && (
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={selectedItems.length === filteredItems.length ? <FaCheckSquare /> : <FaSquare />}
|
||||
onClick={selectedItems.length === filteredItems.length ? deselectAllItems : selectAllItems}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
title={selectedItems.length === filteredItems.length ? 'Deselect all items' : 'Select all items'}
|
||||
>
|
||||
{selectedItems.length === filteredItems.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
)}
|
||||
{selectedItems.length > 0 && (
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaTrash />}
|
||||
onClick={deleteSelectedItems}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-red-600"
|
||||
title="Delete selected items"
|
||||
>
|
||||
Delete ({selectedItems.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{breadcrumbItems.length > 1 && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={<FaArrowUp />}
|
||||
onClick={goUpOneLevel}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600"
|
||||
title="Go up one level"
|
||||
>
|
||||
Go Up
|
||||
</Button>
|
||||
</>
|
||||
{filteredItems.length > 0 && (
|
||||
<Button
|
||||
variant="plain"
|
||||
icon={
|
||||
selectedItems.length ===
|
||||
filteredItems.filter((item) => !item.isReadOnly).length ? (
|
||||
<FaCheckSquare />
|
||||
) : (
|
||||
<FaSquare />
|
||||
)
|
||||
}
|
||||
onClick={
|
||||
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||
? deselectAllItems
|
||||
: selectAllItems
|
||||
}
|
||||
size="sm"
|
||||
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||
title={
|
||||
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||
? 'Deselect all selectable items'
|
||||
: 'Select all selectable items'
|
||||
}
|
||||
>
|
||||
<span className="hidden lg:inline">
|
||||
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</span>
|
||||
<span className="lg:hidden">
|
||||
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||
? 'Deselect'
|
||||
: 'Select'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section - Search, Sort, View */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap justify-start lg:justify-end min-w-0 w-full lg:w-auto">
|
||||
{/* Search */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center w-full sm:w-auto">
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="Search files..."
|
||||
value={filters.searchTerm}
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
|
||||
prefix={<FaSearch className="text-gray-400" />}
|
||||
className="w-48"
|
||||
className="w-full sm:w-36 md:w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -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 */}
|
||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
|
||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded flex-shrink-0">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
icon={<FaTh />}
|
||||
className={classNames(
|
||||
'rounded-r-none border-r',
|
||||
'rounded-r-none border-r px-2 sm:px-3',
|
||||
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
|
|
@ -673,7 +840,7 @@ const FileManager = () => {
|
|||
size="sm"
|
||||
icon={<FaList />}
|
||||
className={classNames(
|
||||
'rounded-l-none',
|
||||
'rounded-l-none px-2 sm:px-3',
|
||||
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
|
|
@ -682,37 +849,13 @@ const FileManager = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Status Bar - Show when items are selected */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300 font-medium">
|
||||
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({filteredItems.filter(item => selectedItems.includes(item.id)).length} of {filteredItems.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={deselectAllItems}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||
<div className="mb-4 sm:mb-6 overflow-x-auto">
|
||||
<div className="min-w-max">
|
||||
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files Grid/List */}
|
||||
|
|
@ -724,12 +867,11 @@ const FileManager = () => {
|
|||
<>
|
||||
{/* List View Header */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 border-b dark:border-gray-700 mb-2">
|
||||
<div className="col-span-1"></div> {/* Icon column */}
|
||||
<div className="col-span-4">İsim</div>
|
||||
<div className="col-span-2">Tür</div>
|
||||
<div className="col-span-2">Boyut</div>
|
||||
<div className="col-span-2">Değiştirilme</div>
|
||||
<div className="hidden sm:grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 border-b dark:border-gray-700 mb-2">
|
||||
<div className="col-span-5 lg:col-span-5">İsim</div>
|
||||
<div className="col-span-2 lg:col-span-2">Tür</div>
|
||||
<div className="col-span-2 lg:col-span-2">Boyut</div>
|
||||
<div className="col-span-2 lg:col-span-2">Değiştirilme</div>
|
||||
<div className="col-span-1"></div> {/* Actions column */}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -737,52 +879,77 @@ const FileManager = () => {
|
|||
<div
|
||||
className={classNames(
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4'
|
||||
? 'grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-3 sm:gap-4'
|
||||
: 'space-y-1',
|
||||
)}
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="col-span-full text-center py-20">
|
||||
<FaFolder className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
viewMode={viewMode}
|
||||
selected={selectedItems.includes(item.id)}
|
||||
onSelect={handleItemSelect}
|
||||
onDoubleClick={handleItemDoubleClick}
|
||||
onToggleSelect={handleItemSelect}
|
||||
onCreateFolder={(parentItem) => {
|
||||
// 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(<Notification type="info">Move özelliği yakında eklenecek</Notification>)
|
||||
}}
|
||||
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(<Notification type="info">Resim önizleme özelliği yakında eklenecek</Notification>)
|
||||
} else {
|
||||
// Diğer dosya tipleri için download
|
||||
handleDownload(item)
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 sm:py-20">
|
||||
<FaFolder className="mx-auto h-12 w-12 sm:h-16 sm:w-16 text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm sm:text-base px-4">
|
||||
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
viewMode={viewMode}
|
||||
selected={selectedItems.includes(item.id)}
|
||||
onSelect={handleItemSelect}
|
||||
onDoubleClick={handleItemDoubleClick}
|
||||
onToggleSelect={handleItemSelect}
|
||||
onCreateFolder={(parentItem) => {
|
||||
// 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(
|
||||
<Notification title="Cut" type="success">
|
||||
Item ready to move. Navigate to target folder and paste.
|
||||
</Notification>,
|
||||
)
|
||||
}
|
||||
}}
|
||||
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(
|
||||
<Notification type="info">
|
||||
Resim önizleme özelliği yakında eklenecek
|
||||
</Notification>,
|
||||
)
|
||||
} else {
|
||||
// Diğer dosya tipleri için download
|
||||
handleDownload(item)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 <HiFolder className={`${iconSize} text-blue-500`} />
|
||||
if (item.isReadOnly) {
|
||||
return <HiFolder className={`${iconSize} text-gray-500`} />
|
||||
} else {
|
||||
return <HiFolder className={`${iconSize} text-blue-500`} />
|
||||
}
|
||||
}
|
||||
|
||||
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<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onSelect?.(item)
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
onDoubleClick?.(item)
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
onDoubleClick?.(item, e)
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -216,8 +220,6 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
: []),
|
||||
]
|
||||
|
||||
|
||||
|
||||
const dropdownList = (
|
||||
<div className="py-1 min-w-36">
|
||||
{actionMenuItems.map((menuItem) => (
|
||||
|
|
@ -225,16 +227,21 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((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' && <HiFolderPlus className="h-4 w-4 mr-3 text-blue-500" />}
|
||||
{menuItem.icon === 'HiFolderPlus' && (
|
||||
<HiFolderPlus className="h-4 w-4 mr-3 text-blue-500" />
|
||||
)}
|
||||
{menuItem.icon === 'HiEye' && <HiEye className="h-4 w-4 mr-3 text-gray-500" />}
|
||||
{menuItem.icon === 'HiArrowDownTray' && <HiArrowDownTray className="h-4 w-4 mr-3 text-green-500" />}
|
||||
{menuItem.icon === 'HiArrowDownTray' && (
|
||||
<HiArrowDownTray className="h-4 w-4 mr-3 text-green-500" />
|
||||
)}
|
||||
{menuItem.icon === 'HiPencil' && <HiPencil className="h-4 w-4 mr-3 text-orange-500" />}
|
||||
{menuItem.icon === 'HiArrowRightOnRectangle' && (
|
||||
<HiArrowRightOnRectangle className="h-4 w-4 mr-3 text-purple-500" />
|
||||
|
|
@ -249,12 +256,12 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
// Resim preview komponenti
|
||||
const ImagePreview = ({ src, alt }: { src: string; alt: string }) => {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center overflow-hidden">
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={src}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-w-full max-h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
|
|
@ -273,7 +280,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'relative group grid grid-cols-12 gap-4 p-3 border rounded-lg cursor-pointer transition-all duration-200',
|
||||
'relative group grid grid-cols-12 gap-4 p-2 border rounded-lg cursor-pointer transition-all duration-200 select-none',
|
||||
'hover:border-blue-300 hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
|
|
@ -283,52 +290,56 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="col-span-1 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
onClick={handleCheckboxClick}
|
||||
disabled={item.isReadOnly}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
{/* File Name */}
|
||||
<div className="col-span-5 flex items-center min-w-0 gap-2">
|
||||
{/* Checkbox */}
|
||||
{!item.isReadOnly ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
onClick={handleCheckboxClick}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-4 h-4" /> // Boş alan bırak
|
||||
)}
|
||||
|
||||
{/* File Icon or Preview */}
|
||||
<div className="col-span-1 flex items-center">
|
||||
{/* File Icon or Preview */}
|
||||
<div className="w-8 h-8">
|
||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
||||
<ImagePreview src={`/api/app/file-management/${item.id}/download-file`} alt={item.name} />
|
||||
<ImagePreview
|
||||
src={`/api/app/file-management/${item.id}/download-file`}
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{getFileIcon(item, false)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Name */}
|
||||
<div className="col-span-3 flex items-center min-w-0 gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<p
|
||||
className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
{item.isReadOnly && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 flex-shrink-0">
|
||||
Protected
|
||||
<span className="hidden sm:inline">Protected</span>
|
||||
<span className="sm:hidden">!</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Type */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{getFileTypeLabel(item)}
|
||||
</span>
|
||||
{/* File Type - Hidden on mobile */}
|
||||
<div className="hidden sm:flex col-span-2 items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</span>
|
||||
</div>
|
||||
|
||||
{/* File Size */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
<div className="col-span-2 sm:col-span-2 flex items-center">
|
||||
{item.type === 'file' && item.size ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(item.size)}
|
||||
|
|
@ -338,40 +349,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Modified Date */}
|
||||
<div className="col-span-2 flex items-center">
|
||||
{/* Modified Date - Hidden on mobile */}
|
||||
<div className="hidden sm:flex col-span-2 items-center">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(item.modifiedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Menu */}
|
||||
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
|
||||
{!item.isReadOnly && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDropdownOpen(!dropdownOpen)
|
||||
}}
|
||||
>
|
||||
<HiEllipsisVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && actionMenuItems.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
|
||||
{dropdownList}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Menu - Removed */}
|
||||
<div className="col-span-1 sm:col-span-1 flex items-center justify-end">
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -382,7 +368,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
'relative group p-4 border rounded-xl cursor-pointer transition-all duration-300',
|
||||
'relative group p-4 border rounded-xl cursor-pointer transition-all duration-300 select-none',
|
||||
'hover:border-blue-400 hover:shadow-lg hover:-translate-y-1',
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-lg'
|
||||
|
|
@ -393,76 +379,49 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
onClick={handleCheckboxClick}
|
||||
disabled={item.isReadOnly}
|
||||
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Menu */}
|
||||
{!item.isReadOnly && (
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
<button
|
||||
className="p-1.5 rounded-full bg-white dark:bg-gray-700 shadow-md hover:shadow-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-all duration-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDropdownOpen(!dropdownOpen)
|
||||
}}
|
||||
>
|
||||
<HiEllipsisVertical className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && actionMenuItems.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
|
||||
{dropdownList}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
onClick={handleCheckboxClick}
|
||||
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* File/Folder Icon or Preview */}
|
||||
<div className="flex justify-center mb-3">
|
||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
||||
<div className="w-16 h-16">
|
||||
<ImagePreview src={`/api/app/file-management/${item.id}/download-file`} alt={item.name} />
|
||||
<ImagePreview
|
||||
src={`/api/app/file-management/${item.id}/download-file`}
|
||||
alt={item.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
{getFileIcon(item, true)}
|
||||
</div>
|
||||
<div className="flex justify-center">{getFileIcon(item, true)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File/Folder Name and Details */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
<p
|
||||
className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors px-2"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
{item.isReadOnly && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
Protected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Size and Type */}
|
||||
{item.type === 'file' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getFileTypeLabel(item)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</p>
|
||||
{item.size && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(item.size)}
|
||||
|
|
|
|||
|
|
@ -201,10 +201,9 @@ export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalP
|
|||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onClose={onClose}>
|
||||
<div ref={ref} className="max-w-md">
|
||||
<div ref={ref}>
|
||||
<div className="flex items-center justify-between pb-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-red-600">Delete Items</h3>
|
||||
<Button variant="plain" size="sm" icon={<HiXMark />} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
|
|
@ -212,7 +211,7 @@ export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalP
|
|||
Are you sure you want to delete the following items? This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-2">
|
||||
{folderCount > 0 && (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{folderCount} folder{folderCount > 1 ? 's' : ''}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement, FileUploadModalProps>((props, ref) => {
|
||||
|
|
@ -31,7 +33,9 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((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<HTMLDivElement, FileUploadModalProps>((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<HTMLDivElement, FileUploadModalProps>((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<HTMLDivElement, FileUploadModalProps>((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 (
|
||||
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
||||
<div ref={ref}>
|
||||
<div className="flex items-center justify-between pb-4 border-b">
|
||||
<div className="flex items-center justify-between pb-2 border-b">
|
||||
<h3 className="text-lg font-semibold">Upload Files</h3>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
{/* Upload Area */}
|
||||
<div className="py-2">
|
||||
<div
|
||||
className={classNames(
|
||||
'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors',
|
||||
'relative border-2 border-dashed rounded-lg p-4 text-center transition-colors',
|
||||
isDragOver
|
||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
|
||||
|
|
@ -172,67 +265,66 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
|||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Select one or more files to upload
|
||||
</p>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
Select Files
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{uploadFiles.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Files to upload ({totalFiles})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{uploadFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{file.name}
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
{uploadFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* File name and size in one line */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate flex-1 mr-2">
|
||||
{file.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{file.status === 'uploading' && (
|
||||
<div className="mt-2">
|
||||
<Progress percent={file.progress} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{file.status === 'completed' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
Upload completed
|
||||
</p>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{file.error || 'Upload failed'}
|
||||
</p>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
{formatFileSize(file.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.status === 'pending' && (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="xs"
|
||||
icon={<HiXMark />}
|
||||
onClick={() => removeFile(file.id)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{/* Progress Bar */}
|
||||
{file.status === 'uploading' && (
|
||||
<div className="mt-1">
|
||||
<Progress percent={file.progress} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{file.status === 'completed' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
✓ Upload completed
|
||||
</p>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-medium">
|
||||
✗ {file.error || 'Upload failed'}
|
||||
</p>
|
||||
{file.errorDetail && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 mt-0.5 leading-relaxed">
|
||||
{file.errorDetail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(file.status === 'pending' || file.status === 'error') && (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="xs"
|
||||
icon={<HiXMark />}
|
||||
onClick={() => removeFile(file.id)}
|
||||
disabled={uploading}
|
||||
className="ml-2 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -242,26 +334,37 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
|||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{uploading && (
|
||||
<span>
|
||||
Uploading {completedFiles}/{totalFiles} files...
|
||||
Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`}
|
||||
</span>
|
||||
)}
|
||||
{!uploading && completedFiles > 0 && !hasError && (
|
||||
<span className="text-green-600">All files uploaded successfully!</span>
|
||||
{!uploading && !hasError && pendingFiles > 0 && (
|
||||
<span className="text-green-600">Ready to upload {pendingFiles} file(s)</span>
|
||||
)}
|
||||
{!uploading && !hasError && pendingFiles === 0 && totalFiles === 0 && (
|
||||
<span className="text-gray-500">No files selected</span>
|
||||
)}
|
||||
{hasError && <span className="text-red-600">Some files failed to upload</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{hasError && !uploading && (
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={clearErrorFiles}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Clear Errors
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handleClose} disabled={uploading}>
|
||||
{uploading ? 'Uploading...' : 'Cancel'}
|
||||
{uploading ? 'Uploading...' : 'Close'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={handleUpload}
|
||||
disabled={uploadFiles.length === 0 || uploading || completedFiles === totalFiles}
|
||||
disabled={pendingFiles === 0 || uploading}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload Files
|
||||
{uploading ? 'Uploading...' : `Upload ${pendingFiles} File(s)`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue