diff --git a/backend/app/models/__pycache__/folder.cpython-313.pyc b/backend/app/models/__pycache__/folder.cpython-313.pyc index 2ff16e17..2a902be1 100644 Binary files a/backend/app/models/__pycache__/folder.cpython-313.pyc and b/backend/app/models/__pycache__/folder.cpython-313.pyc differ diff --git a/backend/app/models/folder.py b/backend/app/models/folder.py index fb8e9e9a..c2c1fe0b 100644 --- a/backend/app/models/folder.py +++ b/backend/app/models/folder.py @@ -6,13 +6,31 @@ from datetime import datetime class Folder: @staticmethod async def create(folder_data, user_id): - """Create a new folder.""" + """Create a new folder with hierarchical support.""" db = await get_db() # Add metadata folder_data["created_at"] = datetime.utcnow() folder_data["created_by"] = user_id + # Handle hierarchy + parent_folder_id = folder_data.get("parent_folder_id") + if parent_folder_id: + # Validate parent folder exists + parent_folder = await db.folders.find_one({"_id": ObjectId(parent_folder_id)}) + if not parent_folder: + raise ValueError("Parent folder not found") + + # Validate hierarchy depth (max 2 levels: parent -> child) + parent_level = parent_folder.get("level", 0) + if parent_level >= 1: + raise ValueError("Maximum folder depth exceeded (max 2 levels)") + + folder_data["level"] = parent_level + 1 + else: + folder_data["parent_folder_id"] = None + folder_data["level"] = 0 + # Note: No longer storing persona_ids in folders - using persona-centric storage result = await db.folders.insert_one(folder_data) @@ -320,4 +338,233 @@ class Folder: return result except Exception as e: print(f"Error getting folders for persona {persona_id}: {e}") - return [] \ No newline at end of file + return [] + + @staticmethod + async def get_folder_tree(user_id=None, limit=100): + """Get all folders organized in a hierarchical tree structure.""" + db = await get_db() + try: + # Get all folders + query = {} + if user_id: + query["created_by"] = user_id + + cursor = db.folders.find(query).sort("created_at", -1).limit(limit) + folders = await cursor.to_list(length=limit) + + # Convert ObjectIds to strings + processed_folders = [] + for folder in folders: + folder["_id"] = str(folder["_id"]) + if folder.get("parent_folder_id"): + folder["parent_folder_id"] = str(folder["parent_folder_id"]) + processed_folders.append(folder) + + return processed_folders + except Exception as e: + print(f"Error in Folder.get_folder_tree: {e}") + return [] + + @staticmethod + async def get_descendants(folder_id): + """Get all descendant folders of a given folder.""" + db = await get_db() + try: + descendants = [] + + # Get immediate children + children_cursor = db.folders.find({"parent_folder_id": folder_id}) + children = await children_cursor.to_list(length=None) + + for child in children: + child["_id"] = str(child["_id"]) + if child.get("parent_folder_id"): + child["parent_folder_id"] = str(child["parent_folder_id"]) + descendants.append(child) + + # Since we only support 2 levels, children can't have children + # But we'll keep this structure for potential future expansion + + return descendants + except Exception as e: + print(f"Error getting descendants for folder {folder_id}: {e}") + return [] + + @staticmethod + async def get_sibling_names(parent_id): + """Get names of all folders that would be siblings in the target location.""" + db = await get_db() + try: + if parent_id: + # Get folders at the same level under the parent + siblings_cursor = db.folders.find({"parent_folder_id": parent_id}) + else: + # Get root level folders + siblings_cursor = db.folders.find({"$or": [{"parent_folder_id": None}, {"level": 0}]}) + + siblings = await siblings_cursor.to_list(length=None) + return [folder.get("name", "") for folder in siblings] + except Exception as e: + print(f"Error getting sibling names: {e}") + return [] + + @staticmethod + def generate_unique_name(desired_name, existing_names): + """Generate a unique name by adding suffix if conflicts exist.""" + if desired_name not in existing_names: + return desired_name + + counter = 1 + while f"{desired_name}_{counter}" in existing_names: + counter += 1 + + return f"{desired_name}_{counter}" + + @staticmethod + async def move_folder(folder_id, new_parent_id, user_id): + """Move a folder to a new parent with automatic hierarchy flattening.""" + db = await get_db() + try: + # Get the folder to move + folder = await db.folders.find_one({"_id": ObjectId(folder_id)}) + if not folder: + return False, "Folder not found" + + # Folder operations are shared across all users in this system + # No ownership check needed + + # Check if trying to move into current parent (redundant operation) + if new_parent_id and folder.get("parent_folder_id") == new_parent_id: + return False, "Folder is already in this location" + + # If moving to root and folder has children, do nothing (it's already at root) + if not new_parent_id: + descendants = await Folder.get_descendants(folder_id) + if len(descendants) > 0: + return True, "Folder with children is already at root level" + + # Validate new parent + new_level = 0 + if new_parent_id: + parent_folder = await db.folders.find_one({"_id": ObjectId(new_parent_id)}) + if not parent_folder: + return False, "Parent folder not found" + + # Check if moving into descendant (prevent circular reference) + if parent_folder.get("parent_folder_id") == folder_id: + return False, "Cannot move folder into its own descendant" + + parent_level = parent_folder.get("level", 0) + if parent_level >= 1: + return False, "Maximum folder depth exceeded" + + new_level = parent_level + 1 + + # Get children of the folder being moved + descendants = await Folder.get_descendants(folder_id) + + # Get existing sibling names at the destination to handle conflicts + existing_names = await Folder.get_sibling_names(new_parent_id) + + moved_folders = [] + + # First, move the main folder + original_name = folder.get("name", "") + unique_name = Folder.generate_unique_name(original_name, existing_names) + + if unique_name != original_name: + # Update the folder name if there was a conflict + await db.folders.update_one( + {"_id": ObjectId(folder_id)}, + {"$set": {"name": unique_name, "updated_at": datetime.utcnow()}} + ) + + # Move the main folder + update_data = { + "parent_folder_id": new_parent_id, + "level": new_level, + "updated_at": datetime.utcnow() + } + + result = await db.folders.update_one( + {"_id": ObjectId(folder_id)}, + {"$set": update_data} + ) + + if result.modified_count > 0: + moved_folders.append({"name": unique_name, "original_name": original_name}) + existing_names.append(unique_name) # Add to existing names for subsequent conflicts + + # If there are children, flatten them to the same level + if len(descendants) > 0: + for child in descendants: + child_name = child.get("name", "") + unique_child_name = Folder.generate_unique_name(child_name, existing_names) + + # Update child folder name if there was a conflict + if unique_child_name != child_name: + await db.folders.update_one( + {"_id": ObjectId(child["_id"])}, + {"$set": {"name": unique_child_name, "updated_at": datetime.utcnow()}} + ) + + # Move child to the same parent as the moved folder (flattening) + child_result = await db.folders.update_one( + {"_id": ObjectId(child["_id"])}, + {"$set": { + "parent_folder_id": new_parent_id, + "level": new_level, + "updated_at": datetime.utcnow() + }} + ) + + if child_result.modified_count > 0: + moved_folders.append({"name": unique_child_name, "original_name": child_name}) + existing_names.append(unique_child_name) # Add to existing names + + # Build success message + if len(moved_folders) == 1: + message = f"Folder moved successfully" + else: + subfolder_names = [f["name"] for f in moved_folders[1:]] # Exclude main folder + message = f"Folder and {len(subfolder_names)} subfolders moved successfully (flattened): {', '.join(subfolder_names)}" + + return len(moved_folders) > 0, message + + except Exception as e: + print(f"Error moving folder {folder_id}: {e}") + return False, f"Error moving folder: {str(e)}" + + @staticmethod + async def delete_hierarchy(folder_id, user_id): + """Delete a folder and all its descendants.""" + db = await get_db() + try: + # Get the folder to delete + folder = await db.folders.find_one({"_id": ObjectId(folder_id)}) + if not folder: + return False, "Folder not found" + + # Folder operations are shared across all users in this system + # No ownership check needed + + # Get all descendants + descendants = await Folder.get_descendants(folder_id) + all_folder_ids = [folder_id] + [desc["_id"] for desc in descendants] + + # Remove folder references from all personas + for fid in all_folder_ids: + await db.personas.update_many( + {"folder_ids": fid}, + {"$pull": {"folder_ids": fid}, "$set": {"updated_at": datetime.utcnow()}} + ) + + # Delete all folders in the hierarchy + object_ids = [ObjectId(fid) for fid in all_folder_ids] + result = await db.folders.delete_many({"_id": {"$in": object_ids}}) + + return result.deleted_count > 0, f"Deleted {result.deleted_count} folders" + except Exception as e: + print(f"Error deleting folder hierarchy {folder_id}: {e}") + return False, f"Error deleting folder: {str(e)}" \ No newline at end of file diff --git a/backend/app/routes/__pycache__/folders.cpython-313.pyc b/backend/app/routes/__pycache__/folders.cpython-313.pyc index 149d9bb0..c1714c38 100644 Binary files a/backend/app/routes/__pycache__/folders.cpython-313.pyc and b/backend/app/routes/__pycache__/folders.cpython-313.pyc differ diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index b7f41f18..6551894c 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -23,10 +23,10 @@ folders_bp = Blueprint('folders', __name__) @folders_bp.route('/', methods=['GET']) @jwt_required(optional=True) # Make JWT optional for development async def get_folders(): - """Get all folders - shared across all users.""" + """Get all folders in hierarchical tree structure - shared across all users.""" try: - # Always return all folders - this is a shared persona system - folders = await Folder.get_all() + # Return folders in hierarchical tree structure + folders = await Folder.get_folder_tree() # Make folders serializable serializable_folders = make_serializable(folders) @@ -112,24 +112,23 @@ async def update_folder(folder_id): return jsonify({"message": f"Failed to update folder: {str(e)}"}), 500 @folders_bp.route('/', methods=['DELETE']) -@jwt_required() +@jwt_required(optional=True) # Make JWT optional for development async def delete_folder(folder_id): - """Delete a folder.""" - folder = await Folder.find_by_id(folder_id) - if not folder: - return jsonify({"message": "Folder not found"}), 404 - - # Ensure user owns the folder + """Delete a folder and its entire hierarchy.""" user_id = get_jwt_identity() - if folder.get('created_by') != user_id: - return jsonify({"message": "Unauthorized"}), 403 - success = await Folder.delete(folder_id) + # Folder operations are shared across all users in this system - if success: - return jsonify({"message": "Folder deleted successfully"}), 200 - else: - return jsonify({"message": "Failed to delete folder"}), 500 + try: + success, message = await Folder.delete_hierarchy(folder_id, user_id) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"message": message}), 400 + except Exception as e: + print(f"Error deleting folder hierarchy: {e}") + return jsonify({"message": f"Failed to delete folder: {str(e)}"}), 500 @folders_bp.route('//personas', methods=['POST']) @jwt_required() @@ -242,4 +241,38 @@ async def remove_personas_from_folder_batch(folder_id): return jsonify({"message": "Update failed or no changes made"}), 200 except Exception as e: print(f"Error removing personas from folder: {e}") - return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500 \ No newline at end of file + return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500 + +@folders_bp.route('//move', methods=['PUT']) +@jwt_required(optional=True) # Make JWT optional for development +async def move_folder(folder_id): + """Move a folder to a new parent.""" + try: + data = await request.get_json() + user_id = get_jwt_identity() + + new_parent_id = data.get('parent_folder_id') # None for root level + + # Folder operations are shared across all users in this system + + success, message = await Folder.move_folder(folder_id, new_parent_id, user_id) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"message": message}), 400 + except Exception as e: + print(f"Error moving folder: {e}") + return jsonify({"message": f"Failed to move folder: {str(e)}"}), 500 + +@folders_bp.route('//descendants', methods=['GET']) +@jwt_required(optional=True) +async def get_folder_descendants(folder_id): + """Get all descendant folders of a given folder.""" + try: + descendants = await Folder.get_descendants(folder_id) + serializable_descendants = make_serializable(descendants) + return jsonify(serializable_descendants), 200 + except Exception as e: + print(f"Error getting folder descendants: {e}") + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index d233cfce..f1a5c595 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/components/FolderTree.tsx b/src/components/FolderTree.tsx new file mode 100644 index 00000000..2e99812a --- /dev/null +++ b/src/components/FolderTree.tsx @@ -0,0 +1,454 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { FolderPlus, Folder, Check, X } from 'lucide-react'; +import FolderTreeItem from './FolderTreeItem'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + useDroppable, + rectIntersection, +} 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 FolderTreeProps { + folders: Folder[]; + allPersonas: any[]; + selectedFolder: string; + onFolderSelect: (folderId: string) => void; + onCreateFolder: (name: string, parentId?: string) => Promise; + onRenameFolder: (folderId: string, newName: string) => Promise; + onDeleteFolder: (folder: Folder) => void; + onMoveFolder: (folderId: string, newParentId: string | null) => Promise; + defaultFolderId: string; +} + +const FolderTree = ({ + folders, + allPersonas, + selectedFolder, + onFolderSelect, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onMoveFolder, + defaultFolderId, +}: FolderTreeProps) => { + // Local state for folder management + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [creatingParentId, setCreatingParentId] = useState(null); + + const [folderToRename, setFolderToRename] = useState(null); + const [renameFolderName, setRenameFolderName] = useState(''); + + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [activeId, setActiveId] = useState(null); + const [dragConfirmOpen, setDragConfirmOpen] = useState(false); + const [pendingMove, setPendingMove] = useState<{ + folderId: string; + newParentId: string | null; + folderName: string; + parentName: string | null; + subfolders: string[]; + } | null>(null); + + // Drag and drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + // Load expansion state from localStorage + useEffect(() => { + const savedExpansion = localStorage.getItem('folderTreeExpansion'); + if (savedExpansion) { + try { + const parsed = JSON.parse(savedExpansion); + setExpandedFolders(new Set(parsed)); + } catch (e) { + console.warn('Failed to parse folder expansion state:', e); + } + } + }, []); + + // Save expansion state to localStorage + const saveExpansionState = (newExpandedFolders: Set) => { + localStorage.setItem('folderTreeExpansion', JSON.stringify(Array.from(newExpandedFolders))); + }; + + // Organize folders into hierarchy + const organizeHierarchy = () => { + const rootFolders: Folder[] = []; + const childMap: Record = {}; + + folders.forEach(folder => { + if (!folder.parent_folder_id || folder.level === 0) { + rootFolders.push(folder); + } else { + if (!childMap[folder.parent_folder_id]) { + childMap[folder.parent_folder_id] = []; + } + childMap[folder.parent_folder_id].push(folder); + } + }); + + return { rootFolders, childMap }; + }; + + const { rootFolders, childMap } = organizeHierarchy(); + + const handleToggleExpansion = (folderId: string) => { + const newExpandedFolders = new Set(expandedFolders); + if (newExpandedFolders.has(folderId)) { + newExpandedFolders.delete(folderId); + } else { + newExpandedFolders.add(folderId); + } + setExpandedFolders(newExpandedFolders); + saveExpansionState(newExpandedFolders); + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) return; + + try { + await onCreateFolder(newFolderName.trim(), creatingParentId || undefined); + setNewFolderName(''); + setIsCreatingFolder(false); + setCreatingParentId(null); + } catch (error) { + console.error('Failed to create folder:', error); + } + }; + + const handleStartRename = (folder: Folder) => { + setFolderToRename(folder); + setRenameFolderName(folder.name); + }; + + const handleCompleteRename = async () => { + if (!folderToRename || !renameFolderName.trim()) { + setFolderToRename(null); + return; + } + + try { + await onRenameFolder(folderToRename._id, renameFolderName.trim()); + setFolderToRename(null); + } catch (error) { + console.error('Failed to rename folder:', error); + setFolderToRename(null); + } + }; + + const handleStartCreateSubfolder = (parentId: string) => { + setCreatingParentId(parentId); + setIsCreatingFolder(true); + setNewFolderName(''); + }; + + const handleDragStart = (event: DragStartEvent) => { + const draggedFolderId = event.active.id as string; + setActiveId(draggedFolderId); + console.log('🖱️ Drag started:', draggedFolderId); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + console.log('🖱️ Drag ended:', { + activeId: active.id, + overId: over?.id, + overType: over?.data.current?.type, + }); + + if (!over || active.id === over.id) return; + + const draggedFolder = folders.find(f => f._id === active.id); + if (!draggedFolder) return; + + let newParentId: string | null = null; + let parentName: string | null = null; + + if (over.data.current?.type === 'folder') { + const targetFolder = over.data.current.folder as Folder; + + // Special case: All Personas folder (move to root level) + if (targetFolder._id === 'all-personas-root') { + console.log('🖱️ Dropping to root level via All Personas'); + newParentId = null; + parentName = null; + } else { + // Regular folder drop + + // Validate: can't move into a child folder or same level child + if (targetFolder.level >= 1) return; + + // Validate: can't move into self + if (targetFolder._id === draggedFolder._id) return; + + // Validate: can't move into current parent (redundant operation) + if (draggedFolder.parent_folder_id === targetFolder._id) return; + + newParentId = targetFolder._id; + parentName = targetFolder.name; + } + } else { + // If no valid drop zone, don't proceed + return; + } + + // Get subfolders of the dragged folder + const { childMap } = organizeHierarchy(); + const subfolders = childMap[draggedFolder._id] || []; + const subfolderNames = subfolders.map(subfolder => subfolder.name); + + // Show confirmation dialog + setPendingMove({ + folderId: draggedFolder._id, + newParentId, + folderName: draggedFolder.name, + parentName, + subfolders: subfolderNames, + }); + setDragConfirmOpen(true); + }; + + const handleConfirmMove = async () => { + if (!pendingMove) return; + + try { + await onMoveFolder(pendingMove.folderId, pendingMove.newParentId); + setDragConfirmOpen(false); + setPendingMove(null); + } catch (error) { + console.error('Failed to move folder:', error); + } + }; + + const activeDragFolder = activeId ? folders.find(f => f._id === activeId) : null; + + + return ( + <> +
+
+

