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
+100 -1
View File
@@ -56,7 +56,10 @@ export function StudioApp() {
const [rightWidth, setRightWidth] = useState(400);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(true);
const [globalDragOver, setGlobalDragOver] = useState(false);
const [uploadToast, setUploadToast] = useState<string | null>(null);
const [timelineVisible, setTimelineVisible] = useState(false);
const dragCounterRef = useRef(0);
const panelDragRef = useRef<{
side: "left" | "right";
startX: number;
@@ -368,6 +371,46 @@ export function StudioApp() {
const handleMoveFile = handleRenameFile;
const showUploadToast = useCallback((msg: string) => {
setUploadToast(msg);
setTimeout(() => setUploadToast(null), 4000);
}, []);
const handleImportFiles = useCallback(
async (files: FileList, dir?: string) => {
const pid = projectIdRef.current;
if (!pid || files.length === 0) return;
const formData = new FormData();
for (const file of Array.from(files)) {
formData.append("file", file);
}
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
try {
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
method: "POST",
body: formData,
});
if (res.ok) {
const data = await res.json();
if (data.skipped?.length) {
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
}
await refreshFileTree();
setRefreshKey((k) => k + 1);
} else if (res.status === 413) {
showUploadToast("Upload rejected: payload too large");
} else {
showUploadToast(`Upload failed (${res.status})`);
}
} catch {
showUploadToast("Upload failed: network error");
}
},
[refreshFileTree, showUploadToast],
);
const handleLint = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) return;
@@ -447,7 +490,31 @@ export function StudioApp() {
// At this point projectId is guaranteed non-null (narrowed by the guard above)
return (
<div className="flex flex-col h-screen w-screen bg-neutral-950">
<div
className="flex flex-col h-screen w-screen bg-neutral-950 relative"
onDragOver={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
}}
onDragEnter={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragCounterRef.current++;
setGlobalDragOver(true);
}}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setGlobalDragOver(false);
}}
onDrop={(e) => {
dragCounterRef.current = 0;
setGlobalDragOver(false);
// Skip if a child (e.g. AssetsTab) already handled the drop
if (e.defaultPrevented) return;
e.preventDefault();
if (e.dataTransfer.files.length) handleImportFiles(e.dataTransfer.files);
}}
>
{/* Header bar */}
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
{/* Left: project name */}
@@ -561,6 +628,7 @@ export function StudioApp() {
onRenameFile={handleRenameFile}
onDuplicateFile={handleDuplicateFile}
onMoveFile={handleMoveFile}
onImportFiles={handleImportFiles}
codeChildren={
editingFile ? (
isMediaFile(editingFile.path) ? (
@@ -642,6 +710,37 @@ export function StudioApp() {
{lintModal !== null && projectId && (
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
)}
{/* Global drag-drop overlay */}
{globalDragOver && (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-studio-accent"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
</div>
</div>
)}
{uploadToast && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
{uploadToast}
</div>
)}
</div>
);
}
@@ -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") &&
@@ -22,7 +22,7 @@ interface LeftSidebarProps {
assets: string[];
activeComposition: string | null;
onSelectComposition: (comp: string) => void;
onImportFiles?: (files: FileList) => void;
onImportFiles?: (files: FileList, dir?: string) => void;
fileTree?: string[];
editingFile?: { path: string; content: string | null } | null;
onSelectFile?: (path: string) => void;
@@ -156,6 +156,7 @@ export const LeftSidebar = memo(function LeftSidebar({
onRenameFile={onRenameFile}
onDuplicateFile={onDuplicateFile}
onMoveFile={onMoveFile}
onImportFiles={onImportFiles}
/>
</div>
)}