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
7 changes: 7 additions & 0 deletions src/backend/user-server/src/user/dto/profile-image.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from "class-validator";

export class ProfileImageDto {
@IsString()
@IsOptional()
profileImageUrl: string;
}
18 changes: 18 additions & 0 deletions src/backend/user-server/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: "내 정보 수정" })
Expand Down
11 changes: 11 additions & 0 deletions src/backend/user-server/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } });
Expand Down
12 changes: 12 additions & 0 deletions src/frontend/src/api/endpoints/file/file.api.ts
Original file line number Diff line number Diff line change
@@ -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<string>(
`files/upload/${encodedFileName}?contentType=${encodeURIComponent(contentType)}`
);
return data;
},
};
8 changes: 8 additions & 0 deletions src/frontend/src/api/endpoints/user/user.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down
31 changes: 30 additions & 1 deletion src/frontend/src/components/Profile/MyProfile/index.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
55 changes: 52 additions & 3 deletions src/frontend/src/components/Profile/MyProfile/index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +13,8 @@ import {
StateMessageInput,
StateMessagePlus,
ErrorMessage,
ProfileImageResetButton,
ProfileImageContainer,
} from './index.css';

import Edit from '@/assets/img/Edit.svg';
Expand All @@ -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<HTMLInputElement>(null);

useEffect(() => {
if (user) {
Expand Down Expand Up @@ -78,11 +82,56 @@ export const MyProfile = () => {
setStateMessage(user?.stateMessage);
};

const handleImageClick = () => {
if (isEditMode) {
fileInputRef.current?.click();
}
};

const handleImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
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 (
<Container>
<Profile>
<Header>
<ProfileImage src={user.profileImageUrl ?? DefaultProfile} />
<ProfileImageContainer>
<ProfileImage
src={user.profileImageUrl ?? DefaultProfile}
onClick={handleImageClick}
$onClick={isEditMode}
onError={e => {
e.currentTarget.src = DefaultProfile;
}}
/>
<input
type="file"
ref={fileInputRef}
onChange={handleImageChange}
style={{ display: 'none' }}
accept="image/*"
/>
{isEditMode && (
<ProfileImageResetButton onClick={handleImageReset}>
<img src={Cancel} alt="cancel" />
</ProfileImageResetButton>
)}
</ProfileImageContainer>
<HeaderButtonContainer>
<IconButton
beforeImgUrl={isEditMode ? Check : Edit}
Expand Down
10 changes: 10 additions & 0 deletions src/frontend/src/stores/useUserStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface UserStore {
updateMyProfile: (
updateUserRequestDto: UpdateUserRequestDto,
) => Promise<UserResponseDto | undefined>;
updateProfileImage: (profileImageUrl: string | null) => Promise<UserResponseDto | undefined>;
clearProfile: () => void;
}

Expand All @@ -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 });
},
Expand Down
26 changes: 26 additions & 0 deletions src/frontend/src/utils/uploadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import axios from 'axios';
import { fileApi } from '@/api/endpoints/file/file.api';

export const uploadImageToS3 = async (file: File): Promise<string> => {
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;
}
};
Loading