feat(studio): drag-drop assetfile/folder and asset import anywhere in the studio (#155)

## Summary

- Add `/api/projects/:id/upload` endpoint for multipart file uploads with automatic dedup naming
- Wire `onImportFiles` from Assets tab "Import media" button through to the upload API
- Add global drag-drop overlay — drop media files **anywhere** in the studio, not just the Assets panel
- Files that already exist get `(2)`, `(3)` suffixes instead of overwriting
- Support uploading into subdirectories via `?dir=` param — dropping on a folder imports there
- Make folders draggable in the file tree + support drop-to-root
- Add `bodyLimit` middleware for early rejection of oversized payloads
- Surface skipped/failed uploads via toast notification instead of console-only

Addresses feedback: _"Wish I could upload/drag-drop assets directly in the Studio (music, images, video) like a CapCut media panel"_

## Test plan

- [x] Open studio, drag an image/video/audio file onto any part of the UI
- [x] Verify the drop overlay appears with "Drop files to import" message
- [x] Drop the file — verify it appears in the Assets tab and file tree
- [x] Drop a file with the same name — verify it gets a `(2)` suffix
- [x] Click "Import media" button in Assets tab — verify file picker works
- [x] Import multiple files at once via drag-drop
- [x] Drop a file onto a nested folder in the file tree — verify it lands in that folder
- [x] Drag a folder in the file tree and drop it on another folder or root — verify it moves
- [x] Drop a file >500MB — verify toast notification appears
- [x] Verify drag overlay doesn't get stuck when dragging over nested UI elements
This commit is contained in:
Miguel Ángel
2026-04-01 00:21:34 +02:00
committed by GitHub
parent 7265d0adfd
commit 5f3488e996
4 changed files with 204 additions and 5 deletions
@@ -39,6 +39,7 @@ export interface FileTreeProps {
onRenameFile?: (oldPath: string, newPath: string) => void;
onDuplicateFile?: (path: string) => void;
onMoveFile?: (oldPath: string, newPath: string) => void;
onImportFiles?: (files: FileList, dir?: string) => void;
}
interface TreeNode {
@@ -475,6 +476,8 @@ function TreeFolder({
return (
<>
<button
draggable
onDragStart={(e) => onDragStart(e, node.fullPath)}
onClick={toggle}
onContextMenu={(e) => {
e.preventDefault();
@@ -638,6 +641,7 @@ export const FileTree = memo(function FileTree({
onRenameFile,
onDuplicateFile,
onMoveFile,
onImportFiles,
}: FileTreeProps) {
const tree = useMemo(() => buildTree(files), [files]);
const children = useMemo(() => sortChildren(tree.children), [tree]);
@@ -770,7 +774,15 @@ export const FileTree = memo(function FileTree({
}, []);
const handleDrop = useCallback(
(_e: React.DragEvent, folderPath: string) => {
(e: React.DragEvent, folderPath: string) => {
// External files from desktop — import into the target folder
if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) {
e.preventDefault();
onImportFiles?.(e.dataTransfer.files, folderPath || undefined);
setDragOverFolder(null);
return;
}
const sourcePath = dragSourceRef.current;
if (!sourcePath || !onMoveFile) {
setDragOverFolder(null);
@@ -788,7 +800,7 @@ export const FileTree = memo(function FileTree({
setDragOverFolder(null);
dragSourceRef.current = null;
},
[onMoveFile],
[onMoveFile, onImportFiles],
);
const handleDragLeave = useCallback(() => {
@@ -836,7 +848,26 @@ export const FileTree = memo(function FileTree({
</div>
)}
<div className="flex-1 overflow-y-auto py-1" onContextMenu={handleRootContextMenu}>
<div
className={`flex-1 overflow-y-auto py-1 transition-colors ${
dragOverFolder === ""
? "bg-[#3CE6AC]/5 outline outline-1 outline-[#3CE6AC]/30 -outline-offset-1"
: ""
}`}
onContextMenu={handleRootContextMenu}
onDragOver={(e) => {
e.preventDefault();
// Show root highlight when dragging over the background (not a child folder)
if (e.target === e.currentTarget) setDragOverFolder("");
}}
onDragLeave={(e) => {
if (e.target === e.currentTarget) setDragOverFolder(null);
}}
onDrop={(e) => {
e.preventDefault();
handleDrop(e, "");
}}
>
{/* Root-level inline input for new file/folder */}
{inlineInput &&
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&