Skip to content

Commit c0f8e98

Browse files
authored
Merge pull request #235 from sgdevcamp2025/common/dev/feat_image/#229
[COMMON] FEAT: 프로필 이미지 S3 업로드 #229
2 parents 97751af + 6dfee9f commit c0f8e98

File tree

9 files changed

+173
-4
lines changed

9 files changed

+173
-4
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsOptional, IsString } from "class-validator";
2+
3+
export class ProfileImageDto {
4+
@IsString()
5+
@IsOptional()
6+
profileImageUrl: string;
7+
}

src/backend/user-server/src/user/user.controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { UpdateUserDto } from "./dto/update-user.dto";
2626
import { MESSAGES } from "./constants/constants";
2727
import { VERSION_NEUTRAL } from "@nestjs/common";
2828
import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger";
29+
import { ProfileImageDto } from "./dto/profile-image.dto";
2930

3031
@Controller({ path: "api/users", version: VERSION_NEUTRAL })
3132
@UseInterceptors(ClassSerializerInterceptor)
@@ -79,6 +80,23 @@ export class UserController {
7980
return this.userService.getUserById(+id);
8081
}
8182

83+
@Patch("me/profile-image")
84+
@ApiBearerAuth()
85+
@ApiOperation({ summary: "프로필 이미지 수정" })
86+
async updateProfileImage(
87+
@Req() req: Request,
88+
@Body() profileImageDto: ProfileImageDto,
89+
) {
90+
const userId = req.headers["x-user-id"];
91+
if (!userId) {
92+
throw new UnauthorizedException(MESSAGES.UNAUTHORIZED_IN_HEADER);
93+
}
94+
return await this.userService.updateProfileImage(
95+
+userId,
96+
profileImageDto.profileImageUrl,
97+
);
98+
}
99+
82100
@Patch("me")
83101
@ApiBearerAuth()
84102
@ApiOperation({ summary: "내 정보 수정" })

src/backend/user-server/src/user/user.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,16 @@ export class UserService {
141141
return updatedUser;
142142
}
143143

