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: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ dist-ssr
# Environment variables
.env
.env.local
.env.development
.env.development.local
.env.test.local
.env.production
.env.production.local
.env.test.local

# claude
CLAUDE.md
.claude/

# docs
/docs
5 changes: 4 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>독크독크</title>
</head>
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon.ico
Binary file not shown.
1 change: 0 additions & 1 deletion public/vite.svg

This file was deleted.

9 changes: 9 additions & 0 deletions src/features/auth/hooks/authQueryKeys.ts
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
1 change: 1 addition & 0 deletions src/features/auth/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './authQueryKeys'
export * from './useAuth'
export * from './useLogout'
115 changes: 115 additions & 0 deletions src/features/user/components/MyPageDropdown.tsx
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>
)
}
40 changes: 40 additions & 0 deletions src/features/user/components/NicknameInput.tsx
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}
/>
)
}
123 changes: 123 additions & 0 deletions src/features/user/components/ProfileImagePicker.tsx
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>
)
}
3 changes: 3 additions & 0 deletions src/features/user/components/index.ts
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'
7 changes: 7 additions & 0 deletions src/features/user/hooks/index.ts
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'
14 changes: 9 additions & 5 deletions src/features/user/hooks/useCheckNickname.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query'

import { ApiError } from '@/api'

import { checkNickname } from '../user.api'
import type { NicknameCheckResult } from '../user.types'
import { userQueryKeys } from './userQueryKeys'

/**
* 닉네임 중복 확인 query 훅
Expand All @@ -26,11 +30,11 @@ import { checkNickname } from '../user.api'
* ```
*/
export function useCheckNickname(nickname: string, enabled: boolean) {
return useQuery({
queryKey: ['user', 'check-nickname', nickname],
return useQuery<NicknameCheckResult, ApiError>({
queryKey: userQueryKeys.checkNickname(nickname),
queryFn: () => checkNickname(nickname),
enabled: enabled && nickname.length >= 2, // 2자 이상일 때만 체크
staleTime: 30 * 1000, // 30초 캐싱
retry: false, // 에러 발생 시 재시도 안 함
enabled: enabled && nickname.length >= 2,
staleTime: 30 * 1000,
retry: false,
})
}
28 changes: 28 additions & 0 deletions src/features/user/hooks/useDeleteProfileImage.ts
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 }
})
},
})
}
Loading