From e86f4148f1cbd3718f6e381d38ac5ab4122a74f4 Mon Sep 17 00:00:00 2001 From: itqop Date: Wed, 31 Dec 2025 03:53:37 +0300 Subject: [PATCH] add tree-list --- backend/src/app/api/schemas.py | 1 + backend/src/app/api/v1/uploads.py | 1 + backend/src/app/services/asset_service.py | 3 + frontend/src/components/MoveFolderDialog.tsx | 151 +++++++++++++++++-- frontend/src/components/UploadDialog.tsx | 4 +- frontend/src/pages/LibraryPage.tsx | 1 + frontend/src/types/index.ts | 2 + 7 files changed, 147 insertions(+), 16 deletions(-) diff --git a/backend/src/app/api/schemas.py b/backend/src/app/api/schemas.py index 10e55f3..c808fbb 100644 --- a/backend/src/app/api/schemas.py +++ b/backend/src/app/api/schemas.py @@ -80,6 +80,7 @@ class CreateUploadRequest(BaseModel): original_filename: str = Field(max_length=512) content_type: str = Field(max_length=100) size_bytes: int = Field(gt=0, le=21474836480) # Max 20GB + folder_id: Optional[str] = Field(None, max_length=36) class CreateUploadResponse(BaseModel): diff --git a/backend/src/app/api/v1/uploads.py b/backend/src/app/api/v1/uploads.py index 3bc2065..98792e8 100644 --- a/backend/src/app/api/v1/uploads.py +++ b/backend/src/app/api/v1/uploads.py @@ -39,6 +39,7 @@ async def create_upload( original_filename=data.original_filename, content_type=data.content_type, size_bytes=data.size_bytes, + folder_id=data.folder_id, ) return CreateUploadResponse( diff --git a/backend/src/app/services/asset_service.py b/backend/src/app/services/asset_service.py index f2fea78..103dc6c 100644 --- a/backend/src/app/services/asset_service.py +++ b/backend/src/app/services/asset_service.py @@ -50,6 +50,7 @@ class AssetService: original_filename: str, content_type: str, size_bytes: int, + folder_id: Optional[str] = None, ) -> tuple[Asset, dict]: """ Create an asset and generate pre-signed upload URL. @@ -59,6 +60,7 @@ class AssetService: original_filename: Original filename content_type: MIME type size_bytes: File size in bytes + folder_id: Optional folder ID to upload to Returns: Tuple of (asset, presigned_post_data) @@ -74,6 +76,7 @@ class AssetService: content_type=content_type, size_bytes=size_bytes, storage_key_original="", # Will be set after upload + folder_id=folder_id, ) # Generate storage key diff --git a/frontend/src/components/MoveFolderDialog.tsx b/frontend/src/components/MoveFolderDialog.tsx index 4c0e7f4..7897543 100644 --- a/frontend/src/components/MoveFolderDialog.tsx +++ b/frontend/src/components/MoveFolderDialog.tsx @@ -11,32 +11,112 @@ import { ListItemText, CircularProgress, Typography, + Collapse, } from '@mui/material'; -import { Folder as FolderIcon, Home as HomeIcon } from '@mui/icons-material'; +import { + Folder as FolderIcon, + Home as HomeIcon, + ExpandMore as ExpandMoreIcon, + ChevronRight as ChevronRightIcon, + FolderOpen as FolderOpenIcon, +} from '@mui/icons-material'; import api from '../services/api'; +interface Folder { + id: string; + name: string; + parent_folder_id: string | null; + children?: Folder[]; +} + interface MoveFolderDialogProps { open: boolean; onClose: () => void; onMove: (folderId: string | null) => void; } +interface FolderTreeItemProps { + folder: Folder; + level: number; + selectedFolder: string | null; + expanded: Set; + onSelect: (folderId: string) => void; + onToggle: (folderId: string) => void; +} + +function FolderTreeItem({ folder, level, selectedFolder, expanded, onSelect, onToggle }: FolderTreeItemProps) { + const hasChildren = folder.children && folder.children.length > 0; + const isExpanded = expanded.has(folder.id); + const isSelected = selectedFolder === folder.id; + + return ( + <> + onSelect(folder.id)} + sx={{ pl: 2 + level * 3 }} + > + { + e.stopPropagation(); + if (hasChildren) { + onToggle(folder.id); + } + }} sx={{ minWidth: 36, cursor: hasChildren ? 'pointer' : 'default' }}> + {hasChildren ? ( + isExpanded ? : + ) : ( + + )} + + + {isExpanded && hasChildren ? : } + + + + + {hasChildren && ( + + + {folder.children!.map((child) => ( + + ))} + + + )} + + ); +} + export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDialogProps) { - const [folders, setFolders] = useState([]); + const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(false); const [selectedFolder, setSelectedFolder] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); useEffect(() => { if (open) { - loadFolders(); + loadAllFolders(); } }, [open]); - const loadFolders = async () => { + const loadAllFolders = async () => { try { setLoading(true); + // Load all folders (no parent_id filter to get all) const response = await api.listFolders(); - setFolders(response.items || []); + const allFolders: Folder[] = response.items || []; + + // Build tree structure + const tree = buildTree(allFolders); + setFolders(tree); } catch (error) { console.error('Failed to load folders:', error); } finally { @@ -44,14 +124,56 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi } }; + const buildTree = (flatFolders: Folder[]): Folder[] => { + const folderMap = new Map(); + const roots: Folder[] = []; + + // Create map and initialize children array + flatFolders.forEach((folder) => { + folderMap.set(folder.id, { ...folder, children: [] }); + }); + + // Build tree + flatFolders.forEach((folder) => { + const node = folderMap.get(folder.id)!; + if (folder.parent_folder_id === null) { + roots.push(node); + } else { + const parent = folderMap.get(folder.parent_folder_id); + if (parent) { + parent.children!.push(node); + } else { + // Parent not found, treat as root + roots.push(node); + } + } + }); + + return roots; + }; + + const handleToggle = (folderId: string) => { + setExpanded((prev) => { + const newExpanded = new Set(prev); + if (newExpanded.has(folderId)) { + newExpanded.delete(folderId); + } else { + newExpanded.add(folderId); + } + return newExpanded; + }); + }; + const handleMove = () => { onMove(selectedFolder); setSelectedFolder(null); + setExpanded(new Set()); onClose(); }; const handleClose = () => { setSelectedFolder(null); + setExpanded(new Set()); onClose(); }; @@ -67,7 +189,7 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi selected={selectedFolder === null} onClick={() => setSelectedFolder(null)} > - + @@ -79,16 +201,15 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi ) : ( folders.map((folder) => ( - setSelectedFolder(folder.id)} - > - - - - - + folder={folder} + level={0} + selectedFolder={selectedFolder} + expanded={expanded} + onSelect={setSelectedFolder} + onToggle={handleToggle} + /> )) )} diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index fbfcb69..4704af3 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -35,9 +35,10 @@ interface UploadDialogProps { open: boolean; onClose: () => void; onComplete?: () => void; + currentFolderId?: string | null; } -export default function UploadDialog({ open, onClose, onComplete }: UploadDialogProps) { +export default function UploadDialog({ open, onClose, onComplete, currentFolderId }: UploadDialogProps) { const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -75,6 +76,7 @@ export default function UploadDialog({ open, onClose, onComplete }: UploadDialog original_filename: file.name, content_type: file.type, size_bytes: file.size, + folder_id: currentFolderId || undefined, }); updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id); diff --git a/frontend/src/pages/LibraryPage.tsx b/frontend/src/pages/LibraryPage.tsx index 6ec8d17..0bbb1d0 100644 --- a/frontend/src/pages/LibraryPage.tsx +++ b/frontend/src/pages/LibraryPage.tsx @@ -400,6 +400,7 @@ function LibraryPageContent() { open={uploadOpen} onClose={() => setUploadOpen(false)} onComplete={handleUploadComplete} + currentFolderId={currentFolderId} />