Dosya Yöneticisi eklendi
This commit is contained in:
parent
b06edfd1ff
commit
753efd5f07
10 changed files with 1555 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,5 +2,4 @@ configs/**/data/**
|
||||||
|
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
files/
|
|
||||||
logs/
|
logs/
|
||||||
|
|
@ -1815,6 +1815,12 @@
|
||||||
"en": "Change Log",
|
"en": "Change Log",
|
||||||
"tr": "Versiyon Günlüğü"
|
"tr": "Versiyon Günlüğü"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Files",
|
||||||
|
"en": "Files",
|
||||||
|
"tr": "Dosyalar"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.BlogManagement",
|
"key": "App.BlogManagement",
|
||||||
|
|
@ -12882,6 +12888,15 @@
|
||||||
"App.Reports.Categories"
|
"App.Reports.Categories"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "admin.fileManagement",
|
||||||
|
"path": "/admin/files",
|
||||||
|
"componentPath": "@/views/admin/files/FileManager",
|
||||||
|
"routeType": "protected",
|
||||||
|
"authority": [
|
||||||
|
"App.Files"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "admin.coordinator.classroom.dashboard",
|
"key": "admin.coordinator.classroom.dashboard",
|
||||||
"path": "/admin/coordinator/classroom/dashboard",
|
"path": "/admin/coordinator/classroom/dashboard",
|
||||||
|
|
@ -14387,6 +14402,16 @@
|
||||||
"RequiredPermissionName": null,
|
"RequiredPermissionName": null,
|
||||||
"IsDisabled": false
|
"IsDisabled": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"ParentCode": "App.Administration",
|
||||||
|
"Code": "App.Files",
|
||||||
|
"DisplayName": "App.Files",
|
||||||
|
"Order": 5,
|
||||||
|
"Url": "/admin/files",
|
||||||
|
"Icon": "FcFolder",
|
||||||
|
"RequiredPermissionName": "App.Files",
|
||||||
|
"IsDisabled": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"ParentCode": "App.Definitions",
|
"ParentCode": "App.Definitions",
|
||||||
"Code": "App.Definitions.WorkHour",
|
"Code": "App.Definitions.WorkHour",
|
||||||
|
|
@ -19951,6 +19976,42 @@
|
||||||
"MultiTenancySide": 3,
|
"MultiTenancySide": 3,
|
||||||
"MenuGroup": "Erp|Kurs"
|
"MenuGroup": "Erp|Kurs"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Administration",
|
||||||
|
"Name": "App.Files",
|
||||||
|
"ParentName": null,
|
||||||
|
"DisplayName": "App.Files",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Erp|Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Administration",
|
||||||
|
"Name": "App.Files.Create",
|
||||||
|
"ParentName": "App.Files",
|
||||||
|
"DisplayName": "Create",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Erp|Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Administration",
|
||||||
|
"Name": "App.Files.Update",
|
||||||
|
"ParentName": "App.Files",
|
||||||
|
"DisplayName": "Update",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Erp|Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Administration",
|
||||||
|
"Name": "App.Files.Delete",
|
||||||
|
"ParentName": "App.Files",
|
||||||
|
"DisplayName": "Delete",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Erp|Kurs"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"GroupName": "App.Administration",
|
"GroupName": "App.Administration",
|
||||||
"Name": "App.Definitions.Sector",
|
"Name": "App.Definitions.Sector",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export const ROUTES_ENUM = {
|
||||||
activityLog: '/admin/activityLog',
|
activityLog: '/admin/activityLog',
|
||||||
changeLog: '/admin/changeLog',
|
changeLog: '/admin/changeLog',
|
||||||
settings: '/admin/settings',
|
settings: '/admin/settings',
|
||||||
|
files: '/admin/files',
|
||||||
identity: {
|
identity: {
|
||||||
user: {
|
user: {
|
||||||
detail: '/admin/users/detail/:userId',
|
detail: '/admin/users/detail/:userId',
|
||||||
|
|
|
||||||
119
ui/src/services/fileManagement.service.ts
Normal file
119
ui/src/services/fileManagement.service.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import ApiService from './api.service'
|
||||||
|
import type {
|
||||||
|
FileItem,
|
||||||
|
CreateFolderRequest,
|
||||||
|
RenameItemRequest,
|
||||||
|
MoveItemRequest,
|
||||||
|
DeleteItemRequest,
|
||||||
|
UploadFileRequest,
|
||||||
|
} from '@/types/fileManagement'
|
||||||
|
|
||||||
|
class FileManagementService {
|
||||||
|
// Get files and folders for a specific directory
|
||||||
|
async getItems(folderId?: string): Promise<{ data: { items: FileItem[] } }> {
|
||||||
|
const params = folderId ? { parentId: folderId } : {}
|
||||||
|
|
||||||
|
return ApiService.fetchData<{ items: FileItem[] }>({
|
||||||
|
url: `/api/files`,
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new folder
|
||||||
|
async createFolder(request: CreateFolderRequest): Promise<{ data: FileItem }> {
|
||||||
|
return ApiService.fetchData<FileItem>({
|
||||||
|
url: `/api/files/folders`,
|
||||||
|
method: 'POST',
|
||||||
|
data: request as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
async uploadFile(request: UploadFileRequest): Promise<{ data: FileItem }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', request.file)
|
||||||
|
if (request.parentId) {
|
||||||
|
formData.append('parentId', request.parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiService.fetchData<FileItem>({
|
||||||
|
url: `/api/files/upload`,
|
||||||
|
method: 'POST',
|
||||||
|
data: formData as any,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename a file or folder
|
||||||
|
async renameItem(request: RenameItemRequest): Promise<{ data: FileItem }> {
|
||||||
|
return ApiService.fetchData<FileItem>({
|
||||||
|
url: `/api/files/${request.id}/rename`,
|
||||||
|
method: 'PUT',
|
||||||
|
data: { name: request.newName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move a file or folder
|
||||||
|
async moveItem(request: MoveItemRequest): Promise<{ data: FileItem }> {
|
||||||
|
return ApiService.fetchData<FileItem>({
|
||||||
|
url: `/api/files/${request.itemId}/move`,
|
||||||
|
method: 'PUT',
|
||||||
|
data: { targetFolderId: request.targetFolderId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a file or folder
|
||||||
|
async deleteItem(request: DeleteItemRequest): Promise<void> {
|
||||||
|
await ApiService.fetchData<void>({
|
||||||
|
url: `/api/files/${request.id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a file
|
||||||
|
async downloadFile(fileId: string): Promise<Blob> {
|
||||||
|
const response = await ApiService.fetchData<Blob>({
|
||||||
|
url: `/api/files/${fileId}/download`,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file preview/thumbnail
|
||||||
|
async getFilePreview(fileId: string): Promise<string> {
|
||||||
|
const response = await ApiService.fetchData<Blob>({
|
||||||
|
url: `/api/files/${fileId}/preview`,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return URL.createObjectURL(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search files and folders
|
||||||
|
async searchItems(query: string, folderId?: string): Promise<{ data: { items: FileItem[] } }> {
|
||||||
|
const params = {
|
||||||
|
q: query,
|
||||||
|
...(folderId && { parentId: folderId }),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiService.fetchData<{ items: FileItem[] }>({
|
||||||
|
url: `/api/files/search`,
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get folder breadcrumb path
|
||||||
|
async getFolderPath(folderId?: string): Promise<{ data: { path: Array<{ id: string; name: string }> } }> {
|
||||||
|
return ApiService.fetchData<{ path: Array<{ id: string; name: string }> }>({
|
||||||
|
url: `/api/files/folders/${folderId || 'root'}/path`,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new FileManagementService()
|
||||||
78
ui/src/types/fileManagement.ts
Normal file
78
ui/src/types/fileManagement.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
export interface FileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'file' | 'folder'
|
||||||
|
size?: number
|
||||||
|
mimeType?: string
|
||||||
|
createdAt: Date
|
||||||
|
modifiedAt: Date
|
||||||
|
parentId?: string
|
||||||
|
path: string
|
||||||
|
isDirectory: boolean
|
||||||
|
extension?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderItem extends Omit<FileItem, 'type' | 'size' | 'mimeType' | 'extension'> {
|
||||||
|
type: 'folder'
|
||||||
|
childCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFolderRequest {
|
||||||
|
name: string
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameItemRequest {
|
||||||
|
id: string
|
||||||
|
newName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveItemRequest {
|
||||||
|
itemId: string
|
||||||
|
targetFolderId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteItemRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFileRequest {
|
||||||
|
file: File
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileManagerState {
|
||||||
|
items: FileItem[]
|
||||||
|
currentPath: string[]
|
||||||
|
currentFolderId?: string
|
||||||
|
loading: boolean
|
||||||
|
uploading: boolean
|
||||||
|
selectedItems: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileActionMenuItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
disabled?: boolean
|
||||||
|
dangerous?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewMode = 'grid' | 'list'
|
||||||
|
|
||||||
|
export type SortBy = 'name' | 'size' | 'type' | 'modified'
|
||||||
|
export type SortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export interface FileManagerFilters {
|
||||||
|
searchTerm?: string
|
||||||
|
fileType?: string
|
||||||
|
sortBy: SortBy
|
||||||
|
sortOrder: SortOrder
|
||||||
|
}
|
||||||
495
ui/src/views/admin/files/FileManager.tsx
Normal file
495
ui/src/views/admin/files/FileManager.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
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 AdaptableCard from '@/components/shared/AdaptableCard'
|
||||||
|
import Container from '@/components/shared/Container'
|
||||||
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
import fileManagementService from '@/services/fileManagement.service'
|
||||||
|
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'
|
||||||
|
|
||||||
|
// Select options for sorting
|
||||||
|
|
||||||
|
const FileManager = () => {
|
||||||
|
const { translate } = useLocalization()
|
||||||
|
|
||||||
|
// 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[]>([])
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
// Fetch items from API
|
||||||
|
const fetchItems = useCallback(async (folderId?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await fileManagementService.getItems(folderId)
|
||||||
|
setItems(response.data.items)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch items:', error)
|
||||||
|
toast.push(<Notification type="danger">Failed to load files and folders</Notification>)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
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) => {
|
||||||
|
setSelectedItems((prev) => {
|
||||||
|
if (prev.includes(item.id)) {
|
||||||
|
return prev.filter((id) => id !== item.id)
|
||||||
|
} else {
|
||||||
|
return [...prev, item.id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemDoubleClick = (item: FileItemType) => {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
setCurrentFolderId(item.id)
|
||||||
|
setSelectedItems([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
const handleUploadFiles = async (files: File[]) => {
|
||||||
|
try {
|
||||||
|
setUploading(true)
|
||||||
|
for (const file of files) {
|
||||||
|
await fileManagementService.uploadFile({
|
||||||
|
file,
|
||||||
|
parentId: currentFolderId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
for (const item of itemsToDelete) {
|
||||||
|
await fileManagementService.deleteItem({ id: item.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 = async (item: FileItemType) => {
|
||||||
|
try {
|
||||||
|
const blob = await fileManagementService.downloadFile(item.id)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = item.name
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error)
|
||||||
|
toast.push(<Notification type="danger">Failed to download file</Notification>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
const openRenameModal = (item: FileItemType) => {
|
||||||
|
setItemToRename(item)
|
||||||
|
setRenameModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteModal = (items: FileItemType[]) => {
|
||||||
|
setItemsToDelete(items)
|
||||||
|
setDeleteModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSelected = () => {
|
||||||
|
const itemsToDelete = items.filter((item) => selectedItems.includes(item.id))
|
||||||
|
openDeleteModal(itemsToDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goUpOneLevel = () => {
|
||||||
|
if (breadcrumbItems.length > 1) {
|
||||||
|
const parentBreadcrumb = breadcrumbItems[breadcrumbItems.length - 2]
|
||||||
|
handleBreadcrumbNavigate(parentBreadcrumb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>File Manager</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold">Files</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Upload, organize, and manage files in your application
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
>
|
||||||
|
Delete Selected ({selectedItems.length})
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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 }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<Spinner size={40} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4'
|
||||||
|
: 'space-y-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="col-span-full text-center py-20">
|
||||||
|
<FaFolder className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{filters.searchTerm ? 'No files match your search' : 'This folder is empty'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item) => (
|
||||||
|
<FileItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selectedItems.includes(item.id)}
|
||||||
|
onSelect={handleItemSelect}
|
||||||
|
onDoubleClick={handleItemDoubleClick}
|
||||||
|
onRename={openRenameModal}
|
||||||
|
onDelete={(item) => openDeleteModal([item])}
|
||||||
|
onDownload={item.type === 'file' ? handleDownload : undefined}
|
||||||
|
className={viewMode === 'list' ? 'flex items-center p-3 space-x-4' : 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileManager
|
||||||
45
ui/src/views/admin/files/components/Breadcrumb.tsx
Normal file
45
ui/src/views/admin/files/components/Breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { HiChevronRight, HiFolder, HiHome } from 'react-icons/hi2'
|
||||||
|
import type { BreadcrumbItem } from '@/types/fileManagement'
|
||||||
|
|
||||||
|
export interface BreadcrumbProps {
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
onNavigate: (item: BreadcrumbItem) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Breadcrumb = forwardRef<HTMLDivElement, BreadcrumbProps>((props, ref) => {
|
||||||
|
const { items, onNavigate, className } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={classNames('flex items-center space-x-1 text-sm', className)}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.path} className="flex items-center">
|
||||||
|
{index > 0 && <HiChevronRight className="mx-2 h-4 w-4 text-gray-400" />}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(item)}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-center px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
||||||
|
index === items.length - 1
|
||||||
|
? 'text-gray-900 dark:text-gray-100 font-medium cursor-default'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100',
|
||||||
|
)}
|
||||||
|
disabled={index === items.length - 1}
|
||||||
|
>
|
||||||
|
{index === 0 ? (
|
||||||
|
<HiHome className="h-4 w-4 mr-1" />
|
||||||
|
) : (
|
||||||
|
<HiFolder className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
<span className="truncate max-w-32">{item.name}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Breadcrumb.displayName = 'Breadcrumb'
|
||||||
|
|
||||||
|
export default Breadcrumb
|
||||||
225
ui/src/views/admin/files/components/FileItem.tsx
Normal file
225
ui/src/views/admin/files/components/FileItem.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { forwardRef, useState } from 'react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import {
|
||||||
|
HiFolder,
|
||||||
|
HiDocument,
|
||||||
|
HiPhoto,
|
||||||
|
HiFilm,
|
||||||
|
HiMusicalNote,
|
||||||
|
HiArchiveBox,
|
||||||
|
HiEllipsisVertical,
|
||||||
|
HiPencil,
|
||||||
|
HiArrowRightOnRectangle,
|
||||||
|
HiTrash,
|
||||||
|
HiArrowDownTray,
|
||||||
|
HiEye,
|
||||||
|
} from 'react-icons/hi2'
|
||||||
|
import { Dropdown } from '@/components/ui'
|
||||||
|
import type { FileItem as FileItemType, FileActionMenuItem } from '@/types/fileManagement'
|
||||||
|
|
||||||
|
export interface FileItemProps {
|
||||||
|
item: FileItemType
|
||||||
|
selected?: boolean
|
||||||
|
onSelect?: (item: FileItemType) => void
|
||||||
|
onDoubleClick?: (item: FileItemType) => void
|
||||||
|
onRename?: (item: FileItemType) => void
|
||||||
|
onMove?: (item: FileItemType) => void
|
||||||
|
onDelete?: (item: FileItemType) => void
|
||||||
|
onDownload?: (item: FileItemType) => void
|
||||||
|
onPreview?: (item: FileItemType) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (item: FileItemType) => {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
return <HiFolder className="h-8 w-8 text-blue-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = item.extension?.toLowerCase()
|
||||||
|
const mimeType = item.mimeType?.toLowerCase()
|
||||||
|
|
||||||
|
if (mimeType?.startsWith('image/')) {
|
||||||
|
return <HiPhoto className="h-8 w-8 text-green-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType?.startsWith('video/')) {
|
||||||
|
return <HiFilm className="h-8 w-8 text-purple-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType?.startsWith('audio/')) {
|
||||||
|
return <HiMusicalNote className="h-8 w-8 text-pink-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || '')) {
|
||||||
|
return <HiArchiveBox className="h-8 w-8 text-orange-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HiDocument className="h-8 w-8 text-gray-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number): string => {
|
||||||
|
if (!bytes) return ''
|
||||||
|
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileItem = forwardRef<HTMLDivElement, FileItemProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
item,
|
||||||
|
selected = false,
|
||||||
|
onSelect,
|
||||||
|
onDoubleClick,
|
||||||
|
onRename,
|
||||||
|
onMove,
|
||||||
|
onDelete,
|
||||||
|
onDownload,
|
||||||
|
onPreview,
|
||||||
|
className,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onSelect?.(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDoubleClick = () => {
|
||||||
|
onDoubleClick?.(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionMenuItems: FileActionMenuItem[] = [
|
||||||
|
...(item.type === 'file' && onPreview
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'preview',
|
||||||
|
label: 'Preview',
|
||||||
|
icon: 'HiEye',
|
||||||
|
onClick: () => onPreview(item),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(item.type === 'file' && onDownload
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
label: 'Download',
|
||||||
|
icon: 'HiArrowDownTray',
|
||||||
|
onClick: () => onDownload(item),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
label: 'Rename',
|
||||||
|
icon: 'HiPencil',
|
||||||
|
onClick: () => onRename?.(item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'move',
|
||||||
|
label: 'Move',
|
||||||
|
icon: 'HiArrowRightOnRectangle',
|
||||||
|
onClick: () => onMove?.(item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: 'Delete',
|
||||||
|
icon: 'HiTrash',
|
||||||
|
dangerous: true,
|
||||||
|
onClick: () => onDelete?.(item),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const dropdownList = (
|
||||||
|
<div className="py-1">
|
||||||
|
{actionMenuItems.map((menuItem) => (
|
||||||
|
<div
|
||||||
|
key={menuItem.key}
|
||||||
|
className={classNames(
|
||||||
|
'flex items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
menuItem.dangerous && 'text-red-600 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
menuItem.onClick()
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItem.icon === 'HiEye' && <HiEye className="h-4 w-4 mr-2" />}
|
||||||
|
{menuItem.icon === 'HiArrowDownTray' && <HiArrowDownTray className="h-4 w-4 mr-2" />}
|
||||||
|
{menuItem.icon === 'HiPencil' && <HiPencil className="h-4 w-4 mr-2" />}
|
||||||
|
{menuItem.icon === 'HiArrowRightOnRectangle' && (
|
||||||
|
<HiArrowRightOnRectangle className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{menuItem.icon === 'HiTrash' && <HiTrash className="h-4 w-4 mr-2" />}
|
||||||
|
{menuItem.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(
|
||||||
|
'relative group p-4 border rounded-lg cursor-pointer transition-all duration-200',
|
||||||
|
'hover:border-blue-300 hover:shadow-md',
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{/* Action Menu */}
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Dropdown
|
||||||
|
onToggle={(open) => setDropdownOpen(open || false)}
|
||||||
|
renderTitle={
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HiEllipsisVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dropdownList}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File/Folder Icon */}
|
||||||
|
<div className="flex justify-center mb-3">{getFileIcon(item)}</div>
|
||||||
|
|
||||||
|
{/* File/Folder Name */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* File Size */}
|
||||||
|
{item.type === 'file' && item.size && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{formatFileSize(item.size)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folder Child Count */}
|
||||||
|
{item.type === 'folder' &&
|
||||||
|
'childCount' in item &&
|
||||||
|
typeof (item as any).childCount === 'number' && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{(item as any).childCount} items
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
FileItem.displayName = 'FileItem'
|
||||||
|
|
||||||
|
export default FileItem
|
||||||
256
ui/src/views/admin/files/components/FileModals.tsx
Normal file
256
ui/src/views/admin/files/components/FileModals.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { forwardRef, useState, useEffect } from 'react'
|
||||||
|
import { Dialog, Button, Input, FormItem } from '@/components/ui'
|
||||||
|
import { HiXMark } from 'react-icons/hi2'
|
||||||
|
import type { FileItem } from '@/types/fileManagement'
|
||||||
|
|
||||||
|
// Create Folder Modal
|
||||||
|
export interface CreateFolderModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreate: (name: string) => Promise<void>
|
||||||
|
loading?: boolean
|
||||||
|
currentFolderId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateFolderModal = forwardRef<HTMLDivElement, CreateFolderModalProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { isOpen, onClose, onCreate, loading = false } = props
|
||||||
|
const [folderName, setFolderName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!folderName.trim()) {
|
||||||
|
setError('Folder name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onCreate(folderName.trim())
|
||||||
|
setFolderName('')
|
||||||
|
setError('')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to create folder')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setFolderName('')
|
||||||
|
setError('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} onClose={handleClose}>
|
||||||
|
<div ref={ref}>
|
||||||
|
<form onSubmit={handleSubmit} className="py-6">
|
||||||
|
<FormItem label="Folder Name" invalid={!!error} errorMessage={error}>
|
||||||
|
<Input
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => setFolderName(e.target.value)}
|
||||||
|
placeholder="Enter folder name"
|
||||||
|
autoFocus
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||||
|
<Button variant="default" onClick={handleClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!folderName.trim()}
|
||||||
|
>
|
||||||
|
Create Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
CreateFolderModal.displayName = 'CreateFolderModal'
|
||||||
|
|
||||||
|
// Rename Item Modal
|
||||||
|
export interface RenameItemModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onRename: (newName: string) => Promise<void>
|
||||||
|
item?: FileItem
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RenameItemModal = forwardRef<HTMLDivElement, RenameItemModalProps>((props, ref) => {
|
||||||
|
const { isOpen, onClose, onRename, item, loading = false } = props
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Update input when item changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
setNewName(item.name)
|
||||||
|
}
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!newName.trim()) {
|
||||||
|
setError(`${item?.type === 'folder' ? 'Folder' : 'File'} name is required`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName.trim() === item?.name) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onRename(newName.trim())
|
||||||
|
setError('')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Failed to rename ${item?.type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setNewName('')
|
||||||
|
setError('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} onClose={handleClose}>
|
||||||
|
<div ref={ref} className="max-w-md">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Rename {item.type === 'folder' ? 'Folder' : 'File'}
|
||||||
|
</h3>
|
||||||
|
<Button variant="plain" size="sm" icon={<HiXMark />} onClick={handleClose} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="py-6">
|
||||||
|
<FormItem
|
||||||
|
label={`${item.type === 'folder' ? 'Folder' : 'File'} Name`}
|
||||||
|
invalid={!!error}
|
||||||
|
errorMessage={error}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={`Enter ${item.type} name`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||||
|
<Button variant="default" onClick={handleClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
RenameItemModal.displayName = 'RenameItemModal'
|
||||||
|
|
||||||
|
// Delete Confirmation Modal
|
||||||
|
export interface DeleteConfirmModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onDelete: () => Promise<void>
|
||||||
|
items: FileItem[]
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteConfirmModal = forwardRef<HTMLDivElement, DeleteConfirmModalProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { isOpen, onClose, onDelete, items, loading = false } = props
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await onDelete()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
// Error handling is done in parent component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderCount = items.filter((item) => item.type === 'folder').length
|
||||||
|
const fileCount = items.filter((item) => item.type === 'file').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} onClose={onClose}>
|
||||||
|
<div ref={ref} className="max-w-md">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold text-red-600">Delete Items</h3>
|
||||||
|
<Button variant="plain" size="sm" icon={<HiXMark />} onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
<p className="text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Are you sure you want to delete the following items? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
{folderCount > 0 && (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{folderCount} folder{folderCount > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fileCount > 0 && (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{fileCount} file{fileCount > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 1 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{items[0].name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
onClick={handleDelete}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
DeleteConfirmModal.displayName = 'DeleteConfirmModal'
|
||||||
275
ui/src/views/admin/files/components/FileUploadModal.tsx
Normal file
275
ui/src/views/admin/files/components/FileUploadModal.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { forwardRef, useState, useCallback, useRef } from 'react'
|
||||||
|
import { Dialog, Button, Progress } from '@/components/ui'
|
||||||
|
import { HiCloudArrowUp, HiXMark } from 'react-icons/hi2'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
export interface FileUploadModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onUpload: (files: File[]) => Promise<void>
|
||||||
|
currentFolderId?: string
|
||||||
|
loading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadFileWithProgress extends File {
|
||||||
|
id: string
|
||||||
|
progress: number
|
||||||
|
status: 'pending' | 'uploading' | 'completed' | 'error'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUploadModal = forwardRef<HTMLDivElement, FileUploadModalProps>((props, ref) => {
|
||||||
|
const { isOpen, onClose, onUpload, currentFolderId, loading = false, className } = props
|
||||||
|
|
||||||
|
const [uploadFiles, setUploadFiles] = useState<UploadFileWithProgress[]>([])
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const generateFileId = () => Math.random().toString(36).substr(2, 9)
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilesSelect = useCallback((files: FileList | File[]) => {
|
||||||
|
const fileArray = Array.from(files)
|
||||||
|
const newFiles: UploadFileWithProgress[] = fileArray.map((file) => ({
|
||||||
|
...file,
|
||||||
|
id: generateFileId(),
|
||||||
|
progress: 0,
|
||||||
|
status: 'pending' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setUploadFiles((prev) => [...prev, ...newFiles])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
handleFilesSelect(e.target.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files) {
|
||||||
|
handleFilesSelect(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleFilesSelect],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = (fileId: string) => {
|
||||||
|
setUploadFiles((prev) => prev.filter((f) => f.id !== fileId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (uploadFiles.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
const filesToUpload = uploadFiles.filter((f) => f.status === 'pending')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate upload progress for demo - replace with actual upload logic
|
||||||
|
for (const file of filesToUpload) {
|
||||||
|
setUploadFiles((prev) =>
|
||||||
|
prev.map((f) => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate progress
|
||||||
|
for (let progress = 0; progress <= 100; progress += 10) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
setUploadFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, progress } : f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadFiles((prev) =>
|
||||||
|
prev.map((f) => (f.id === file.id ? { ...f, status: 'completed' as const } : f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the actual upload function
|
||||||
|
await onUpload(filesToUpload)
|
||||||
|
|
||||||
|
// Close modal after successful upload
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
setUploadFiles([])
|
||||||
|
}, 1000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error)
|
||||||
|
setUploadFiles((prev) =>
|
||||||
|
prev.map((f) =>
|
||||||
|
f.status === 'uploading' ? { ...f, status: 'error' as const, error: 'Upload failed' } : f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!uploading) {
|
||||||
|
setUploadFiles([])
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFiles = uploadFiles.length
|
||||||
|
const completedFiles = uploadFiles.filter((f) => f.status === 'completed').length
|
||||||
|
const hasError = uploadFiles.some((f) => f.status === 'error')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={isOpen} onClose={handleClose} className={className}>
|
||||||
|
<div ref={ref}>
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b">
|
||||||
|
<h3 className="text-lg font-semibold">Upload Files</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors',
|
||||||
|
isDragOver
|
||||||
|
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400',
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HiCloudArrowUp className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Choose files or drag and drop here
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Select one or more files to upload
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
Select Files
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File List */}
|
||||||
|
{uploadFiles.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
Files to upload ({totalFiles})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{uploadFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{file.status === 'uploading' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Progress percent={file.progress} size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Messages */}
|
||||||
|
{file.status === 'completed' && (
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||||
|
Upload completed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{file.status === 'error' && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{file.error || 'Upload failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
icon={<HiXMark />}
|
||||||
|
onClick={() => removeFile(file.id)}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{uploading && (
|
||||||
|
<span>
|
||||||
|
Uploading {completedFiles}/{totalFiles} files...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!uploading && completedFiles > 0 && !hasError && (
|
||||||
|
<span className="text-green-600">All files uploaded successfully!</span>
|
||||||
|
)}
|
||||||
|
{hasError && <span className="text-red-600">Some files failed to upload</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="default" onClick={handleClose} disabled={uploading}>
|
||||||
|
{uploading ? 'Uploading...' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploadFiles.length === 0 || uploading || completedFiles === totalFiles}
|
||||||
|
loading={uploading}
|
||||||
|
>
|
||||||
|
Upload Files
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
FileUploadModal.displayName = 'FileUploadModal'
|
||||||
|
|
||||||
|
export default FileUploadModal
|
||||||
Loading…
Reference in a new issue