feat: implement drag-and-drop media uploads and modernize upload UI with circular progress indicators
This commit is contained in:
parent
62be9ace66
commit
fddc2ff69f
4 changed files with 329 additions and 20 deletions
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue