add tree-list

This commit is contained in:
itqop 2025-12-31 03:53:37 +03:00
parent 1fda6d5cda
commit e86f4148f1
7 changed files with 147 additions and 16 deletions

View File

@ -80,6 +80,7 @@ class CreateUploadRequest(BaseModel):
original_filename: str = Field(max_length=512) original_filename: str = Field(max_length=512)
content_type: str = Field(max_length=100) content_type: str = Field(max_length=100)
size_bytes: int = Field(gt=0, le=21474836480) # Max 20GB size_bytes: int = Field(gt=0, le=21474836480) # Max 20GB
folder_id: Optional[str] = Field(None, max_length=36)
class CreateUploadResponse(BaseModel): class CreateUploadResponse(BaseModel):

View File

@ -39,6 +39,7 @@ async def create_upload(
original_filename=data.original_filename, original_filename=data.original_filename,
content_type=data.content_type, content_type=data.content_type,
size_bytes=data.size_bytes, size_bytes=data.size_bytes,
folder_id=data.folder_id,
) )
return CreateUploadResponse( return CreateUploadResponse(

View File

@ -50,6 +50,7 @@ class AssetService:
original_filename: str, original_filename: str,
content_type: str, content_type: str,
size_bytes: int, size_bytes: int,
folder_id: Optional[str] = None,
) -> tuple[Asset, dict]: ) -> tuple[Asset, dict]:
""" """
Create an asset and generate pre-signed upload URL. Create an asset and generate pre-signed upload URL.
@ -59,6 +60,7 @@ class AssetService:
original_filename: Original filename original_filename: Original filename
content_type: MIME type content_type: MIME type
size_bytes: File size in bytes size_bytes: File size in bytes
folder_id: Optional folder ID to upload to
Returns: Returns:
Tuple of (asset, presigned_post_data) Tuple of (asset, presigned_post_data)
@ -74,6 +76,7 @@ class AssetService:
content_type=content_type, content_type=content_type,
size_bytes=size_bytes, size_bytes=size_bytes,
storage_key_original="", # Will be set after upload storage_key_original="", # Will be set after upload
folder_id=folder_id,
) )
# Generate storage key # Generate storage key

View File

