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
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Collections.Generic;
using Volo.Abp.Content;
namespace Kurs.Platform.FileManagement;
@ -32,12 +32,10 @@ public class UploadFileDto
[Required]
public string FileName { get; set; } = string.Empty;
// Either FileStream or FileContent can be used
public Stream? FileStream { get; set; }
public byte[]? FileContent { get; set; }
public string? ParentId { get; set; }
// ActivityModal pattern - Files array
public IRemoteStreamContent[]? Files { get; set; }
}
public class SearchFilesDto
@ -53,3 +51,19 @@ public class BulkDeleteDto
[Required]
public List<string> ItemIds { get; set; } = new();
}
public class CopyItemsDto
{
[Required]
public List<string> ItemIds { get; set; } = new();
public string? TargetFolderId { get; set; }
}
public class MoveItemsDto
{
[Required]
public List<string> ItemIds { get; set; } = new();
public string? TargetFolderId { get; set; }
}

View file

@ -1,5 +1,6 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
@ -31,4 +32,10 @@ public interface IFileManagementAppService : IApplicationService
Task<FolderPathDto> GetFolderPathAsync();
Task<FolderPathDto> GetFolderPathByIdAsync(string folderId);
Task BulkDeleteItemsAsync(BulkDeleteDto input);
Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input);
Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input);
}

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);
// Decode parent ID if provided
@ -333,14 +337,12 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
var items = await GetFolderIndexAsync(decodedParentId);
if (items.Any(x => x.Name.Equals(input.FileName, StringComparison.OrdinalIgnoreCase)))
{
throw new UserFriendlyException("A file with this name already exists");
}
// Generate unique filename if file already exists
var uniqueFileName = GetUniqueFileName(items, input.FileName);
var filePath = string.IsNullOrEmpty(decodedParentId)
? input.FileName
: $"{decodedParentId}/{input.FileName}";
? uniqueFileName
: $"{decodedParentId}/{uniqueFileName}";
var fileId = EncodePathAsId(filePath);
@ -362,32 +364,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
// Dizini oluştur
Directory.CreateDirectory(fullCdnPath);
var fullFilePath = Path.Combine(fullCdnPath, input.FileName);
var fullFilePath = Path.Combine(fullCdnPath, uniqueFileName);
long fileSize;
if (input.FileStream != null)
if (input.Files != null && input.Files.Length > 0)
{
input.FileStream.Position = 0;
// İlk dosyayı kullan (tek dosya upload için)
var file = input.Files[0];
using var stream = file.GetStream();
using var fileStream = File.Create(fullFilePath);
await input.FileStream.CopyToAsync(fileStream);
fileSize = input.FileStream.Length;
}
else if (input.FileContent != null)
{
await File.WriteAllBytesAsync(fullFilePath, input.FileContent);
fileSize = input.FileContent.Length;
await stream.CopyToAsync(fileStream);
fileSize = stream.Length;
}
else
{
throw new UserFriendlyException("Either FileStream or FileContent must be provided");
throw new UserFriendlyException("Files must be provided");
}
var fileInfo = new FileInfo(input.FileName);
var fileInfo = new FileInfo(uniqueFileName);
var metadata = new FileMetadata
{
Id = fileId,
Name = input.FileName,
Name = uniqueFileName,
Type = "file",
Size = fileSize,
Extension = fileInfo.Extension,
@ -631,6 +630,239 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
}
}
public async Task<List<FileItemDto>> CopyItemsAsync(CopyItemsDto input)
{
if (input.ItemIds == null || !input.ItemIds.Any())
{
throw new UserFriendlyException("No items selected for copy");
}
var cdnBasePath = _configuration["App:CdnPath"];
if (string.IsNullOrEmpty(cdnBasePath))
{
throw new UserFriendlyException("CDN path is not configured");
}
var tenantId = _currentTenant.Id?.ToString() ?? "host";
var basePath = Path.Combine(cdnBasePath, tenantId);
string? targetPath = null;
if (!string.IsNullOrEmpty(input.TargetFolderId))
{
targetPath = DecodeIdAsPath(input.TargetFolderId);
}
var copiedItems = new List<FileItemDto>();
var errors = new List<string>();
foreach (var itemId in input.ItemIds)
{
try
{
var sourcePath = DecodeIdAsPath(itemId);
var sourceFullPath = Path.Combine(basePath, sourcePath);
// Get source item name
var sourceItemName = Path.GetFileName(sourcePath);
// Generate unique name if item already exists in target
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
var targetFullPath = Path.Combine(basePath, targetItemPath);
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/');
if (Directory.Exists(sourceFullPath))
{
// Copy directory recursively
CopyDirectory(sourceFullPath, uniqueTargetPath);
var dirInfo = new DirectoryInfo(uniqueTargetPath);
copiedItems.Add(new FileItemDto
{
Id = EncodePathAsId(finalTargetPath),
Name = dirInfo.Name,
Type = "folder",
CreatedAt = dirInfo.CreationTime,
ModifiedAt = dirInfo.LastWriteTime,
Path = finalTargetPath,
ParentId = input.TargetFolderId ?? string.Empty,
IsReadOnly = false
});
}
else if (File.Exists(sourceFullPath))
{
// Copy file
var targetDir = Path.GetDirectoryName(uniqueTargetPath);
if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir))
{
Directory.CreateDirectory(targetDir);
}
File.Copy(sourceFullPath, uniqueTargetPath);
var fileInfo = new FileInfo(uniqueTargetPath);
var extension = fileInfo.Extension;
copiedItems.Add(new FileItemDto
{
Id = EncodePathAsId(finalTargetPath),
Name = fileInfo.Name,
Type = "file",
Size = fileInfo.Length,
Extension = extension,
MimeType = GetMimeType(extension),
CreatedAt = fileInfo.CreationTime,
ModifiedAt = fileInfo.LastWriteTime,
Path = finalTargetPath,
ParentId = input.TargetFolderId ?? string.Empty,
IsReadOnly = false
});
}
else
{
errors.Add($"Source item not found: {itemId}");
}
}
catch (Exception ex)
{
errors.Add($"Failed to copy {itemId}: {ex.Message}");
}
}
if (errors.Any())
{
throw new UserFriendlyException($"Some items could not be copied: {string.Join(", ", errors)}");
}
return copiedItems;
}
public async Task<List<FileItemDto>> MoveItemsAsync(MoveItemsDto input)
{
if (input.ItemIds == null || !input.ItemIds.Any())
{
throw new UserFriendlyException("No items selected for move");
}
var cdnBasePath = _configuration["App:CdnPath"];
if (string.IsNullOrEmpty(cdnBasePath))
{
throw new UserFriendlyException("CDN path is not configured");
}
var tenantId = _currentTenant.Id?.ToString() ?? "host";
var basePath = Path.Combine(cdnBasePath, tenantId);
string? targetPath = null;
if (!string.IsNullOrEmpty(input.TargetFolderId))
{
targetPath = DecodeIdAsPath(input.TargetFolderId);
}
var movedItems = new List<FileItemDto>();
var errors = new List<string>();
foreach (var itemId in input.ItemIds)
{
try
{
// Check if this is a protected system folder
ValidateNotProtectedFolder(itemId, "move");
var sourcePath = DecodeIdAsPath(itemId);
var sourceFullPath = Path.Combine(basePath, sourcePath);
// Get source item name
var sourceItemName = Path.GetFileName(sourcePath);
// Generate target path
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}";
var targetFullPath = Path.Combine(basePath, targetItemPath);
// Check if moving to same location
if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath))
{
errors.Add($"Cannot move item to the same location: {sourceItemName}");
continue;
}
// Generate unique name if item already exists in target
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName);
var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/');
if (Directory.Exists(sourceFullPath))
{
// Move directory
var targetDir = Path.GetDirectoryName(uniqueTargetPath);
if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir))
{
Directory.CreateDirectory(targetDir);
}
Directory.Move(sourceFullPath, uniqueTargetPath);
var dirInfo = new DirectoryInfo(uniqueTargetPath);
movedItems.Add(new FileItemDto
{
Id = EncodePathAsId(finalTargetPath),
Name = dirInfo.Name,
Type = "folder",
CreatedAt = dirInfo.CreationTime,
ModifiedAt = dirInfo.LastWriteTime,
Path = finalTargetPath,
ParentId = input.TargetFolderId ?? string.Empty,
IsReadOnly = false
});
}
else if (File.Exists(sourceFullPath))
{
// Move file
var targetDir = Path.GetDirectoryName(uniqueTargetPath);
if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir))
{
Directory.CreateDirectory(targetDir);
}
File.Move(sourceFullPath, uniqueTargetPath);
var fileInfo = new FileInfo(uniqueTargetPath);
var extension = fileInfo.Extension;
movedItems.Add(new FileItemDto
{
Id = EncodePathAsId(finalTargetPath),
Name = fileInfo.Name,
Type = "file",
Size = fileInfo.Length,
Extension = extension,
MimeType = GetMimeType(extension),
CreatedAt = fileInfo.CreationTime,
ModifiedAt = fileInfo.LastWriteTime,
Path = finalTargetPath,
ParentId = input.TargetFolderId ?? string.Empty,
IsReadOnly = false
});
}
else
{
errors.Add($"Source item not found: {itemId}");
}
}
catch (Exception ex)
{
errors.Add($"Failed to move {itemId}: {ex.Message}");
}
}
if (errors.Any())
{
throw new UserFriendlyException($"Some items could not be moved: {string.Join(", ", errors)}");
}
return movedItems;
}
public async Task<Stream> DownloadFileAsync(string id)
{
var cdnBasePath = _configuration["App:CdnPath"];
@ -691,6 +923,29 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
#region Private Helper Methods
private string GetUniqueFileName(List<FileMetadata> existingItems, string originalFileName)
{
// Check if file name is already unique
if (!existingItems.Any(x => x.Name.Equals(originalFileName, StringComparison.OrdinalIgnoreCase)))
{
return originalFileName;
}
var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
var extension = Path.GetExtension(originalFileName);
var counter = 1;
string uniqueName;
do
{
uniqueName = $"{nameWithoutExt} ({counter}){extension}";
counter++;
} while (existingItems.Any(x => x.Name.Equals(uniqueName, StringComparison.OrdinalIgnoreCase)));
return uniqueName;
}
private async Task<FileMetadata?> FindItemMetadataAsync(string id)
{
// This is not efficient, but IBlobContainer doesn't have built-in search
@ -823,5 +1078,54 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
return new FolderPathDto { Path = pathItems };
}
private string GetUniqueItemPath(string targetPath, string originalName)
{
if (!File.Exists(targetPath) && !Directory.Exists(targetPath))
{
return targetPath;
}
var directory = Path.GetDirectoryName(targetPath) ?? "";
var nameWithoutExt = Path.GetFileNameWithoutExtension(originalName);
var extension = Path.GetExtension(originalName);
var counter = 1;
string newPath;
do
{
var newName = $"{nameWithoutExt} ({counter}){extension}";
newPath = Path.Combine(directory, newName);
counter++;
} while (File.Exists(newPath) || Directory.Exists(newPath));
return newPath;
}
private void CopyDirectory(string sourceDir, string targetDir)
{
if (!Directory.Exists(sourceDir))
throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
// Create target directory
Directory.CreateDirectory(targetDir);
// Copy files
foreach (var file in Directory.GetFiles(sourceDir))
{
var fileName = Path.GetFileName(file);
var destFile = Path.Combine(targetDir, fileName);
File.Copy(file, destFile, overwrite: false);
}
// Copy subdirectories recursively
foreach (var subDir in Directory.GetDirectories(sourceDir))
{
var subDirName = Path.GetFileName(subDir);
var destSubDir = Path.Combine(targetDir, subDirName);
CopyDirectory(subDir, destSubDir);
}
}
#endregion
}

