From fddc2ff69f24f44f70f9b8869c4f3a0f9b3d45c2 Mon Sep 17 00:00:00 2001 From: Jaya Prasad Kavuru Date: Wed, 22 Apr 2026 15:39:22 +0530 Subject: [PATCH] feat: implement drag-and-drop media uploads and modernize upload UI with circular progress indicators --- components/StandaloneShell.js | 72 +++++++++- .../studio/src/components/ImageStudio.jsx | 77 +++++++++- .../studio/src/components/LipSyncStudio.jsx | 64 ++++++++- .../studio/src/components/VideoStudio.jsx | 136 +++++++++++++++++- 4 files changed, 329 insertions(+), 20 deletions(-) diff --git a/components/StandaloneShell.js b/components/StandaloneShell.js index 821cf7d..c153784 100644 --- a/components/StandaloneShell.js +++ b/components/StandaloneShell.js @@ -56,6 +56,10 @@ export default function StandaloneShell() { const [isHeaderVisible, setIsHeaderVisible] = useState(true); const [hasMounted, setHasMounted] = useState(false); + // Drag and Drop State + const [isDragging, setIsDragging] = useState(false); + const [droppedFiles, setDroppedFiles] = useState(null); + // Sync tab with URL if user navigates manually or via browser back/forward useEffect(() => { const info = getWorkflowInfo(); @@ -161,6 +165,43 @@ export default function StandaloneShell() { return () => clearInterval(interval); }, [apiKey, fetchBalance]); + // Drag and Drop Handlers + const handleDragOver = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragEnter = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + setIsDragging(true); + } + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + // Only set to false if we're leaving the container itself, not moving between children + if (e.currentTarget.contains(e.relatedTarget)) return; + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + setDroppedFiles(files); + } + }, []); + + const handleFilesHandled = useCallback(() => { + setDroppedFiles(null); + }, []); + if (!hasMounted) return (
@@ -172,7 +213,30 @@ export default function StandaloneShell() { } return ( -
+
+ {/* Drag Overlay */} + {isDragging && ( +
+
+
+ + + +
+
+ Drop your media here + Images, videos, or audio files +
+
+
+ )} + {/* Header */} {isHeaderVisible && (
@@ -227,9 +291,9 @@ export default function StandaloneShell() { {/* Studio Content */}
- {activeTab === 'image' && } - {activeTab === 'video' && } - {activeTab === 'lipsync' && } + {activeTab === 'image' && } + {activeTab === 'video' && } + {activeTab === 'lipsync' && } {activeTab === 'cinema' && } {activeTab === 'workflows' && } {activeTab === 'agents' && } diff --git a/packages/studio/src/components/ImageStudio.jsx b/packages/studio/src/components/ImageStudio.jsx index bf923d5..163170f 100644 --- a/packages/studio/src/components/ImageStudio.jsx +++ b/packages/studio/src/components/ImageStudio.jsx @@ -242,9 +242,30 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [] } let badge; if (uploading && !hasSelection) { badge = ( -
-
- +
+ + + + + {lastUploadProgress}%
@@ -718,6 +739,8 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems, + droppedFiles, + onFilesHandled, }) { const PERSIST_KEY = "hg_image_studio_persistent"; @@ -823,6 +846,54 @@ export default function ImageStudio({ localHistory, ]); + const processDroppedImages = async (files) => { + const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE); + if (tooLarge.length > 0) { + alert( + `The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}` + ); + return; + } + + setGenerating(true); // Show as generating/busy + try { + const toUpload = + maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages); + const urls = await Promise.all( + toUpload.map(async (file) => { + try { + return await uploadFile(apiKey, file); + } catch (err) { + console.error( + "[ImageStudio] Drop upload failed for", + file.name, + err + ); + throw err; + } + }) + ); + + handleUploadSelect({ urls }); + } catch (err) { + alert(`Image upload failed: ${err.message}`); + } finally { + setGenerating(false); + } + }; + + // ── Handle Dropped Files ──────────────────────────────────────────────── + useEffect(() => { + if (droppedFiles && droppedFiles.length > 0) { + const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + processDroppedImages(imageFiles); + } + onFilesHandled?.(); + } + }, [droppedFiles, onFilesHandled, processDroppedImages]); + // ── Derived: current model lists & helpers ─────────────────────────────── const currentModels = imageMode ? i2iModels : t2iModels; const currentAspectRatios = imageMode diff --git a/packages/studio/src/components/LipSyncStudio.jsx b/packages/studio/src/components/LipSyncStudio.jsx index 32fe0cc..f36e27a 100644 --- a/packages/studio/src/components/LipSyncStudio.jsx +++ b/packages/studio/src/components/LipSyncStudio.jsx @@ -83,9 +83,30 @@ function MediaPickerButton({ {/* Uploading indicator */} {uploadState === UPLOAD_STATE.UPLOADING && ( -
-
- +
+ + + + + {progress}%
@@ -93,7 +114,7 @@ function MediaPickerButton({ {/* Ready state */} {uploadState === UPLOAD_STATE.READY && ( -
+
{previewUrl ? ( isVideo ? (
)} @@ -291,6 +319,8 @@ export default function LipSyncStudio({ apiKey, onGenerationComplete, historyItems, + droppedFiles, + onFilesHandled, }) { const PERSIST_KEY = "hg_lipsync_studio_persistent"; @@ -513,6 +543,26 @@ export default function LipSyncStudio({ [apiKey], ); + // ── Handle Dropped Files ──────────────────────────────────────────────── + useEffect(() => { + if (droppedFiles && droppedFiles.length > 0) { + const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/')); + const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/')); + const audioFiles = droppedFiles.filter(f => f.type.startsWith('audio/')); + + if (audioFiles.length > 0) { + handleAudioPick(audioFiles[0]); + } else if (videoFiles.length > 0) { + switchToVideo(); + handleVideoPick(videoFiles[0]); + } else if (imageFiles.length > 0) { + switchToImage(); + handleImageUpload(imageFiles[0]); + } + onFilesHandled?.(); + } + }, [droppedFiles, onFilesHandled, handleAudioPick, handleVideoPick, handleImageUpload]); + // ── Mode toggle ───────────────────────────────────────────────────────── const switchToImage = () => { if (inputMode === "image") return; diff --git a/packages/studio/src/components/VideoStudio.jsx b/packages/studio/src/components/VideoStudio.jsx index 6cd99a7..352f8c2 100644 --- a/packages/studio/src/components/VideoStudio.jsx +++ b/packages/studio/src/components/VideoStudio.jsx @@ -235,6 +235,8 @@ export default function VideoStudio({ apiKey, onGenerationComplete, historyItems, + droppedFiles, + onFilesHandled, }) { const PERSIST_KEY = "hg_video_studio_persistent"; @@ -487,6 +489,86 @@ export default function VideoStudio({ localHistory, ]); + // ── Derived UI values ──────────────────────────────────────────────────── + + const processDroppedImage = async (file) => { + if (file.size > 10 * 1024 * 1024) { + alert("Image exceeds 10MB limit."); + return; + } + setImageUploading(true); + setImageProgress(0); + try { + const url = await uploadFile(apiKey, file, (pct) => { + setImageProgress(pct); + }); + setUploadedImageUrl(url); + setUploadedVideoUrl(null); + setUploadedVideoName(null); + setV2vMode(false); + if (!imageMode) { + const firstI2V = i2vModels[0]; + setImageMode(true); + setSelectedModel(firstI2V.id); + setSelectedModelName(firstI2V.name); + applyControlsForModel(firstI2V.id, true, false); + } + setPromptDisabled(false); + } catch (err) { + alert(`Image upload failed: ${err.message}`); + } finally { + setImageUploading(false); + setImageProgress(0); + } + }; + + const processDroppedVideo = async (file) => { + if (file.size > 50 * 1024 * 1024) { + alert("Video exceeds 50MB limit."); + return; + } + setVideoUploading(true); + setVideoProgress(0); + try { + const url = await uploadFile(apiKey, file, (pct) => { + setVideoProgress(pct); + }); + setUploadedVideoUrl(url); + setUploadedVideoName(file.name); + if (imageMode) { + setUploadedImageUrl(null); + setImageMode(false); + } + setV2vMode(true); + const firstV2V = v2vModels[0]; + setSelectedModel(firstV2V.id); + setSelectedModelName(firstV2V.name); + applyControlsForModel(firstV2V.id, false, true); + setPrompt(""); + setPromptDisabled(true); + } catch (err) { + alert(`Video upload failed: ${err.message}`); + } finally { + setVideoUploading(false); + setVideoProgress(0); + } + }; + + // ── Handle Dropped Files ──────────────────────────────────────────────── + useEffect(() => { + if (droppedFiles && droppedFiles.length > 0) { + const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/')); + const videoFiles = droppedFiles.filter(f => f.type.startsWith('video/')); + + if (videoFiles.length > 0) { + processDroppedVideo(videoFiles[0]); + } else if (imageFiles.length > 0) { + processDroppedImage(imageFiles[0]); + } + onFilesHandled?.(); + } + }, [droppedFiles, onFilesHandled, processDroppedImage, processDroppedVideo]); + // Initialise controls for default model on mount useEffect(() => { if (hasRestored.current) return; @@ -1052,9 +1134,30 @@ export default function VideoStudio({ className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedImageUrl ? "border-primary/60 bg-primary/5" : "bg-white/5 border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`} > {imageUploading ? ( -
-
- +
+ + + + + {imageProgress}%
@@ -1117,9 +1220,30 @@ export default function VideoStudio({ className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden ${uploadedVideoUrl ? "border-primary/60 bg-white/5" : "bg-white/[0.03] border-white/[0.03] hover:bg-white/10 hover:border-primary/40"} group`} > {videoUploading ? ( -
-
- +
+ + + + + {videoProgress}%