Dosya Yöneticisi Korumalı klasör yapısı
This commit is contained in:
parent
79bd897a0b
commit
730430ac93
5 changed files with 661 additions and 165 deletions
|
|
@ -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<string> ItemIds { get; set; } = new();
|
||||
}
|
||||
|
|
@ -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<string> 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<List<FileMetadata>> 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<FileItemDto> 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<string>();
|
||||
|
||||
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<Stream> DownloadFileAsync(string id)
|
||||
{
|
||||
var cdnBasePath = _configuration["App:CdnPath"];
|
||||
|
|
|
|||
|
|
@ -122,6 +122,15 @@ class FileManagementService {
|
|||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk delete items
|
||||
async bulkDeleteItems(itemIds: string[]): Promise<{ data: any }> {
|
||||
return ApiService.fetchData<any>({
|
||||
url: `/api/app/file-management/bulk-delete`,
|
||||
method: 'POST',
|
||||
data: { itemIds },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new FileManagementService()
|
||||
|
|
@ -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(<Notification type="danger">Failed to load files and folders</Notification>)
|
||||
|
|
@ -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(<Notification type="success">Items deleted successfully</Notification>)
|
||||
|
|
@ -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(
|
||||
<Notification title="Warning" type="warning">
|
||||
{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 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(
|
||||
<Notification title="Warning" type="warning">
|
||||
{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
|
||||
}))
|
||||
setHasClipboardData(true)
|
||||
toast.push(
|
||||
<Notification title="Copied" type="success">
|
||||
{copyableItems.length} item(s) copied to clipboard
|
||||
</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)
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<Notification title="Cut" type="success">
|
||||
{cuttableItems.length} item(s) cut to clipboard
|
||||
</Notification>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pasteItems = () => {
|
||||
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 {
|
||||
toast.push(
|
||||
<Notification title="Clipboard Empty" type="info">
|
||||
No items in clipboard
|
||||
</Notification>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Helmet
|
||||
|
|
@ -285,105 +492,222 @@ const FileManager = () => {
|
|||
defaultTitle="Sözsoft Kurs Platform"
|
||||
></Helmet>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-4 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="solid"
|
||||
icon={<FaCloudUploadAlt />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
Upload Files
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<FaFolder />}
|
||||
onClick={() => setCreateFolderModalOpen(true)}
|
||||
>
|
||||
Create Folder
|
||||
</Button>
|
||||
{breadcrumbItems.length > 1 && (
|
||||
<Button variant="default" icon={<FaArrowUp />} onClick={goUpOneLevel}>
|
||||
Go Up
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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">
|
||||
{/* Main Toolbar Row */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between 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>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex items-center">
|
||||
<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-64"
|
||||
/>
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'size-asc', label: 'Size (Small to Large)' },
|
||||
{ value: 'size-desc', label: 'Size (Large to Small)' },
|
||||
{ value: 'modified-desc', label: 'Modified (Newest)' },
|
||||
{ value: 'modified-asc', label: 'Modified (Oldest)' },
|
||||
]}
|
||||
value={{
|
||||
value: `${filters.sortBy}-${filters.sortOrder}`,
|
||||
label: (() => {
|
||||
const sortOptions = {
|
||||
'name-asc': 'Name (A-Z)',
|
||||
'name-desc': 'Name (Z-A)',
|
||||
'size-asc': 'Size (Small to Large)',
|
||||
'size-desc': 'Size (Large to Small)',
|
||||
'modified-desc': 'Modified (Newest)',
|
||||
'modified-asc': 'Modified (Oldest)',
|
||||
{/* Right Section - Search, Sort, View */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end min-w-0">
|
||||
{/* Search */}
|
||||
<div className="flex items-center">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Select
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||
{ value: 'size-asc', label: 'Size (Small to Large)' },
|
||||
{ value: 'size-desc', label: 'Size (Large to Small)' },
|
||||
{ value: 'modified-desc', label: 'Modified (Newest)' },
|
||||
{ value: 'modified-asc', label: 'Modified (Oldest)' },
|
||||
]}
|
||||
value={{
|
||||
value: `${filters.sortBy}-${filters.sortOrder}`,
|
||||
label: (() => {
|
||||
const sortOptions = {
|
||||
'name-asc': 'Name (A-Z)',
|
||||
'name-desc': 'Name (Z-A)',
|
||||
'size-asc': 'Size (Small to Large)',
|
||||
'size-desc': 'Size (Large to Small)',
|
||||
'modified-desc': 'Modified (Newest)',
|
||||
'modified-asc': 'Modified (Oldest)',
|
||||
}
|
||||
return sortOptions[
|
||||
`${filters.sortBy}-${filters.sortOrder}` as keyof typeof sortOptions
|
||||
]
|
||||
})(),
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (option && 'value' in option) {
|
||||
const [sortBy, sortOrder] = (option.value as string).split('-') as [
|
||||
SortBy,
|
||||
SortOrder,
|
||||
]
|
||||
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
|
||||
}
|
||||
return sortOptions[
|
||||
`${filters.sortBy}-${filters.sortOrder}` as keyof typeof sortOptions
|
||||
]
|
||||
})(),
|
||||
}}
|
||||
onChange={(option) => {
|
||||
if (option && 'value' in option) {
|
||||
const [sortBy, sortOrder] = (option.value as string).split('-') as [
|
||||
SortBy,
|
||||
SortOrder,
|
||||
]
|
||||
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
className="min-w-36"
|
||||
/>
|
||||
|
||||
{/* View Mode */}
|
||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
icon={<FaTh />}
|
||||
className={classNames(
|
||||
'rounded-r-none border-r',
|
||||
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
icon={<FaList />}
|
||||
className={classNames(
|
||||
'rounded-l-none',
|
||||
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
/>
|
||||
{/* View Mode */}
|
||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
icon={<FaTh />}
|
||||
className={classNames(
|
||||
'rounded-r-none border-r',
|
||||
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Grid view"
|
||||
/>
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
icon={<FaList />}
|
||||
className={classNames(
|
||||
'rounded-l-none',
|
||||
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||
)}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List view"
|
||||
/>
|
||||
</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 */}
|
||||
|
|
@ -433,6 +757,7 @@ const FileManager = () => {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface FileItemProps {
|
|||
onDelete?: (item: FileItemType) => void
|
||||
onDownload?: (item: FileItemType) => void
|
||||
onPreview?: (item: FileItemType) => void
|
||||
onToggleSelect?: (item: FileItemType) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +123,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onDelete,
|
||||
onDownload,
|
||||
onPreview,
|
||||
onToggleSelect,
|
||||
className,
|
||||
} = props
|
||||
|
||||
|
|
@ -135,6 +137,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onDoubleClick?.(item)
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation() // Prevent item selection when clicking checkbox
|
||||
onToggleSelect?.(item)
|
||||
}
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent item selection when clicking checkbox
|
||||
}
|
||||
|
||||
const actionMenuItems: FileActionMenuItem[] = [
|
||||
// Preview - sadece dosyalar için
|
||||
...(item.type === 'file' && onPreview
|
||||
|
|
@ -169,8 +180,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
},
|
||||
]
|
||||
: []),
|
||||
// Rename - her şey için
|
||||
...(onRename
|
||||
// Rename - sadece read-only olmayanlar için
|
||||
...(onRename && !item.isReadOnly
|
||||
? [
|
||||
{
|
||||
key: 'rename',
|
||||
|
|
@ -180,8 +191,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
},
|
||||
]
|
||||
: []),
|
||||
// Move - her şey için
|
||||
...(onMove
|
||||
// Move - sadece read-only olmayanlar için
|
||||
...(onMove && !item.isReadOnly
|
||||
? [
|
||||
{
|
||||
key: 'move',
|
||||
|
|
@ -191,8 +202,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
},
|
||||
]
|
||||
: []),
|
||||
// Delete - her şey için
|
||||
...(onDelete
|
||||
// Delete - sadece read-only olmayanlar için
|
||||
...(onDelete && !item.isReadOnly
|
||||
? [
|
||||
{
|
||||
key: 'delete',
|
||||
|
|
@ -205,11 +216,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
: []),
|
||||
]
|
||||
|
||||
// Debug için
|
||||
useEffect(() => {
|
||||
console.log('Action menu items for', item.name, ':', actionMenuItems)
|
||||
console.log('Props:', { onCreateFolder: !!onCreateFolder, onRename: !!onRename, onMove: !!onMove, onDelete: !!onDelete })
|
||||
}, [actionMenuItems, item.name])
|
||||
|
||||
|
||||
const dropdownList = (
|
||||
<div className="py-1 min-w-36">
|
||||
|
|
@ -276,6 +283,18 @@ 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 Icon or Preview */}
|
||||
<div className="col-span-1 flex items-center">
|
||||
<div className="w-8 h-8">
|
||||
|
|
@ -290,10 +309,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
</div>
|
||||
|
||||
{/* File Name */}
|
||||
<div className="col-span-4 flex items-center min-w-0">
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Type */}
|
||||
|
|
@ -323,25 +347,29 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
|
||||
{/* Action Menu */}
|
||||
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
|
||||
<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 && (
|
||||
{!item.isReadOnly && (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -364,31 +392,45 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Action Menu */}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* File/Folder Icon or Preview */}
|
||||
<div className="flex justify-center mb-3">
|
||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
||||
|
|
@ -404,9 +446,16 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
|||
|
||||
{/* File/Folder Name and Details */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1">
|
||||
{item.name}
|
||||
</p>
|
||||
<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">
|
||||
{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' && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue