feat: implement drag-and-drop media uploads and modernize upload UI with circular progress indicators

This commit is contained in:
Jaya Prasad Kavuru 2026-04-22 15:39:22 +05:30
parent 62be9ace66
commit fddc2ff69f
4 changed files with 329 additions and 20 deletions

View file

@ -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 (
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-spin text-[#d9ff00] text-3xl"></div>
@ -172,7 +213,30 @@ export default function StandaloneShell() {
}
return (
<div className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white">
<div
className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white relative"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Overlay */}
{isDragging && (
<div className="fixed inset-0 z-[100] bg-[#d9ff00]/10 backdrop-blur-md border-4 border-dashed border-[#d9ff00]/50 flex items-center justify-center pointer-events-none transition-all duration-300">
<div className="bg-[#0a0a0a] p-8 rounded-3xl border border-white/10 shadow-2xl flex flex-col items-center gap-4 scale-110 animate-pulse">
<div className="w-20 h-20 bg-[#d9ff00] rounded-2xl flex items-center justify-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
</div>
<div className="flex flex-col items-center">
<span className="text-xl font-bold text-white">Drop your media here</span>
<span className="text-sm text-white/40">Images, videos, or audio files</span>
</div>
</div>
</div>
)}
{/* Header */}
{isHeaderVisible && (
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
@ -227,9 +291,9 @@ export default function StandaloneShell() {
{/* Studio Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
{activeTab === 'agents' && <AgentStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}

View file

@ -242,9 +242,30 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [] }
let badge;
if (uploading && !hasSelection) {
badge = (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * lastUploadProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{lastUploadProgress}%
</span>
</div>
@ -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

View file

@ -83,9 +83,30 @@ function MediaPickerButton({
{/* Uploading indicator */}
{uploadState === UPLOAD_STATE.UPLOADING && (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/60 z-10 animate-pulse">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-xs font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * progress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{progress}%
</span>
</div>
@ -93,7 +114,7 @@ function MediaPickerButton({
{/* Ready state */}
{uploadState === UPLOAD_STATE.READY && (
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full">
<div className="flex flex-col items-center justify-center gap-1 w-full h-full absolute inset-0 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-all">
{previewUrl ? (
isVideo ? (
<video
@ -109,9 +130,16 @@ function MediaPickerButton({
/>
)
) : (
<>
{icon}
</>
<div className="flex flex-col items-center justify-center w-full px-1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-primary mb-0.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<span className="text-[7px] font-black text-primary uppercase truncate w-full text-center">
{fileName?.split('.').pop() || "AUD"}
</span>
</div>
)}
</div>
)}
@ -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;

View file

@ -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 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * imageProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{imageProgress}%
</span>
</div>
@ -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 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
<span className="text-[8px] font-black text-primary">
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * videoProgress) / 100}
className="text-primary transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-primary leading-none">
{videoProgress}%
</span>
</div>