diff --git a/src/backend/user-server/src/user/dto/profile-image.dto.ts b/src/backend/user-server/src/user/dto/profile-image.dto.ts new file mode 100644 index 00000000..b6cc3bcb --- /dev/null +++ b/src/backend/user-server/src/user/dto/profile-image.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from "class-validator"; + +export class ProfileImageDto { + @IsString() + @IsOptional() + profileImageUrl: string; +} diff --git a/src/backend/user-server/src/user/user.controller.ts b/src/backend/user-server/src/user/user.controller.ts index cba7705e..d4f9f574 100644 --- a/src/backend/user-server/src/user/user.controller.ts +++ b/src/backend/user-server/src/user/user.controller.ts @@ -26,6 +26,7 @@ import { UpdateUserDto } from "./dto/update-user.dto"; import { MESSAGES } from "./constants/constants"; import { VERSION_NEUTRAL } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; +import { ProfileImageDto } from "./dto/profile-image.dto"; @Controller({ path: "api/users", version: VERSION_NEUTRAL }) @UseInterceptors(ClassSerializerInterceptor) @@ -79,6 +80,23 @@ export class UserController { return this.userService.getUserById(+id); } + @Patch("me/profile-image") + @ApiBearerAuth() + @ApiOperation({ summary: "프로필 이미지 수정" }) + async updateProfileImage( + @Req() req: Request, + @Body() profileImageDto: ProfileImageDto, + ) { + const userId = req.headers["x-user-id"]; + if (!userId) { + throw new UnauthorizedException(MESSAGES.UNAUTHORIZED_IN_HEADER); + } + return await this.userService.updateProfileImage( + +userId, + profileImageDto.profileImageUrl, + ); + } + @Patch("me") @ApiBearerAuth() @ApiOperation({ summary: "내 정보 수정" }) diff --git a/src/backend/user-server/src/user/user.service.ts b/src/backend/user-server/src/user/user.service.ts index 5fbc93ca..b976ea08 100644 --- a/src/backend/user-server/src/user/user.service.ts +++ b/src/backend/user-server/src/user/user.service.ts @@ -17,6 +17,7 @@ import { REDIS_KEY } from "./constants/redis-key.constant"; import { DeviceType } from "./enum/device-type.enum"; import { MESSAGES } from "./constants/constants"; import { ENV_KEY } from "./constants/env-key.constants"; + @Injectable() export class UserService { private readonly redis: Redis; @@ -137,6 +138,16 @@ export class UserService { return updatedUser; } + async updateProfileImage(userId: number, profileImageUrl: string) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException(MESSAGES.USER_NOT_FOUND); + } + user.profileImageUrl = profileImageUrl; + const updatedUser = await this.userRepository.save(user); + return updatedUser; + } + async delete(id: number) { try { const user = await this.userRepository.findOne({ where: { id } }); diff --git a/src/frontend/src/api/endpoints/file/file.api.ts b/src/frontend/src/api/endpoints/file/file.api.ts new file mode 100644 index 00000000..06d1ed59 --- /dev/null +++ b/src/frontend/src/api/endpoints/file/file.api.ts @@ -0,0 +1,12 @@ +import instance from '@/api/axios.instance'; + +export const fileApi = { + // 파일 업로드를 위한 presigned URL 받아오기 + getPresignedUrl: async (fileName: string, contentType: string) => { + const encodedFileName = encodeURIComponent(fileName); + const { data } = await instance.get( + `files/upload/${encodedFileName}?contentType=${encodeURIComponent(contentType)}` + ); + return data; + }, +}; diff --git a/src/frontend/src/api/endpoints/user/user.api.ts b/src/frontend/src/api/endpoints/user/user.api.ts index 72707d0d..cc3e1e7d 100644 --- a/src/frontend/src/api/endpoints/user/user.api.ts +++ b/src/frontend/src/api/endpoints/user/user.api.ts @@ -14,6 +14,14 @@ export const userApi = { return data; }, + // 프로필 이미지 업데이트 + updateProfileImage: async (profileImageUrl: string | null) => { + const { data } = await instance.patch(`users/me/profile-image`, { + profileImageUrl, + }); + return data; + }, + // 전체 유저 조회 getUsers: async (page: number = 0, size: number = 10) => { const { data } = await instance.get(`users`, { params: { page, size } }); diff --git a/src/frontend/src/components/Profile/MyProfile/index.css.ts b/src/frontend/src/components/Profile/MyProfile/index.css.ts index c9bc7ef4..be85d822 100644 --- a/src/frontend/src/components/Profile/MyProfile/index.css.ts +++ b/src/frontend/src/components/Profile/MyProfile/index.css.ts @@ -20,10 +20,39 @@ export const Header = styled.div` justify-content: space-between; `; -export const ProfileImage = styled.img` +export const ProfileImageContainer = styled.div` + position: relative; +`; + +export const ProfileImage = styled.img<{ $onClick: boolean }>` width: 64px; height: 64px; border-radius: 10px; + transition: opacity 0.2s ease-in-out; + + &:hover { + cursor: ${({ $onClick }) => ($onClick ? 'pointer' : 'default')}; + opacity: ${({ $onClick }) => ($onClick ? 0.7 : 1)}; + } +`; + +export const ProfileImageResetButton = styled.button` + position: absolute; + top: -6px; + left: -6px; + background-color: var(--palette-background-normal-alternative); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--palette-line-normal-neutral); + cursor: pointer; + border-radius: 50%; + padding: 8px; + + & > img { + width: 8px; + height: 8px; + } `; export const HeaderButtonContainer = styled.div` diff --git a/src/frontend/src/components/Profile/MyProfile/index.tsx b/src/frontend/src/components/Profile/MyProfile/index.tsx index f0c30910..7702210d 100644 --- a/src/frontend/src/components/Profile/MyProfile/index.tsx +++ b/src/frontend/src/components/Profile/MyProfile/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { IconButton } from '@/components/IconButton'; import { @@ -13,6 +13,8 @@ import { StateMessageInput, StateMessagePlus, ErrorMessage, + ProfileImageResetButton, + ProfileImageContainer, } from './index.css'; import Edit from '@/assets/img/Edit.svg'; @@ -23,15 +25,17 @@ import { useUserStore } from '@/stores/useUserStore'; import DefaultProfile from '@/assets/img/DefaultProfile.svg'; import AddIcon from '@/assets/img/Add.svg'; import { AxiosError } from 'axios'; +import { uploadImageToS3 } from '@/utils/uploadFile'; export const MyProfile = () => { - const { user, updateMyProfile } = useUserStore(); + const { user, updateMyProfile, updateProfileImage } = useUserStore(); const navigate = useNavigate(); const [isEditMode, setIsEditMode] = useState(false); const [nickname, setNickname] = useState(user?.nickname); const [stateMessage, setStateMessage] = useState(user?.stateMessage); const [isChanged, setIsChanged] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const fileInputRef = useRef(null); useEffect(() => { if (user) { @@ -78,11 +82,56 @@ export const MyProfile = () => { setStateMessage(user?.stateMessage); }; + const handleImageClick = () => { + if (isEditMode) { + fileInputRef.current?.click(); + } + }; + + const handleImageChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const imageUrl = await uploadImageToS3(file); + const updateUser = await updateProfileImage(imageUrl); + console.log('image change', updateUser); + } catch { + setErrorMessage('이미지 업로드에 실패했습니다.'); + } + }; + + const handleImageReset = async () => { + const updateUser = await updateProfileImage(null); + console.log('image reset', updateUser); + }; + return (
- + + { + e.currentTarget.src = DefaultProfile; + }} + /> + + {isEditMode && ( + + cancel + + )} + Promise; + updateProfileImage: (profileImageUrl: string | null) => Promise; clearProfile: () => void; } @@ -33,6 +34,15 @@ export const useUserStore = create( set({ user: data }); return data; }, + updateProfileImage: async (profileImageUrl: string | null) => { + try { + const data = await userApi.updateProfileImage(profileImageUrl); + set({ user: data }); + return data; + } catch (error) { + console.error(error); + } + }, clearProfile: () => { set({ user: null }); }, diff --git a/src/frontend/src/utils/uploadFile.ts b/src/frontend/src/utils/uploadFile.ts new file mode 100644 index 00000000..10c79120 --- /dev/null +++ b/src/frontend/src/utils/uploadFile.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { fileApi } from '@/api/endpoints/file/file.api'; + +export const uploadImageToS3 = async (file: File): Promise => { + try { + // presigned URL 받아오기 + const presignedUrl = await fileApi.getPresignedUrl(file.name, file.type); + + // S3에 업로드 + const uploadResponse = await axios.put(presignedUrl, file, { + headers: { + 'Content-Type': file.type, + }, + }); + + if (uploadResponse.status !== 200) { + throw new Error('Failed to upload image'); + } + + // 최종 이미지 URL (presigned URL에서 쿼리 파라미터 제거) + return presignedUrl.split('?')[0]; + } catch (error) { + console.error('Error uploading image:', error); + throw error; + } +};