sozsoft-platform/ui/src/views/admin/files/FileManager.tsx
2026-05-26 14:30:04 +03:00

1184 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react'
import { Helmet } from 'react-helmet'
import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui'
import { useStoreState } from '@/store'
import {
FaFolder,
FaCloudUploadAlt,
FaSearch,
FaTh,
FaList,
FaArrowUp,
FaCheckSquare,
FaSquare,
FaTrash,
FaCut,
FaCopy,
FaEdit,
FaDownload,
FaPaste,
FaBuilding,
FaUsers,
FaChevronRight,
} from 'react-icons/fa'
import Container from '@/components/shared/Container'
import { useLocalization } from '@/utils/hooks/useLocalization'
import fileManagementService from '@/services/fileManagement.service'
import { getTenants } from '@/services/tenant.service'
import type { TenantDto } from '@/proxy/config/models'
import Breadcrumb from './components/Breadcrumb'
import FileItem from './components/FileItem'
import FileUploadModal from './components/FileUploadModal'
import { CreateFolderModal, RenameItemModal, DeleteConfirmModal } from './components/FileModals'
import type {
FileItem as FileItemType,
BreadcrumbItem,
ViewMode,
SortBy,
SortOrder,
FileManagerFilters,
} from '@/types/fileManagement'
import classNames from 'classnames'
import { APP_NAME } from '@/constants/app.constant'
const { VITE_CDN_URL } = import.meta.env
// Select options for sorting
const FileManager = () => {
const { translate } = useLocalization()
const authTenantId = useStoreState((state) => state.auth.tenant?.tenantId)
const authTenantName = useStoreState((state) => state.auth.tenant?.tenantName)
const isHostContext = !authTenantId
// State
const [loading, setLoading] = useState(true)
const [items, setItems] = useState<FileItemType[]>([])
const [filteredItems, setFilteredItems] = useState<FileItemType[]>([])
const [currentFolderId, setCurrentFolderId] = useState<string | undefined>()
const [breadcrumbItems, setBreadcrumbItems] = useState<BreadcrumbItem[]>([
{ name: 'Files', path: '', id: undefined },
])
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [filters, setFilters] = useState<FileManagerFilters>({
searchTerm: '',
sortBy: 'name',
sortOrder: 'asc',
})
// Modal states
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [createFolderModalOpen, setCreateFolderModalOpen] = useState(false)
const [renameModalOpen, setRenameModalOpen] = useState(false)
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [itemToRename, setItemToRename] = useState<FileItemType | undefined>()
const [itemsToDelete, setItemsToDelete] = useState<FileItemType[]>([])
// Tenant state
const [tenants, setTenants] = useState<TenantDto[]>([])
const [tenantsLoading, setTenantsLoading] = useState(false)
const [selectedTenant, setSelectedTenant] = useState<{ id: string; name: string } | undefined>(
authTenantId ? { id: authTenantId, name: authTenantName || '' } : undefined,
)
// Tracks mid-flight tenant change so the fetch effect doesn't fire with a stale folderId
const pendingTenantChange = useRef(false)
// Loading states
const [uploading, setUploading] = useState(false)
const [creating, setCreating] = useState(false)
const [renaming, setRenaming] = useState(false)
const [deleting, setDeleting] = useState(false)
// Fetch tenants
const fetchTenants = useCallback(async () => {
try {
setTenantsLoading(true)
const response = await getTenants(0, 1000)
setTenants(response.data.items || [])
} catch (error) {
console.error('Failed to fetch tenants:', error)
} finally {
setTenantsLoading(false)
}
}, [])
useEffect(() => {
if (isHostContext) {
fetchTenants()
}
}, [fetchTenants, isHostContext])
// If user is in a tenant context, lock selection to that tenant.
useEffect(() => {
if (!authTenantId) return
setSelectedTenant((prev) => {
if (prev?.id === authTenantId) return prev
return { id: authTenantId, name: authTenantName || prev?.name || '' }
})
}, [authTenantId, authTenantName])
// Reset navigation when tenant changes
useEffect(() => {
pendingTenantChange.current = true
setCurrentFolderId(undefined)
setSelectedItems([])
setBreadcrumbItems([{ name: 'Files', path: '', id: undefined }])
}, [selectedTenant])
// Fetch items from API
const fetchItems = useCallback(
async (folderId?: string) => {
try {
setLoading(true)
const response = await fileManagementService.getItems(folderId, selectedTenant?.id)
// Backend returns GetFilesDto which has Items property
const items = response.data.items || []
// Manual protection for system folders
const protectedItems = items.map((item) => {
const isSystemFolder = ['intranet', 'avatar', 'import', 'note', 'backup'].includes(
item.name.toLowerCase(),
)
return {
...item,
isReadOnly: item.isReadOnly || isSystemFolder,
}
})
setItems(protectedItems)
} catch (error) {
console.error('Failed to fetch items:', error)
toast.push(<Notification type="danger">Failed to load files and folders</Notification>)
} finally {
setLoading(false)
}
},
[selectedTenant],
)
// Fetch breadcrumb path
const fetchBreadcrumb = useCallback(
async (folderId?: string) => {
try {
if (!folderId) {
setBreadcrumbItems([{ name: 'Files', path: '', id: undefined }])
return
}
const response = await fileManagementService.getFolderPath(folderId, selectedTenant?.id)
const pathItems: BreadcrumbItem[] = [
{ name: 'Files', path: '', id: undefined },
...response.data.path.map((item) => ({
name: item.name,
path: item.id,
id: item.id,
})),
]
setBreadcrumbItems(pathItems)
} catch (error) {
console.error('Failed to fetch breadcrumb:', error)
}
},
[selectedTenant],
)
// Initial load
useEffect(() => {
if (pendingTenantChange.current) {
pendingTenantChange.current = false
if (currentFolderId !== undefined) {
// The reset effect already called setCurrentFolderId(undefined);
// wait for that state update to re-trigger this effect at root.
return
}
}
fetchItems(currentFolderId)
fetchBreadcrumb(currentFolderId)
}, [currentFolderId, fetchItems, fetchBreadcrumb])
// Filter and sort items
useEffect(() => {
let filtered = [...items]
// Apply search filter
if (filters.searchTerm) {
filtered = filtered.filter((item) =>
item.name.toLowerCase().includes(filters.searchTerm!.toLowerCase()),
)
}
// Apply sorting
filtered.sort((a, b) => {
let comparison = 0
switch (filters.sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'size':
comparison = (a.size || 0) - (b.size || 0)
break
case 'type':
comparison = a.type.localeCompare(b.type)
break
case 'modified':
comparison = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()
break
}
return filters.sortOrder === 'desc' ? -comparison : comparison
})
// Folders first
filtered.sort((a, b) => {
if (a.type === 'folder' && b.type === 'file') return -1
if (a.type === 'file' && b.type === 'folder') return 1
return 0
})
setFilteredItems(filtered)
}, [items, filters])
// Navigation handlers
const handleBreadcrumbNavigate = (breadcrumb: BreadcrumbItem) => {
setCurrentFolderId(breadcrumb.id)
setSelectedItems([])
}
const handleItemSelect = (item: FileItemType) => {
// Protected öğeler seçilemez
if (item.isReadOnly) {
return
}
setSelectedItems((prev) => {
if (prev.includes(item.id)) {
return prev.filter((id) => id !== item.id)
} else {
return [...prev, item.id]
}
})
}
const handleItemDoubleClick = (item: FileItemType, event?: React.MouseEvent) => {
// Prevent text selection and other default behaviors
if (event) {
event.preventDefault()
event.stopPropagation()
}
// Clear any text selection that might have occurred
if (window.getSelection) {
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
}
if (item.type === 'folder') {
setCurrentFolderId(item.id)
setSelectedItems([])
}
}
// File operations
const handleUploadFiles = async (files: File[]) => {
try {
setUploading(true)
for (const file of files) {
// NoteModal pattern'ini kullan - Files array ile FormData
const formData = new FormData()
formData.append('fileName', file.name)
formData.append('Files', file) // NoteModal pattern - Files array
if (currentFolderId) {
formData.append('parentId', currentFolderId)
}
await fileManagementService.uploadFileDirectly(formData, selectedTenant?.id)
}
await fetchItems(currentFolderId)
toast.push(<Notification type="success">Files uploaded successfully</Notification>)
} catch (error) {
console.error('Upload failed:', error)
toast.push(<Notification type="danger">Failed to upload files</Notification>)
throw error
} finally {
setUploading(false)
}
}
const handleCreateFolder = async (name: string) => {
try {
setCreating(true)
await fileManagementService.createFolder(
{
name,
parentId: currentFolderId,
},
selectedTenant?.id,
)
await fetchItems(currentFolderId)
toast.push(<Notification type="success">Folder created successfully</Notification>)
} catch (error) {
console.error('Create folder failed:', error)
toast.push(<Notification type="danger">Failed to create folder</Notification>)
throw error
} finally {
setCreating(false)
}
}
const handleRenameItem = async (newName: string) => {
if (!itemToRename) return
try {
setRenaming(true)
await fileManagementService.renameItem(
{
id: itemToRename.id,
newName,
},
selectedTenant?.id,
)
await fetchItems(currentFolderId)
toast.push(<Notification type="success">Item renamed successfully</Notification>)
} catch (error) {
console.error('Rename failed:', error)
toast.push(<Notification type="danger">Failed to rename item</Notification>)
throw error
} finally {
setRenaming(false)
}
}
const handleDeleteItems = async () => {
try {
setDeleting(true)
if (itemsToDelete.length === 1) {
// Single item delete - use existing API
await fileManagementService.deleteItem({ id: itemsToDelete[0].id }, selectedTenant?.id)
} else {
// Multiple items - use bulk delete API
const itemIds = itemsToDelete.map((item) => item.id)
await fileManagementService.bulkDeleteItems(itemIds, selectedTenant?.id)
}
await fetchItems(currentFolderId)
setSelectedItems([])
toast.push(<Notification type="success">Items deleted successfully</Notification>)
} catch (error) {
console.error('Delete failed:', error)
toast.push(<Notification type="danger">Failed to delete items</Notification>)
throw error
} finally {
setDeleting(false)
}
}
const handleDownload = (item: FileItemType) => {
const tenantSegment = selectedTenant?.id ? `tenants/${selectedTenant.id}` : 'host'
const filePath = item.id.replace(/\|/g, '/')
const url = `${VITE_CDN_URL}/${tenantSegment}/${filePath}`
const a = document.createElement('a')
a.href = url
a.download = item.name
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
// Action handlers
const openRenameModal = (item: FileItemType) => {
setItemToRename(item)
setRenameModalOpen(true)
}
const openDeleteModal = (items: FileItemType[]) => {
setItemsToDelete(items)
setDeleteModalOpen(true)
}
const handleSingleItemDelete = async (item: FileItemType) => {
// Check if it's a protected item
if (item.isReadOnly) {
toast.push(
<Notification title="Warning" type="warning">
Protected system folders cannot be deleted.
</Notification>,
)
return
}
// Check if it's a folder containing files
if (item.type === 'folder') {
const hasFiles = await checkFolderHasFiles(item.id)
if (hasFiles) {
toast.push(
<Notification title="Security Warning" type="warning">
Folder '{item.name}' contains files and cannot be deleted for security reasons.
</Notification>,
)
return
}
}
// If all checks pass, open delete modal
openDeleteModal([item])
}
const goUpOneLevel = () => {
if (breadcrumbItems.length > 1) {
const parentBreadcrumb = breadcrumbItems[breadcrumbItems.length - 2]
handleBreadcrumbNavigate(parentBreadcrumb)
}
}
// 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 = () => {
// Sadece protected olmayan öğeleri seç
const selectableItems = filteredItems.filter((item) => !item.isReadOnly)
setSelectedItems(selectableItems.map((item) => item.id))
}
const deselectAllItems = () => {
setSelectedItems([])
}
// Check if a folder contains files (for security purposes)
const checkFolderHasFiles = async (folderId: string): Promise<boolean> => {
try {
const response = await fileManagementService.getItems(folderId, selectedTenant?.id)
const items = response.data.items || []
// Check if folder contains any files (not just other folders)
return items.some((item) => item.type === 'file')
} catch (error) {
console.error('Error checking folder contents:', error)
return false
}
}
const deleteSelectedItems = async () => {
const itemsToDelete = filteredItems.filter((item) => selectedItems.includes(item.id))
const deletableItems = itemsToDelete.filter((item) => !item.isReadOnly)
const protectedItems = itemsToDelete.filter((item) => item.isReadOnly)
// Check for folders containing files
const foldersWithFiles: string[] = []
for (const item of deletableItems) {
if (item.type === 'folder') {
const hasFiles = await checkFolderHasFiles(item.id)
if (hasFiles) {
foldersWithFiles.push(item.name)
}
}
}
// Filter out folders that contain files
const finalDeletableItems = deletableItems.filter((item) => {
if (item.type === 'folder') {
return !foldersWithFiles.includes(item.name)
}
return true
})
// Show warnings
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 (foldersWithFiles.length > 0) {
toast.push(
<Notification title="Security Warning" type="warning">
{foldersWithFiles.length} folder(s) containing files cannot be deleted for security
reasons: {foldersWithFiles.join(', ')}
</Notification>,
)
}
if (finalDeletableItems.length > 0) {
openDeleteModal(finalDeletableItems)
// Remove protected items and folders with files from selection
const deletableIds = finalDeletableItems.map((item) => item.id)
setSelectedItems((prev) => prev.filter((id) => deletableIds.includes(id)))
} else if (itemsToDelete.length > 0) {
// If no items can be deleted, show info message
toast.push(
<Notification title="Info" type="info">
No items can be deleted. Selected items are either protected or folders containing files.
</Notification>,
)
}
}
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 = async () => {
const clipboardData = localStorage.getItem('fileManager_clipboard')
if (!clipboardData) {
toast.push(
<Notification title="Clipboard Empty" type="info">
No items in clipboard
</Notification>,
)
return
}
try {
const clipboard = JSON.parse(clipboardData)
const itemIds = clipboard.items.map((item: FileItemType) => item.id)
if (clipboard.operation === 'copy') {
setLoading(true)
try {
await fileManagementService.copyItems(itemIds, currentFolderId, selectedTenant?.id)
await fetchItems(currentFolderId)
toast.push(
<Notification title={translate('::App.Platform.Success')} type="success">
{itemIds.length} item(s) copied successfully
</Notification>,
)
} catch (error) {
console.error('Copy failed:', error)
toast.push(
<Notification title={translate('::App.Platform.Error')} type="danger">
Failed to copy items
</Notification>,
)
} finally {
setLoading(false)
}
} else if (clipboard.operation === 'cut') {
// Aynı klasörde move yapmaya çalışırsa engelleyelim
if (clipboard.sourceFolder === currentFolderId) {
toast.push(
<Notification title="Warning" type="warning">
Cannot move items to the same folder
</Notification>,
)
return
}
setLoading(true)
try {
await fileManagementService.moveItems(itemIds, currentFolderId, selectedTenant?.id)
await fetchItems(currentFolderId)
// Clipboard'ı temizle
localStorage.removeItem('fileManager_clipboard')
setHasClipboardData(false)
toast.push(
<Notification title={translate('::App.Platform.Success')} type="success">
{itemIds.length} item(s) moved successfully
</Notification>,
)
} catch (error) {
console.error('Move failed:', error)
toast.push(
<Notification title={translate('::App.Platform.Error')} type="danger">
Failed to move items
</Notification>,
)
} finally {
setLoading(false)
}
}
} catch (error) {
toast.push(
<Notification title="Error" type="danger">
Invalid clipboard data
</Notification>,
)
}
}
return (
<Container className="px-3">
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + 'App.Files')}
defaultTitle={APP_NAME}
></Helmet>
{/* Enhanced Unified Toolbar */}
<div className="dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-2 mb-4 mt-2">
{/* Main Toolbar Row */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4">
{/* Left Section - Primary Actions */}
<div className="flex items-center gap-2 flex-wrap min-w-0">
{/* File Operations */}
{/* Navigation */}
<Button
variant="plain"
icon={<FaArrowUp />}
onClick={goUpOneLevel}
size="sm"
disabled={breadcrumbItems.length <= 1}
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title="Go up one level"
/>
<Button
variant="solid"
icon={<FaCloudUploadAlt />}
onClick={() => setUploadModalOpen(true)}
size="sm"
className="flex-shrink-0"
>
<span className="hidden sm:inline">{translate('::FileManager.UploadFiles')}</span>
<span className="sm:hidden">{translate('::FileManager.Upload')}</span>
</Button>
<Button
variant="default"
icon={<FaFolder />}
onClick={() => setCreateFolderModalOpen(true)}
size="sm"
className="flex-shrink-0"
>
<span className="hidden sm:inline">{translate('::FileManager.CreateFolder')}</span>
<span className="sm:hidden">{translate('::FileManager.Create')}</span>
</Button>
{/* Clipboard Operations */}
<Button
variant="plain"
icon={<FaCopy />}
onClick={copySelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-blue-600 disabled:opacity-50 flex-shrink-0"
title={translate('::FileManager.CopySelectedItems')}
/>
<Button
variant="plain"
icon={<FaCut />}
onClick={cutSelectedItems}
disabled={selectedItems.length === 0}
size="sm"
className="text-gray-600 hover:text-orange-600 disabled:opacity-50 flex-shrink-0"
title={translate('::FileManager.CutSelectedItems')}
/>
<Button
variant="plain"
icon={<FaPaste />}
onClick={pasteItems}
disabled={!hasClipboardData}
size="sm"
className="text-gray-600 hover:text-green-600 disabled:opacity-50 flex-shrink-0"
title={translate('::FileManager.PasteSelectedItems')}
/>
<Button
variant="plain"
icon={<FaEdit />}
onClick={() => {
if (selectedItems.length === 1) {
const itemToRename = filteredItems.find((item) => item.id === selectedItems[0])
if (itemToRename && !itemToRename.isReadOnly) {
openRenameModal(itemToRename)
} else {
toast.push(
<Notification title="Warning" type="warning">
Protected system folders cannot be renamed
</Notification>,
)
}
}
}}
size="sm"
disabled={selectedItems.length !== 1}
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title={translate('::FileManager.RenameSelectedItem')}
></Button>
<Button
variant="plain"
icon={<FaDownload />}
onClick={() => {
if (selectedItems.length === 1) {
const itemToDownload = filteredItems.find((item) => item.id === selectedItems[0])
if (itemToDownload && itemToDownload.type === 'file') {
handleDownload(itemToDownload)
} else {
toast.push(
<Notification title="Warning" type="warning">
Only files can be downloaded
</Notification>,
)
}
}
}}
size="sm"
disabled={
selectedItems.length !== 1 ||
(() => {
const selectedItem = filteredItems.find((item) => item.id === selectedItems[0])
return selectedItem?.type !== 'file'
})()
}
className="text-gray-600 hover:text-green-600 flex-shrink-0"
title={translate('::FileManager.DownloadSelectedFile')}
></Button>
<Button
variant="plain"
icon={<FaTrash />}
onClick={deleteSelectedItems}
size="sm"
disabled={selectedItems.length === 0}
className="text-gray-600 hover:text-red-600 flex-shrink-0"
title={translate('::FileManager.DeleteSelectedItems')}
>
<span>{translate('::Delete')}</span> {selectedItems.length > 0 && `(${selectedItems.length})`}
</Button>
{/* Selection Actions */}
{filteredItems.length > 0 && (
<Button
variant="plain"
icon={
selectedItems.length ===
filteredItems.filter((item) => !item.isReadOnly).length ? (
<FaCheckSquare />
) : (
<FaSquare />
)
}
onClick={
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? deselectAllItems
: selectAllItems
}
size="sm"
className="text-gray-600 hover:text-blue-600 flex-shrink-0"
title={
selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? 'Deselect all selectable items'
: 'Select all selectable items'
}
>
<span className="hidden lg:inline">
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? translate('::FileManager.DeselectAll')
: translate('::FileManager.SelectAll')}
</span>
<span className="lg:hidden">
{selectedItems.length === filteredItems.filter((item) => !item.isReadOnly).length
? translate('::FileManager.Deselect')
: translate('::FileManager.Select')}
</span>
</Button>
)}
</div>
{/* Right Section - Search, Sort, View */}
<div className="flex items-center gap-2 sm:gap-3 flex-wrap justify-start lg:justify-end min-w-0 w-full lg:w-auto">
{/* Search */}
<div className="flex items-center w-full sm:w-auto">
<Input
size="sm"
placeholder={translate('::FileManager.SearchFiles')}
value={filters.searchTerm}
onChange={(e) => setFilters((prev) => ({ ...prev, searchTerm: e.target.value }))}
prefix={<FaSearch className="text-gray-400" />}
className="w-full sm:w-36 md:w-48"
/>
</div>
{/* Sort */}
<Select
size="xs"
options={[
{ value: 'name-asc', label: translate('::FileManager.SortByNameAsc') },
{ value: 'name-desc', label: translate('::FileManager.SortByNameDesc') },
{ value: 'size-asc', label: translate('::FileManager.SortBySizeAsc') },
{ value: 'size-desc', label: translate('::FileManager.SortBySizeDesc') },
{ value: 'modified-desc', label: translate('::FileManager.SortByModifiedDesc') },
{ value: 'modified-asc', label: translate('::FileManager.SortByModifiedAsc') },
]}
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 }))
}
}}
/>
{/* View Mode */}
<div className="flex gap-1 ml-auto">
<Button
size="sm"
icon={<FaTh />}
variant={viewMode === 'grid' ? 'solid' : 'default'}
onClick={() => setViewMode('grid')}
title="Grid view"
/>
<Button
size="sm"
icon={<FaList />}
variant={viewMode === 'list' ? 'solid' : 'default'}
onClick={() => setViewMode('list')}
title="List view"
/>
</div>
</div>
</div>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-4 sm:mb-6">
<div className="flex items-center gap-2">
{isHostContext ? (
<Select
size="xs"
isLoading={tenantsLoading}
options={[
{
value: '',
label: 'Host',
icon: <FaBuilding className="flex-shrink-0 text-gray-500" />,
},
...tenants.map((t) => ({
value: t.id ?? '',
label: t.name ?? '',
icon: <FaUsers className="flex-shrink-0 text-blue-500" />,
})),
]}
value={{
value: selectedTenant ? selectedTenant.id : '',
label: selectedTenant ? selectedTenant.name : 'Host',
icon: selectedTenant ? (
<FaUsers className="flex-shrink-0 text-blue-500" />
) : (
<FaBuilding className="flex-shrink-0 text-gray-500" />
),
}}
formatOptionLabel={(option) => (
<div className="flex items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</div>
)}
onChange={(option) => {
if (option && 'value' in option) {
const val = option.value as string
if (!val) {
setSelectedTenant(undefined)
} else {
const found = tenants.find((t) => t.id === val)
if (found) setSelectedTenant({ id: found.id!, name: found.name ?? '' })
}
}
}}
/>
) : (
<div
className="max-w-[220px] truncate text-sm font-medium text-gray-700 dark:text-gray-200"
title={authTenantName || selectedTenant?.name || ''}
>
{authTenantName || selectedTenant?.name || ''}
</div>
)}
</div>
<FaChevronRight className="mx-2 h-4 w-4 text-gray-400" />
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-2">
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
</div>
</div>
</div>
{/* Files Grid/List */}
{loading ? (
<div className="flex justify-center items-center py-20">
<Spinner size="lg" />
</div>
) : (
<>
{/* List View Header */}
{viewMode === 'list' && (
<div className="hidden sm:grid grid-cols-12 gap-4 px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 border-b dark:border-gray-700 mb-2">
<div className="col-span-5 lg:col-span-5">{translate('::App.Listform.ListformField.Name')}</div>
<div className="col-span-2 lg:col-span-2">{translate('::App.Listform.ListformField.Type')}</div>
<div className="col-span-2 lg:col-span-2">{translate('::App.Listform.ListformField.Size')}</div>
<div className="col-span-2 lg:col-span-2">{translate('::App.Listform.ListformField.Modified')}</div>
<div className="col-span-1"></div> {/* Actions column */}
</div>
)}
<div
className={classNames(
viewMode === 'grid'
? 'grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-3 sm:gap-4'
: 'space-y-1',
)}
>
{filteredItems.length === 0 ? (
<div className="col-span-full text-center py-12 sm:py-20">
<FaFolder className="mx-auto h-12 w-12 sm:h-16 sm:w-16 text-gray-400 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-sm sm:text-base px-4">
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
</p>
</div>
) : (
filteredItems.map((item) => (
<FileItem
key={item.id}
item={item}
viewMode={viewMode}
selected={selectedItems.includes(item.id)}
onSelect={handleItemSelect}
onDoubleClick={handleItemDoubleClick}
onToggleSelect={handleItemSelect}
onCreateFolder={(parentItem) => {
// Klasör içinde yeni klasör oluşturmak için parent klasörü set et
setCurrentFolderId(parentItem.id)
setCreateFolderModalOpen(true)
}}
onRename={openRenameModal}
onMove={(item) => {
// Move işlemi için öğeyi cut olarak clipboard'a koy
cutSelectedItems()
if (!selectedItems.includes(item.id)) {
setSelectedItems([item.id])
localStorage.setItem(
'fileManager_clipboard',
JSON.stringify({
operation: 'cut',
items: [item],
sourceFolder: currentFolderId,
}),
)
setHasClipboardData(true)
toast.push(
<Notification title="Cut" type="success">
Item ready to move. Navigate to target folder and paste.
</Notification>,
)
}
}}
onDelete={handleSingleItemDelete}
onDownload={item.type === 'file' ? handleDownload : undefined}
onPreview={
item.type === 'file'
? (item) => {
// Preview işlevi - resimler için modal açabiliriz
if (item.mimeType?.startsWith('image/')) {
// Resim preview modal'ıılabilir
toast.push(
<Notification type="info">
Image preview feature will be added soon.
</Notification>,
)
} else {
// Diğer dosya tipleri için download
handleDownload(item)
}
}
: undefined
}
/>
))
)}
</div>
</>
)}
{/* Modals */}
<FileUploadModal
isOpen={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
onUpload={handleUploadFiles}
currentFolderId={currentFolderId}
loading={uploading}
/>
<CreateFolderModal
isOpen={createFolderModalOpen}
onClose={() => setCreateFolderModalOpen(false)}
onCreate={handleCreateFolder}
loading={creating}
currentFolderId={currentFolderId}
/>
<RenameItemModal
isOpen={renameModalOpen}
onClose={() => {
setRenameModalOpen(false)
setItemToRename(undefined)
}}
onRename={handleRenameItem}
item={itemToRename}
loading={renaming}
/>
<DeleteConfirmModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false)
setItemsToDelete([])
}}
onDelete={handleDeleteItems}
items={itemsToDelete}
loading={deleting}
/>
</Container>
)
}
export default FileManager