mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
+100
-1
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user