-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 마이페이지 드롭다운 구현 (#36) #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
98f6bff
feat: 프로필 관련 API 및 hooks 추가 (#36)
mgYang53 088e9ca
feat: 공통 프로필 컴포넌트 및 hooks 추가 (#36)
mgYang53 cf6b9b1
feat: 마이페이지 드롭다운 및 Header 연동 (#36)
mgYang53 74f6f71
refactor: 온보딩 페이지 공통 컴포넌트 적용 (#36)
mgYang53 12cedc5
feat: 메인페이지 사용자 닉네임 인사말 표시 (#36)
mgYang53 15baba8
chore: 파비콘 및 gitignore 설정 (#36)
mgYang53 df25abe
fix: coderabbit 리뷰 반영 (#36)
mgYang53 72a71c3
fix: nicknameStatus 중복 체크 조건 개선 (#36)
mgYang53 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /** | ||
| * @file authQueryKeys.ts | ||
| * @description 인증 관련 Query Key Factory | ||
| */ | ||
|
|
||
| export const authQueryKeys = { | ||
| all: ['auth'] as const, | ||
| me: () => [...authQueryKeys.all, 'me'] as const, | ||
| } as const |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from './authQueryKeys' | ||
| export * from './useAuth' | ||
| export * from './useLogout' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import { useLogout } from '@/features/auth' | ||
| import { Button, TextButton } from '@/shared/ui' | ||
| import { useGlobalModalStore } from '@/store' | ||
|
|
||
| import { useDeleteUser, useProfileForm, useUpdateNickname, useUserProfile } from '../hooks' | ||
| import { NicknameInput } from './NicknameInput' | ||
| import { ProfileImagePicker } from './ProfileImagePicker' | ||
|
|
||
| interface MyPageDropdownProps { | ||
| onClose?: () => void | ||
| } | ||
|
|
||
| /** | ||
| * 마이페이지 드롭다운 컴포넌트 | ||
| * | ||
| * @description | ||
| * Header의 프로필 아바타 클릭 시 나타나는 드롭다운 | ||
| * - 프로필 이미지 변경/제거 | ||
| * - 닉네임 수정 | ||
| * - 로그아웃/탈퇴하기 | ||
| */ | ||
| export function MyPageDropdown({ onClose }: MyPageDropdownProps) { | ||
| const { data: user } = useUserProfile() | ||
| const { mutate: updateNickname, isPending: isUpdating } = useUpdateNickname() | ||
| const { mutate: logout } = useLogout() | ||
| const { mutate: deleteUser } = useDeleteUser() | ||
| const { openConfirm } = useGlobalModalStore() | ||
|
|
||
| const { | ||
| nickname, | ||
| setNickname, | ||
| nicknameValidation, | ||
| handleImageChange, | ||
| handleImageRemove, | ||
| canSubmit, | ||
| isImagePending, | ||
| } = useProfileForm({ | ||
| initialNickname: user?.nickname ?? '', | ||
| initialImageUrl: user?.profileImageUrl ?? null, | ||
| checkNicknameChange: true, | ||
| immediateImageUpload: true, | ||
| }) | ||
|
|
||
| // immediateImageUpload 모드에서는 서버에서 받은 최신 이미지 URL 사용 | ||
| const currentImageUrl = user?.profileImageUrl ?? null | ||
|
|
||
| const isPending = isUpdating || isImagePending | ||
|
|
||
| // 저장 가능 조건: 닉네임이 변경되었고, 유효하고, 로딩 중이 아닐 때 | ||
| const nicknameChanged = nickname !== user?.nickname | ||
| const canSave = nicknameChanged && canSubmit && !isUpdating | ||
|
|
||
| // 닉네임 저장 | ||
| const handleSave = () => { | ||
| if (!canSave) return | ||
|
|
||
| updateNickname(nickname, { | ||
| onSuccess: () => onClose?.(), | ||
| }) | ||
| } | ||
|
|
||
| const handleLogout = () => { | ||
| logout() | ||
| } | ||
|
|
||
| const handleDeleteClick = async () => { | ||
| const confirmed = await openConfirm( | ||
| '회원 탈퇴', | ||
| '탈퇴 시 계정 정보 및 서비스 이용 기록이 모두 삭제되며, 복구가 불가능해요.\n\n정말로 탈퇴하시겠어요?', | ||
| { confirmText: '탈퇴하기', variant: 'danger' } | ||
| ) | ||
| if (confirmed) { | ||
| deleteUser() | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex w-94 flex-col gap-xlarge rounded-medium bg-white px-9 pb-large pt-9 shadow-drop"> | ||
| {/* 제목 */} | ||
| <h2 className="text-black typo-heading3">마이페이지</h2> | ||
|
|
||
| {/* 프로필 이미지 & 닉네임 */} | ||
| <div className="flex flex-col items-center gap-9"> | ||
| <ProfileImagePicker | ||
| imageUrl={currentImageUrl} | ||
| onFileChange={handleImageChange} | ||
| onRemove={handleImageRemove} | ||
| showRemoveButton={!!currentImageUrl} | ||
| disabled={isPending} | ||
| variant="mypage" | ||
| /> | ||
|
|
||
| <NicknameInput | ||
| value={nickname} | ||
| onChange={setNickname} | ||
| validation={nicknameValidation} | ||
| disabled={isPending} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* 하단 액션 버튼 */} | ||
| <div className="flex items-center justify-between"> | ||
| <div className="flex items-center gap-base"> | ||
| <TextButton onClick={handleLogout}>로그아웃</TextButton> | ||
| <span className="h-3 w-px bg-grey-400" aria-hidden /> | ||
| <TextButton onClick={handleDeleteClick}>탈퇴하기</TextButton> | ||
| </div> | ||
|
|
||
| <Button variant="primary" size="small" onClick={handleSave} disabled={!canSave}> | ||
| 저장 | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { Input } from '@/shared/ui' | ||
|
|
||
| import type { NicknameValidation } from '../hooks/useProfileForm' | ||
|
|
||
| interface NicknameInputProps { | ||
| value: string | ||
| onChange: (value: string) => void | ||
| validation: NicknameValidation | ||
| maxLength?: number | ||
| disabled?: boolean | ||
| placeholder?: string | ||
| } | ||
|
|
||
| /** | ||
| * 닉네임 입력 컴포넌트 | ||
| * | ||
| * @description | ||
| * - useProfileForm에서 제공하는 validation 정보를 받아 UI 렌더링 | ||
| */ | ||
| export function NicknameInput({ | ||
| value, | ||
| onChange, | ||
| validation, | ||
| maxLength = 20, | ||
| disabled = false, | ||
| placeholder = '닉네임을 입력해주세요', | ||
| }: NicknameInputProps) { | ||
| return ( | ||
| <Input | ||
| value={value} | ||
| onChange={(e) => onChange(e.target.value)} | ||
| placeholder={placeholder} | ||
| maxLength={maxLength} | ||
| disabled={disabled} | ||
| error={validation.isError} | ||
| errorMessage={validation.errorMessage} | ||
| helperText={validation.helperText} | ||
| /> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { Camera } from 'lucide-react' | ||
| import { useId, useRef } from 'react' | ||
|
|
||
| import UserAvatarIcon from '@/shared/assets/icon/UserAvatar.svg' | ||
| import { ALLOWED_IMAGE_ACCEPT, MAX_IMAGE_SIZE } from '@/shared/constants' | ||
| import { Avatar, AvatarFallback, AvatarImage, Button, TextButton } from '@/shared/ui' | ||
| import { useGlobalModalStore } from '@/store' | ||
|
|
||
| interface ProfileImagePickerProps { | ||
| /** 현재 표시할 이미지 URL */ | ||
| imageUrl?: string | null | ||
| /** 파일 변경 핸들러 */ | ||
| onFileChange: (file: File) => void | ||
| /** 이미지 제거 핸들러 */ | ||
| onRemove?: () => void | ||
| /** 제거 버튼 표시 여부 */ | ||
| showRemoveButton?: boolean | ||
| /** 비활성화 상태 */ | ||
| disabled?: boolean | ||
| /** variant: 'onboarding' | 'mypage' */ | ||
| variant?: 'onboarding' | 'mypage' | ||
| } | ||
|
|
||
| function ProfileAvatar({ imageUrl, className }: { imageUrl?: string | null; className?: string }) { | ||
| return ( | ||
| <Avatar className={className ?? 'size-27.5 border border-grey-300 bg-grey-200'}> | ||
| <AvatarImage src={imageUrl ?? undefined} alt="프로필 이미지" /> | ||
| <AvatarFallback className="bg-grey-200"> | ||
| <img src={UserAvatarIcon} alt="" className="size-14.25" /> | ||
| </AvatarFallback> | ||
| </Avatar> | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * 프로필 이미지 선택 컴포넌트 | ||
| * | ||
| * @description | ||
| * - `onboarding`: 카메라 아이콘 오버레이, 이미지 클릭으로 파일 선택 | ||
| * - `mypage`: "프로필 사진 변경" 버튼 + "제거하기" 텍스트 버튼 | ||
| */ | ||
| export function ProfileImagePicker({ | ||
| imageUrl, | ||
| onFileChange, | ||
| onRemove, | ||
| showRemoveButton = false, | ||
| disabled = false, | ||
| variant = 'onboarding', | ||
| }: ProfileImagePickerProps) { | ||
| const inputId = useId() | ||
| const fileInputRef = useRef<HTMLInputElement>(null) | ||
| const { openAlert } = useGlobalModalStore() | ||
|
|
||
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const file = e.target.files?.[0] | ||
| if (file) { | ||
| if (file.size > MAX_IMAGE_SIZE) { | ||
| openAlert('파일 크기 초과', '이미지 파일은 5MB 이하만 업로드할 수 있습니다.') | ||
| e.target.value = '' | ||
| return | ||
| } | ||
| onFileChange(file) | ||
| } | ||
| // 같은 파일 재선택 허용을 위해 value 초기화 | ||
| e.target.value = '' | ||
| } | ||
|
|
||
| const handleButtonClick = () => { | ||
| fileInputRef.current?.click() | ||
| } | ||
|
|
||
| const fileInput = ( | ||
| <input | ||
| id={variant === 'onboarding' ? inputId : undefined} | ||
| ref={fileInputRef} | ||
| type="file" | ||
| accept={ALLOWED_IMAGE_ACCEPT} | ||
| onChange={handleFileChange} | ||
| disabled={disabled} | ||
| className="hidden" | ||
| /> | ||
| ) | ||
|
|
||
| if (variant === 'onboarding') { | ||
| return ( | ||
| <label htmlFor={inputId} className="flex cursor-pointer items-end pr-6.25"> | ||
| <ProfileAvatar | ||
| imageUrl={imageUrl} | ||
| className="relative -mr-6.25 size-27.5 border border-grey-300 bg-grey-200" | ||
| /> | ||
| <div className="relative -mr-6.25 flex size-9.5 items-center justify-center rounded-full border-2 border-white bg-grey-500 transition-colors hover:bg-grey-600"> | ||
| <Camera className="size-5 text-white" strokeWidth={2} /> | ||
| </div> | ||
| {fileInput} | ||
| </label> | ||
| ) | ||
| } | ||
|
|
||
| // mypage variant | ||
| return ( | ||
| <div className="flex flex-col items-center gap-base"> | ||
| <ProfileAvatar imageUrl={imageUrl} /> | ||
| <div className="flex w-32.5 flex-col items-center gap-small"> | ||
| <Button | ||
| type="button" | ||
| variant="secondary" | ||
| size="small" | ||
| className="w-full" | ||
| onClick={handleButtonClick} | ||
| disabled={disabled} | ||
| > | ||
| 프로필 사진 변경 | ||
| </Button> | ||
| {showRemoveButton && ( | ||
| <TextButton onClick={onRemove} disabled={disabled}> | ||
| 제거하기 | ||
| </TextButton> | ||
| )} | ||
| </div> | ||
| {fileInput} | ||
| </div> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export { MyPageDropdown } from './MyPageDropdown' | ||
| export { NicknameInput } from './NicknameInput' | ||
| export { ProfileImagePicker } from './ProfileImagePicker' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,9 @@ | ||
| export { useCheckNickname } from './useCheckNickname' | ||
| export { useDeleteProfileImage } from './useDeleteProfileImage' | ||
| export { useDeleteUser } from './useDeleteUser' | ||
| export { useOnboarding } from './useOnboarding' | ||
| export { useProfileForm } from './useProfileForm' | ||
| export * from './userQueryKeys' | ||
| export { useUpdateNickname } from './useUpdateNickname' | ||
| export { useUpdateProfileImage } from './useUpdateProfileImage' | ||
| export { useUserProfile } from './useUserProfile' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { useMutation, useQueryClient } from '@tanstack/react-query' | ||
|
|
||
| import { ApiError, type ApiResponse } from '@/api' | ||
|
|
||
| import { deleteProfileImage } from '../user.api' | ||
| import type { User } from '../user.types' | ||
| import { userQueryKeys } from './userQueryKeys' | ||
|
|
||
| /** | ||
| * 프로필 이미지 삭제 훅 | ||
| * | ||
| * @description | ||
| * - 프로필 이미지를 기본 이미지로 초기화 | ||
| * - 성공 시 user 프로필 캐시의 profileImageUrl을 null로 설정 | ||
| */ | ||
| export function useDeleteProfileImage() { | ||
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation<ApiResponse<null>, ApiError, void>({ | ||
| mutationFn: deleteProfileImage, | ||
| onSuccess: () => { | ||
| queryClient.setQueryData<User>(userQueryKeys.profile(), (oldData) => { | ||
| if (!oldData) return oldData | ||
| return { ...oldData, profileImageUrl: null } | ||
| }) | ||
| }, | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.