Folders

+ +
+ + {/* Hierarchical folder tree with drag-and-drop */} + { + console.log('🖱️ Drag over:', { + activeId: event.active.id, + overId: event.over?.id, + overData: event.over?.data.current, + }); + }} + onDragEnd={handleDragEnd} + > +
+ {/* All Personas as a special FolderTreeItem */} + onFolderSelect(defaultFolderId)} + onToggleExpansion={() => {}} // No expansion for All Personas + onStartRename={() => {}} // No rename for All Personas + onCompleteRename={() => {}} + onCancelRename={() => {}} + onStartDelete={() => {}} // No delete for All Personas + onStartCreateSubfolder={() => {}} // No subfolder creation + onRenameFolderNameChange={() => {}} + currentlyDraggedFolderId={activeId} + /> + + {/* Regular folder list */} + {rootFolders.map(folder => ( + setFolderToRename(null)} + onStartDelete={onDeleteFolder} + onStartCreateSubfolder={handleStartCreateSubfolder} + onRenameFolderNameChange={setRenameFolderName} + currentlyDraggedFolderId={activeId} + /> + ))} + + {/* Create folder input */} + {isCreatingFolder && ( +
+
+
{/* Spacer */} + + setNewFolderName(e.target.value)} + placeholder={creatingParentId ? "Sub-folder name" : "Folder name"} + className="h-7 text-sm" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') { + handleCreateFolder(); + } else if (e.key === 'Escape') { + setIsCreatingFolder(false); + setNewFolderName(''); + setCreatingParentId(null); + } + }} + /> +
+ + +
+ )} +
+ + + {activeDragFolder && ( + {}} + onToggleExpansion={() => {}} + onStartRename={() => {}} + onCompleteRename={() => {}} + onCancelRename={() => {}} + onStartDelete={() => {}} + onStartCreateSubfolder={() => {}} + onRenameFolderNameChange={() => {}} + currentlyDraggedFolderId={activeId} + isDragOverlay + /> + )} + + +
+ + {/* Drag confirmation dialog */} + + + + Move Folder + + {pendingMove && ( + <> + {pendingMove.subfolders.length > 0 ? ( + <> + Are you sure you want to move "{pendingMove.folderName}" and its subfolders ({pendingMove.subfolders.join(', ')}) + {!pendingMove.parentName ? ' to the root level' : ` into "${pendingMove.parentName}"`}? + {pendingMove.parentName && ( + <> +

+ Note: The subfolders will be moved to the same level as "{pendingMove.folderName}" (hierarchy will be flattened). + + )} + + ) : ( + <> + Are you sure you want to move "{pendingMove.folderName}" + {!pendingMove.parentName ? ' to the root level' : ` into "${pendingMove.parentName}"`}? + + )} + + )} +
+
+ + + + +
+
+ + ); +}; + +export default FolderTree; \ No newline at end of file diff --git a/src/components/FolderTreeItem.tsx b/src/components/FolderTreeItem.tsx new file mode 100644 index 00000000..924cf6da --- /dev/null +++ b/src/components/FolderTreeItem.tsx @@ -0,0 +1,273 @@ +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 ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {folderToRename && folderToRename._id === folder._id ? ( + // Rename mode +
+
{/* Spacer for alignment */} + + 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(); + } + }} + /> + + +
+ ) : ( + // Normal display mode + <> +
+ {/* Expansion chevron */} + {hasChildren && ( + + )} + {!hasChildren &&
} {/* Spacer for alignment */} + + + + + {isAllPersonasFolder ? allPersonas.length : personaCount} + +
+ + {/* Actions dropdown - or invisible spacer for All Personas */} + {!isAllPersonasFolder ? ( + + + + + + onStartRename(folder)}> + Rename + + {canHaveChildren && ( + onStartCreateSubfolder(folder._id)}> + + Create Sub-folder + + )} + onStartDelete(folder)} + > + Delete + + + + ) : ( + // Invisible spacer to match dropdown menu width +
+ )} + + )} +
+ + {/* Child folders */} + {hasChildren && isExpanded && ( +
+ {children.map(childFolder => ( + + ))} +
+ )} +
+ ); +}; + +export default FolderTreeItem; \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3c622c08..350f66e2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -714,6 +714,13 @@ export const foldersApi = { persona_ids: personaIds }); }, + + // Hierarchical operations + moveFolder: (folderId: string, parentFolderId: string | null) => + api.put(`/folders/${folderId}/move`, { parent_folder_id: parentFolderId }), + + getDescendants: (folderId: string) => + api.get(`/folders/${folderId}/descendants`), // New endpoints for multiple folder management addPersonaToMultipleFolders: (personaId: string, folderIds: string[]) => { diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index 54a2fbf5..42ba7941 100644 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -44,12 +44,15 @@ import { getSocket } from '@/services/websocketServiceNew'; import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api'; import { toastService } from '@/lib/toast'; import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; +import FolderTree from '@/components/FolderTree'; interface Folder { _id: string; id?: string; // Legacy field for compatibility name: string; + parent_folder_id?: string | null; + level: number; created_by?: string; created_at?: string; updated_at?: string; @@ -91,8 +94,6 @@ const SyntheticUsers = () => { const [searchTerm, setSearchTerm] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [selectedFolder, setSelectedFolder] = useState(DEFAULT_FOLDER_ID); - const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); // Handle URL parameters to set mode and folder useEffect(() => { @@ -113,12 +114,10 @@ const SyntheticUsers = () => { const [error, setError] = useState(null); const [selectedPersonas, setSelectedPersonas] = useState>(new Set()); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [folderToRename, setFolderToRename] = useState(null); - const [renameFolderName, setRenameFolderName] = useState(''); const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [moveToFolderOpen, setMoveToFolderOpen] = useState(false); - const [targetFolder, setTargetFolder] = useState(null); + const [targetFolders, setTargetFolders] = useState>(new Set()); // Filter state const [isFilterOpen, setIsFilterOpen] = useState(false); const [activeFilters, setActiveFilters] = useState({ @@ -450,67 +449,70 @@ const SyntheticUsers = () => { // Note: Server-side folder management eliminates need for manual synchronization - const createNewFolder = async () => { - if (!newFolderName.trim()) { + const createNewFolder = async (name: string, parentId?: string) => { + if (!name.trim()) { toastService.error("Please enter a folder name"); return; } try { - const response = await foldersApi.create({ - name: newFolderName.trim(), + const folderData: any = { + name: name.trim(), persona_ids: [] - }); + }; + + if (parentId) { + folderData.parent_folder_id = parentId; + } + + const response = await foldersApi.create(folderData); // Refresh folders from server await fetchFolders(); - setNewFolderName(''); - setIsCreatingFolder(false); - - toastService.success(`Folder "${newFolderName}" created`); + const folderType = parentId ? 'Sub-folder' : 'Folder'; + toastService.success(`${folderType} "${name}" created`); } catch (error) { console.error("Error creating folder:", error); - toastService.error("Failed to create folder"); + const errorMessage = error.response?.data?.message || "Failed to create folder"; + toastService.error(errorMessage); } }; - const cancelFolderCreation = () => { - setNewFolderName(''); - setIsCreatingFolder(false); - }; - - const startRenameFolder = (folder: Folder) => { - setFolderToRename(folder); - setRenameFolderName(folder.name); - }; - - const completeRenameFolder = async () => { - if (!folderToRename || !renameFolderName.trim()) { - setFolderToRename(null); - return; - } - + const renameFolder = async (folderId: string, newName: string) => { try { - await foldersApi.update(folderToRename._id, { - name: renameFolderName.trim() + await foldersApi.update(folderId, { + name: newName }); // Refresh folders from server await fetchFolders(); - setFolderToRename(null); - toastService.success(`Folder renamed to "${renameFolderName}"`); + toastService.success(`Folder renamed to "${newName}"`); } catch (error) { console.error("Error renaming folder:", error); - toastService.error("Failed to rename folder"); - setFolderToRename(null); + const errorMessage = error.response?.data?.message || "Failed to rename folder"; + toastService.error(errorMessage); } }; - const cancelRenameFolder = () => { - setFolderToRename(null); - setRenameFolderName(''); + const moveFolder = async (folderId: string, newParentId: string | null) => { + try { + await foldersApi.moveFolder(folderId, newParentId); + + // Refresh folders from server + await fetchFolders(); + + const targetName = newParentId + ? folders.find(f => f._id === newParentId)?.name || 'folder' + : 'root level'; + + toastService.success(`Folder moved to ${targetName}`); + } catch (error) { + console.error("Error moving folder:", error); + const errorMessage = error.response?.data?.message || "Failed to move folder"; + toastService.error(errorMessage); + } }; const startDeleteFolder = (folder: Folder) => { @@ -541,12 +543,12 @@ const SyntheticUsers = () => { } }; - const movePersonasToFolder = async (personasToMove?: Set, targetFolderId?: string) => { + const movePersonasToFolder = async (personasToMove?: Set, targetFolderIds?: Set) => { // Support both direct calls and calls from the dialog button const personas = personasToMove || selectedPersonas; - const folderId = targetFolderId || targetFolder; + const folderIds = targetFolderIds || targetFolders; - if (!folderId || personas.size === 0) return; + if (folderIds.size === 0 || personas.size === 0) return; const personaIds = Array.from(personas); @@ -559,36 +561,56 @@ const SyntheticUsers = () => { try { const successfulUpdates: string[] = []; const failedUpdates: string[] = []; + const folderNames: string[] = []; - // Add personas to the target folder (supports multiple folders per persona) - if (folderId !== DEFAULT_FOLDER_ID) { - try { - await foldersApi.addPersonasBatch(folderId, mongoIds); - successfulUpdates.push(...personaIds); - } catch (error) { - console.error("Error adding personas to folder:", error); - failedUpdates.push(...personaIds); + // Handle "All Personas" selection (remove from all folders) + if (folderIds.has(DEFAULT_FOLDER_ID)) { + // Remove personas from all current folders + const currentFolderIds = new Set(); + personaIds.forEach(personaId => { + const persona = allPersonas.find(p => p.id === personaId); + if (persona?.folder_ids) { + persona.folder_ids.forEach(fid => currentFolderIds.add(fid)); + } + }); + + for (const folderId of currentFolderIds) { + try { + await foldersApi.removePersonasBatch(folderId, mongoIds); + } catch (error) { + console.error("Error removing personas from folder:", error); + } } - } else { - // If moving to "All Personas", this is essentially removing from current folder - // For now, we'll just mark as successful since "All Personas" shows everything successfulUpdates.push(...personaIds); + folderNames.push("All Personas (removed from folders)"); + } else { + // Add personas to multiple selected folders + for (const folderId of folderIds) { + try { + await foldersApi.addPersonasBatch(folderId, mongoIds); + successfulUpdates.push(...personaIds); + const folderName = folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder"; + folderNames.push(folderName); + } catch (error) { + console.error(`Error adding personas to folder ${folderId}:`, error); + failedUpdates.push(...personaIds); + } + } } // Refresh data from server await Promise.all([fetchFolders(), fetchPersonas()]); // Show toast messages - const folderName = folderId === DEFAULT_FOLDER_ID - ? "All Personas" - : folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder"; - if (successfulUpdates.length > 0) { - toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderName}`); + const folderList = folderNames.length > 1 + ? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1) + : folderNames[0]; + toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`); } if (failedUpdates.length > 0) { - toastService.error(`Failed to add ${failedUpdates.length} persona${failedUpdates.length !== 1 ? 's' : ''} to ${folderName}.`); + toastService.error(`Failed to add some personas to selected folders.`); } // Clear selection - caller can also handle this if needed @@ -1138,156 +1160,17 @@ const SyntheticUsers = () => { {mode === 'view' ? ( <>
-
-
-

