added hierarchical folders (just two levels) with drag and drop management
This commit is contained in:
parent
e29d2a0bb9
commit
abc9731e4a
9 changed files with 1202 additions and 257 deletions
Binary file not shown.
|
|
@ -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 []
|
||||
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)}"
|
||||
Binary file not shown.
|
|
@ -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('/<folder_id>', 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('/<folder_id>/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
|
||||
return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500
|
||||
|
||||
@folders_bp.route('/<folder_id>/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('/<folder_id>/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
|
||||
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -7,8 +7,8 @@
|
|||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<script type="module" crossorigin src="/semblance/assets/index-BwNvaEWm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-CvzgaSoN.css">
|
||||
<script type="module" crossorigin src="/semblance/assets/index-CBPpFZFw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-BAs_xR_U.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
454
src/components/FolderTree.tsx
Normal file
454
src/components/FolderTree.tsx
Normal file
|
|
@ -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<void>;
|
||||
onRenameFolder: (folderId: string, newName: string) => Promise<void>;
|
||||
onDeleteFolder: (folder: Folder) => void;
|
||||
onMoveFolder: (folderId: string, newParentId: string | null) => Promise<void>;
|
||||
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<string | null>(null);
|
||||
|
||||
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [activeId, setActiveId] = useState<string | null>(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<string>) => {
|
||||
localStorage.setItem('folderTreeExpansion', JSON.stringify(Array.from(newExpandedFolders)));
|
||||
};
|
||||
|
||||
// Organize folders into hierarchy
|
||||
const organizeHierarchy = () => {
|
||||
const rootFolders: Folder[] = [];
|
||||
const childMap: Record<string, Folder[]> = {};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="w-full md:w-64 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Folders</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreatingFolder(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical folder tree with drag-and-drop */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={rectIntersection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={(event) => {
|
||||
console.log('🖱️ Drag over:', {
|
||||
activeId: event.active.id,
|
||||
overId: event.over?.id,
|
||||
overData: event.over?.data.current,
|
||||
});
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{/* All Personas as a special FolderTreeItem */}
|
||||
<FolderTreeItem
|
||||
folder={{
|
||||
_id: 'all-personas-root',
|
||||
name: 'All Personas',
|
||||
level: 0, // Same level as root folders for proper spacing
|
||||
parent_folder_id: null,
|
||||
}}
|
||||
children={[]}
|
||||
allPersonas={allPersonas}
|
||||
selectedFolder={selectedFolder === defaultFolderId ? 'all-personas-root' : selectedFolder}
|
||||
isExpanded={false}
|
||||
folderToRename={null}
|
||||
renameFolderName=""
|
||||
onFolderSelect={() => 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 => (
|
||||
<FolderTreeItem
|
||||
key={folder._id}
|
||||
folder={folder}
|
||||
children={childMap[folder._id] || []}
|
||||
allPersonas={allPersonas}
|
||||
selectedFolder={selectedFolder}
|
||||
isExpanded={expandedFolders.has(folder._id)}
|
||||
folderToRename={folderToRename}
|
||||
renameFolderName={renameFolderName}
|
||||
onFolderSelect={onFolderSelect}
|
||||
onToggleExpansion={handleToggleExpansion}
|
||||
onStartRename={handleStartRename}
|
||||
onCompleteRename={handleCompleteRename}
|
||||
onCancelRename={() => setFolderToRename(null)}
|
||||
onStartDelete={onDeleteFolder}
|
||||
onStartCreateSubfolder={handleStartCreateSubfolder}
|
||||
onRenameFolderNameChange={setRenameFolderName}
|
||||
currentlyDraggedFolderId={activeId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Create folder input */}
|
||||
{isCreatingFolder && (
|
||||
<div className="flex items-center px-3 py-2 space-x-2">
|
||||
<div className="flex-1 flex items-center space-x-2">
|
||||
<div className="w-4 h-4" /> {/* Spacer */}
|
||||
<Folder className="h-4 w-4" />
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={e => 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCreateFolder}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
setCreatingParentId(null);
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeDragFolder && (
|
||||
<FolderTreeItem
|
||||
folder={activeDragFolder}
|
||||
children={[]}
|
||||
allPersonas={allPersonas}
|
||||
selectedFolder=""
|
||||
isExpanded={false}
|
||||
folderToRename={null}
|
||||
renameFolderName=""
|
||||
onFolderSelect={() => {}}
|
||||
onToggleExpansion={() => {}}
|
||||
onStartRename={() => {}}
|
||||
onCompleteRename={() => {}}
|
||||
onCancelRename={() => {}}
|
||||
onStartDelete={() => {}}
|
||||
onStartCreateSubfolder={() => {}}
|
||||
onRenameFolderNameChange={() => {}}
|
||||
currentlyDraggedFolderId={activeId}
|
||||
isDragOverlay
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Drag confirmation dialog */}
|
||||
<Dialog open={dragConfirmOpen} onOpenChange={setDragConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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 && (
|
||||
<>
|
||||
<br /><br />
|
||||
<strong>Note:</strong> 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}"`}?
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDragConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmMove}>
|
||||
{pendingMove?.subfolders.length && pendingMove.subfolders.length > 0 ? 'Move & Flatten' : 'Move'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderTree;
|
||||
273
src/components/FolderTreeItem.tsx
Normal file
273
src/components/FolderTreeItem.tsx
Normal file
|
|
@ -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 (
|
||||
<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;
|
||||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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<Persona | null>(null);
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>(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<string | null>(null);
|
||||
const [selectedPersonas, setSelectedPersonas] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<Folder | null>(null);
|
||||
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
|
||||
const [targetFolder, setTargetFolder] = useState<string | null>(null);
|
||||
const [targetFolders, setTargetFolders] = useState<Set<string>>(new Set());
|
||||
// Filter state
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterState>({
|
||||
|
|
@ -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<string>, targetFolderId?: string) => {
|
||||
const movePersonasToFolder = async (personasToMove?: Set<string>, targetFolderIds?: Set<string>) => {
|
||||
// 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<string>();
|
||||
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' ? (
|
||||
<>
|
||||
<div className="flex flex-col md:flex-row gap-6 mb-6">
|
||||
<div className="w-full md:w-64 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Folders</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreatingFolder(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setSelectedFolder(DEFAULT_FOLDER_ID)}
|
||||
className={`w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
|
||||
selectedFolder === DEFAULT_FOLDER_ID
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>All Personas</span>
|
||||
</button>
|
||||
|
||||
{folders.map(folder => (
|
||||
<div
|
||||
key={folder._id}
|
||||
className="flex items-center justify-between group"
|
||||
>
|
||||
{folderToRename && folderToRename._id === folder._id ? (
|
||||
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<Input
|
||||
value={renameFolderName}
|
||||
onChange={e => 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={completeRenameFolder}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={cancelRenameFolder}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSelectedFolder(folder._id)}
|
||||
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
|
||||
selectedFolder === folder._id
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
<span className="text-muted-foreground text-xs ml-auto">
|
||||
{allPersonas.filter(persona =>
|
||||
persona.folder_ids && persona.folder_ids.includes(folder._id)
|
||||
).length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => startRenameFolder(folder)}>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => startDeleteFolder(folder)}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingFolder && (
|
||||
<div className="flex items-center px-3 py-2 space-x-2">
|
||||
<div className="flex-1 flex items-center space-x-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={e => 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={createNewFolder}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={cancelFolderCreation}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FolderTree
|
||||
folders={folders}
|
||||
allPersonas={allPersonas}
|
||||
selectedFolder={selectedFolder}
|
||||
onFolderSelect={setSelectedFolder}
|
||||
onCreateFolder={createNewFolder}
|
||||
onRenameFolder={renameFolder}
|
||||
onDeleteFolder={startDeleteFolder}
|
||||
onMoveFolder={moveFolder}
|
||||
defaultFolderId={DEFAULT_FOLDER_ID}
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
|
|
@ -1538,34 +1421,82 @@ const SyntheticUsers = () => {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Move to Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<RadioGroup
|
||||
value={targetFolder || ''}
|
||||
onValueChange={setTargetFolder}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={DEFAULT_FOLDER_ID} id="folder-all" />
|
||||
<Label htmlFor="folder-all" className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="folder-all"
|
||||
checked={targetFolders.has(DEFAULT_FOLDER_ID)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newTargetFolders = new Set(targetFolders);
|
||||
if (checked) {
|
||||
// If "All Personas" is selected, clear all other selections
|
||||
setTargetFolders(new Set([DEFAULT_FOLDER_ID]));
|
||||
} else {
|
||||
newTargetFolders.delete(DEFAULT_FOLDER_ID);
|
||||
setTargetFolders(newTargetFolders);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="folder-all" className="flex items-center gap-2 cursor-pointer">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>All Personas (Remove from folders)</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{folders.map(folder => (
|
||||
<div key={folder._id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={folder._id} id={`folder-${folder._id}`} />
|
||||
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{(() => {
|
||||
// Organize folders into hierarchy for the dialog
|
||||
const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0);
|
||||
const childMap: Record<string, Folder[]> = {};
|
||||
|
||||
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) => (
|
||||
<div key={folder._id}>
|
||||
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
|
||||
<Checkbox
|
||||
id={`folder-${folder._id}`}
|
||||
checked={targetFolders.has(folder._id)}
|
||||
onCheckedChange={(checked) => handleFolderToggle(folder._id, !!checked)}
|
||||
/>
|
||||
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
|
||||
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
|
||||
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
|
||||
</Label>
|
||||
</div>
|
||||
{/* Render children if any */}
|
||||
{childMap[folder._id] && childMap[folder._id].map(childFolder =>
|
||||
renderFolderOption(childFolder, true)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return rootFolders.map(folder => renderFolderOption(folder));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
@ -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
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue