273 lines
No EOL
8.9 KiB
TypeScript
273 lines
No EOL
8.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Folder, FolderPlus, MoreHorizontal, Check, X, ChevronRight, ChevronDown } from 'lucide-react';
|
|
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
|
|
|
interface Folder {
|
|
_id: string;
|
|
id?: string;
|
|
name: string;
|
|
parent_folder_id?: string | null;
|
|
level: number;
|
|
created_by?: string;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface FolderTreeItemProps {
|
|
folder: Folder;
|
|
children?: Folder[];
|
|
allPersonas: any[];
|
|
selectedFolder: string;
|
|
isExpanded: boolean;
|
|
folderToRename: Folder | null;
|
|
renameFolderName: string;
|
|
onFolderSelect: (folderId: string) => void;
|
|
onToggleExpansion: (folderId: string) => void;
|
|
onStartRename: (folder: Folder) => void;
|
|
onCompleteRename: () => void;
|
|
onCancelRename: () => void;
|
|
onStartDelete: (folder: Folder) => void;
|
|
onStartCreateSubfolder: (parentId: string) => void;
|
|
onRenameFolderNameChange: (name: string) => void;
|
|
isDragOverlay?: boolean;
|
|
currentlyDraggedFolderId?: string | null;
|
|
}
|
|
|
|
const FolderTreeItem = ({
|
|
folder,
|
|
children = [],
|
|
allPersonas,
|
|
selectedFolder,
|
|
isExpanded,
|
|
folderToRename,
|
|
renameFolderName,
|
|
onFolderSelect,
|
|
onToggleExpansion,
|
|
onStartRename,
|
|
onCompleteRename,
|
|
onCancelRename,
|
|
onStartDelete,
|
|
onStartCreateSubfolder,
|
|
onRenameFolderNameChange,
|
|
isDragOverlay = false,
|
|
currentlyDraggedFolderId = null,
|
|
}: FolderTreeItemProps) => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
// Calculate persona count for this folder (explicit membership only)
|
|
const personaCount = allPersonas.filter(persona =>
|
|
persona.folder_ids && persona.folder_ids.includes(folder._id)
|
|
).length;
|
|
|
|
const hasChildren = children.length > 0;
|
|
const canHaveChildren = folder.level === 0; // Only root folders can have children
|
|
const isAllPersonasFolder = folder._id === 'all-personas-root';
|
|
|
|
// Check if this folder is the current parent of the dragged folder
|
|
const isCurrentParentOfDraggedFolder = currentlyDraggedFolderId &&
|
|
children.some(child => child._id === currentlyDraggedFolderId);
|
|
|
|
// Don't allow dropping into current parent or invalid targets
|
|
// All Personas folder can always receive drops (special case for root moves)
|
|
const canReceiveDrop = isAllPersonasFolder || (canHaveChildren && !isCurrentParentOfDraggedFolder);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef: setDragRef,
|
|
isDragging,
|
|
} = useDraggable({
|
|
id: folder._id,
|
|
data: {
|
|
type: 'folder',
|
|
folder,
|
|
},
|
|
});
|
|
|
|
const {
|
|
setNodeRef: setDropRef,
|
|
isOver,
|
|
} = useDroppable({
|
|
id: `drop-${folder._id}`,
|
|
data: {
|
|
type: 'folder',
|
|
folder,
|
|
},
|
|
disabled: !canReceiveDrop, // Only enable drop zone for valid targets
|
|
});
|
|
|
|
const setNodeRef = (node: HTMLElement | null) => {
|
|
setDragRef(node);
|
|
setDropRef(node);
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} className={isDragOverlay ? 'z-50' : ''}>
|
|
<div
|
|
className={`flex items-center justify-between group ${
|
|
isOver && canReceiveDrop
|
|
? 'bg-blue-50 border-2 border-blue-200 border-dashed rounded-md'
|
|
: ''
|
|
} ${
|
|
isDragging ? 'opacity-50' : ''
|
|
}`}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{folderToRename && folderToRename._id === folder._id ? (
|
|
// Rename mode
|
|
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
|
|
<div className="w-4 h-4" /> {/* Spacer for alignment */}
|
|
<Folder className="h-4 w-4" />
|
|
<Input
|
|
value={renameFolderName}
|
|
onChange={e => onRenameFolderNameChange(e.target.value)}
|
|
placeholder="Folder name"
|
|
className="h-7 text-sm"
|
|
autoFocus
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') {
|
|
onCompleteRename();
|
|
} else if (e.key === 'Escape') {
|
|
onCancelRename();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onCompleteRename}
|
|
className="h-7 w-7 p-0"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onCancelRename}
|
|
className="h-7 w-7 p-0"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
// Normal display mode
|
|
<>
|
|
<div
|
|
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors ${
|
|
selectedFolder === folder._id
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'hover:bg-slate-100'
|
|
}`}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
{/* Expansion chevron */}
|
|
{hasChildren && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleExpansion(folder._id);
|
|
}}
|
|
className="h-4 w-4 hover:bg-slate-200 rounded flex-shrink-0"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{!hasChildren && <div className="w-4 h-4 flex-shrink-0" />} {/* Spacer for alignment */}
|
|
|
|
<Folder className="h-4 w-4 flex-shrink-0" />
|
|
<button
|
|
onClick={() => onFolderSelect(folder._id)}
|
|
className="truncate text-left flex-1 hover:underline"
|
|
>
|
|
{folder.name}
|
|
</button>
|
|
<span className="text-muted-foreground text-xs ml-auto flex-shrink-0">
|
|
{isAllPersonasFolder ? allPersonas.length : personaCount}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Actions dropdown - or invisible spacer for All Personas */}
|
|
{!isAllPersonasFolder ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-7 w-7 p-0 transition-opacity ${
|
|
isHovered ? 'opacity-100' : 'opacity-0'
|
|
} group-hover:opacity-100`}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => onStartRename(folder)}>
|
|
Rename
|
|
</DropdownMenuItem>
|
|
{canHaveChildren && (
|
|
<DropdownMenuItem onClick={() => onStartCreateSubfolder(folder._id)}>
|
|
<FolderPlus className="h-4 w-4 mr-2" />
|
|
Create Sub-folder
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="text-red-600"
|
|
onClick={() => onStartDelete(folder)}
|
|
>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
// Invisible spacer to match dropdown menu width
|
|
<div className="h-7 w-7 flex-shrink-0" />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Child folders */}
|
|
{hasChildren && isExpanded && (
|
|
<div className="ml-4 border-l border-slate-200">
|
|
{children.map(childFolder => (
|
|
<FolderTreeItem
|
|
key={childFolder._id}
|
|
folder={childFolder}
|
|
children={[]} // Children can't have children in 2-level system
|
|
allPersonas={allPersonas}
|
|
selectedFolder={selectedFolder}
|
|
isExpanded={false} // Children can't be expanded
|
|
folderToRename={folderToRename}
|
|
renameFolderName={renameFolderName}
|
|
onFolderSelect={onFolderSelect}
|
|
onToggleExpansion={onToggleExpansion}
|
|
onStartRename={onStartRename}
|
|
onCompleteRename={onCompleteRename}
|
|
onCancelRename={onCancelRename}
|
|
onStartDelete={onStartDelete}
|
|
onStartCreateSubfolder={onStartCreateSubfolder}
|
|
onRenameFolderNameChange={onRenameFolderNameChange}
|
|
currentlyDraggedFolderId={currentlyDraggedFolderId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FolderTreeItem; |