View file

@ -10,6 +10,7 @@ using Kurs.Notifications.Application;
using Kurs.Platform.Classrooms;
using Kurs.Platform.EntityFrameworkCore;
using Kurs.Platform.Extensions;
using Kurs.Platform.FileManagement;
using Kurs.Platform.Identity;
using Kurs.Platform.Localization;
using Kurs.Settings;
@ -200,6 +201,7 @@ public class PlatformHttpApiHostModule : AbpModule
options.ConventionalControllers.Create(typeof(NotificationApplicationModule).Assembly);
options.ChangeControllerModelApiExplorerGroupName = false;
options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(PlatformUpdateProfileDto));
options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(UploadFileDto));
});
}

View file

@ -100,8 +100,8 @@ export const updateProfile = async (data: UpdateProfileDto) => {
return await apiService.fetchData({
url: 'api/account/my-profile',
method: 'put',
data,
headers: { 'Content-Type': 'multipart/form-data' },
data: formData,
// Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder
})
} catch (error) {
if (error instanceof AxiosError) {

View file

@ -30,27 +30,44 @@ class FileManagementService {
})
}
// Upload a file
// Upload a file (DTO pattern)
async uploadFile(request: UploadFileRequest): Promise<{ data: FileItem }> {
const formData = new FormData()
formData.append('fileName', request.file.name)
formData.append('fileName', request.fileName)
if (request.parentId) {
formData.append('parentId', request.parentId)
}
// Send the actual file for FileContent property
formData.append('fileContent', request.file)
// ActivityModal pattern - Files array
request.files.forEach(file => {
formData.append('Files', file)
})
return ApiService.fetchData<FileItem>({
url: `/api/app/file-management/upload-file`,
method: 'POST',
data: formData as any,
headers: {
'Content-Type': 'multipart/form-data',
},
// Browser otomatik olarak Content-Type'ı multipart/form-data boundary ile set eder
})
}
// Upload a file directly with FormData (ActivityModal pattern)
async uploadFileDirectly(formData: FormData): Promise<{ data: FileItem }> {
try {
console.log('Uploading file directly with FormData')
// ABP convention-based routing: UploadFileAsync -> upload-file (Async suffix kaldırılır)
return await ApiService.fetchData<FileItem>({
url: 'api/app/file-management/upload-file',
method: 'POST',
data: formData as any,
})
} catch (error) {
console.error('File upload error:', error)
throw error
}
}
// Rename a file or folder
async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> {
return ApiService.fetchData<FileItem>({
@ -126,11 +143,29 @@ class FileManagementService {
// Bulk delete items
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
return ApiService.fetchData<any>({
url: `/api/app/file-management/bulk-delete`,
url: `/api/app/file-management/bulk-delete-items`,
method: 'POST',
data: { itemIds },
})
}
// Copy items to target folder
async copyItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> {
return ApiService.fetchData<FileItem[]>({
url: `/api/app/file-management/copy-items`,
method: 'POST',
data: { itemIds, targetFolderId },
})
}
// Move items to target folder
async moveItems(itemIds: string[], targetFolderId?: string): Promise<{ data: FileItem[] }> {
return ApiService.fetchData<FileItem[]>({
url: `/api/app/file-management/move-items`,
method: 'POST',
data: { itemIds, targetFolderId },
})
}
}
export default new FileManagementService()

View file

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

View file

@ -1,7 +1,22 @@
import { useState, useEffect, useCallback } from 'react'
import { Helmet } from 'react-helmet'
import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui'
import { FaFolder, FaCloudUploadAlt, FaSearch, FaTh, FaList, FaArrowUp, FaCheckSquare, FaSquare, FaTrash, FaCut, FaCopy } from 'react-icons/fa'
import {
FaFolder,
FaCloudUploadAlt,
FaSearch,
FaTh,
FaList,
FaArrowUp,
FaCheckSquare,
FaSquare,
FaTrash,
FaCut,
FaCopy,
FaEdit,
FaDownload,
FaPaste,
} from 'react-icons/fa'
import Container from '@/components/shared/Container'
import { useLocalization } from '@/utils/hooks/useLocalization'
import fileManagementService from '@/services/fileManagement.service'
@ -62,16 +77,19 @@ const FileManager = () => {
// Backend returns GetFilesDto which has Items property
const items = response.data.items || []
// Manual protection for system folders
const protectedItems = items.map(item => {
const protectedItems = items.map((item) => {
const isSystemFolder = ['avatar', 'import', 'activity'].includes(item.name.toLowerCase())
return {
...item,
isReadOnly: item.isReadOnly || isSystemFolder
isReadOnly: item.isReadOnly || isSystemFolder,
}
})
console.log('Fetched items:', protectedItems)
console.log('Protected folders check:', protectedItems.filter(item => item.isReadOnly))
console.log(
'Protected folders check:',
protectedItems.filter((item) => item.isReadOnly),
)
setItems(protectedItems)
} catch (error) {
console.error('Failed to fetch items:', error)
@ -161,6 +179,11 @@ const FileManager = () => {
}
const handleItemSelect = (item: FileItemType) => {
// Protected öğeler seçilemez
if (item.isReadOnly) {
return
}
setSelectedItems((prev) => {
if (prev.includes(item.id)) {
return prev.filter((id) => id !== item.id)
@ -170,7 +193,21 @@ const FileManager = () => {
})
}
const handleItemDoubleClick = (item: FileItemType) => {
const handleItemDoubleClick = (item: FileItemType, event?: React.MouseEvent) => {
// Prevent text selection and other default behaviors
if (event) {
event.preventDefault()
event.stopPropagation()
}
// Clear any text selection that might have occurred
if (window.getSelection) {
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
}
if (item.type === 'folder') {
setCurrentFolderId(item.id)
setSelectedItems([])
@ -182,10 +219,22 @@ const FileManager = () => {
try {
setUploading(true)
for (const file of files) {
await fileManagementService.uploadFile({
file,
// ActivityModal pattern'ini kullan - Files array ile FormData
const formData = new FormData()
formData.append('fileName', file.name)
formData.append('Files', file) // ActivityModal pattern - Files array
if (currentFolderId) {
formData.append('parentId', currentFolderId)
}
console.log('FileManager uploading:', {
fileName: file.name,
fileSize: file.size,
fileType: file.type,
parentId: currentFolderId,
})
await fileManagementService.uploadFileDirectly(formData)
}
await fetchItems(currentFolderId)
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
@ -245,7 +294,7 @@ const FileManager = () => {
await fileManagementService.deleteItem({ id: itemsToDelete[0].id })
} else {
// Multiple items - use bulk delete API
const itemIds = itemsToDelete.map(item => item.id)
const itemIds = itemsToDelete.map((item) => item.id)
await fileManagementService.bulkDeleteItems(itemIds)
}
@ -364,7 +413,9 @@ const FileManager = () => {
// Bulk operations
const selectAllItems = () => {
setSelectedItems(filteredItems.map(item => item.id))
// Sadece protected olmayan öğeleri seç
const selectableItems = filteredItems.filter((item) => !item.isReadOnly)
setSelectedItems(selectableItems.map((item) => item.id))
}
const deselectAllItems = () => {
@ -372,120 +423,173 @@ const FileManager = () => {
}
const deleteSelectedItems = () => {
const itemsToDelete = filteredItems.filter(item => selectedItems.includes(item.id))
const deletableItems = itemsToDelete.filter(item => !item.isReadOnly)
const protectedItems = itemsToDelete.filter(item => item.isReadOnly)
const itemsToDelete = filteredItems.filter((item) => selectedItems.includes(item.id))
const deletableItems = itemsToDelete.filter((item) => !item.isReadOnly)
const protectedItems = itemsToDelete.filter((item) => item.isReadOnly)
if (protectedItems.length > 0) {
toast.push(
<Notification title="Warning" type="warning">
{protectedItems.length} protected system folder(s) cannot be deleted: {protectedItems.map(i => i.name).join(', ')}
</Notification>
{protectedItems.length} protected system folder(s) cannot be deleted:{' '}
{protectedItems.map((i) => i.name).join(', ')}
</Notification>,
)
}
if (deletableItems.length > 0) {
openDeleteModal(deletableItems)
// Remove protected items from selection
const deletableIds = deletableItems.map(item => item.id)
setSelectedItems(prev => prev.filter(id => deletableIds.includes(id)))
const deletableIds = deletableItems.map((item) => item.id)
setSelectedItems((prev) => prev.filter((id) => deletableIds.includes(id)))
}
}
const copySelectedItems = () => {
const itemsToCopy = filteredItems.filter(item => selectedItems.includes(item.id))
const copyableItems = itemsToCopy.filter(item => !item.isReadOnly)
const protectedItems = itemsToCopy.filter(item => item.isReadOnly)
const itemsToCopy = filteredItems.filter((item) => selectedItems.includes(item.id))
const copyableItems = itemsToCopy.filter((item) => !item.isReadOnly)
const protectedItems = itemsToCopy.filter((item) => item.isReadOnly)
if (protectedItems.length > 0) {
toast.push(
<Notification title="Warning" type="warning">
{protectedItems.length} protected system folder(s) cannot be copied: {protectedItems.map(i => i.name).join(', ')}
</Notification>
{protectedItems.length} protected system folder(s) cannot be copied:{' '}
{protectedItems.map((i) => i.name).join(', ')}
</Notification>,
)
}
if (copyableItems.length > 0) {
// Store in local storage or context for paste operation
localStorage.setItem('fileManager_clipboard', JSON.stringify({
operation: 'copy',
items: copyableItems,
sourceFolder: currentFolderId
}))
localStorage.setItem(
'fileManager_clipboard',
JSON.stringify({
operation: 'copy',
items: copyableItems,
sourceFolder: currentFolderId,
}),
)
setHasClipboardData(true)
toast.push(
<Notification title="Copied" type="success">
{copyableItems.length} item(s) copied to clipboard
</Notification>
</Notification>,
)
}
}
const cutSelectedItems = () => {
const itemsToCut = filteredItems.filter(item => selectedItems.includes(item.id))
const cuttableItems = itemsToCut.filter(item => !item.isReadOnly)
const protectedItems = itemsToCut.filter(item => item.isReadOnly)
const itemsToCut = filteredItems.filter((item) => selectedItems.includes(item.id))
const cuttableItems = itemsToCut.filter((item) => !item.isReadOnly)
const protectedItems = itemsToCut.filter((item) => item.isReadOnly)
if (protectedItems.length > 0) {
toast.push(
<Notification title="Warning" type="warning">
{protectedItems.length} protected system folder(s) cannot be moved: {protectedItems.map(i => i.name).join(', ')}
</Notification>
{protectedItems.length} protected system folder(s) cannot be moved:{' '}
{protectedItems.map((i) => i.name).join(', ')}
</Notification>,
)
}
if (cuttableItems.length > 0) {
// Store in local storage or context for paste operation
localStorage.setItem('fileManager_clipboard', JSON.stringify({
operation: 'cut',
items: cuttableItems,
sourceFolder: currentFolderId
}))
localStorage.setItem(
'fileManager_clipboard',
JSON.stringify({
operation: 'cut',
items: cuttableItems,
sourceFolder: currentFolderId,
}),
)
setHasClipboardData(true)
toast.push(
<Notification title="Cut" type="success">
{cuttableItems.length} item(s) cut to clipboard
</Notification>
</Notification>,
)
}
}
const pasteItems = () => {
const pasteItems = async () => {
const clipboardData = localStorage.getItem('fileManager_clipboard')
if (clipboardData) {
try {
const clipboard = JSON.parse(clipboardData)
if (clipboard.operation === 'copy') {
toast.push(
<Notification title="Copy" type="info">
Copy functionality will be implemented soon
</Notification>
)
} else if (clipboard.operation === 'cut') {
toast.push(
<Notification title="Move" type="info">
Move functionality will be implemented soon
</Notification>
)
}
} catch (error) {
toast.push(
<Notification title="Error" type="danger">
Invalid clipboard data
</Notification>
)
}
} else {
if (!clipboardData) {
toast.push(
<Notification title="Clipboard Empty" type="info">
No items in clipboard
</Notification>
</Notification>,
)
return
}
try {
const clipboard = JSON.parse(clipboardData)
const itemIds = clipboard.items.map((item: FileItemType) => item.id)
if (clipboard.operation === 'copy') {
setLoading(true)
try {
await fileManagementService.copyItems(itemIds, currentFolderId)
await fetchItems(currentFolderId)
toast.push(
<Notification title="Success" type="success">
{itemIds.length} item(s) copied successfully
</Notification>,
)
} catch (error) {
console.error('Copy failed:', error)
toast.push(
<Notification title="Error" type="danger">
Failed to copy items
</Notification>,
)
} finally {
setLoading(false)
}
} else if (clipboard.operation === 'cut') {
// Aynı klasörde move yapmaya çalışırsa engelleyelim
if (clipboard.sourceFolder === currentFolderId) {
toast.push(
<Notification title="Warning" type="warning">
Cannot move items to the same folder
</Notification>,
)
return
}
setLoading(true)
try {
await fileManagementService.moveItems(itemIds, currentFolderId)
await fetchItems(currentFolderId)
// Clipboard'ı temizle
localStorage.removeItem('fileManager_clipboard')
setHasClipboardData(false)
toast.push(
<Notification title="Success" type="success">
{itemIds.length} item(s) moved successfully
</Notification>,
)
} catch (error) {
console.error('Move failed:', error)
toast.push(
<Notification title="Error" type="danger">
Failed to move items
</Notification>,
)
} finally {
setLoading(false)
}
}
} catch (error) {
toast.push(
<Notification title="Error" type="danger">
Invalid clipboard data
</Notification>,
)
}
}
return (
<Container>
<Container className="px-3 sm:px-4 md:px-6">
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + 'App.Files')}
@ -493,126 +597,189 @@ const FileManager = () => {
></Helmet>
{/* Enhanced Unified Toolbar */}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 mt-2">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 sm:p-4 mb-4 mt-2">
{/* Main Toolbar Row */}
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4">
{/* Left Section - Primary Actions */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
{/* File Operations */}
<div className="flex items-center gap-2">
<Button
variant="solid"
icon={<FaCloudUploadAlt />}
onClick={() => setUploadModalOpen(true)}
size="sm"
>
Upload Files
</Button>
<Button
variant="default"
icon={<FaFolder />}
onClick={() => setCreateFolderModalOpen(true)}
size="sm"
>
Create Folder
</Button>
</div>
{/* Divider */}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
{/* Navigation */}
<Button
variant="plain"
icon={<FaArrowUp />}
onClick={goUpOneLevel}
size="sm"
disabled={breadcrumbItems.length <= 1}
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title="Go up one level"
/>
<Button
variant="solid"
icon={<FaCloudUploadAlt />}
onClick={() => setUploadModalOpen(true)}
size="sm"
className="flex-shrink-0"
>
<span className="hidden sm:inline">Upload Files</span>
<span className="sm:hidden">Upload</span>
</Button>
<Button
variant="default"
icon={<FaFolder />}
onClick={() => setCreateFolderModalOpen(true)}
size="sm"
className="flex-shrink-0"
>
<span className="hidden sm:inline">Create Folder</span>
<span className="sm:hidden">Create</span>
</Button>
{/* Clipboard Operations */}
<div className="flex items-center gap-1">
<Button
variant="plain"
icon={<FaCopy />}
onClick={copySelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-blue-600 disabled:opacity-50"
title="Copy selected items"
/>
<Button
variant="plain"
icon={<FaCut />}
onClick={cutSelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-orange-600 disabled:opacity-50"
title="Cut selected items"
/>
<Button
variant="plain"
onClick={pasteItems}
disabled={!hasClipboardData}
size="sm"
className="text-gray-600 hover:text-green-600 disabled:opacity-50"
title="Paste items"
>
Paste
</Button>
</div>
<Button
variant="plain"
icon={<FaCopy />}
onClick={copySelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-blue-600 disabled:opacity-50 flex-shrink-0"
title="Copy selected items"
/>
<Button
variant="plain"
icon={<FaCut />}
onClick={cutSelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-orange-600 disabled:opacity-50 flex-shrink-0"
title="Cut selected items"
/>
<Button
variant="plain"
icon={<FaPaste />}
onClick={pasteItems}
disabled={!hasClipboardData}
size="sm"
className="text-gray-600 hover:text-green-600 disabled:opacity-50 flex-shrink-0"
title="Paste items"
/>
<Button
variant="plain"
icon={<FaEdit />}
onClick={() => {
if (selectedItems.length === 1) {
const itemToRename = filteredItems.find((item) => item.id === selectedItems[0])
if (itemToRename && !itemToRename.isReadOnly) {
openRenameModal(itemToRename)
} else {
toast.push(
<Notification title="Warning" type="warning">
Protected system folders cannot be renamed
</Notification>,
)
}
}
}}
size="sm"
disabled={selectedItems.length !== 1}
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title="Rename selected item"
>
Rename
</Button>
{/* Divider */}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
<Button
variant="plain"
icon={<FaDownload />}
onClick={() => {
if (selectedItems.length === 1) {
const itemToDownload = filteredItems.find((item) => item.id === selectedItems[0])
if (itemToDownload && itemToDownload.type === 'file') {
handleDownload(itemToDownload)
} else {
toast.push(
<Notification title="Warning" type="warning">
Only files can be downloaded
</Notification>,
)
}
}
}}
size="sm"
disabled={
selectedItems.length !== 1 ||
(() => {
const selectedItem = filteredItems.find((item) => item.id === selectedItems[0])
return selectedItem?.type !== 'file'
})()
}
className="text-gray-600 hover:text-green-600 flex-shrink-0"
title="Download selected file"
>
Download
</Button>
<Button
variant="plain"
icon={<FaTrash />}
onClick={deleteSelectedItems}
size="sm"
disabled={selectedItems.length === 0}
className="text-gray-600 hover:text-red-600 flex-shrink-0"
title="Delete selected items"
>
<span>Delete {selectedItems.length > 0 && `(${selectedItems.length})`}</span>
</Button>
{/* Selection Actions */}
<div className="flex items-center gap-1">
{filteredItems.length > 0 && (
<Button
variant="plain"
icon={selectedItems.length === filteredItems.length ? <FaCheckSquare /> : <FaSquare />}
onClick={selectedItems.length === filteredItems.length ? deselectAllItems : selectAllItems}
size="sm"
className="text-gray-600 hover:text-blue-600"
title={selectedItems.length === filteredItems.length ? 'Deselect all items' : 'Select all items'}
>
{selectedItems.length === filteredItems.length ? 'Deselect All' : 'Select All'}
</Button>
)}
{selectedItems.length > 0 && (
<Button
variant="plain"
icon={<FaTrash />}
onClick={deleteSelectedItems}
size="sm"
className="text-gray-600 hover:text-red-600"
title="Delete selected items"
>
Delete ({selectedItems.length})
</Button>
)}
</div>
{/* Navigation */}
{breadcrumbItems.length > 1 && (
<>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
<Button
variant="plain"
icon={<FaArrowUp />}
onClick={goUpOneLevel}
size="sm"
className="text-gray-600 hover:text-blue-600"
title="Go up one level"
>
Go Up
</Button>
</>
{filteredItems.length > 0 && (
<Button
variant="plain"
icon={
selectedItems.length ===
filteredItems.filter((item) => !item.isReadOnly).length ? (
<FaCheckSquare />
) : (
<FaSquare />
)
}
onClick={
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? deselectAllItems
: selectAllItems
}
size="sm"
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title={
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? 'Deselect all selectable items'
: 'Select all selectable items'
}
>
<span className="hidden lg:inline">
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? 'Deselect All'
: 'Select All'}
</span>
<span className="lg:hidden">
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? 'Deselect'
: 'Select'}
</span>
</Button>
)}
</div>
{/* Right Section - Search, Sort, View */}
<div className="flex items-center gap-3 flex-wrap justify-end min-w-0">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap justify-start lg:justify-end min-w-0 w-full lg:w-auto">
{/* Search */}
<div className="flex items-center">
<div className="flex items-center w-full sm:w-auto">
<Input
size="sm"
placeholder="Search files..."
value={filters.searchTerm}
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
prefix={<FaSearch className="text-gray-400" />}
className="w-48"
className="w-full sm:w-36 md:w-48"
/>
</div>
@ -652,17 +819,17 @@ const FileManager = () => {
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
}
}}
className="min-w-36"
className="min-w-32 sm:min-w-36 flex-shrink-0"
/>
{/* View Mode */}
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
<div className="flex border border-gray-300 dark:border-gray-600 rounded flex-shrink-0">
<Button
variant="plain"
size="sm"
icon={<FaTh />}
className={classNames(
'rounded-r-none border-r',
'rounded-r-none border-r px-2 sm:px-3',
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
)}
onClick={() => setViewMode('grid')}
@ -673,7 +840,7 @@ const FileManager = () => {
size="sm"
icon={<FaList />}
className={classNames(
'rounded-l-none',
'rounded-l-none px-2 sm:px-3',
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
)}
onClick={() => setViewMode('list')}
@ -682,37 +849,13 @@ const FileManager = () => {
</div>
</div>
</div>
{/* Selection Status Bar - Show when items are selected */}
{selectedItems.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-blue-700 dark:text-blue-300 font-medium">
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected
</span>
<span className="text-xs text-gray-500">
({filteredItems.filter(item => selectedItems.includes(item.id)).length} of {filteredItems.length})
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="plain"
size="xs"
onClick={deselectAllItems}
className="text-gray-500 hover:text-gray-700"
>
Clear Selection
</Button>
</div>
</div>
</div>
)}
</div>
{/* Breadcrumb */}
<div className="mb-6">
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
<div className="mb-4 sm:mb-6 overflow-x-auto">
<div className="min-w-max">
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
</div>
</div>
{/* Files Grid/List */}
@ -724,12 +867,11 @@ const FileManager = () => {
<>
{/* List View Header */}
{viewMode === 'list' && (
<div className="grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 border-b dark:border-gray-700 mb-2">
<div className="col-span-1"></div> {/* Icon column */}
<div className="col-span-4">İsim</div>
<div className="col-span-2">Tür</div>
<div className="col-span-2">Boyut</div>
<div className="col-span-2">Değiştirilme</div>
<div className="hidden sm:grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 border-b dark:border-gray-700 mb-2">
<div className="col-span-5 lg:col-span-5">İsim</div>
<div className="col-span-2 lg:col-span-2">Tür</div>
<div className="col-span-2 lg:col-span-2">Boyut</div>
<div className="col-span-2 lg:col-span-2">Değiştirilme</div>
<div className="col-span-1"></div> {/* Actions column */}
</div>
)}
@ -737,52 +879,77 @@ const FileManager = () => {
<div
className={classNames(
viewMode === 'grid'
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4'
? 'grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-3 sm:gap-4'
: 'space-y-1',
)}
>
{filteredItems.length === 0 ? (
<div className="col-span-full text-center py-20">
<FaFolder className="mx-auto h-16 w-16 text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
</p>
</div>
) : (
filteredItems.map((item) => (
<FileItem
key={item.id}
item={item}
viewMode={viewMode}
selected={selectedItems.includes(item.id)}
onSelect={handleItemSelect}
onDoubleClick={handleItemDoubleClick}
onToggleSelect={handleItemSelect}
onCreateFolder={(parentItem) => {
// Klasör içinde yeni klasör oluşturmak için parent klasörü set et
setCurrentFolderId(parentItem.id)
setCreateFolderModalOpen(true)
}}
onRename={openRenameModal}
onMove={(item) => {
// Move işlevi henüz implement edilmedi
toast.push(<Notification type="info">Move özelliği yakında eklenecek</Notification>)
}}
onDelete={(item) => openDeleteModal([item])}
onDownload={item.type === 'file' ? handleDownload : undefined}
onPreview={item.type === 'file' ? (item) => {
// Preview işlevi - resimler için modal açabiliriz
if (item.mimeType?.startsWith('image/')) {
// Resim preview modal'ıılabilir
toast.push(<Notification type="info">Resim önizleme özelliği yakında eklenecek</Notification>)
} else {
// Diğer dosya tipleri için download
handleDownload(item)
{filteredItems.length === 0 ? (
<div className="col-span-full text-center py-12 sm:py-20">
<FaFolder className="mx-auto h-12 w-12 sm:h-16 sm:w-16 text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-sm sm:text-base px-4">
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
</p>
</div>
) : (
filteredItems.map((item) => (
<FileItem
key={item.id}
item={item}
viewMode={viewMode}
selected={selectedItems.includes(item.id)}
onSelect={handleItemSelect}
onDoubleClick={handleItemDoubleClick}
onToggleSelect={handleItemSelect}
onCreateFolder={(parentItem) => {
// Klasör içinde yeni klasör oluşturmak için parent klasörü set et
setCurrentFolderId(parentItem.id)
setCreateFolderModalOpen(true)
}}
onRename={openRenameModal}
onMove={(item) => {
// Move işlemi için öğeyi cut olarak clipboard'a koy
cutSelectedItems()
if (!selectedItems.includes(item.id)) {
setSelectedItems([item.id])
localStorage.setItem(
'fileManager_clipboard',
JSON.stringify({
operation: 'cut',
items: [item],
sourceFolder: currentFolderId,
}),
)
setHasClipboardData(true)
toast.push(
<Notification title="Cut" type="success">
Item ready to move. Navigate to target folder and paste.
</Notification>,
)
}
}}
onDelete={(item) => openDeleteModal([item])}
onDownload={item.type === 'file' ? handleDownload : undefined}
onPreview={
item.type === 'file'
? (item) => {
// Preview işlevi - resimler için modal açabiliriz
if (item.mimeType?.startsWith('image/')) {
// Resim preview modal'ıılabilir
toast.push(
<Notification type="info">
Resim önizleme özelliği yakında eklenecek
</Notification>,
)
} else {
// Diğer dosya tipleri için download
handleDownload(item)
}
}
: undefined
}
} : undefined}
/>
))
)}
/>
))
)}
</div>
</>
)}

View file

@ -23,7 +23,7 @@ export interface FileItemProps {
selected?: boolean
viewMode?: 'grid' | 'list'
onSelect?: (item: FileItemType) => void
onDoubleClick?: (item: FileItemType) => void
onDoubleClick?: (item: FileItemType, event?: React.MouseEvent) => void
onCreateFolder?: (parentItem: FileItemType) => void
onRename?: (item: FileItemType) => void
onMove?: (item: FileItemType) => void
@ -35,10 +35,14 @@ export interface FileItemProps {
}
const getFileIcon = (item: FileItemType, large: boolean = false) => {
const iconSize = large ? "h-12 w-12" : "h-8 w-8"
const iconSize = large ? 'h-12 w-12' : 'h-8 w-8'
if (item.type === 'folder') {
return <HiFolder className={`${iconSize} text-blue-500`} />
if (item.isReadOnly) {
return <HiFolder className={`${iconSize} text-gray-500`} />
} else {
return <HiFolder className={`${iconSize} text-blue-500`} />
}
}
const extension = item.extension?.toLowerCase()
@ -83,7 +87,7 @@ const formatDate = (date?: string | Date): string => {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
})
} catch {
return ''
@ -133,8 +137,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
onSelect?.(item)
}
const handleDoubleClick = () => {
onDoubleClick?.(item)
const handleDoubleClick = (e: React.MouseEvent) => {
onDoubleClick?.(item, e)
}
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -216,8 +220,6 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
: []),
]
const dropdownList = (
<div className="py-1 min-w-36">
{actionMenuItems.map((menuItem) => (
@ -225,16 +227,21 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
key={menuItem.key}
className={classNames(
'flex items-center px-2 py-1 text-xs cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
menuItem.dangerous && 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20',
menuItem.dangerous &&
'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20',
)}
onClick={() => {
menuItem.onClick()
setDropdownOpen(false)
}}
>
{menuItem.icon === 'HiFolderPlus' && <HiFolderPlus className="h-4 w-4 mr-3 text-blue-500" />}
{menuItem.icon === 'HiFolderPlus' && (
<HiFolderPlus className="h-4 w-4 mr-3 text-blue-500" />
)}
{menuItem.icon === 'HiEye' && <HiEye className="h-4 w-4 mr-3 text-gray-500" />}
{menuItem.icon === 'HiArrowDownTray' && <HiArrowDownTray className="h-4 w-4 mr-3 text-green-500" />}
{menuItem.icon === 'HiArrowDownTray' && (
<HiArrowDownTray className="h-4 w-4 mr-3 text-green-500" />
)}
{menuItem.icon === 'HiPencil' && <HiPencil className="h-4 w-4 mr-3 text-orange-500" />}
{menuItem.icon === 'HiArrowRightOnRectangle' && (
<HiArrowRightOnRectangle className="h-4 w-4 mr-3 text-purple-500" />
@ -273,7 +280,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
<div
ref={ref}
className={classNames(
'relative group grid grid-cols-12 gap-4 p-3 border rounded-lg cursor-pointer transition-all duration-200',
'relative group grid grid-cols-12 gap-4 p-2 border rounded-lg cursor-pointer transition-all duration-200 select-none',
'hover:border-blue-300 hover:bg-gray-50 dark:hover:bg-gray-700/50',
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
@ -283,52 +290,56 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{/* Checkbox */}
<div className="col-span-1 flex items-center">
<input
type="checkbox"
checked={selected}
onChange={handleCheckboxChange}
onClick={handleCheckboxClick}
disabled={item.isReadOnly}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* File Name */}
<div className="col-span-5 flex items-center min-w-0 gap-2">
{/* Checkbox */}
{!item.isReadOnly ? (
<input
type="checkbox"
checked={selected}
onChange={handleCheckboxChange}
onClick={handleCheckboxClick}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
) : (
<div className="w-4 h-4" /> // Boş alan bırak
)}
{/* File Icon or Preview */}
<div className="col-span-1 flex items-center">
{/* File Icon or Preview */}
<div className="w-8 h-8">
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
<ImagePreview src={`/api/app/file-management/${item.id}/download-file`} alt={item.name} />
<ImagePreview
src={`/api/app/file-management/${item.id}/download-file`}
alt={item.name}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{getFileIcon(item, false)}
</div>
)}
</div>
</div>
{/* File Name */}
<div className="col-span-3 flex items-center min-w-0 gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<p
className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={item.name}
>
{item.name}
</p>
{item.isReadOnly && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 flex-shrink-0">
Protected
<span className="hidden sm:inline">Protected</span>
<span className="sm:hidden">!</span>
</span>
)}
</div>
{/* File Type */}
<div className="col-span-2 flex items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">
{getFileTypeLabel(item)}
</span>
{/* File Type - Hidden on mobile */}
<div className="hidden sm:flex col-span-2 items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</span>
</div>
{/* File Size */}
<div className="col-span-2 flex items-center">
<div className="col-span-2 sm:col-span-2 flex items-center">
{item.type === 'file' && item.size ? (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formatFileSize(item.size)}
@ -338,40 +349,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
)}
</div>
{/* Modified Date */}
<div className="col-span-2 flex items-center">
{/* Modified Date - Hidden on mobile */}
<div className="hidden sm:flex col-span-2 items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">
{formatDate(item.modifiedAt)}
</span>
</div>
{/* Action Menu */}
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
{!item.isReadOnly && (
<>
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={(e) => {
e.stopPropagation()
setDropdownOpen(!dropdownOpen)
}}
>
<HiEllipsisVertical className="h-4 w-4" />
</button>
{dropdownOpen && actionMenuItems.length > 0 && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setDropdownOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
{dropdownList}
</div>
</>
)}
</>
)}
{/* Action Menu - Removed */}
<div className="col-span-1 sm:col-span-1 flex items-center justify-end">
</div>
</div>
)
@ -382,7 +368,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
<div
ref={ref}
className={classNames(
'relative group p-4 border rounded-xl cursor-pointer transition-all duration-300',
'relative group p-4 border rounded-xl cursor-pointer transition-all duration-300 select-none',
'hover:border-blue-400 hover:shadow-lg hover:-translate-y-1',
selected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-lg'
@ -393,76 +379,49 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
onDoubleClick={handleDoubleClick}
>
{/* Checkbox */}
<div className="absolute top-3 left-3 z-10">
<input
type="checkbox"
checked={selected}
onChange={handleCheckboxChange}
onClick={handleCheckboxClick}
disabled={item.isReadOnly}
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Action Menu */}
{!item.isReadOnly && (
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
<button
className="p-1.5 rounded-full bg-white dark:bg-gray-700 shadow-md hover:shadow-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-all duration-200"
onClick={(e) => {
e.stopPropagation()
setDropdownOpen(!dropdownOpen)
}}
>
<HiEllipsisVertical className="h-4 w-4 text-gray-600 dark:text-gray-300" />
</button>
{dropdownOpen && actionMenuItems.length > 0 && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setDropdownOpen(false)}
/>
<div className="absolute right-0 top-full mt-2 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
{dropdownList}
</div>
</>
)}
<div className="absolute top-3 left-3 z-10">
<input
type="checkbox"
checked={selected}
onChange={handleCheckboxChange}
onClick={handleCheckboxClick}
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 shadow-sm"
/>
</div>
)}
{/* File/Folder Icon or Preview */}
<div className="flex justify-center mb-3">
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
<div className="w-16 h-16">
<ImagePreview src={`/api/app/file-management/${item.id}/download-file`} alt={item.name} />
<ImagePreview
src={`/api/app/file-management/${item.id}/download-file`}
alt={item.name}
/>
</div>
) : (
<div className="flex justify-center">
{getFileIcon(item, true)}
</div>
<div className="flex justify-center">{getFileIcon(item, true)}</div>
)}
</div>
{/* File/Folder Name and Details */}
<div className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<p
className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors px-2"
title={item.name}
>
{item.name}
</p>
{item.isReadOnly && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
Protected
</span>
)}
</div>
{/* File Size and Type */}
{item.type === 'file' && (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">
{getFileTypeLabel(item)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{getFileTypeLabel(item)}</p>
{item.size && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(item.size)}

View file

@ -201,10 +201,9 @@ export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalP
return (
<Dialog isOpen={isOpen} onClose={onClose}>
<div ref={ref} className="max-w-md">
<div ref={ref}>
<div className="flex items-center justify-between pb-4 border-b">
<h3 className="text-lg font-semibold text-red-600">Delete Items</h3>
<Button variant="plain" size="sm" icon={<HiXMark />} onClick={onClose} />
</div>
<div className="py-6">
@ -212,7 +211,7 @@ export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalP
Are you sure you want to delete the following items? This action cannot be undone.
</p>
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-2">
{folderCount > 0 && (
<p className="text-sm text-gray-700 dark:text-gray-300">
{folderCount} folder{folderCount > 1 ? 's' : ''}

View file

@ -12,11 +12,13 @@ export interface FileUploadModalProps {
className?: string
}
interface UploadFileWithProgress extends File {
interface UploadFileWithProgress {
id: string
file: File
progress: number
status: 'pending' | 'uploading' | 'completed' | 'error'
error?: string
errorDetail?: string
}
const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props, ref) => {
@ -31,7 +33,9 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
const formatFileSize = (bytes: number): string => {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
if (bytes === 0) return '0 B'
// Handle undefined, null, NaN values
if (!bytes || bytes === 0 || isNaN(bytes)) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]
@ -40,8 +44,8 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
const handleFilesSelect = useCallback((files: FileList | File[]) => {
const fileArray = Array.from(files)
const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({
...file,
id: generateFileId(),
file: file,
progress: 0,
status: 'pending' as const,
}))
@ -87,41 +91,121 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
setUploading(true)
const filesToUpload = uploadFiles.filter((f) => f.status === 'pending')
try {
// Simulate upload progress for demo - replace with actual upload logic
for (const file of filesToUpload) {
// Upload files one by one
for (const fileData of filesToUpload) {
let progressInterval: NodeJS.Timeout | null = null
try {
// Set status to uploading
setUploadFiles((prev) =>
prev.map((f) => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)),
prev.map((f) => (f.id === fileData.id ? { ...f, status: 'uploading' as const } : f)),
)
// Simulate progress
for (let progress = 0; progress <= 100; progress += 10) {
await new Promise((resolve) => setTimeout(resolve, 100))
setUploadFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, progress } : f)))
// Simulate progress for visual feedback
progressInterval = setInterval(() => {
setUploadFiles((prev) =>
prev.map((f) => {
if (f.id === fileData.id && f.progress < 90) {
return { ...f, progress: f.progress + 10 }
}
return f
}),
)
}, 100)
// Call the actual upload function for single file
await onUpload([fileData.file])
// Clear progress interval
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
// Mark as completed and remove from list after delay
setUploadFiles((prev) =>
prev.map((f) => (f.id === file.id ? { ...f, status: 'completed' as const } : f)),
prev.map((f) =>
f.id === fileData.id ? { ...f, status: 'completed' as const, progress: 100 } : f,
),
)
// Remove completed files from list after 2 seconds
setTimeout(() => {
setUploadFiles((prev) => prev.filter((f) => f.id !== fileData.id))
}, 2000)
} catch (error: any) {
console.error('Upload failed for file:', fileData.file.name, error)
// Clear progress interval in case of error
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
// Extract detailed error message from ABP response
let errorMessage = 'Upload failed'
let detailMessage = ''
if (error?.response?.data?.error) {
const errorData = error.response.data.error
// Ana hata mesajı
if (errorData.message) {
errorMessage = errorData.message
}
// Detay mesajı - validationErrors veya details'den
if (errorData.details) {
detailMessage = errorData.details
} else if (errorData.validationErrors && errorData.validationErrors.length > 0) {
detailMessage = errorData.validationErrors[0].message || errorData.validationErrors[0]
}
// Dosya boyutu kontrolü için özel mesaj
if (detailMessage.includes('Request body too large') || detailMessage.includes('max request body size')) {
const maxSizeMB = 30 // 30MB limit
errorMessage = 'File too large'
detailMessage = `File size exceeds the maximum allowed size of ${maxSizeMB}MB. Your file is ${(fileData.file.size / (1024 * 1024)).toFixed(1)}MB.`
}
} else if (error?.message) {
errorMessage = error.message
} else if (typeof error === 'string') {
errorMessage = error
}
// Mark as error with detailed message
setUploadFiles((prev) =>
prev.map((f) =>
f.id === fileData.id
? {
...f,
status: 'error' as const,
error: errorMessage,
errorDetail: detailMessage,
progress: 0,
}
: f,
),
)
}
}
// Call the actual upload function
await onUpload(filesToUpload)
setUploading(false)
// Close modal after successful upload
setTimeout(() => {
onClose()
setUploadFiles([])
}, 1000)
} catch (error) {
console.error('Upload failed:', error)
setUploadFiles((prev) =>
prev.map((f) =>
f.status === 'uploading' ? { ...f, status: 'error' as const, error: 'Upload failed' } : f,
),
)
} finally {
setUploading(false)
// Check if all files are processed (completed or error)
const remainingFiles = uploadFiles.filter(
(f) => f.status === 'pending' || f.status === 'uploading',
)
if (remainingFiles.length === 0) {
// If no pending files and no errors, close modal
const hasErrors = uploadFiles.some((f) => f.status === 'error')
if (!hasErrors) {
setTimeout(() => {
onClose()
setUploadFiles([])
}, 2000)
}
}
}
@ -132,22 +216,31 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
}
}
const clearCompletedFiles = () => {
setUploadFiles((prev) => prev.filter((f) => f.status !== 'completed'))
}
const clearErrorFiles = () => {
setUploadFiles((prev) => prev.filter((f) => f.status !== 'error'))
}
const totalFiles = uploadFiles.length
const completedFiles = uploadFiles.filter((f) => f.status === 'completed').length
const hasError = uploadFiles.some((f) => f.status === 'error')
const errorFiles = uploadFiles.filter((f) => f.status === 'error').length
const pendingFiles = uploadFiles.filter((f) => f.status === 'pending').length
const hasError = errorFiles > 0
return (
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
<div ref={ref}>
<div className="flex items-center justify-between pb-4 border-b">
<div className="flex items-center justify-between pb-2 border-b">
<h3 className="text-lg font-semibold">Upload Files</h3>
</div>
<div className="py-6">
{/* Upload Area */}
<div className="py-2">
<div
className={classNames(
'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors',
'relative border-2 border-dashed rounded-lg p-4 text-center transition-colors',
isDragOver
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
@ -172,67 +265,66 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Select one or more files to upload
</p>
<Button
variant="solid"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
Select Files
</Button>
</div>
{/* File List */}
{uploadFiles.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
Files to upload ({totalFiles})
</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{uploadFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{file.name}
<div className="space-y-1 max-h-80 overflow-y-auto">
{uploadFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex-1 min-w-0">
{/* File name and size in one line */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate flex-1 mr-2">
{file.file.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(file.size)}
</p>
{/* Progress Bar */}
{file.status === 'uploading' && (
<div className="mt-2">
<Progress percent={file.progress} size="sm" />
</div>
)}
{/* Status Messages */}
{file.status === 'completed' && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
Upload completed
</p>
)}
{file.status === 'error' && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{file.error || 'Upload failed'}
</p>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
{formatFileSize(file.file.size)}
</span>
</div>
{file.status === 'pending' && (
<Button
variant="plain"
size="xs"
icon={<HiXMark />}
onClick={() => removeFile(file.id)}
disabled={uploading}
/>
{/* Progress Bar */}
{file.status === 'uploading' && (
<div className="mt-1">
<Progress percent={file.progress} size="sm" />
</div>
)}
{/* Status Messages */}
{file.status === 'completed' && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
Upload completed
</p>
)}
{file.status === 'error' && (
<div className="mt-1">
<p className="text-xs text-red-600 dark:text-red-400 font-medium">
{file.error || 'Upload failed'}
</p>
{file.errorDetail && (
<p className="text-xs text-red-500 dark:text-red-400 mt-0.5 leading-relaxed">
{file.errorDetail}
</p>
)}
</div>
)}
</div>
))}
</div>
{(file.status === 'pending' || file.status === 'error') && (
<Button
variant="plain"
size="xs"
icon={<HiXMark />}
onClick={() => removeFile(file.id)}
disabled={uploading}
className="ml-2 flex-shrink-0"
/>
)}
</div>
))}
</div>
)}
</div>
@ -242,26 +334,37 @@ const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props,
<div className="text-sm text-gray-500 dark:text-gray-400">
{uploading && (
<span>
Uploading {completedFiles}/{totalFiles} files...
Uploading files... {completedFiles > 0 && `(${completedFiles} completed)`}
</span>
)}
{!uploading && completedFiles > 0 && !hasError && (
<span className="text-green-600">All files uploaded successfully!</span>
{!uploading && !hasError && pendingFiles > 0 && (
<span className="text-green-600">Ready to upload {pendingFiles} file(s)</span>
)}
{!uploading && !hasError && pendingFiles === 0 && totalFiles === 0 && (
<span className="text-gray-500">No files selected</span>
)}
{hasError && <span className="text-red-600">Some files failed to upload</span>}
</div>
<div className="flex space-x-2">
{hasError && !uploading && (
<Button
variant="plain"
onClick={clearErrorFiles}
className="text-red-600 hover:text-red-700"
>
Clear Errors
</Button>
)}
<Button variant="default" onClick={handleClose} disabled={uploading}>
{uploading ? 'Uploading...' : 'Cancel'}
{uploading ? 'Uploading...' : 'Close'}
</Button>
<Button
variant="solid"
onClick={handleUpload}
disabled={uploadFiles.length === 0 || uploading || completedFiles === totalFiles}
disabled={pendingFiles === 0 || uploading}
loading={uploading}
>
Upload Files
{uploading ? 'Uploading...' : `Upload ${pendingFiles} File(s)`}
</Button>
</div>
</div>