Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions src/pages/MessagePage/MessagePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { FONT_OPTIONS, FONT_DROPDOWN_ITEMS } from '@/constants/fontMap';
import { useEffect } from 'react';
import Button from '@/components/Button/Button';
import EditorWrapper from '@/pages/MessagePage/components/EditorWrapper';
import logoIcon from '@/assets/icons/icon_logo_white.svg';
import backIcon from '@/assets/icons/icon_back.svg';

// 상대와의 관계 옵션
const RELATIONSHIP_OPTIONS = ['친구', '지인', '동료', '가족'];
Expand Down Expand Up @@ -87,6 +89,11 @@ function MessagePage() {
navigate(`/post/${recipientId}`); // 실제 라우트에 맞게 수정
};

const handleGoBackClick = (e) => {
e.preventDefault();
navigate(-1);
};

return (
<div className={styles['message-page']}>
<form className={styles['message-page__form']} onSubmit={handleSubmit}>
Expand Down Expand Up @@ -160,16 +167,19 @@ function MessagePage() {
</div>

{/* 6) 전송 버튼 */}
<div className={styles['message-page__actions']}>
<Button
type='submit'
variant='primary'
size='stretch'
<div className={styles['message-page__button-area']}>
<button
className={styles['message-page__submit']}
disabled={!isFormValid || loading}
type='submit'
>
<img className={styles['message-page__button-logo']} src={logoIcon} />
생성하기
</Button>
</button>
<button className={styles['message-page__back']} onClick={handleGoBackClick}>
<img className={styles['message-page__button-logo']} src={backIcon} />
돌아가기
</button>
</div>
</form>
</div>
Expand Down
67 changes: 51 additions & 16 deletions src/pages/MessagePage/MessagePage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,38 +34,73 @@
}

/* 전송 버튼 영역 */
&__actions {
&__button-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
&__spacer {
height: 200px;
gap: 30px;

@include tablet {
flex-direction: row;
gap: 20px;
}

@include mobile {
flex-direction: row;
gap: 20px;
}
}

&__submit {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 0;
background-color: var(--color-purple-600);
color: var(--color-white);
padding: 0.75rem 2rem;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
font-weight: 700;
font-size: 18px;
line-height: 28px;
color: var(--color-white);
cursor: pointer;
transition: background-color 0.3s ease;

&:hover {
background-color: var(--color-purple-700);
}

&:active {
background-color: var(--color-purple-800);
}

&:disabled {
background-color: var(--color-gray-400);
background-color: var(--color-gray-300);
cursor: not-allowed;
}
}

&__back {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 0;
background-color: var(--color-white);
border: 1px solid var(--color-purple-600);
border-radius: 12px;
font-weight: 700;
font-size: 18px;
line-height: 28px;
color: var(--color-purple-600);
cursor: pointer;

&:hover {
background-color: var(--color-purple-100);
}
}

&__button-logo {
aspect-ratio: 1;
width: 18px;
}
}
162 changes: 115 additions & 47 deletions src/pages/MessagePage/components/ProfileSelector.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useApi } from '@/hooks/useApi';
import { getProfileImages } from '@/apis/profileImagesApi';
import ImagePreloader from '@/components/ImagePreloader';
Expand All @@ -7,6 +7,9 @@ import AVATAR_PLACEHOLDER from '@/assets/images/image_profile_default.svg';
import HorizontalScrollContainer from '@/components/HorizontalScrollContainer/HorizontalScrollContainer';
import GradientImage from '@/components/GradientImage/GradientImage';
import LoadingLabel from '@/components/LoadingLabel/LoadingLabel';
import Button from '@/components/Button/Button';
import { uploadImageToCloudinary } from '@/apis/syncApi/uploadImageToCloudinary';
import { useToast } from '@/hooks/useToast';

/**
* 프로필 이미지 선택기
Expand All @@ -16,78 +19,143 @@ import LoadingLabel from '@/components/LoadingLabel/LoadingLabel';
*/

function ProfileSelector({ onSelectImage }) {
const { showToast } = useToast();
// 프로필 이미지 목록 가져오기 (API 호출)
const { data: imageData, loading } = useApi(getProfileImages);

// imageData가 배열이 아닐 경우 빈 배열로 초기화
const imageUrls = useMemo(() => {
const apiImageUrls = useMemo(() => {
return Array.isArray(imageData?.imageUrls) ? imageData.imageUrls : [];
}, [imageData]);

// 선택된 이미지 URL 상태
const [selectedUrl, setSelectedUrl] = useState('');
// 로딩된 이미지 개수 상태
const [loadedCount, setLoadedCount] = useState(0);
const allLoaded = imageUrls.length > 0 && loadedCount >= imageUrls.length;
// 모든 이미지가 로드되었는지 여부
const allLoaded = apiImageUrls.length > 0 && loadedCount >= apiImageUrls.length;

/* ---------------- 사용자 업로드 이미지 ---------------- */
const [uploading, setUploading] = useState(false); // 업로드 진행 여부
const fileInputRef = useRef(null);

// 로딩 후, 선택된 이미지 URL이 없으면 첫 번째 URL을 기본값으로 설정
useEffect(() => {
if (!loading && imageUrls.length > 0 && !selectedUrl) {
if (!loading && apiImageUrls.length > 0 && !selectedUrl) {
// 아직 선택된 값이 없으면 첫 번째 URL을 기본값으로
setSelectedUrl((prev) => prev || imageUrls[0]);
onSelectImage && onSelectImage(imageUrls[0]);
setSelectedUrl((prev) => prev || apiImageUrls[0]);
onSelectImage && onSelectImage(apiImageUrls[0]);
}
}, [loading, imageUrls, selectedUrl, onSelectImage]);
}, [loading, apiImageUrls, selectedUrl, onSelectImage]);

const handleImageSelect = (url) => {
setSelectedUrl(url);
onSelectImage && onSelectImage(url);
};

const handleFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file || !file.type.startsWith('image/')) {
alert('이미지 파일만 업로드할 수 있습니다.');
return;
}

const previewUrl = URL.createObjectURL(file);

setSelectedUrl(previewUrl);
onSelectImage?.(previewUrl);
setUploading(true);
// Cloudinary 업로드 시작
try {
const uploadedUrl = await uploadImageToCloudinary(file);
setSelectedUrl(uploadedUrl);
onSelectImage?.(uploadedUrl);
} catch (err) {
console.error(err);
showToast?.({
type: 'fail',
message: '프로필 이미지 업로드에 실패했습니다.',
});
setSelectedUrl('');
onSelectImage?.('');
} finally {
setUploading(false);
URL.revokeObjectURL(previewUrl);
e.target.value = '';
}
};