@ -11,32 +11,112 @@ import {
ListItemText, ListItemText,
CircularProgress, CircularProgress,
Typography, Typography,
Collapse,
} from '@mui/material'; } 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'; import api from '../services/api';
interface Folder {
id: string;
name: string;
parent_folder_id: string | null;
children?: Folder[];
}
interface MoveFolderDialogProps { interface MoveFolderDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onMove: (folderId: string | null) => void; onMove: (folderId: string | null) => void;
} }
interface FolderTreeItemProps {
folder: Folder;
level: number;
selectedFolder: string | null;
expanded: Set<string>;
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 (
<>
<ListItemButton
selected={isSelected}
onClick={() => onSelect(folder.id)}
sx={{ pl: 2 + level * 3 }}
>
<ListItemIcon onClick={(e) => {
e.stopPropagation();
if (hasChildren) {
onToggle(folder.id);
}
}} sx={{ minWidth: 36, cursor: hasChildren ? 'pointer' : 'default' }}>
{hasChildren ? (
isExpanded ? <ExpandMoreIcon /> : <ChevronRightIcon />
) : (
<span style={{ width: 24, display: 'inline-block' }} />
)}
</ListItemIcon>
<ListItemIcon sx={{ minWidth: 40 }}>
{isExpanded && hasChildren ? <FolderOpenIcon /> : <FolderIcon />}
</ListItemIcon>
<ListItemText primary={folder.name} />
</ListItemButton>
{hasChildren && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{folder.children!.map((child) => (
<FolderTreeItem
key={child.id}
folder={child}
level={level + 1}
selectedFolder={selectedFolder}
expanded={expanded}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</List>
</Collapse>
)}
</>
);
}
export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDialogProps) { export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDialogProps) {
const [folders, setFolders] = useState<any[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedFolder, setSelectedFolder] = useState<string | null>(null); const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (open) { if (open) {
loadFolders(); loadAllFolders();
} }
}, [open]); }, [open]);
const loadFolders = async () => { const loadAllFolders = async () => {
try { try {
setLoading(true); setLoading(true);
// Load all folders (no parent_id filter to get all)
const response = await api.listFolders(); const response = await api.listFolders();
setFolders(response.items || []); const allFolders: Folder[] = response.items || [];
// Build tree structure
const tree = buildTree(allFolders);
setFolders(tree);
} catch (error) { } catch (error) {
console.error('Failed to load folders:', error); console.error('Failed to load folders:', error);
} finally { } finally {
@ -44,14 +124,56 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi
} }
}; };
const buildTree = (flatFolders: Folder[]): Folder[] => {
const folderMap = new Map<string, Folder>();
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 = () => { const handleMove = () => {
onMove(selectedFolder); onMove(selectedFolder);
setSelectedFolder(null); setSelectedFolder(null);
setExpanded(new Set());
onClose(); onClose();
}; };
const handleClose = () => { const handleClose = () => {
setSelectedFolder(null); setSelectedFolder(null);
setExpanded(new Set());
onClose(); onClose();
}; };
@ -67,7 +189,7 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi
selected={selectedFolder === null} selected={selectedFolder === null}
onClick={() => setSelectedFolder(null)} onClick={() => setSelectedFolder(null)}
> >
<ListItemIcon> <ListItemIcon sx={{ minWidth: 40 }}>
<HomeIcon /> <HomeIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Библиотека (корень)" /> <ListItemText primary="Библиотека (корень)" />
@ -79,16 +201,15 @@ export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDi
</Typography> </Typography>
) : ( ) : (
folders.map((folder) => ( folders.map((folder) => (
<ListItemButton <FolderTreeItem
key={folder.id} key={folder.id}
selected={selectedFolder === folder.id} folder={folder}
onClick={() => setSelectedFolder(folder.id)} level={0}
> selectedFolder={selectedFolder}
<ListItemIcon> expanded={expanded}
<FolderIcon /> onSelect={setSelectedFolder}
</ListItemIcon> onToggle={handleToggle}
<ListItemText primary={folder.name} /> />
</ListItemButton>
)) ))
)} )}
</List> </List>

View File

@ -35,9 +35,10 @@ interface UploadDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onComplete?: () => 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<UploadFile[]>([]); const [files, setFiles] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -75,6 +76,7 @@ export default function UploadDialog({ open, onClose, onComplete }: UploadDialog
original_filename: file.name, original_filename: file.name,
content_type: file.type, content_type: file.type,
size_bytes: file.size, size_bytes: file.size,
folder_id: currentFolderId || undefined,
}); });
updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id); updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id);

View File

@ -400,6 +400,7 @@ function LibraryPageContent() {
open={uploadOpen} open={uploadOpen}
onClose={() => setUploadOpen(false)} onClose={() => setUploadOpen(false)}
onComplete={handleUploadComplete} onComplete={handleUploadComplete}
currentFolderId={currentFolderId}
/> />
<ViewerModal <ViewerModal

View File

@ -17,6 +17,7 @@ export type AssetStatus = 'uploading' | 'ready' | 'failed';
export interface Asset { export interface Asset {
id: string; id: string;
user_id: string; user_id: string;
folder_id?: string;
type: AssetType; type: AssetType;
status: AssetStatus; status: AssetStatus;
original_filename: string; original_filename: string;
@ -42,6 +43,7 @@ export interface CreateUploadRequest {
original_filename: string; original_filename: string;
content_type: string; content_type: string;
size_bytes: number; size_bytes: number;
folder_id?: string;
} }
export interface CreateUploadResponse { export interface CreateUploadResponse {