Spaces:
Running
Running
"use client"; | |
import React, { useState } from 'react'; | |
import { FileDocument, FolderDocument } from '@/types'; | |
import { FileTreeItem } from './file-tree-item'; | |
import { FolderTreeItem } from './folder-tree-item'; | |
import { | |
DndContext, | |
DragEndEvent, | |
DragOverlay, | |
DragStartEvent, | |
closestCenter, | |
KeyboardSensor, | |
PointerSensor, | |
useSensor, | |
useSensors, | |
} from '@dnd-kit/core'; | |
import { | |
SortableContext, | |
sortableKeyboardCoordinates, | |
verticalListSortingStrategy, | |
} from '@dnd-kit/sortable'; | |
import { FileTypeDetector } from '@/lib/file-type-detector'; | |
export interface FileTreeProps { | |
files: FileDocument[]; | |
folders: FolderDocument[]; | |
selectedFileId?: string; | |
selectedFolderId?: string | null; | |
expandedFolders: Set<string>; | |
onFileSelect: (file: FileDocument) => void; | |
onFolderSelect: (folderId: string | null) => void; | |
onFolderToggle: (folderId: string) => void; | |
onFileDelete: (fileId: string) => void; | |
onFileRename: (fileId: string, newName: string) => void; | |
onFileDuplicate: (fileId: string) => void; | |
onFileDrop?: (fileId: string, targetFolderId: string | null) => void; | |
onFolderDrop?: (folderId: string, targetFolderId: string | null) => void; | |
} | |
interface DragItem { | |
id: string; | |
type: 'file' | 'folder'; | |
name: string; | |
} | |
export function FileTree({ | |
files, | |
folders, | |
selectedFileId, | |
selectedFolderId, | |
expandedFolders, | |
onFileSelect, | |
onFolderSelect, | |
onFolderToggle, | |
onFileDelete, | |
onFileRename, | |
onFileDuplicate, | |
onFileDrop, | |
onFolderDrop | |
}: FileTreeProps) { | |
const [activeItem, setActiveItem] = useState<DragItem | null>(null); | |
const sensors = useSensors( | |
useSensor(PointerSensor, { | |
activationConstraint: { | |
distance: 8, | |
}, | |
}), | |
useSensor(KeyboardSensor, { | |
coordinateGetter: sortableKeyboardCoordinates, | |
}) | |
); | |
type TreeNode = { type: 'file' | 'folder'; item: FileDocument | FolderDocument; children?: TreeNode[] }; | |
// Build tree structure | |
const buildTree = () => { | |
const tree: TreeNode[] = []; | |
// Add root folders first | |
const rootFolders = folders.filter(folder => !folder.parentFolderId); | |
const addFolderToTree = (folder: FolderDocument, parentArray: TreeNode[]) => { | |
const folderNode = { | |
type: 'folder' as const, | |
item: folder, | |
children: [] as TreeNode[] | |
}; | |
parentArray.push(folderNode); | |
// Add subfolders | |
const subfolders = folders.filter(f => f.parentFolderId === folder._id); | |
subfolders.forEach(subfolder => addFolderToTree(subfolder, folderNode.children)); | |
// Add files in this folder | |
const folderFiles = files.filter(f => f.parentFolderId === folder._id); | |
folderFiles.forEach(file => { | |
folderNode.children.push({ | |
type: 'file' as const, | |
item: file | |
}); | |
}); | |
}; | |
// Add root folders and their contents | |
rootFolders.forEach(folder => addFolderToTree(folder, tree)); | |
// Add root files (files without parent folder) | |
const rootFiles = files.filter(file => !file.parentFolderId); | |
rootFiles.forEach(file => { | |
tree.push({ | |
type: 'file' as const, | |
item: file | |
}); | |
}); | |
return tree; | |
}; | |
const tree = buildTree(); | |
const handleDragStart = (event: DragStartEvent) => { | |
const { active } = event; | |
const [type, id] = (active.id as string).split(':'); | |
let name = ''; | |
if (type === 'file') { | |
const file = files.find(f => f._id === id); | |
name = file?.name || ''; | |
} else if (type === 'folder') { | |
const folder = folders.find(f => f._id === id); | |
name = folder?.name || ''; | |
} | |
setActiveItem({ | |
id, | |
type: type as 'file' | 'folder', | |
name | |
}); | |
}; | |
const handleDragEnd = (event: DragEndEvent) => { | |
const { active, over } = event; | |
setActiveItem(null); | |
if (!over) return; | |
const [activeType, activeId] = (active.id as string).split(':'); | |
const [overType, overId] = (over.id as string).split(':'); | |
// Don't allow dropping on self | |
if (activeId === overId) return; | |
// Handle file drop | |
if (activeType === 'file') { | |
const targetFolderId = overType === 'folder' ? overId : null; | |
onFileDrop?.(activeId, targetFolderId); | |
} | |
// Handle folder drop | |
if (activeType === 'folder') { | |
const targetFolderId = overType === 'folder' ? overId : null; | |
onFolderDrop?.(activeId, targetFolderId); | |
} | |
}; | |
const renderTreeNode = (node: TreeNode, level: number = 0) => { | |
const { type, item, children } = node; | |
if (type === 'folder') { | |
const folder = item as FolderDocument; | |
const isExpanded = expandedFolders.has(folder._id); | |
const isSelected = selectedFolderId === folder._id; | |
return ( | |
<div key={folder._id}> | |
<FolderTreeItem | |
folder={folder} | |
level={level} | |
isExpanded={isExpanded} | |
isSelected={isSelected} | |
onToggle={() => onFolderToggle(folder._id)} | |
onSelect={() => onFolderSelect(folder._id)} | |
isDragOver={false} // TODO: implement drag over state | |
/> | |
{isExpanded && children && children.length > 0 && ( | |
<div className="ml-4"> | |
{children.map((child: TreeNode) => renderTreeNode(child, level + 1))} | |
</div> | |
)} | |
</div> | |
); | |
} else { | |
const file = item as FileDocument; | |
const isSelected = selectedFileId === file._id; | |
return ( | |
<FileTreeItem | |
key={file._id} | |
file={file} | |
level={level} | |
isSelected={isSelected} | |
onSelect={() => onFileSelect(file)} | |
onDelete={() => onFileDelete(file._id)} | |
onRename={(newName) => onFileRename(file._id, newName)} | |
onDuplicate={() => onFileDuplicate(file._id)} | |
/> | |
); | |
} | |
}; | |
const allItems = [ | |
...folders.map(f => `folder:${f._id}`), | |
...files.map(f => `file:${f._id}`) | |
]; | |
if (tree.length === 0) { | |
return ( | |
<div className="text-center text-neutral-400 py-8"> | |
<div className="text-sm">No files or folders</div> | |
<div className="text-xs mt-1">Create your first file or folder to get started</div> | |
</div> | |
); | |
} | |
return ( | |
<DndContext | |
sensors={sensors} | |
collisionDetection={closestCenter} | |
onDragStart={handleDragStart} | |
onDragEnd={handleDragEnd} | |
> | |
<SortableContext items={allItems} strategy={verticalListSortingStrategy}> | |
<div className="space-y-1"> | |
{tree.map(node => renderTreeNode(node))} | |
</div> | |
</SortableContext> | |
<DragOverlay> | |
{activeItem ? ( | |
<div className="bg-neutral-800 border border-neutral-600 rounded px-2 py-1 text-sm text-white shadow-lg"> | |
<div className="flex items-center gap-2"> | |
<span> | |
{activeItem.type === 'file' | |
? FileTypeDetector.getFileIcon( | |
files.find(f => f._id === activeItem.id)?.type || 'txt' | |
) | |
: '๐' | |
} | |
</span> | |
<span>{activeItem.name}</span> | |
</div> | |
</div> | |
) : null} | |
</DragOverlay> | |
</DndContext> | |
); | |
} | |