const handleThumbLoad = () => setLoadedCount((c) => c + 1);
const uploadBtnClick = () => {
if (uploading) return; // 업로드 중이면 무시
if (fileInputRef.current) {
fileInputRef.current.value = ''; // *** 핵심: 값 초기화 ***
fileInputRef.current.click(); // 파일 다이얼로그 오픈
}
};

return (
<div className={styles['profile-selector']}>
{/* 백그라운드에서 모든 이미지 미리 로드 */}
<ImagePreloader imageUrls={imageUrls} />
{/* 현재 선택된 이미지를 보여주는 영역 */}
<div className={styles['profile-selector__preview-container']}>
<GradientImage
src={selectedUrl || AVATAR_PLACEHOLDER}
alt='선택된 프로필'
className={styles['profile-selector__preview']}
/>
</div>
<div className={styles['profile-selector__container']}>
<div className={styles['profile-selector']}>
{/* 백그라운드에서 모든 이미지 미리 로드 */}
<ImagePreloader imageUrls={apiImageUrls} />
{/* 현재 선택된 이미지를 보여주는 영역 */}
<div className={styles['profile-selector__preview-container']}>
<GradientImage
src={selectedUrl || AVATAR_PLACEHOLDER}
alt='선택된 프로필'
className={styles['profile-selector__preview']}
/>
</div>

{/* 이미지 리스트 */}
<div className={styles['profile-selector__images-container']}>
<LoadingLabel
loading={!allLoaded}
loadingText='프로필 리스트 로딩 중...'
loadedText='프로필 이미지를 선택해주세요!'
className={styles['profile-selector__label']}
/>
<HorizontalScrollContainer className={styles['profile-selector__images']}>
{imageUrls.map((url, idx) => {
const isSelected = url === selectedUrl;
{/* 이미지 리스트 */}
<div className={styles['profile-selector__images-container']}>
<LoadingLabel
loading={!allLoaded}
loadingText={uploading ? '이미지 업로드 중...' : '프로필 리스트 로딩 중...'}
loadedText='프로필 이미지를 선택해주세요!'
className={styles['profile-selector__label']}
/>
<HorizontalScrollContainer className={styles['profile-selector__images']}>
{apiImageUrls.map((url, idx) => {
const isSelected = url === selectedUrl;

return (
<GradientImage
key={url}
src={url}
alt={`프로필 썸네일 ${idx + 1}`}
className={
isSelected
? styles['profile-selector__image-selected']
: styles['profile-selector__image']
}
onClick={() => handleImageSelect(url)}
draggable='false'
onLoaded={handleThumbLoad}
/>
);
})}
</HorizontalScrollContainer>
return (
<GradientImage
key={url}
src={url}
alt={`프로필 썸네일 ${idx + 1}`}
className={
isSelected
? styles['profile-selector__image-selected']
: styles['profile-selector__image']
}
onClick={() => handleImageSelect(url)}
draggable='false'
onLoaded={handleThumbLoad}
/>
);
})}
</HorizontalScrollContainer>
</div>
</div>
<input
style={{ display: 'none' }}
type='file'
accept='image/*'
ref={fileInputRef}
onChange={handleFileChange}
disabled={uploading}
/>

<Button
variant='outlined'
type='button'
size={36}
onClick={uploadBtnClick}
disabled={uploading}
>
내 프로필 업로드
</Button>
</div>
);
}
Expand Down
14 changes: 14 additions & 0 deletions src/pages/MessagePage/components/ProfileSelector.module.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
.profile-selector__container {
display: flex;
flex-direction: column;
gap: 20px;
}

.profile-selector {
width: 100%;
display: flex;
gap: 32px;
align-items: center;
justify-content: center;
@include mobile {
gap: 0;
}

&__preview-container {
display: flex;
Expand Down Expand Up @@ -31,6 +40,11 @@
font-size: var(--font-size-16);
font-weight: var(--font-weight-regular);
color: var(--color-gray-500);
//텍스트 잘리지 않게
white-space: nowrap;
@include mobile {
margin-left: -40px;
}
}
&__images {
gap: 4px;
Expand Down
Loading