Dosya Yöneticisi

File exists, Toolbar ve style güncellemeleri
This commit is contained in:
Sedat Öztürk 2025-10-26 19:27:19 +03:00
parent 730430ac93
commit 697c7c1d65
11 changed files with 1103 additions and 512 deletions

View file

@ -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; }
}

View file

@ -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);
} }

View file

@ -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
} }

View file

@ -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));
}); });
} }

View file

@ -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) {

View file

@ -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()

View file

@ -37,7 +37,8 @@ export interface DeleteItemRequest {
} }
export interface UploadFileRequest { export interface UploadFileRequest {
file: File fileName: string
files: File[]
parentId?: string parentId?: string
} }

View file

@ -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'ıılabilir // Resim preview modal'ıı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
}
/> />
)) ))
)} )}

View file

@ -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)}

View file

@ -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' : ''}

View file

@ -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>