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() {