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.ComponentModel.DataAnnotations;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Kurs.Platform.FileManagement;
|
namespace Kurs.Platform.FileManagement;
|
||||||
|
|
||||||
|
|
@ -46,3 +47,9 @@ public class SearchFilesDto
|
||||||
|
|
||||||
public string? ParentId { get; set; }
|
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.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Kurs.Platform.BlobStoring;
|
using Kurs.Platform.BlobStoring;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
|
|
@ -26,6 +27,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
private const string FolderMarkerSuffix = ".folder";
|
private const string FolderMarkerSuffix = ".folder";
|
||||||
private const string IndexFileName = "index.json";
|
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(
|
public FileManagementAppService(
|
||||||
ICurrentTenant currentTenant,
|
ICurrentTenant currentTenant,
|
||||||
BlobManager blobContainer,
|
BlobManager blobContainer,
|
||||||
|
|
@ -60,6 +69,36 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
return id.Replace("|", "/");
|
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)
|
private async Task<List<FileMetadata>> GetFolderIndexAsync(string? parentId = null)
|
||||||
{
|
{
|
||||||
return await GetRealCdnContentsAsync(parentId);
|
return await GetRealCdnContentsAsync(parentId);
|
||||||
|
|
@ -190,19 +229,27 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
{
|
{
|
||||||
var items = await GetFolderIndexAsync(parentId);
|
var items = await GetFolderIndexAsync(parentId);
|
||||||
|
|
||||||
var result = items.Select(metadata => new FileItemDto
|
var result = items.Select(metadata => {
|
||||||
{
|
var isRootLevel = string.IsNullOrEmpty(parentId);
|
||||||
Id = metadata.Id,
|
var isProtected = IsProtectedFolder(metadata.Path);
|
||||||
Name = metadata.Name,
|
var finalIsReadOnly = metadata.IsReadOnly || (isRootLevel && isProtected);
|
||||||
Type = metadata.Type,
|
|
||||||
Size = metadata.Size,
|
Logger.LogInformation($"Item: {metadata.Name}, Path: {metadata.Path}, IsRootLevel: {isRootLevel}, IsProtected: {isProtected}, FinalIsReadOnly: {finalIsReadOnly}");
|
||||||
Extension = metadata.Extension,
|
|
||||||
MimeType = metadata.MimeType,
|
return new FileItemDto
|
||||||
CreatedAt = metadata.CreatedAt,
|
{
|
||||||
ModifiedAt = metadata.ModifiedAt,
|
Id = metadata.Id,
|
||||||
Path = metadata.Path,
|
Name = metadata.Name,
|
||||||
ParentId = parentId ?? string.Empty,
|
Type = metadata.Type,
|
||||||
IsReadOnly = metadata.IsReadOnly
|
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();
|
}).OrderBy(x => x.Type == "folder" ? 0 : 1).ThenBy(x => x.Name).ToList();
|
||||||
|
|
||||||
return new GetFilesDto { Items = result };
|
return new GetFilesDto { Items = result };
|
||||||
|
|
@ -373,6 +420,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
public async Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input)
|
public async Task<FileItemDto> RenameItemAsync(string id, RenameItemDto input)
|
||||||
{
|
{
|
||||||
|
// Check if this is a protected system folder
|
||||||
|
ValidateNotProtectedFolder(id, "rename");
|
||||||
|
|
||||||
ValidateFileName(input.Name);
|
ValidateFileName(input.Name);
|
||||||
|
|
||||||
var metadata = await FindItemMetadataAsync(id);
|
var metadata = await FindItemMetadataAsync(id);
|
||||||
|
|
@ -499,6 +549,9 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
|
||||||
|
|
||||||
public async Task DeleteItemAsync(string id)
|
public async Task DeleteItemAsync(string id)
|
||||||
{
|
{
|
||||||
|
// Check if this is a protected system folder
|
||||||
|
ValidateNotProtectedFolder(id, "delete");
|
||||||
|
|
||||||
var cdnBasePath = _configuration["App:CdnPath"];
|
var cdnBasePath = _configuration["App:CdnPath"];
|
||||||
if (string.IsNullOrEmpty(cdnBasePath))
|
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)
|
public async Task<Stream> DownloadFileAsync(string id)
|
||||||
{
|
{
|
||||||
var cdnBasePath = _configuration["App:CdnPath"];
|
var cdnBasePath = _configuration["App:CdnPath"];
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,15 @@ class FileManagementService {
|
||||||
method: 'GET',
|
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()
|
export default new FileManagementService()
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui'
|
import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui'
|
||||||
import { FaFolder, FaCloudUploadAlt, FaSearch, FaTh, FaList, FaArrowUp } 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 Container from '@/components/shared/Container'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import fileManagementService from '@/services/fileManagement.service'
|
import fileManagementService from '@/services/fileManagement.service'
|
||||||
|
|
@ -60,7 +60,19 @@ const FileManager = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fileManagementService.getItems(folderId)
|
const response = await fileManagementService.getItems(folderId)
|
||||||
// Backend returns GetFilesDto which has Items property
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch items:', error)
|
console.error('Failed to fetch items:', error)
|
||||||
toast.push(<Notification type="danger">Failed to load files and folders</Notification>)
|
toast.push(<Notification type="danger">Failed to load files and folders</Notification>)
|
||||||
|
|
@ -227,9 +239,16 @@ const FileManager = () => {
|
||||||
const handleDeleteItems = async () => {
|
const handleDeleteItems = async () => {
|
||||||
try {
|
try {
|
||||||
setDeleting(true)
|
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)
|
await fetchItems(currentFolderId)
|
||||||
setSelectedItems([])
|
setSelectedItems([])
|
||||||
toast.push(<Notification type="success">Items deleted successfully</Notification>)
|
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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Helmet
|
<Helmet
|
||||||
|
|
@ -285,105 +492,222 @@ const FileManager = () => {
|
||||||
defaultTitle="Sözsoft Kurs Platform"
|
defaultTitle="Sözsoft Kurs Platform"
|
||||||
></Helmet>
|
></Helmet>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Enhanced Unified Toolbar */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-4 mt-2">
|
<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="flex items-center gap-2">
|
{/* Main Toolbar Row */}
|
||||||
<Button
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||||
variant="solid"
|
{/* Left Section - Primary Actions */}
|
||||||
icon={<FaCloudUploadAlt />}
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
onClick={() => setUploadModalOpen(true)}
|
{/* File Operations */}
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Upload Files
|
<Button
|
||||||
</Button>
|
variant="solid"
|
||||||
<Button
|
icon={<FaCloudUploadAlt />}
|
||||||
variant="default"
|
onClick={() => setUploadModalOpen(true)}
|
||||||
icon={<FaFolder />}
|
size="sm"
|
||||||
onClick={() => setCreateFolderModalOpen(true)}
|
>
|
||||||
>
|
Upload Files
|
||||||
Create Folder
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
{breadcrumbItems.length > 1 && (
|
variant="default"
|
||||||
<Button variant="default" icon={<FaArrowUp />} onClick={goUpOneLevel}>
|
icon={<FaFolder />}
|
||||||
Go Up
|
onClick={() => setCreateFolderModalOpen(true)}
|
||||||
</Button>
|
size="sm"
|
||||||
)}
|
>
|
||||||
</div>
|
Create Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{/* Divider */}
|
||||||
{/* Search */}
|
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600" />
|
||||||
<div className="flex items-center">
|
|
||||||
<Input
|
{/* Clipboard Operations */}
|
||||||
size="sm"
|
<div className="flex items-center gap-1">
|
||||||
placeholder="Search files..."
|
<Button
|
||||||
value={filters.searchTerm}
|
variant="plain"
|
||||||
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
|
icon={<FaCopy />}
|
||||||
prefix={<FaSearch className="text-gray-400" />}
|
onClick={copySelectedItems}
|
||||||
className="w-64"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Right Section - Search, Sort, View */}
|
||||||
<Select
|
<div className="flex items-center gap-3 flex-wrap justify-end min-w-0">
|
||||||
size="sm"
|
{/* Search */}
|
||||||
options={[
|
<div className="flex items-center">
|
||||||
{ value: 'name-asc', label: 'Name (A-Z)' },
|
<Input
|
||||||
{ value: 'name-desc', label: 'Name (Z-A)' },
|
size="sm"
|
||||||
{ value: 'size-asc', label: 'Size (Small to Large)' },
|
placeholder="Search files..."
|
||||||
{ value: 'size-desc', label: 'Size (Large to Small)' },
|
value={filters.searchTerm}
|
||||||
{ value: 'modified-desc', label: 'Modified (Newest)' },
|
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
|
||||||
{ value: 'modified-asc', label: 'Modified (Oldest)' },
|
prefix={<FaSearch className="text-gray-400" />}
|
||||||
]}
|
className="w-48"
|
||||||
value={{
|
/>
|
||||||
value: `${filters.sortBy}-${filters.sortOrder}`,
|
</div>
|
||||||
label: (() => {
|
|
||||||
const sortOptions = {
|
{/* Sort */}
|
||||||
'name-asc': 'Name (A-Z)',
|
<Select
|
||||||
'name-desc': 'Name (Z-A)',
|
size="sm"
|
||||||
'size-asc': 'Size (Small to Large)',
|
options={[
|
||||||
'size-desc': 'Size (Large to Small)',
|
{ value: 'name-asc', label: 'Name (A-Z)' },
|
||||||
'modified-desc': 'Modified (Newest)',
|
{ value: 'name-desc', label: 'Name (Z-A)' },
|
||||||
'modified-asc': 'Modified (Oldest)',
|
{ 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
|
className="min-w-36"
|
||||||
]
|
/>
|
||||||
})(),
|
|
||||||
}}
|
|
||||||
onChange={(option) => {
|
|
||||||
if (option && 'value' in option) {
|
|
||||||
const [sortBy, sortOrder] = (option.value as string).split('-') as [
|
|
||||||
SortBy,
|
|
||||||
SortOrder,
|
|
||||||
]
|
|
||||||
setFilters((prev) => ({ ...prev, sortBy, sortOrder }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View Mode */}
|
{/* View Mode */}
|
||||||
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
|
<div className="flex border border-gray-300 dark:border-gray-600 rounded">
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={<FaTh />}
|
icon={<FaTh />}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'rounded-r-none border-r',
|
'rounded-r-none border-r',
|
||||||
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
viewMode === 'grid' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
/>
|
title="Grid view"
|
||||||
<Button
|
/>
|
||||||
variant="plain"
|
<Button
|
||||||
size="sm"
|
variant="plain"
|
||||||
icon={<FaList />}
|
size="sm"
|
||||||
className={classNames(
|
icon={<FaList />}
|
||||||
'rounded-l-none',
|
className={classNames(
|
||||||
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
'rounded-l-none',
|
||||||
)}
|
viewMode === 'list' && 'bg-blue-50 dark:bg-blue-900/20 text-blue-600',
|
||||||
onClick={() => setViewMode('list')}
|
)}
|
||||||
/>
|
onClick={() => setViewMode('list')}
|
||||||
|
title="List view"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Selection Status Bar - Show when items are selected */}
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-blue-700 dark:text-blue-300 font-medium">
|
||||||
|
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
({filteredItems.filter(item => selectedItems.includes(item.id)).length} of {filteredItems.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
onClick={deselectAllItems}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Clear Selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
|
|
@ -433,6 +757,7 @@ const FileManager = () => {
|
||||||
selected={selectedItems.includes(item.id)}
|
selected={selectedItems.includes(item.id)}
|
||||||
onSelect={handleItemSelect}
|
onSelect={handleItemSelect}
|
||||||
onDoubleClick={handleItemDoubleClick}
|
onDoubleClick={handleItemDoubleClick}
|
||||||
|
onToggleSelect={handleItemSelect}
|
||||||
onCreateFolder={(parentItem) => {
|
onCreateFolder={(parentItem) => {
|
||||||
// Klasör içinde yeni klasör oluşturmak için parent klasörü set et
|
// Klasör içinde yeni klasör oluşturmak için parent klasörü set et
|
||||||
setCurrentFolderId(parentItem.id)
|
setCurrentFolderId(parentItem.id)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export interface FileItemProps {
|
||||||
onDelete?: (item: FileItemType) => void
|
onDelete?: (item: FileItemType) => void
|
||||||
onDownload?: (item: FileItemType) => void
|
onDownload?: (item: FileItemType) => void
|
||||||
onPreview?: (item: FileItemType) => void
|
onPreview?: (item: FileItemType) => void
|
||||||
|
onToggleSelect?: (item: FileItemType) => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +123,7 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onDelete,
|
onDelete,
|
||||||
onDownload,
|
onDownload,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onToggleSelect,
|
||||||
className,
|
className,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
|
@ -135,6 +137,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onDoubleClick?.(item)
|
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[] = [
|
const actionMenuItems: FileActionMenuItem[] = [
|
||||||
// Preview - sadece dosyalar için
|
// Preview - sadece dosyalar için
|
||||||
...(item.type === 'file' && onPreview
|
...(item.type === 'file' && onPreview
|
||||||
|
|
@ -169,8 +180,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
// Rename - her şey için
|
// Rename - sadece read-only olmayanlar için
|
||||||
...(onRename
|
...(onRename && !item.isReadOnly
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
|
|
@ -180,8 +191,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
// Move - her şey için
|
// Move - sadece read-only olmayanlar için
|
||||||
...(onMove
|
...(onMove && !item.isReadOnly
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'move',
|
key: 'move',
|
||||||
|
|
@ -191,8 +202,8 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
// Delete - her şey için
|
// Delete - sadece read-only olmayanlar için
|
||||||
...(onDelete
|
...(onDelete && !item.isReadOnly
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'delete',
|
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 = (
|
const dropdownList = (
|
||||||
<div className="py-1 min-w-36">
|
<div className="py-1 min-w-36">
|
||||||
|
|
@ -276,6 +283,18 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
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 */}
|
{/* File Icon or Preview */}
|
||||||
<div className="col-span-1 flex items-center">
|
<div className="col-span-1 flex items-center">
|
||||||
<div className="w-8 h-8">
|
<div className="w-8 h-8">
|
||||||
|
|
@ -290,10 +309,15 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Name */}
|
{/* 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">
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
|
{item.isReadOnly && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 flex-shrink-0">
|
||||||
|
Protected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Type */}
|
{/* File Type */}
|
||||||
|
|
@ -323,25 +347,29 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
|
|
||||||
{/* Action Menu */}
|
{/* Action Menu */}
|
||||||
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
|
<div className="col-span-1 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity relative">
|
||||||
<button
|
{!item.isReadOnly && (
|
||||||
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
|
<button
|
||||||
className="fixed inset-0 z-10"
|
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
onClick={() => setDropdownOpen(false)}
|
onClick={(e) => {
|
||||||
/>
|
e.stopPropagation()
|
||||||
<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">
|
setDropdownOpen(!dropdownOpen)
|
||||||
{dropdownList}
|
}}
|
||||||
</div>
|
>
|
||||||
|
<HiEllipsisVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && actionMenuItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
|
||||||
|
{dropdownList}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -364,31 +392,45 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Action Menu */}
|
{/* Checkbox */}
|
||||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
<div className="absolute top-3 left-3 z-10">
|
||||||
<button
|
<input
|
||||||
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"
|
type="checkbox"
|
||||||
onClick={(e) => {
|
checked={selected}
|
||||||
e.stopPropagation()
|
onChange={handleCheckboxChange}
|
||||||
setDropdownOpen(!dropdownOpen)
|
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"
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Menu */}
|
||||||
|
{!item.isReadOnly && (
|
||||||
|
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded-full bg-white dark:bg-gray-700 shadow-md hover:shadow-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-all duration-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDropdownOpen(!dropdownOpen)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HiEllipsisVertical className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && actionMenuItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 top-full mt-2 z-20 bg-white dark:bg-gray-800 rounded-md shadow-lg border dark:border-gray-600">
|
||||||
|
{dropdownList}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File/Folder Icon or Preview */}
|
{/* File/Folder Icon or Preview */}
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex justify-center mb-3">
|
||||||
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
|
||||||
|
|
@ -404,9 +446,16 @@ const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
|
|
||||||
{/* File/Folder Name and Details */}
|
{/* File/Folder Name and Details */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1">
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
{item.name}
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
</p>
|
{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 */}
|
{/* File Size and Type */}
|
||||||
{item.type === 'file' && (
|
{item.type === 'file' && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue