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 [isHeaderVisible, setIsHeaderVisible] = useState(true);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
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
|
// Sync tab with URL if user navigates manually or via browser back/forward
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const info = getWorkflowInfo();
|
const info = getWorkflowInfo();
|
||||||
|
|
@ -161,6 +165,43 @@ export default function StandaloneShell() {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [apiKey, fetchBalance]);
|
}, [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 (
|
if (!hasMounted) return (
|
||||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||||
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||||
|
|
@ -172,7 +213,30 @@ export default function StandaloneShell() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
{isHeaderVisible && (
|
{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">
|
<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 */}
|
{/* Studio Content */}
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
{activeTab === 'image' && <ImageStudio apiKey={apiKey} />}
|
{activeTab === 'image' && <ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'video' && <VideoStudio apiKey={apiKey} />}
|
{activeTab === 'video' && <VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} />}
|
{activeTab === 'lipsync' && <LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />}
|
||||||
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
|
{activeTab === 'cinema' && <CinemaStudio apiKey={apiKey} />}
|
||||||
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
|
{activeTab === 'workflows' && <WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />}
|
||||||
{activeTab === 'agents' && <AgentStudio 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;
|
let badge;
|
||||||
if (uploading && !hasSelection) {
|
if (uploading && !hasSelection) {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<svg className="w-8 h-8 -rotate-90">
|
||||||
<span className="text-[8px] font-black text-primary">
|
<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}%
|
{lastUploadProgress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -718,6 +739,8 @@ export default function ImageStudio({
|
||||||
apiKey,
|
apiKey,
|
||||||
onGenerationComplete,
|
onGenerationComplete,
|
||||||
historyItems,
|
historyItems,
|
||||||
|
droppedFiles,
|
||||||
|
onFilesHandled,
|
||||||
}) {
|
}) {
|
||||||
const PERSIST_KEY = "hg_image_studio_persistent";
|
const PERSIST_KEY = "hg_image_studio_persistent";
|
||||||
|
|
||||||
|
|
@ -823,6 +846,54 @@ export default function ImageStudio({
|
||||||
localHistory,
|
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 ───────────────────────────────
|
// ── Derived: current model lists & helpers ───────────────────────────────
|
||||||
const currentModels = imageMode ? i2iModels : t2iModels;
|
const currentModels = imageMode ? i2iModels : t2iModels;
|
||||||
const currentAspectRatios = imageMode
|
const currentAspectRatios = imageMode
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,30 @@ function MediaPickerButton({
|
||||||
|
|
||||||
{/* Uploading indicator */}
|
{/* Uploading indicator */}
|
||||||
{uploadState === UPLOAD_STATE.UPLOADING && (
|
{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="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<svg className="w-8 h-8 -rotate-90">
|
||||||
<span className="text-xs font-black text-primary">
|
<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}%
|
{progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,7 +114,7 @@ function MediaPickerButton({
|
||||||
|
|
||||||
{/* Ready state */}
|
{/* Ready state */}
|
||||||
{uploadState === UPLOAD_STATE.READY && (
|
{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 ? (
|
{previewUrl ? (
|
||||||
isVideo ? (
|
isVideo ? (
|
||||||
<video
|
<video
|
||||||
|
|
@ -109,9 +130,16 @@ function MediaPickerButton({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col items-center justify-center w-full px-1">
|
||||||
{icon}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -291,6 +319,8 @@ export default function LipSyncStudio({
|
||||||
apiKey,
|
apiKey,
|
||||||
onGenerationComplete,
|
onGenerationComplete,
|
||||||
historyItems,
|
historyItems,
|
||||||
|
droppedFiles,
|
||||||
|
onFilesHandled,
|
||||||
}) {
|
}) {
|
||||||
const PERSIST_KEY = "hg_lipsync_studio_persistent";
|
const PERSIST_KEY = "hg_lipsync_studio_persistent";
|
||||||
|
|
||||||
|
|
@ -513,6 +543,26 @@ export default function LipSyncStudio({
|
||||||
[apiKey],
|
[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 ─────────────────────────────────────────────────────────
|
// ── Mode toggle ─────────────────────────────────────────────────────────
|
||||||
const switchToImage = () => {
|
const switchToImage = () => {
|
||||||
if (inputMode === "image") return;
|
if (inputMode === "image") return;
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,8 @@ export default function VideoStudio({
|
||||||
apiKey,
|
apiKey,
|
||||||
onGenerationComplete,
|
onGenerationComplete,
|
||||||
historyItems,
|
historyItems,
|
||||||
|
droppedFiles,
|
||||||
|
onFilesHandled,
|
||||||
}) {
|
}) {
|
||||||
const PERSIST_KEY = "hg_video_studio_persistent";
|
const PERSIST_KEY = "hg_video_studio_persistent";
|
||||||
|
|
||||||
|
|
@ -487,6 +489,86 @@ export default function VideoStudio({
|
||||||
localHistory,
|
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
|
// Initialise controls for default model on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRestored.current) return;
|
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`}
|
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 ? (
|
{imageUploading ? (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<svg className="w-8 h-8 -rotate-90">
|
||||||
<span className="text-[8px] font-black text-primary">
|
<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}%
|
{imageProgress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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`}
|
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 ? (
|
{videoUploading ? (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<svg className="w-8 h-8 -rotate-90">
|
||||||
<span className="text-[8px] font-black text-primary">
|
<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}%
|
{videoProgress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue