diff --git a/src/api/postUpload.js b/src/api/postUpload.js
index 4cfef03..428d4b4 100644
--- a/src/api/postUpload.js
+++ b/src/api/postUpload.js
@@ -1,6 +1,6 @@
import { api } from './api';
-export default async function uploadImage(file) {
+export default async function uploadImage(file, onProgress) {
const formData = new FormData();
formData.append('file', file);
formData.append('upload_preset', 'profile_upload');
@@ -14,6 +14,12 @@ export default async function uploadImage(file) {
headers: {
'Content-Type': 'multipart/form-data',
},
+ onUploadProgress: (e) => {
+ if (onProgress) {
+ const percent = Math.round((e.loaded * 100) / e.total);
+ onProgress(percent);
+ }
+ },
},
);
return res.data.secure_url;
diff --git a/src/components/BackgroundCard/BackgroundCard.jsx b/src/components/BackgroundCard/BackgroundCard.jsx
index f486a54..c83dc6d 100644
--- a/src/components/BackgroundCard/BackgroundCard.jsx
+++ b/src/components/BackgroundCard/BackgroundCard.jsx
@@ -3,6 +3,7 @@ import styles from './BackgroundCard.module.scss';
import uploadImage from '../../api/postUpload';
import checked from '../../assets/images/checked.svg';
import upload from '../../assets/images/upload.svg';
+import UploadProgressBar from '../common/UploadProgressBar';
export default function BackgroundCard({
type, // 'color' 또는 'image' 또는 'upload'
@@ -13,7 +14,10 @@ export default function BackgroundCard({
isLoading,
onLoad,
onSelect,
+ isUploading,
+ setIsUploading,
}) {
+ const [uploadProgress, setUploadProgress] = useState(0);
const [imageUrl, setImageUrl] = useState(url);
const getClassName = () => {
@@ -33,12 +37,21 @@ export default function BackgroundCard({
const file = e.target.files[0];
if (!file) return;
+ e.target.value = null;
+
+ setIsUploading(true);
+
try {
- const uploadedUrl = await uploadImage(file);
+ const uploadedUrl = await uploadImage(file, (percent) => {
+ setUploadProgress(percent);
+ });
setImageUrl(uploadedUrl);
onSelect?.(uploadedUrl);
} catch {
alert('이미지 업로드 실패했습니다.');
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
}
};
@@ -115,6 +128,7 @@ export default function BackgroundCard({
className={styles['background-card__check-icon']}
/>
)}
+ {isUploading && }
);
}
diff --git a/src/components/BackgroundCard/BackgroundCard.module.scss b/src/components/BackgroundCard/BackgroundCard.module.scss
index 4fb1765..2e80b27 100644
--- a/src/components/BackgroundCard/BackgroundCard.module.scss
+++ b/src/components/BackgroundCard/BackgroundCard.module.scss
@@ -81,6 +81,7 @@
display: flex;
justify-content: center;
align-items: center;
+ position: relative;
}
.background-card__upload-btn {
display: flex;
diff --git a/src/components/UserProfileSelector/UserProfileSelector.jsx b/src/components/UserProfileSelector/UserProfileSelector.jsx
index 4f919a5..c30dc28 100644
--- a/src/components/UserProfileSelector/UserProfileSelector.jsx
+++ b/src/components/UserProfileSelector/UserProfileSelector.jsx
@@ -2,14 +2,18 @@ import { useEffect, useState, useRef } from 'react';
import getProfileImages from '../../api/getProfileImages.js';
import uploadImage from '../../api/postUpload.js';
import DEFAULT_PROFILE_IMAGE from '../../constants/image.js';
+import UploadProgressBar from '../common/UploadProgressBar.jsx';
import styles from './UserProfileSelector.module.scss';
export default function UserProfileSelector({
value = DEFAULT_PROFILE_IMAGE,
onSelect,
+ isUploading,
+ setIsUploading,
}) {
const [profileImages, setProfileImages] = useState([]);
const [loadedImages, setLoadedImages] = useState({});
+ const [uploadProgress, setUploadProgress] = useState(0);
const fileInput = useRef(null);
useEffect(() => {
@@ -28,11 +32,21 @@ export default function UserProfileSelector({
const file = e.target.files[0];
if (!file) return;
+ e.target.value = null;
+
+ setIsUploading(true);
+
try {
- const uploadedUrl = await uploadImage(file);
+ const uploadedUrl = await uploadImage(file, (percent) => {
+ setUploadProgress(percent);
+ });
+
onSelect?.(uploadedUrl);
} catch (error) {
alert('이미지 업로드 실패했습니다.');
+ } finally {
+ setIsUploading(false);
+ setUploadProgress(0);
}
};
@@ -44,12 +58,17 @@ export default function UserProfileSelector({
프로필 이미지
-

+
+

+
+ {isUploading &&
}
+
+
onSelect?.(url)}
+ } ${!loadedImages[url] ? styles['profile-select__image--loading'] : ''}
+ ${isUploading ? styles['profile-select__image--disabled'] : ''}`}
+ onClick={() => {
+ if (!isUploading) onSelect?.(url);
+ }}
onLoad={() => handleImageLoad(url)}
/>
))}
diff --git a/src/components/UserProfileSelector/UserProfileSelector.module.scss b/src/components/UserProfileSelector/UserProfileSelector.module.scss
index 6219311..da8bd1b 100644
--- a/src/components/UserProfileSelector/UserProfileSelector.module.scss
+++ b/src/components/UserProfileSelector/UserProfileSelector.module.scss
@@ -23,6 +23,10 @@
cursor: pointer;
}
+ &__selected-wrapper {
+ position: relative;
+ }
+
&__content {
display: flex;
align-items: center;
@@ -49,6 +53,12 @@
gap: 4px;
}
+ &__image--disabled {
+ pointer-events: none;
+ opacity: 0.4;
+ filter: grayscale(60%);
+ }
+
&__image {
width: 56px;
border: 1px solid $gray-200;
diff --git a/src/components/common/UploadProgressBar.jsx b/src/components/common/UploadProgressBar.jsx
new file mode 100644
index 0000000..c9b2c60
--- /dev/null
+++ b/src/components/common/UploadProgressBar.jsx
@@ -0,0 +1,11 @@
+import styles from './UploadProgressBar.module.scss';
+
+export default function UploadProgressBar({ progress = 0 }) {
+ return (
+
+ );
+}
diff --git a/src/components/common/UploadProgressBar.module.scss b/src/components/common/UploadProgressBar.module.scss
new file mode 100644
index 0000000..5e9d0bc
--- /dev/null
+++ b/src/components/common/UploadProgressBar.module.scss
@@ -0,0 +1,27 @@
+@use '../../assets/styles/variables.scss' as *;
+
+.overlay {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ z-index: 10;
+ background-color: rgba(255, 255, 255, 0.6);
+}
+
+.bar {
+ overflow: hidden;
+ width: 60%;
+ height: 8px;
+ border-radius: 5px;
+ background-color: $gray-200;
+}
+
+.fill {
+ width: 0%;
+ height: 100%;
+ background-color: $purple-700;
+ transition: width 0.3s ease;
+}
diff --git a/src/pages/CreateRecipient/CreateRecipient.jsx b/src/pages/CreateRecipient/CreateRecipient.jsx
index a7ac884..2b930fb 100644
--- a/src/pages/CreateRecipient/CreateRecipient.jsx
+++ b/src/pages/CreateRecipient/CreateRecipient.jsx
@@ -19,6 +19,8 @@ export default function CreateRecipient() {
const [imageLoading, setImageLoading] = useState([]);
const [uploadedImageUrl, setUploadedImageUrl] = useState(null);
const [isCreating, setIsCreating] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+
const navigate = useNavigate();
useEffect(() => {
@@ -175,6 +177,8 @@ export default function CreateRecipient() {
onClick={() => handleImageClick(data.length + 1)}
isSelected={selectedImage === data.length + 1}
onSelect={handleUploadImage}
+ isUploading={isUploading}
+ setIsUploading={setIsUploading}
/>
)}
@@ -184,7 +188,7 @@ export default function CreateRecipient() {
diff --git a/src/pages/MessageForm/MessageForm.jsx b/src/pages/MessageForm/MessageForm.jsx
index 9f1c757..8ef0e91 100644
--- a/src/pages/MessageForm/MessageForm.jsx
+++ b/src/pages/MessageForm/MessageForm.jsx
@@ -21,6 +21,7 @@ export default function MessageForm() {
const [font, setFont] = useState('Noto Sans');
const [isRestored, setIsRestored] = useState(false);
const [isCreating, setIsCreating] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
const stripHtml = (html) => html.replace(/<[^>]+>/g, '').trim();
const isValid = sender.trim() !== '' && stripHtml(message) !== '';
@@ -144,6 +145,8 @@ export default function MessageForm() {
@@ -165,7 +168,7 @@ export default function MessageForm() {