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
|
#nullable enable
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.IO;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Volo.Abp.Content;
|
||||||
|
|
||||||
namespace Kurs.Platform.FileManagement;
|
namespace Kurs.Platform.FileManagement;
|
||||||
|
|
||||||
|
|
@ -32,12 +32,10 @@ public class UploadFileDto
|
||||||
[Required]
|
[Required]
|
||||||
public string FileName { get; set; } = string.Empty;
|
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; }
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
// ActivityModal pattern - Files array
|
||||||
|
public IRemoteStreamContent[]? Files { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SearchFilesDto
|
public class SearchFilesDto
|
||||||
|
|
@ -53,3 +51,19 @@ public class BulkDeleteDto
|
||||||
[Required]
|
[Required]
|
||||||
public List<string> ItemIds { get; set; } = new();
|
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
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Volo.Abp.Application.Services;
|
using Volo.Abp.Application.Services;
|
||||||
|
|
@ -31,4 +32,10 @@ public interface IFileManagementAppService : IApplicationService
|
||||||
Task<FolderPathDto> GetFolderPathAsync();
|
Task<FolderPathDto> GetFolderPathAsync();
|
||||||
|
|
||||||
Task<FolderPathDto> GetFolderPathByIdAsync(string folderId);
|
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);
|
ValidateFileName(input.FileName);
|
||||||
|
|
||||||
// Decode parent ID if provided
|
// Decode parent ID if provided
|
||||||
|
|
@ -333,14 +337,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
var items = await GetFolderIndexAsync(decodedParentId);
|
var items = await GetFolderIndexAsync(decodedParentId);
|
||||||
|
|
||||||
if (items.Any(x => x.Name.Equals(input.FileName, StringComparison.OrdinalIgnoreCase)))
|
// Generate unique filename if file already exists
|
||||||
{
|
var uniqueFileName = GetUniqueFileName(items, input.FileName);
|
||||||
throw new UserFriendlyException("A file with this name already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
var filePath = string.IsNullOrEmpty(decodedParentId)
|
var filePath = string.IsNullOrEmpty(decodedParentId)
|
||||||
? input.FileName
|
? uniqueFileName
|
||||||
: $"{decodedParentId}/{input.FileName}";
|
: $"{decodedParentId}/{uniqueFileName}";
|
||||||
|
|
||||||
var fileId = EncodePathAsId(filePath);
|
var fileId = EncodePathAsId(filePath);
|
||||||
|
|
||||||
|
|
@ -362,32 +364,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
// Dizini oluştur
|
// Dizini oluştur
|
||||||
Directory.CreateDirectory(fullCdnPath);
|
Directory.CreateDirectory(fullCdnPath);
|
||||||
|
|
||||||
var fullFilePath = Path.Combine(fullCdnPath, input.FileName);
|
var fullFilePath = Path.Combine(fullCdnPath, uniqueFileName);
|
||||||
|
|
||||||
long fileSize;
|
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);
|
using var fileStream = File.Create(fullFilePath);
|
||||||
await input.FileStream.CopyToAsync(fileStream);
|
await stream.CopyToAsync(fileStream);
|
||||||
fileSize = input.FileStream.Length;
|
fileSize = stream.Length;
|
||||||
}
|
|
||||||
else if (input.FileContent != null)
|
|
||||||
{
|
|
||||||
await File.WriteAllBytesAsync(fullFilePath, input.FileContent);
|
|
||||||
fileSize = input.FileContent.Length;
|
|
||||||
}
|
}
|
||||||
else
|
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
|
var metadata = new FileMetadata
|
||||||
{
|
{
|
||||||
Id = fileId,
|
Id = fileId,
|
||||||
Name = input.FileName,
|
Name = uniqueFileName,
|
||||||
Type = "file",
|
Type = "file",
|
||||||
Size = fileSize,
|
Size = fileSize,
|
||||||
Extension = fileInfo.Extension,
|
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)
|
public async Task<Stream> DownloadFileAsync(string id)
|
||||||
{
|
{
|
||||||
var cdnBasePath = _configuration["App:CdnPath"];
|
var cdnBasePath = _configuration["App:CdnPath"];
|
||||||
|
|
@ -691,6 +923,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
#region Private Helper Methods
|
#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)
|
private async Task<FileMetadata?> FindItemMetadataAsync(string id)
|
||||||
{
|
{
|
||||||
// This is not efficient, but IBlobContainer doesn't have built-in search
|
// 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 };
|
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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ using Kurs.Notifications.Application;
|
||||||
using Kurs.Platform.Classrooms;
|
using Kurs.Platform.Classrooms;
|
||||||
using Kurs.Platform.EntityFrameworkCore;
|
using Kurs.Platform.EntityFrameworkCore;
|
||||||
using Kurs.Platform.Extensions;
|
using Kurs.Platform.Extensions;
|
||||||
|
using Kurs.Platform.FileManagement;
|
||||||
using Kurs.Platform.Identity;
|
using Kurs.Platform.Identity;
|
||||||
using Kurs.Platform.Localization;
|
using Kurs.Platform.Localization;
|
||||||
using Kurs.Settings;
|
using Kurs.Settings;
|
||||||
|
|
@ -200,6 +201,7 @@ public class PlatformHttpApiHostModule : AbpModule
|
||||||
options.ConventionalControllers.Create(typeof(NotificationApplicationModule).Assembly);
|
options.ConventionalControllers.Create(typeof(NotificationApplicationModule).Assembly);
|
||||||
options.ChangeControllerModelApiExplorerGroupName = false;
|
options.ChangeControllerModelApiExplorerGroupName = false;
|
||||||
options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(PlatformUpdateProfileDto));
|
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({
|
return await apiService.fetchData({
|
||||||
url: 'api/account/my-profile',
|
url: 'api/account/my-profile',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data: formData,
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
// Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AxiosError) {
|
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 }> {
|
async uploadFile(request: UploadFileRequest): Promise<{ data: FileItem }> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('fileName', request.file.name)
|
formData.append('fileName', request.fileName)
|
||||||
if (request.parentId) {
|
if (request.parentId) {
|
||||||
formData.append('parentId', request.parentId)
|
formData.append('parentId', request.parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the actual file for FileContent property
|
// ActivityModal pattern - Files array
|
||||||
formData.append('fileContent', request.file)
|
request.files.forEach(file => {
|
||||||
|
formData.append('Files', file)
|
||||||
|
})
|
||||||
|
|
||||||
return ApiService.fetchData<FileItem>({
|
return ApiService.fetchData<FileItem>({
|
||||||
url: `/api/app/file-management/upload-file`,
|
url: `/api/app/file-management/upload-file`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData as any,
|
data: formData as any,
|
||||||
headers: {
|
// Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Rename a file or folder
|
||||||
async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> {
|
async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> {
|
||||||
return ApiService.fetchData<FileItem>({
|
return ApiService.fetchData<FileItem>({
|
||||||
|
|
@ -126,11 +143,29 @@ class FileManagementService {
|
||||||
// Bulk delete items
|
// Bulk delete items
|
||||||
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
|
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
|
||||||
return ApiService.fetchData<any>({
|
return ApiService.fetchData<any>({
|
||||||
url: `/api/app/file-management/bulk-delete`,
|
url: `/api/app/file-management/bulk-delete-items`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { itemIds },
|
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()
|
export default new FileManagementService()
|
||||||
|
|
@ -37,7 +37,8 @@ export interface DeleteItemRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadFileRequest {
|
export interface UploadFileRequest {
|
||||||
file: File
|
fileName: string
|
||||||
|
files: File[]
|
||||||
parentId?: string
|
parentId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui'
|
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 Container from '@/components/shared/Container'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import fileManagementService from '@/services/fileManagement.service'
|
import fileManagementService from '@/services/fileManagement.service'
|
||||||
|
|
@ -62,16 +77,19 @@ const FileManager = () => {
|
||||||
// Backend returns GetFilesDto which has Items property
|
// Backend returns GetFilesDto which has Items property
|
||||||
const items = response.data.items || []
|
const items = response.data.items || []
|
||||||
// Manual protection for system folders
|
// Manual protection for system folders
|
||||||
const protectedItems = items.map(item => {
|
const protectedItems = items.map((item) => {
|
||||||
const isSystemFolder = ['avatar', 'import', 'activity'].includes(item.name.toLowerCase())
|
const isSystemFolder = ['avatar', 'import', 'activity'].includes(item.name.toLowerCase())
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
isReadOnly: item.isReadOnly || isSystemFolder
|
isReadOnly: item.isReadOnly || isSystemFolder,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Fetched items:', protectedItems)
|
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)
|
setItems(protectedItems)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch items:', error)
|
console.error('Failed to fetch items:', error)
|
||||||
|
|
@ -161,6 +179,11 @@ const FileManager = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleItemSelect = (item: FileItemType) => {
|
const handleItemSelect = (item: FileItemType) => {
|
||||||
|
// Protected öğeler seçilemez
|
||||||
|
if (item.isReadOnly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedItems((prev) => {
|
setSelectedItems((prev) => {
|
||||||
if (prev.includes(item.id)) {
|
if (prev.includes(item.id)) {
|
||||||
return prev.filter((id) => id !== 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') {
|
if (item.type === 'folder') {
|
||||||
setCurrentFolderId(item.id)
|
setCurrentFolderId(item.id)
|
||||||
setSelectedItems([])
|
setSelectedItems([])
|
||||||
|
|
@ -182,10 +219,22 @@ const FileManager = () => {
|
||||||
try {
|
try {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await fileManagementService.uploadFile({
|
// ActivityModal pattern'ini kullan - Files array ile FormData
|
||||||
file,
|
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,
|
parentId: currentFolderId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await fileManagementService.uploadFileDirectly(formData)
|
||||||
}
|
}
|
||||||
await fetchItems(currentFolderId)
|
await fetchItems(currentFolderId)
|
||||||
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
|
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
|
||||||
|
|
@ -245,7 +294,7 @@ const FileManager = () => {
|
||||||
await fileManagementService.deleteItem({ id: itemsToDelete[0].id })
|
await fileManagementService.deleteItem({ id: itemsToDelete[0].id })
|
||||||
} else {
|
} else {
|
||||||
// Multiple items - use bulk delete API
|
// 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 fileManagementService.bulkDeleteItems(itemIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,7 +413,9 @@ const FileManager = () => {
|
||||||
|
|
||||||
// Bulk operations
|
// Bulk operations
|
||||||
const selectAllItems = () => {
|
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 = () => {
|
const deselectAllItems = () => {
|
||||||
|
|
@ -372,120 +423,173 @@ const FileManager = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSelectedItems = () => {
|
const deleteSelectedItems = () => {
|
||||||
const itemsToDelete = filteredItems.filter(item => selectedItems.includes(item.id))
|
const itemsToDelete = filteredItems.filter((item) => selectedItems.includes(item.id))
|
||||||
const deletableItems = itemsToDelete.filter(item => !item.isReadOnly)
|
const deletableItems = itemsToDelete.filter((item) => !item.isReadOnly)
|
||||||
const protectedItems = itemsToDelete.filter(item => item.isReadOnly)
|
const protectedItems = itemsToDelete.filter((item) => item.isReadOnly)
|
||||||
|
|
||||||
if (protectedItems.length > 0) {
|
if (protectedItems.length > 0) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Warning" type="warning">
|
<Notification title="Warning" type="warning">
|
||||||
{protectedItems.length} protected system folder(s) cannot be deleted: {protectedItems.map(i => i.name).join(', ')}
|
{protectedItems.length} protected system folder(s) cannot be deleted:{' '}
|
||||||
</Notification>
|
{protectedItems.map((i) => i.name).join(', ')}
|
||||||
|
</Notification>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deletableItems.length > 0) {
|
if (deletableItems.length > 0) {
|
||||||
openDeleteModal(deletableItems)
|
openDeleteModal(deletableItems)
|
||||||
// Remove protected items from selection
|
// Remove protected items from selection
|
||||||
const deletableIds = deletableItems.map(item => item.id)
|
const deletableIds = deletableItems.map((item) => item.id)
|
||||||
setSelectedItems(prev => prev.filter(id => deletableIds.includes(id)))
|
setSelectedItems((prev) => prev.filter((id) => deletableIds.includes(id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copySelectedItems = () => {
|
const copySelectedItems = () => {
|
||||||
const itemsToCopy = filteredItems.filter(item => selectedItems.includes(item.id))
|
const itemsToCopy = filteredItems.filter((item) => selectedItems.includes(item.id))
|
||||||
const copyableItems = itemsToCopy.filter(item => !item.isReadOnly)
|
const copyableItems = itemsToCopy.filter((item) => !item.isReadOnly)
|
||||||
const protectedItems = itemsToCopy.filter(item => item.isReadOnly)
|
const protectedItems = itemsToCopy.filter((item) => item.isReadOnly)
|
||||||
|
|
||||||
if (protectedItems.length > 0) {
|
if (protectedItems.length > 0) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Warning" type="warning">
|
<Notification title="Warning" type="warning">
|
||||||
{protectedItems.length} protected system folder(s) cannot be copied: {protectedItems.map(i => i.name).join(', ')}
|
{protectedItems.length} protected system folder(s) cannot be copied:{' '}
|
||||||
</Notification>
|
{protectedItems.map((i) => i.name).join(', ')}
|
||||||
|
</Notification>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyableItems.length > 0) {
|
if (copyableItems.length > 0) {
|
||||||
// Store in local storage or context for paste operation
|
// Store in local storage or context for paste operation
|
||||||
localStorage.setItem('fileManager_clipboard', JSON.stringify({
|
localStorage.setItem(
|
||||||
|
'fileManager_clipboard',
|
||||||
|
JSON.stringify({
|
||||||
operation: 'copy',
|
operation: 'copy',
|
||||||
items: copyableItems,
|
items: copyableItems,
|
||||||
sourceFolder: currentFolderId
|
sourceFolder: currentFolderId,
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
setHasClipboardData(true)
|
setHasClipboardData(true)
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Copied" type="success">
|
<Notification title="Copied" type="success">
|
||||||
{copyableItems.length} item(s) copied to clipboard
|
{copyableItems.length} item(s) copied to clipboard
|
||||||
</Notification>
|
</Notification>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutSelectedItems = () => {
|
const cutSelectedItems = () => {
|
||||||
const itemsToCut = filteredItems.filter(item => selectedItems.includes(item.id))
|
const itemsToCut = filteredItems.filter((item) => selectedItems.includes(item.id))
|
||||||
const cuttableItems = itemsToCut.filter(item => !item.isReadOnly)
|
const cuttableItems = itemsToCut.filter((item) => !item.isReadOnly)
|
||||||
const protectedItems = itemsToCut.filter(item => item.isReadOnly)
|
const protectedItems = itemsToCut.filter((item) => item.isReadOnly)
|
||||||
|
|
||||||
if (protectedItems.length > 0) {
|
if (protectedItems.length > 0) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Warning" type="warning">
|
<Notification title="Warning" type="warning">
|
||||||
{protectedItems.length} protected system folder(s) cannot be moved: {protectedItems.map(i => i.name).join(', ')}
|
{protectedItems.length} protected system folder(s) cannot be moved:{' '}
|
||||||
</Notification>
|
{protectedItems.map((i) => i.name).join(', ')}
|
||||||
|
</Notification>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cuttableItems.length > 0) {
|
if (cuttableItems.length > 0) {
|
||||||
// Store in local storage or context for paste operation
|
// Store in local storage or context for paste operation
|
||||||
localStorage.setItem('fileManager_clipboard', JSON.stringify({
|
localStorage.setItem(
|
||||||
|
'fileManager_clipboard',
|
||||||
|
JSON.stringify({
|
||||||
operation: 'cut',
|
operation: 'cut',
|
||||||
items: cuttableItems,
|
items: cuttableItems,
|
||||||
sourceFolder: currentFolderId
|
sourceFolder: currentFolderId,
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
setHasClipboardData(true)
|
setHasClipboardData(true)
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Cut" type="success">
|
<Notification title="Cut" type="success">
|
||||||
{cuttableItems.length} item(s) cut to clipboard
|
{cuttableItems.length} item(s) cut to clipboard
|
||||||
</Notification>
|
</Notification>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pasteItems = () => {
|
const pasteItems = async () => {
|
||||||
const clipboardData = localStorage.getItem('fileManager_clipboard')
|
const clipboardData = localStorage.getItem('fileManager_clipboard')
|
||||||
if (clipboardData) {
|
if (!clipboardData) {
|
||||||
|
toast.push(
|
||||||
|
<Notification title="Clipboard Empty" type="info">
|
||||||
|
No items in clipboard
|
||||||
|
</Notification>,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clipboard = JSON.parse(clipboardData)
|
const clipboard = JSON.parse(clipboardData)
|
||||||
|
const itemIds = clipboard.items.map((item: FileItemType) => item.id)
|
||||||
|
|
||||||
if (clipboard.operation === 'copy') {
|
if (clipboard.operation === 'copy') {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await fileManagementService.copyItems(itemIds, currentFolderId)
|
||||||
|
await fetchItems(currentFolderId)
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Copy" type="info">
|
<Notification title="Success" type="success">
|
||||||
Copy functionality will be implemented soon
|
{itemIds.length} item(s) copied successfully
|
||||||
</Notification>
|
</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') {
|
} else if (clipboard.operation === 'cut') {
|
||||||
|
// Aynı klasörde move yapmaya çalışırsa engelleyelim
|
||||||
|
if (clipboard.sourceFolder === currentFolderId) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Move" type="info">
|
<Notification title="Warning" type="warning">
|
||||||
Move functionality will be implemented soon
|
Cannot move items to the same folder
|
||||||
</Notification>
|
</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) {
|
} catch (error) {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title="Error" type="danger">
|
<Notification title="Error" type="danger">
|
||||||
Invalid clipboard data
|
Invalid clipboard data
|
||||||
</Notification>
|
</Notification>,
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.push(
|
|
||||||
<Notification title="Clipboard Empty" type="info">
|
|
||||||
No items in clipboard
|
|
||||||
</Notification>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container className="px-3 sm:px-4 md:px-6">
|
||||||
<Helmet
|
<Helmet
|
||||||
titleTemplate="%s | Sözsoft Kurs Platform"
|
titleTemplate="%s | Sözsoft Kurs Platform"
|
||||||
title={translate('::' + 'App.Files')}
|
title={translate('::' + 'App.Files')}
|
||||||
|
|
@ -493,43 +597,51 @@ const FileManager = () => {
|
||||||
></Helmet>
|
></Helmet>
|
||||||
|
|
||||||
{/* Enhanced Unified Toolbar */}
|
{/* 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 */}
|
{/* 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 */}
|
{/* Left Section - Primary Actions */}
|
||||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
{/* File Operations */}
|
{/* File Operations */}
|
||||||
<div className="flex items-center gap-2">
|
{/* 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
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
icon={<FaCloudUploadAlt />}
|
icon={<FaCloudUploadAlt />}
|
||||||
onClick={() => setUploadModalOpen(true)}
|
onClick={() => setUploadModalOpen(true)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
Upload Files
|
<span className="hidden sm:inline">Upload Files</span>
|
||||||
|
<span className="sm:hidden">Upload</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
icon={<FaFolder />}
|
icon={<FaFolder />}
|
||||||
onClick={() => setCreateFolderModalOpen(true)}
|
onClick={() => setCreateFolderModalOpen(true)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
Create Folder
|
<span className="hidden sm:inline">Create Folder</span>
|
||||||
|
<span className="sm:hidden">Create</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
|
||||||
|
|
||||||
{/* Clipboard Operations */}
|
{/* Clipboard Operations */}
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
icon={<FaCopy />}
|
icon={<FaCopy />}
|
||||||
onClick={copySelectedItems}
|
onClick={copySelectedItems}
|
||||||
disabled={selectedItems.length === 0}
|
disabled={selectedItems.length === 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-blue-600 disabled:opacity-50"
|
className="text-gray-600 hover:text-blue-600 disabled:opacity-50 flex-shrink-0"
|
||||||
title="Copy selected items"
|
title="Copy selected items"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -538,81 +650,136 @@ const FileManager = () => {
|
||||||
onClick={cutSelectedItems}
|
onClick={cutSelectedItems}
|
||||||
disabled={selectedItems.length === 0}
|
disabled={selectedItems.length === 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-orange-600 disabled:opacity-50"
|
className="text-gray-600 hover:text-orange-600 disabled:opacity-50 flex-shrink-0"
|
||||||
title="Cut selected items"
|
title="Cut selected items"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
icon={<FaPaste />}
|
||||||
onClick={pasteItems}
|
onClick={pasteItems}
|
||||||
disabled={!hasClipboardData}
|
disabled={!hasClipboardData}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-green-600 disabled:opacity-50"
|
className="text-gray-600 hover:text-green-600 disabled:opacity-50 flex-shrink-0"
|
||||||
title="Paste items"
|
title="Paste items"
|
||||||
>
|
/>
|
||||||
Paste
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
|
||||||
|
|
||||||
{/* Selection Actions */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{filteredItems.length > 0 && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
icon={selectedItems.length === filteredItems.length ? <FaCheckSquare /> : <FaSquare />}
|
icon={<FaEdit />}
|
||||||
onClick={selectedItems.length === filteredItems.length ? deselectAllItems : selectAllItems}
|
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"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-blue-600"
|
disabled={selectedItems.length !== 1}
|
||||||
title={selectedItems.length === filteredItems.length ? 'Deselect all items' : 'Select all items'}
|
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||||
|
title="Rename selected item"
|
||||||
>
|
>
|
||||||
{selectedItems.length === filteredItems.length ? 'Deselect All' : 'Select All'}
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{selectedItems.length > 0 && (
|
<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
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
icon={<FaTrash />}
|
icon={<FaTrash />}
|
||||||
onClick={deleteSelectedItems}
|
onClick={deleteSelectedItems}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-red-600"
|
disabled={selectedItems.length === 0}
|
||||||
|
className="text-gray-600 hover:text-red-600 flex-shrink-0"
|
||||||
title="Delete selected items"
|
title="Delete selected items"
|
||||||
>
|
>
|
||||||
Delete ({selectedItems.length})
|
<span>Delete {selectedItems.length > 0 && `(${selectedItems.length})`}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Selection Actions */}
|
||||||
{breadcrumbItems.length > 1 && (
|
{filteredItems.length > 0 && (
|
||||||
<>
|
|
||||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
icon={<FaArrowUp />}
|
icon={
|
||||||
onClick={goUpOneLevel}
|
selectedItems.length ===
|
||||||
|
filteredItems.filter((item) => !item.isReadOnly).length ? (
|
||||||
|
<FaCheckSquare />
|
||||||
|
) : (
|
||||||
|
<FaSquare />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={
|
||||||
|
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||||
|
? deselectAllItems
|
||||||
|
: selectAllItems
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-blue-600"
|
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
|
||||||
title="Go up one level"
|
title={
|
||||||
|
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
|
||||||
|
? 'Deselect all selectable items'
|
||||||
|
: 'Select all selectable items'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Go Up
|
<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>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section - Search, Sort, View */}
|
{/* 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 */}
|
{/* Search */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center w-full sm:w-auto">
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={filters.searchTerm}
|
value={filters.searchTerm}
|
||||||
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
|
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
|
||||||
prefix={<FaSearch className="text-gray-400" />}
|
prefix={<FaSearch className="text-gray-400" />}
|
||||||
className="w-48"
|
className="w-full sm:w-36 md:w-48"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -652,17 +819,17 @@ const FileManager = () => {
|
||||||
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
|
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="min-w-36"
|
className="min-w-32 sm:min-w-36 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View Mode */}
|
{/* 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
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<FaTh />}
|
icon={<FaTh />}
|
||||||
className={classNames(
|
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',
|
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
|
|
@ -673,7 +840,7 @@ const FileManager = () => {
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<FaList />}
|
icon={<FaList />}
|
||||||
className={classNames(
|
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',
|
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
|
@ -682,38 +849,14 @@ const FileManager = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="mb-6">
|
<div className="mb-4 sm:mb-6 overflow-x-auto">
|
||||||
|
<div className="min-w-max">
|
||||||
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Files Grid/List */}
|
{/* Files Grid/List */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -724,12 +867,11 @@ const FileManager = () => {
|
||||||
<>
|
<>
|
||||||
{/* List View Header */}
|
{/* List View Header */}
|
||||||
{viewMode === 'list' && (
|
{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="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-1"></div> {/* Icon column */}
|
<div className="col-span-5 lg:col-span-5">İsim</div>
|
||||||
<div className="col-span-4">İsim</div>
|
<div className="col-span-2 lg:col-span-2">Tür</div>
|
||||||
<div className="col-span-2">Tür</div>
|
<div className="col-span-2 lg:col-span-2">Boyut</div>
|
||||||
<div className="col-span-2">Boyut</div>
|
<div className="col-span-2 lg:col-span-2">Değiştirilme</div>
|
||||||
<div className="col-span-2">Değiştirilme</div>
|
|
||||||
<div className="col-span-1"></div> {/* Actions column */}
|
<div className="col-span-1"></div> {/* Actions column */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -737,14 +879,14 @@ const FileManager = () => {
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
viewMode === 'grid'
|
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',
|
: 'space-y-1',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filteredItems.length === 0 ? (
|
{filteredItems.length === 0 ? (
|
||||||
<div className="col-span-full text-center py-20">
|
<div className="col-span-full text-center py-12 sm:py-20">
|
||||||
<FaFolder className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
<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">
|
<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'}
|
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -765,21 +907,46 @@ const FileManager = () => {
|
||||||
}}
|
}}
|
||||||
onRename={openRenameModal}
|
onRename={openRenameModal}
|
||||||
onMove={(item) => {
|
onMove={(item) => {
|
||||||
// Move işlevi henüz implement edilmedi
|
// Move işlemi için öğeyi cut olarak clipboard'a koy
|
||||||
toast.push(<Notification type="info">Move özelliği yakında eklenecek</Notification>)
|
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])}
|
onDelete={(item) => openDeleteModal([item])}
|
||||||
onDownload={item.type === 'file' ? handleDownload : undefined}
|
onDownload={item.type === 'file' ? handleDownload : undefined}
|
||||||
onPreview={item.type === 'file' ? (item) => {
|
onPreview={
|
||||||
|
item.type === 'file'
|
||||||
|
? (item) => {
|
||||||
// Preview işlevi - resimler için modal açabiliriz
|
// Preview işlevi - resimler için modal açabiliriz
|
||||||
if (item.mimeType?.startsWith('image/')) {
|
if (item.mimeType?.startsWith('image/')) {
|
||||||
// Resim preview modal'ı açılabilir
|
// Resim preview modal'ı açılabilir
|
||||||
toast.push(<Notification type="info">Resim önizleme özelliği yakında eklenecek</Notification>)
|
toast.push(
|
||||||
|
<Notification type="info">
|
||||||
|
Resim önizleme özelliği yakında eklenecek
|
||||||
|
</Notification>,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Diğer dosya tipleri için download
|
// Diğer dosya tipleri için download
|
||||||
handleDownload(item)
|
handleDownload(item)
|
||||||
}
|
}
|
||||||
} : undefined}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export interface FileItemProps {
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
viewMode?: 'grid' | 'list'
|
viewMode?: 'grid' | 'list'
|
||||||
onSelect?: (item: FileItemType) => void
|
onSelect?: (item: FileItemType) => void
|
||||||
onDoubleClick?: (item: FileItemType) => void
|
onDoubleClick?: (item: FileItemType, event?: React.MouseEvent) => void
|
||||||
onCreateFolder?: (parentItem: FileItemType) => void
|
onCreateFolder?: (parentItem: FileItemType) => void
|
||||||
onRename?: (item: FileItemType) => void
|
onRename?: (item: FileItemType) => void
|
||||||
onMove?: (item: FileItemType) => void
|
onMove?: (item: FileItemType) => void
|
||||||
|
|
@ -35,11 +35,15 @@ export interface FileItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFileIcon = (item: FileItemType, large: boolean = false) => {
|
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') {
|
if (item.type === 'folder') {
|
||||||
|
if (item.isReadOnly) {
|
||||||
|
return <HiFolder className={`${iconSize} text-gray-500`} />
|
||||||
|
} else {
|
||||||
return <HiFolder className={`${iconSize} text-blue-500`} />
|
return <HiFolder className={`${iconSize} text-blue-500`} />
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extension = item.extension?.toLowerCase()
|
const extension = item.extension?.toLowerCase()
|
||||||
const mimeType = item.mimeType?.toLowerCase()
|
const mimeType = item.mimeType?.toLowerCase()
|
||||||
|
|
@ -83,7 +87,7 @@ const formatDate = (date?: string | Date): string => {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return ''
|
||||||
|
|
@ -133,8 +137,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onSelect?.(item)
|
onSelect?.(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDoubleClick = () => {
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
onDoubleClick?.(item)
|
onDoubleClick?.(item, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -216,8 +220,6 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const dropdownList = (
|
const dropdownList = (
|
||||||
<div className="py-1 min-w-36">
|
<div className="py-1 min-w-36">
|
||||||
{actionMenuItems.map((menuItem) => (
|
{actionMenuItems.map((menuItem) => (
|
||||||
|
|
@ -225,16 +227,21 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
key={menuItem.key}
|
key={menuItem.key}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'flex items-center px-2 py-1 text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
'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={() => {
|
onClick={() => {
|
||||||
menuItem.onClick()
|
menuItem.onClick()
|
||||||
setDropdownOpen(false)
|
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 === '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 === 'HiPencil' && <HiPencil className="h-4 w-4 mr-3 text-orange-500" />}
|
||||||
{menuItem.icon === 'HiArrowRightOnRectangle' && (
|
{menuItem.icon === 'HiArrowRightOnRectangle' && (
|
||||||
<HiArrowRightOnRectangle className="h-4 w-4 mr-3 text-purple-500" />
|
<HiArrowRightOnRectangle className="h-4 w-4 mr-3 text-purple-500" />
|
||||||
|
|
@ -273,7 +280,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
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',
|
'hover:border-blue-300 hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||||
selected
|
selected
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
|
@ -283,52 +290,56 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
|
{/* File Name */}
|
||||||
|
<div className="col-span-5 flex items-center min-w-0 gap-2">
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<div className="col-span-1 flex items-center">
|
{!item.isReadOnly ? (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={handleCheckboxChange}
|
onChange={handleCheckboxChange}
|
||||||
onClick={handleCheckboxClick}
|
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"
|
||||||
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>
|
) : (
|
||||||
|
<div className="w-4 h-4" /> // Boş alan bırak
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File Icon or Preview */}
|
{/* File Icon or Preview */}
|
||||||
<div className="col-span-1 flex items-center">
|
|
||||||
<div className="w-8 h-8">
|
<div className="w-8 h-8">
|
||||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
{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">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
{getFileIcon(item, false)}
|
{getFileIcon(item, false)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Name */}
|
<p
|
||||||
<div className="col-span-3 flex items-center min-w-0 gap-2">
|
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"
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
title={item.name}
|
||||||
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
{item.isReadOnly && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Type */}
|
{/* File Type - Hidden on mobile */}
|
||||||
<div className="col-span-2 flex items-center">
|
<div className="hidden sm:flex col-span-2 items-center">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</span>
|
||||||
{getFileTypeLabel(item)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Size */}
|
{/* 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 ? (
|
{item.type === 'file' && item.size ? (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatFileSize(item.size)}
|
{formatFileSize(item.size)}
|
||||||
|
|
@ -338,40 +349,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modified Date */}
|
{/* Modified Date - Hidden on mobile */}
|
||||||
<div className="col-span-2 flex items-center">
|
<div className="hidden sm:flex col-span-2 items-center">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatDate(item.modifiedAt)}
|
{formatDate(item.modifiedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Menu */}
|
{/* Action Menu - Removed */}
|
||||||
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
|
<div className="col-span-1 sm:col-span-1 flex items-center justify-end">
|
||||||
{!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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -382,7 +368,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
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',
|
'hover:border-blue-400 hover:shadow-lg hover:-translate-y-1',
|
||||||
selected
|
selected
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-lg'
|
? '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}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
|
{!item.isReadOnly && (
|
||||||
<div className="absolute top-3 left-3 z-10">
|
<div className="absolute top-3 left-3 z-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={handleCheckboxChange}
|
onChange={handleCheckboxChange}
|
||||||
onClick={handleCheckboxClick}
|
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"
|
||||||
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>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* File/Folder Icon or Preview */}
|
{/* File/Folder Icon or Preview */}
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
||||||
<div className="w-16 h-16">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">{getFileIcon(item, true)}</div>
|
||||||
{getFileIcon(item, true)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File/Folder Name and Details */}
|
{/* File/Folder Name and Details */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1 mb-1">
|
<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}
|
{item.name}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* File Size and Type */}
|
{/* File Size and Type */}
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</p>
|
||||||
{getFileTypeLabel(item)}
|
|
||||||
</p>
|
|
||||||
{item.size && (
|
{item.size && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{formatFileSize(item.size)}
|
{formatFileSize(item.size)}
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,9 @@ export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onClose={onClose}>
|
<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">
|
<div className="flex items-center justify-between pb-4 border-b">
|
||||||
<h3 className="text-lg font-semibold text-red-600">Delete Items</h3>
|
<h3 className="text-lg font-semibold text-red-600">Delete Items</h3>
|
||||||
<Button variant="plain" size="sm" icon={<HiXMark />} onClick={onClose} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-6">
|
<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.
|
Are you sure you want to delete the following items? This action cannot be undone.
|
||||||
</p>
|
</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 && (
|
{folderCount > 0 && (
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{folderCount} folder{folderCount > 1 ? 's' : ''}
|
{folderCount} folder{folderCount > 1 ? 's' : ''}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ export interface FileUploadModalProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadFileWithProgress extends File {
|
interface UploadFileWithProgress {
|
||||||
id: string
|
id: string
|
||||||
|
file: File
|
||||||
progress: number
|
progress: number
|
||||||
status: 'pending' | 'uploading' | 'completed' | 'error'
|
status: 'pending' | 'uploading' | 'completed' | 'error'
|
||||||
error?: string
|
error?: string
|
||||||
|
errorDetail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props, ref) => {
|
const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props, ref) => {
|
||||||
|
|
@ -31,7 +33,9 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
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))
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]
|
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 handleFilesSelect = useCallback((files: FileList | File[]) => {
|
||||||
const fileArray = Array.from(files)
|
const fileArray = Array.from(files)
|
||||||
const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({
|
const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({
|
||||||
...file,
|
|
||||||
id: generateFileId(),
|
id: generateFileId(),
|
||||||
|
file: file,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
}))
|
}))
|
||||||
|
|
@ -87,41 +91,121 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
const filesToUpload = uploadFiles.filter((f) => f.status === 'pending')
|
const filesToUpload = uploadFiles.filter((f) => f.status === 'pending')
|
||||||
|
|
||||||
|
// Upload files one by one
|
||||||
|
for (const fileData of filesToUpload) {
|
||||||
|
let progressInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate upload progress for demo - replace with actual upload logic
|
// Set status to uploading
|
||||||
for (const file of filesToUpload) {
|
|
||||||
setUploadFiles((prev) =>
|
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
|
// Simulate progress for visual feedback
|
||||||
for (let progress = 0; progress <= 100; progress += 10) {
|
progressInterval = setInterval(() => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
setUploadFiles((prev) =>
|
||||||
setUploadFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, progress } : f)))
|
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) =>
|
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
|
setUploading(false)
|
||||||
await onUpload(filesToUpload)
|
|
||||||
|
|
||||||
// Close modal after successful upload
|
// 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(() => {
|
setTimeout(() => {
|
||||||
onClose()
|
onClose()
|
||||||
setUploadFiles([])
|
setUploadFiles([])
|
||||||
}, 1000)
|
}, 2000)
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 totalFiles = uploadFiles.length
|
||||||
const completedFiles = uploadFiles.filter((f) => f.status === 'completed').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 (
|
return (
|
||||||
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
||||||
<div ref={ref}>
|
<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>
|
<h3 className="text-lg font-semibold">Upload Files</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-6">
|
<div className="py-2">
|
||||||
{/* Upload Area */}
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
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
|
isDragOver
|
||||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
|
||||||
|
|
@ -172,38 +265,30 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
Select one or more files to upload
|
Select one or more files to upload
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
Select Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File List */}
|
{/* File List */}
|
||||||
{uploadFiles.length > 0 && (
|
{uploadFiles.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||||
<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) => (
|
{uploadFiles.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
{/* File name and size in one line */}
|
||||||
{file.name}
|
<div className="flex items-center justify-between">
|
||||||
</p>
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate flex-1 mr-2">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
{file.file.name}
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
</p>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||||
|
{formatFileSize(file.file.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
{file.status === 'uploading' && (
|
{file.status === 'uploading' && (
|
||||||
<div className="mt-2">
|
<div className="mt-1">
|
||||||
<Progress percent={file.progress} size="sm" />
|
<Progress percent={file.progress} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -211,29 +296,36 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
||||||
{/* Status Messages */}
|
{/* Status Messages */}
|
||||||
{file.status === 'completed' && (
|
{file.status === 'completed' && (
|
||||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||||
Upload completed
|
✓ Upload completed
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{file.status === 'error' && (
|
{file.status === 'error' && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
<div className="mt-1">
|
||||||
{file.error || 'Upload failed'}
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{file.status === 'pending' && (
|
{(file.status === 'pending' || file.status === 'error') && (
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="xs"
|
size="xs"
|
||||||
icon={<HiXMark />}
|
icon={<HiXMark />}
|
||||||
onClick={() => removeFile(file.id)}
|
onClick={() => removeFile(file.id)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
|
className="ml-2 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -242,26 +334,37 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<span>
|
<span>
|
||||||
Uploading {completedFiles}/{totalFiles} files...
|
Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!uploading && completedFiles > 0 && !hasError && (
|
{!uploading && !hasError && pendingFiles > 0 && (
|
||||||
<span className="text-green-600">All files uploaded successfully!</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<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}>
|
<Button variant="default" onClick={handleClose} disabled={uploading}>
|
||||||
{uploading ? 'Uploading...' : 'Cancel'}
|
{uploading ? 'Uploading...' : 'Close'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={uploadFiles.length === 0 || uploading || completedFiles === totalFiles}
|
disabled={pendingFiles === 0 || uploading}
|
||||||
loading={uploading}
|
loading={uploading}
|
||||||
>
|
>
|
||||||
Upload Files
|
{uploading ? 'Uploading...' : `Upload ${pendingFiles} File(s)`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue