diff --git a/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs b/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs index c5b5eb2e..2bbdc879 100644 --- a/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs +++ b/api/src/Kurs.Platform.Application.Contracts/FileManagement/FileManagementDtos.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.IO; +using System.Collections.Generic; namespace Kurs.Platform.FileManagement; @@ -45,4 +46,10 @@ public class SearchFilesDto public string Query { get; set; } = string.Empty; public string? ParentId { get; set; } +} + +public class BulkDeleteDto +{ + [Required] + public List ItemIds { get; set; } = new(); } \ No newline at end of file diff --git a/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs b/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs index 632c6b94..a3114473 100644 --- a/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs +++ b/api/src/Kurs.Platform.Application/FileManagement/FileManagementAppService.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; using Kurs.Platform.BlobStoring; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Volo.Abp; @@ -25,6 +26,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe private const string FileMetadataSuffix = ".metadata.json"; private const string FolderMarkerSuffix = ".folder"; private const string IndexFileName = "index.json"; + + // Protected system folders that cannot be deleted, renamed, or moved + private static readonly HashSet ProtectedFolders = new(StringComparer.OrdinalIgnoreCase) + { + BlobContainerNames.Avatar, + BlobContainerNames.Import, + BlobContainerNames.Activity + }; public FileManagementAppService( ICurrentTenant currentTenant, @@ -60,6 +69,36 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe return id.Replace("|", "/"); } + private bool IsProtectedFolder(string path) + { + // Get the root folder name from path + var pathParts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length == 0) return false; + + var rootFolder = pathParts[0]; + var isProtected = ProtectedFolders.Contains(rootFolder); + + Logger.LogInformation($"IsProtectedFolder - Path: '{path}', RootFolder: '{rootFolder}', IsProtected: {isProtected}"); + Logger.LogInformation($"Protected folders: {string.Join(", ", ProtectedFolders)}"); + + return isProtected; + } + + private void ValidateNotProtectedFolder(string id, string operation) + { + var decodedPath = DecodeIdAsPath(id); + Logger.LogInformation($"ValidateNotProtectedFolder - ID: {id}, DecodedPath: {decodedPath}, Operation: {operation}"); + + if (IsProtectedFolder(decodedPath)) + { + var folderName = decodedPath.Split('/')[0]; + Logger.LogWarning($"Blocked {operation} operation on protected folder: {folderName}"); + throw new UserFriendlyException($"Cannot {operation} system folder '{folderName}'. This folder is protected."); + } + + Logger.LogInformation($"Folder {decodedPath} is not protected, allowing {operation}"); + } + private async Task> GetFolderIndexAsync(string? parentId = null) { return await GetRealCdnContentsAsync(parentId); @@ -190,19 +229,27 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe { var items = await GetFolderIndexAsync(parentId); - var result = items.Select(metadata => new FileItemDto - { - Id = metadata.Id, - Name = metadata.Name, - Type = metadata.Type, - Size = metadata.Size, - Extension = metadata.Extension, - MimeType = metadata.MimeType, - CreatedAt = metadata.CreatedAt, - ModifiedAt = metadata.ModifiedAt, - Path = metadata.Path, - ParentId = parentId ?? string.Empty, - IsReadOnly = metadata.IsReadOnly + var result = items.Select(metadata => { + var isRootLevel = string.IsNullOrEmpty(parentId); + var isProtected = IsProtectedFolder(metadata.Path); + var finalIsReadOnly = metadata.IsReadOnly || (isRootLevel && isProtected); + + Logger.LogInformation($"Item: {metadata.Name}, Path: {metadata.Path}, IsRootLevel: {isRootLevel}, IsProtected: {isProtected}, FinalIsReadOnly: {finalIsReadOnly}"); + + return new FileItemDto + { + Id = metadata.Id, + Name = metadata.Name, + Type = metadata.Type, + Size = metadata.Size, + Extension = metadata.Extension, + MimeType = metadata.MimeType, + CreatedAt = metadata.CreatedAt, + ModifiedAt = metadata.ModifiedAt, + Path = metadata.Path, + ParentId = parentId ?? string.Empty, + IsReadOnly = finalIsReadOnly + }; }).OrderBy(x => x.Type == "folder" ? 0 : 1).ThenBy(x => x.Name).ToList(); return new GetFilesDto { Items = result }; @@ -373,6 +420,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe public async Task RenameItemAsync(string id, RenameItemDto input) { + // Check if this is a protected system folder + ValidateNotProtectedFolder(id, "rename"); + ValidateFileName(input.Name); var metadata = await FindItemMetadataAsync(id); @@ -499,6 +549,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe public async Task DeleteItemAsync(string id) { + // Check if this is a protected system folder + ValidateNotProtectedFolder(id, "delete"); + var cdnBasePath = _configuration["App:CdnPath"]; if (string.IsNullOrEmpty(cdnBasePath)) { @@ -525,6 +578,59 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } } + public async Task BulkDeleteItemsAsync(BulkDeleteDto input) + { + if (input.ItemIds == null || !input.ItemIds.Any()) + { + throw new UserFriendlyException("No items selected for deletion"); + } + + var cdnBasePath = _configuration["App:CdnPath"]; + if (string.IsNullOrEmpty(cdnBasePath)) + { + throw new UserFriendlyException("CDN path is not configured"); + } + + var tenantId = _currentTenant.Id?.ToString() ?? "host"; + var errors = new List(); + + foreach (var itemId in input.ItemIds) + { + try + { + // Check if this is a protected system folder + ValidateNotProtectedFolder(itemId, "delete"); + + var actualPath = DecodeIdAsPath(itemId); + var fullPath = Path.Combine(cdnBasePath, tenantId, actualPath); + + if (Directory.Exists(fullPath)) + { + // Klasör sil (içindeki tüm dosyalar ile birlikte) + Directory.Delete(fullPath, recursive: true); + } + else if (File.Exists(fullPath)) + { + // Dosya sil + File.Delete(fullPath); + } + else + { + errors.Add($"Item not found: {itemId}"); + } + } + catch (Exception ex) + { + errors.Add($"Failed to delete {itemId}: {ex.Message}"); + } + } + + if (errors.Any()) + { + throw new UserFriendlyException($"Some items could not be deleted: {string.Join(", ", errors)}"); + } + } + public async Task DownloadFileAsync(string id) { var cdnBasePath = _configuration["App:CdnPath"]; diff --git a/ui/src/services/fileManagement.service.ts b/ui/src/services/fileManagement.service.ts index a67a4eae..9d0adf5c 100644 --- a/ui/src/services/fileManagement.service.ts +++ b/ui/src/services/fileManagement.service.ts @@ -122,6 +122,15 @@ class FileManagementService { method: 'GET', }) } + + // Bulk delete items + async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> { + return ApiService.fetchData({ + url: `/api/app/file-management/bulk-delete`, + method: 'POST', + data: { itemIds }, + }) + } } export default new FileManagementService() \ No newline at end of file diff --git a/ui/src/views/admin/files/FileManager.tsx b/ui/src/views/admin/files/FileManager.tsx index 0773d51e..be4d8095 100644 --- a/ui/src/views/admin/files/FileManager.tsx +++ b/ui/src/views/admin/files/FileManager.tsx @@ -1,7 +1,7 @@ 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 } from 'react-icons/fa' +import { FaFolder, FaCloudUploadAlt, FaSearch, FaTh, FaList, FaArrowUp, FaCheckSquare, FaSquare, FaTrash, FaCut, FaCopy } from 'react-icons/fa' import Container from '@/components/shared/Container' import { useLocalization } from '@/utils/hooks/useLocalization' import fileManagementService from '@/services/fileManagement.service' @@ -60,7 +60,19 @@ const FileManager = () => { setLoading(true) const response = await fileManagementService.getItems(folderId) // Backend returns GetFilesDto which has Items property - setItems(response.data.items || []) + const items = response.data.items || [] + // Manual protection for system folders + const protectedItems = items.map(item => { + const isSystemFolder = ['avatar', 'import', 'activity'].includes(item.name.toLowerCase()) + return { + ...item, + isReadOnly: item.isReadOnly || isSystemFolder + } + }) + + console.log('Fetched items:', protectedItems) + console.log('Protected folders check:', protectedItems.filter(item => item.isReadOnly)) + setItems(protectedItems) } catch (error) { console.error('Failed to fetch items:', error) toast.push(Failed to load files and folders) @@ -227,9 +239,16 @@ const FileManager = () => { const handleDeleteItems = async () => { try { setDeleting(true) - for (const item of itemsToDelete) { - await fileManagementService.deleteItem({ id: item.id }) + + if (itemsToDelete.length === 1) { + // Single item delete - use existing API + await fileManagementService.deleteItem({ id: itemsToDelete[0].id }) + } else { + // Multiple items - use bulk delete API + const itemIds = itemsToDelete.map(item => item.id) + await fileManagementService.bulkDeleteItems(itemIds) } + await fetchItems(currentFolderId) setSelectedItems([]) toast.push(Items deleted successfully) @@ -277,6 +296,194 @@ const FileManager = () => { } } + // Clipboard state for paste button + const [hasClipboardData, setHasClipboardData] = useState(false) + + useEffect(() => { + const checkClipboard = () => { + setHasClipboardData(!!localStorage.getItem('fileManager_clipboard')) + } + + // Check initially + checkClipboard() + + // Check periodically (in case another tab changes it) + const interval = setInterval(checkClipboard, 1000) + + return () => clearInterval(interval) + }, []) + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key) { + case 'a': + e.preventDefault() + selectAllItems() + break + case 'c': + e.preventDefault() + if (selectedItems.length > 0) { + copySelectedItems() + setHasClipboardData(true) + } + break + case 'x': + e.preventDefault() + if (selectedItems.length > 0) { + cutSelectedItems() + setHasClipboardData(true) + } + break + case 'v': + e.preventDefault() + pasteItems() + break + case 'Delete': + case 'Backspace': + e.preventDefault() + if (selectedItems.length > 0) { + deleteSelectedItems() + } + break + } + } else if (e.key === 'Delete') { + e.preventDefault() + if (selectedItems.length > 0) { + deleteSelectedItems() + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }, [selectedItems, filteredItems]) + + // Bulk operations + const selectAllItems = () => { + setSelectedItems(filteredItems.map(item => item.id)) + } + + const deselectAllItems = () => { + setSelectedItems([]) + } + + 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) + + if (protectedItems.length > 0) { + toast.push( + + {protectedItems.length} protected system folder(s) cannot be deleted: {protectedItems.map(i => i.name).join(', ')} + + ) + } + + if (deletableItems.length > 0) { + openDeleteModal(deletableItems) + // Remove protected items from selection + const deletableIds = deletableItems.map(item => item.id) + setSelectedItems(prev => prev.filter(id => deletableIds.includes(id))) + } + } + + const copySelectedItems = () => { + const itemsToCopy = filteredItems.filter(item => selectedItems.includes(item.id)) + const copyableItems = itemsToCopy.filter(item => !item.isReadOnly) + const protectedItems = itemsToCopy.filter(item => item.isReadOnly) + + if (protectedItems.length > 0) { + toast.push( + + {protectedItems.length} protected system folder(s) cannot be copied: {protectedItems.map(i => i.name).join(', ')} + + ) + } + + 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 + })) + setHasClipboardData(true) + toast.push( + + {copyableItems.length} item(s) copied to clipboard + + ) + } + } + + const cutSelectedItems = () => { + const itemsToCut = filteredItems.filter(item => selectedItems.includes(item.id)) + const cuttableItems = itemsToCut.filter(item => !item.isReadOnly) + const protectedItems = itemsToCut.filter(item => item.isReadOnly) + + if (protectedItems.length > 0) { + toast.push( + + {protectedItems.length} protected system folder(s) cannot be moved: {protectedItems.map(i => i.name).join(', ')} + + ) + } + + if (cuttableItems.length > 0) { + // Store in local storage or context for paste operation + localStorage.setItem('fileManager_clipboard', JSON.stringify({ + operation: 'cut', + items: cuttableItems, + sourceFolder: currentFolderId + })) + setHasClipboardData(true) + toast.push( + + {cuttableItems.length} item(s) cut to clipboard + + ) + } + } + + const pasteItems = () => { + const clipboardData = localStorage.getItem('fileManager_clipboard') + if (clipboardData) { + try { + const clipboard = JSON.parse(clipboardData) + if (clipboard.operation === 'copy') { + toast.push( + + Copy functionality will be implemented soon + + ) + } else if (clipboard.operation === 'cut') { + toast.push( + + Move functionality will be implemented soon + + ) + } + } catch (error) { + toast.push( + + Invalid clipboard data + + ) + } + } else { + toast.push( + + No items in clipboard + + ) + } + } + return ( { defaultTitle="Sözsoft Kurs Platform" > - {/* Toolbar */} -
-
- - - {breadcrumbItems.length > 1 && ( - - )} -
+ {/* Enhanced Unified Toolbar */} +
+ {/* Main Toolbar Row */} +
+ {/* Left Section - Primary Actions */} +
+ {/* File Operations */} +
+ + +
-
- {/* Search */} -
- setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))} - prefix={} - className="w-64" - /> + {/* Divider */} +
+ + {/* Clipboard Operations */} +
+ +
+ + {/* Divider */} +
+ + {/* Selection Actions */} +
+ {filteredItems.length > 0 && ( + + )} + {selectedItems.length > 0 && ( + + )} +
+ + {/* Navigation */} + {breadcrumbItems.length > 1 && ( + <> +
+ + + )}
- {/* Sort */} - setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))} + prefix={} + className="w-48" + /> +
+ + {/* Sort */} + +
+ {/* File Icon or Preview */}
@@ -290,10 +309,15 @@ const FileItem = forwardRef((props, ref) => {
{/* File Name */} -
+

{item.name}

+ {item.isReadOnly && ( + + Protected + + )}
{/* File Type */} @@ -323,25 +347,29 @@ const FileItem = forwardRef((props, ref) => { {/* Action Menu */}
- - - {dropdownOpen && actionMenuItems.length > 0 && ( + {!item.isReadOnly && ( <> -
setDropdownOpen(false)} - /> -
- {dropdownList} -
+ + + {dropdownOpen && actionMenuItems.length > 0 && ( + <> +
setDropdownOpen(false)} + /> +
+ {dropdownList} +
+ + )} )}
@@ -364,31 +392,45 @@ const FileItem = forwardRef((props, ref) => { onClick={handleClick} onDoubleClick={handleDoubleClick} > - {/* Action Menu */} -
- - - {dropdownOpen && actionMenuItems.length > 0 && ( - <> -
setDropdownOpen(false)} - /> -
- {dropdownList} -
- - )} + {/* Checkbox */} +
+
+ {/* Action Menu */} + {!item.isReadOnly && ( +
+ + + {dropdownOpen && actionMenuItems.length > 0 && ( + <> +
setDropdownOpen(false)} + /> +
+ {dropdownList} +
+ + )} +
+ )} + {/* File/Folder Icon or Preview */}
{item.type === 'file' && item.mimeType?.startsWith('image/') ? ( @@ -404,9 +446,16 @@ const FileItem = forwardRef((props, ref) => { {/* File/Folder Name and Details */}
-

- {item.name} -

+
+

+ {item.name} +

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