Folders

- -
- -
- - - {folders.map(folder => ( -
- {folderToRename && folderToRename._id === folder._id ? ( -
- - setRenameFolderName(e.target.value)} - placeholder="Folder name" - className="h-7 text-sm" - autoFocus - onKeyDown={e => { - if (e.key === 'Enter') { - completeRenameFolder(); - } else if (e.key === 'Escape') { - cancelRenameFolder(); - } - }} - /> - - -
- ) : ( - <> - - - - - - - - startRenameFolder(folder)}> - Rename - - startDeleteFolder(folder)} - > - Delete - - - - - )} -
- ))} - - {isCreatingFolder && ( -
-
- - setNewFolderName(e.target.value)} - placeholder="Folder name" - className="h-7 text-sm" - autoFocus - onKeyDown={e => { - if (e.key === 'Enter') { - createNewFolder(); - } else if (e.key === 'Escape') { - cancelFolderCreation(); - } - }} - /> -
- - -
- )} -
-
+
@@ -1538,34 +1421,82 @@ const SyntheticUsers = () => { Move to Folder - Choose a folder to move {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. + Choose one or more folders to add {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. Personas can belong to multiple folders.
- +
- -
- {folders.map(folder => ( -
- - -
- ))} - + {(() => { + // Organize folders into hierarchy for the dialog + const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0); + const childMap: Record = {}; + + folders.forEach(folder => { + if (folder.parent_folder_id && folder.level > 0) { + if (!childMap[folder.parent_folder_id]) { + childMap[folder.parent_folder_id] = []; + } + childMap[folder.parent_folder_id].push(folder); + } + }); + + const handleFolderToggle = (folderId: string, checked: boolean) => { + const newTargetFolders = new Set(targetFolders); + if (checked) { + // If selecting a regular folder, remove "All Personas" if it was selected + newTargetFolders.delete(DEFAULT_FOLDER_ID); + newTargetFolders.add(folderId); + } else { + newTargetFolders.delete(folderId); + } + setTargetFolders(newTargetFolders); + }; + + const renderFolderOption = (folder: Folder, isChild = false) => ( +
+
+ handleFolderToggle(folder._id, !!checked)} + /> + +
+ {/* Render children if any */} + {childMap[folder._id] && childMap[folder._id].map(childFolder => + renderFolderOption(childFolder, true) + )} +
+ ); + + return rootFolders.map(folder => renderFolderOption(folder)); + })()} +
@@ -1578,7 +1509,7 @@ const SyntheticUsers = () => { // Close dialog, don't touch selections setMoveToFolderOpen(false); - setTargetFolder(null); + setTargetFolders(new Set()); }} > Cancel @@ -1589,23 +1520,23 @@ const SyntheticUsers = () => { e.preventDefault(); e.stopPropagation(); - if (!targetFolder) return; // Ensure target folder is selected + if (targetFolders.size === 0) return; // Ensure target folders are selected // Capture values before closing dialog or clearing state const currentSelectedPersonas = new Set(selectedPersonas); - const currentTargetFolder = targetFolder; + const currentTargetFolders = new Set(targetFolders); // Close dialog immediately for better UX setMoveToFolderOpen(false); - setTargetFolder(null); // Reset target folder state + setTargetFolders(new Set()); // Reset target folders state // Perform the move operation - if (currentTargetFolder && currentSelectedPersonas.size > 0) { + if (currentTargetFolders.size > 0 && currentSelectedPersonas.size > 0) { setIsLoading(true); // Show loading indicator try { // Call the move function with captured values - await movePersonasToFolder(currentSelectedPersonas, currentTargetFolder); + await movePersonasToFolder(currentSelectedPersonas, currentTargetFolders); } finally { setIsLoading(false); // Hide loading indicator first @@ -1614,7 +1545,7 @@ const SyntheticUsers = () => { } } }} - disabled={!targetFolder} + disabled={targetFolders.size === 0} > Move