144+
async updateProfileImage(userId: number, profileImageUrl: string) {
145+
const user = await this.userRepository.findOne({ where: { id: userId } });
146+
if (!user) {
147+
throw new NotFoundException(MESSAGES.USER_NOT_FOUND);
148+
}
149+
user.profileImageUrl = profileImageUrl;
150+
const updatedUser = await this.userRepository.save(user);
151+
return updatedUser;
152+
}
153+
144154
async delete(id: number) {
145155
try {
146156
const user = await this.userRepository.findOne({ where: { id } });
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import instance from '@/api/axios.instance';
2+
3+
export const fileApi = {
4+
// 파일 업로드를 위한 presigned URL 받아오기
5+
getPresignedUrl: async (fileName: string, contentType: string) => {
6+
const encodedFileName = encodeURIComponent(fileName);
7+
const { data } = await instance.get<string>(
8+
`files/upload/${encodedFileName}?contentType=${encodeURIComponent(contentType)}`
9+
);
10+
return data;
11+
},
12+
};

src/frontend/src/api/endpoints/user/user.api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export const userApi = {
2626
}
2727
},
2828

29+
// 프로필 이미지 업데이트
30+
updateProfileImage: async (profileImageUrl: string | null) => {
31+
const { data } = await instance.patch(`users/me/profile-image`, {
32+
profileImageUrl,
33+
});
34+
return data;
35+
},
36+
2937
// 전체 유저 조회
3038
getUsers: async (page: number = 0, size: number = 10, nickname?: string) => {
3139
try {

src/frontend/src/components/Profile/MyProfile/index.css.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,39 @@ export const Header = styled.div`
2020
justify-content: space-between;
2121
`;
2222

23-
export const ProfileImage = styled.img`
23+
export const ProfileImageContainer = styled.div`
24+
position: relative;
25+
`;
26+
27+
export const ProfileImage = styled.img<{ $onClick: boolean }>`
2428
width: 64px;
2529
height: 64px;
2630
border-radius: 10px;
31+
transition: opacity 0.2s ease-in-out;
32+
33+
&:hover {
34+
cursor: ${({ $onClick }) => ($onClick ? 'pointer' : 'default')};
35+
opacity: ${({ $onClick }) => ($onClick ? 0.7 : 1)};
36+
}
37+
`;
38+
39+
export const ProfileImageResetButton = styled.button`
40+
position: absolute;
41+
top: -6px;
42+
left: -6px;
43+
background-color: var(--palette-background-normal-alternative);
44+
display: flex;
45+
align-items: center;
46+
justify-content: center;
47+
border: 1px solid var(--palette-line-normal-neutral);
48+
cursor: pointer;
49+
border-radius: 50%;
50+
padding: 8px;
51+
52+
& > img {
53+
width: 8px;
54+
height: 8px;
55+
}
2756
`;
2857

2958
export const HeaderButtonContainer = styled.div`

src/frontend/src/components/Profile/MyProfile/index.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { ChangeEvent, useEffect, useRef, useState } from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { IconButton } from '@/components/IconButton';
44
import {
@@ -13,6 +13,8 @@ import {
1313
StateMessageInput,
1414
StateMessagePlus,
1515
ErrorMessage,
16+
ProfileImageResetButton,
17+
ProfileImageContainer,
1618
} from './index.css';
1719

1820
import Edit from '@/assets/img/Edit.svg';
@@ -23,15 +25,17 @@ import { useUserStore } from '@/stores/useUserStore';
2325
import DefaultProfile from '@/assets/img/DefaultProfile.svg';
2426
import AddIcon from '@/assets/img/Add.svg';
2527
import { AxiosError } from 'axios';
28+
import { uploadImageToS3 } from '@/utils/uploadFile';
2629

2730
export const MyProfile = () => {
28-
const { user, updateMyProfile } = useUserStore();
31+
const { user, updateMyProfile, updateProfileImage } = useUserStore();
2932
const navigate = useNavigate();
3033
const [isEditMode, setIsEditMode] = useState(false);
3134
const [nickname, setNickname] = useState(user?.nickname);
3235
const [stateMessage, setStateMessage] = useState(user?.stateMessage || null);
3336
const [isChanged, setIsChanged] = useState(false);
3437
const [errorMessage, setErrorMessage] = useState('');
38+
const fileInputRef = useRef<HTMLInputElement>(null);
3539

3640
useEffect(() => {
3741
if (user) {
@@ -92,11 +96,56 @@ export const MyProfile = () => {
9296
setStateMessage(user?.stateMessage);
9397
};
9498

99+
const handleImageClick = () => {
100+
if (isEditMode) {
101+
fileInputRef.current?.click();
102+
}
103+
};
104+
105+
const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
106+
const file = e.target.files?.[0];
107+
if (!file) return;
108+
109+
try {
110+
const imageUrl = await uploadImageToS3(file);
111+
const updateUser = await updateProfileImage(imageUrl);
112+
console.log('image change', updateUser);
113+
} catch {
114+
setErrorMessage('이미지 업로드에 실패했습니다.');
115+
}
116+
};
117+
118+
const handleImageReset = async () => {
119+
const updateUser = await updateProfileImage(null);
120+
console.log('image reset', updateUser);
121+
};
122+
95123
return (
96124
<Container>
97125
<Profile>
98126
<Header>
99-
<ProfileImage src={user.profileImageUrl ?? DefaultProfile} />
127+
<ProfileImageContainer>
128+
<ProfileImage
129+
src={user.profileImageUrl ?? DefaultProfile}
130+
onClick={handleImageClick}
131+
$onClick={isEditMode}
132+
onError={e => {
133+
e.currentTarget.src = DefaultProfile;
134+
}}
135+
/>
136+
<input
137+
type="file"
138+
ref={fileInputRef}
139+
onChange={handleImageChange}
140+
style={{ display: 'none' }}
141+
accept="image/*"
142+
/>
143+
{isEditMode && (
144+
<ProfileImageResetButton onClick={handleImageReset}>
145+
<img src={Cancel} alt="cancel" />
146+
</ProfileImageResetButton>
147+
)}
148+
</ProfileImageContainer>
100149
<HeaderButtonContainer>
101150
<IconButton
102151
beforeImgUrl={isEditMode ? Check : Edit}

src/frontend/src/stores/useUserStore.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface UserStore {
1010
updateMyProfile: (
1111
updateUserRequestDto: UpdateUserRequestDto,
1212
) => Promise<UserResponseDto | undefined>;
13+
updateProfileImage: (profileImageUrl: string | null) => Promise<UserResponseDto | undefined>;
1314
clearProfile: () => void;
1415
}
1516

@@ -29,6 +30,15 @@ export const useUserStore = create(
2930
set({ user: data });
3031
return data;
3132
},
33+
updateProfileImage: async (profileImageUrl: string | null) => {
34+
try {
35+
const data = await userApi.updateProfileImage(profileImageUrl);
36+
set({ user: data });
37+
return data;
38+
} catch (error) {
39+
console.error(error);
40+
}
41+
},
3242
clearProfile: () => {
3343
set({ user: null });
3444
},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import axios from 'axios';
2+
import { fileApi } from '@/api/endpoints/file/file.api';
3+
4+
export const uploadImageToS3 = async (file: File): Promise<string> => {
5+
try {
6+
// presigned URL 받아오기
7+
const presignedUrl = await fileApi.getPresignedUrl(file.name, file.type);
8+
9+
// S3에 업로드
10+
const uploadResponse = await axios.put(presignedUrl, file, {
11+
headers: {
12+
'Content-Type': file.type,
13+
},
14+
});
15+
16+
if (uploadResponse.status !== 200) {
17+
throw new Error('Failed to upload image');
18+
}
19+
20+
// 최종 이미지 URL (presigned URL에서 쿼리 파라미터 제거)
21+
return presignedUrl.split('?')[0];
22+
} catch (error) {
23+
console.error('Error uploading image:', error);
24+
throw error;
25+
}
26+
};

0 commit comments

Comments
 (0)