diff --git a/src/app/(user-page)/mypage/MyPageClient.tsx b/src/app/(user-page)/mypage/MyPageClient.tsx index 39b9f38..be02f91 100644 --- a/src/app/(user-page)/mypage/MyPageClient.tsx +++ b/src/app/(user-page)/mypage/MyPageClient.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { TAB_TYPES } from '../../../constants/mypage/mypageConstant'; // 컴포넌트 임포트 import BasicEdit from './_features/BasicEdit'; import BasicInfo from './_features/BasicInfo'; @@ -14,41 +16,195 @@ import TechStackInfo from './_features/TechStackInfo'; // 기본 내보내기 (모든 섹션을 관리하는 상위 컴포넌트) const MyPageClient = () => { - // 각 섹션별 편집 모드 상태 관리 - const [isBasicEditMode, setIsBasicEditMode] = useState(false); - const [isContactEditMode, setIsContactEditMode] = useState(false); - const [isTechEditMode, setIsTechEditMode] = useState(false); - const [isPasswordEditMode, setIsPasswordEditMode] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL에서 탭 값만 가져오기 + const tabFromUrl = searchParams.get('tab') || TAB_TYPES.BASIC; + + // 현재 활성화된 탭 상태 관리 + const [activeTab, setActiveTab] = useState(tabFromUrl); + const [indicatorStyle, setIndicatorStyle] = useState({}); + + // 1. 편집 모드 상태 통합 + const [editModeSection, setEditModeSection] = useState(null); + + // 3. 탭 참조 관리 최적화 + const tabRefs = useRef>({ + [TAB_TYPES.BASIC]: null, + [TAB_TYPES.CONTACT]: null, + [TAB_TYPES.TECH]: null, + [TAB_TYPES.PASSWORD]: null, + }); + + // URL 변경 감지 및 상태 업데이트 (중요: 뒤로가기/앞으로가기 처리) + useEffect(() => { + const currentTabFromUrl = searchParams.get('tab') || TAB_TYPES.BASIC; + if (currentTabFromUrl !== activeTab) { + setActiveTab(currentTabFromUrl); + } + // activeTab을 의존성에 포함하면 진동 현상이 발생하므로 의도적으로 제외 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + // URL 업데이트 함수 - 탭 정보만 저장 + const updateUrl = useCallback( + (tab: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', tab); + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams], + ); + + // 탭 변경 핸들러 + const handleTabChange = (tab: string) => { + setActiveTab(tab); + updateUrl(tab); + }; + + // 2. 편집 관련 핸들러 단순화 + const handleEditComplete = () => { + setEditModeSection(null); + }; + + const handleEnableEdit = () => { + setEditModeSection(activeTab); + }; + + // 탭 변경시 편집 모드 초기화 + useEffect(() => { + setEditModeSection(null); + }, [activeTab]); + + // 4. indicator 위치 업데이트 함수 최적화 + const updateIndicator = useCallback(() => { + const currentTab = + tabRefs.current[activeTab] || tabRefs.current[TAB_TYPES.BASIC]; + + if (currentTab) { + setIndicatorStyle({ + left: `${currentTab.offsetLeft}px`, + width: `${currentTab.offsetWidth}px`, + }); + } + }, [activeTab]); + + // 활성 탭이 변경될 때 indicator 업데이트 + useEffect(() => { + updateIndicator(); + }, [updateIndicator]); + + // 창 크기가 변경될 때 indicator 위치 조정 + useEffect(() => { + const handleResize = () => { + updateIndicator(); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [updateIndicator]); + + // 5. 조건부 렌더링 최적화 + const renderContent = () => { + const isEditMode = editModeSection === activeTab; + + switch (activeTab) { + case TAB_TYPES.BASIC: + return isEditMode ? ( + + ) : ( + + ); + case TAB_TYPES.CONTACT: + return isEditMode ? ( + + ) : ( + + ); + case TAB_TYPES.TECH: + return isEditMode ? ( + + ) : ( + + ); + case TAB_TYPES.PASSWORD: + return isEditMode ? ( + + ) : ( + + ); + default: + return null; + } + }; return ( -
- {/* 기본 정보 섹션 */} - {isBasicEditMode ? ( - setIsBasicEditMode(false)} /> - ) : ( - setIsBasicEditMode(true)} /> - )} - - {/* 연락처 정보 섹션 */} - {isContactEditMode ? ( - setIsContactEditMode(false)} /> - ) : ( - setIsContactEditMode(true)} /> - )} - - {/* 기술 스택 섹션 */} - {isTechEditMode ? ( - setIsTechEditMode(false)} /> - ) : ( - setIsTechEditMode(true)} /> - )} - - {/* 비밀번호 섹션 */} - {isPasswordEditMode ? ( - setIsPasswordEditMode(false)} /> - ) : ( - setIsPasswordEditMode(true)} /> - )} +
+ {/* 탭 네비게이션 */} +
+ + + + + + {/* 애니메이션 underbar */} +
+
+ + {/* 활성화된 섹션만 렌더링 */} +
{renderContent()}
); }; diff --git a/src/app/(user-page)/mypage/_features/BasicEdit.tsx b/src/app/(user-page)/mypage/_features/BasicEdit.tsx index e5b0452..a3959ff 100644 --- a/src/app/(user-page)/mypage/_features/BasicEdit.tsx +++ b/src/app/(user-page)/mypage/_features/BasicEdit.tsx @@ -6,9 +6,18 @@ import { useProfileQuery, useUpdateProfileMutation, } from '@/hooks/queries/useMyPageQueries'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Controller, useForm, useWatch } from 'react-hook-form'; +import { + AGE_OPTIONS, + DEFAULT_VALUES, + GENDER_OPTIONS, + ICON_SIZES, + LOCATION_OPTIONS, + MAX_INTRO_LENGTH, + POSITION_OPTIONS, +} from '../../../../constants/mypage/mypageConstant'; import { IFormData } from '../../../../types/mypageTypes'; interface BasicEditProps { @@ -17,7 +26,6 @@ interface BasicEditProps { const BasicEdit = ({ onEditComplete }: BasicEditProps) => { // 드롭다운 디스플레이를 위한 상태 관리 - const [positionLabel, setPositionLabel] = useState('선택 안함'); const [ageLabel, setAgeLabel] = useState('선택 안함'); const [locationLabel, setLocationLabel] = useState('선택 안함'); @@ -38,7 +46,6 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { control, setValue, reset, - watch, formState: { errors, isSubmitting }, } = useForm({ defaultValues: { @@ -57,59 +64,21 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { name: 'gender', }); + // 추가: position 값 관찰 + const currentPosition = useWatch({ + control, + name: 'position', + }); + // 소개글 감시하여 글자 수 업데이트 - const introValue = watch('intro'); + const introValue = useWatch({ + control, + name: 'intro', + }); useEffect(() => { setIntroLength(introValue?.length || 0); }, [introValue]); - // 옵션 데이터 - useMemo로 메모이제이션 - const positionOptions = useMemo( - () => [ - { value: '프론트엔드', label: '프론트엔드' }, - { value: '백엔드', label: '백엔드' }, - { value: '디자이너', label: '디자이너' }, - { value: '선택 안함', label: '선택 안함' }, - ], - [], - ); - - const ageOptions = useMemo( - () => [ - { value: '10대', label: '10대' }, - { value: '20대', label: '20대' }, - { value: '30대', label: '30대' }, - { value: '40대', label: '40대' }, - { value: '50대이상', label: '50대이상' }, - { value: '선택 안함', label: '선택 안함' }, - ], - [], - ); - - const locationOptions = useMemo( - () => [ - { value: '서울', label: '서울' }, - { value: '경기', label: '경기' }, - { value: '인천', label: '인천' }, - { value: '부산', label: '부산' }, - { value: '대구', label: '대구' }, - { value: '광주', label: '광주' }, - { value: '대전', label: '대전' }, - { value: '울산', label: '울산' }, - { value: '세종', label: '세종' }, - { value: '강원', label: '강원' }, - { value: '충북', label: '충북' }, - { value: '충남', label: '충남' }, - { value: '전북', label: '전북' }, - { value: '전남', label: '전남' }, - { value: '경북', label: '경북' }, - { value: '경남', label: '경남' }, - { value: '제주', label: '제주' }, - { value: '선택 안함', label: '선택 안함' }, - ], - [], - ); - // 프로필 데이터로 폼 초기화 useEffect(() => { if (profileData?.data) { @@ -120,7 +89,7 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { name: profile.name || '', intro: profile.intro || '', position: profile.position || '', - gender: profile.gender || '비공개', + gender: profile.gender || DEFAULT_VALUES.GENDER, age: profile.age || '', location: profile.location || '', }); @@ -129,25 +98,20 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { setIntroLength(profile.intro?.length || 0); // 드롭다운 라벨 초기 설정 - const positionOption = positionOptions.find( - (opt) => opt.value === profile.position, - ); - if (positionOption) setPositionLabel(positionOption.label); - - const ageOption = ageOptions.find((opt) => opt.value === profile.age); + const ageOption = AGE_OPTIONS.find((opt) => opt.value === profile.age); if (ageOption) setAgeLabel(ageOption.label); - const locationOption = locationOptions.find( + const locationOption = LOCATION_OPTIONS.find( (opt) => opt.value === profile.location, ); if (locationOption) setLocationLabel(locationOption.label); } - }, [profileData, reset, positionOptions, ageOptions, locationOptions]); + }, [profileData, reset]); // 폼 제출 처리 const onSubmit = (data: IFormData) => { // 글자 수 검사 추가 - if (data.intro && data.intro.length > 250) { + if (data.intro && data.intro.length > MAX_INTRO_LENGTH) { return; } @@ -158,32 +122,24 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { }); }; - // 드롭다운 값 변경 핸들러 - useCallback으로 메모이제이션 - const handlePositionChange = useCallback( - (value: string) => { - setValue('position', value); - const selectedOption = positionOptions.find((opt) => opt.value === value); - if (selectedOption) setPositionLabel(selectedOption.label); - }, - [setValue, positionOptions], - ); - const handleAgeChange = useCallback( (value: string) => { setValue('age', value); - const selectedOption = ageOptions.find((opt) => opt.value === value); + const selectedOption = AGE_OPTIONS.find((opt) => opt.value === value); if (selectedOption) setAgeLabel(selectedOption.label); }, - [setValue, ageOptions], + [setValue], ); const handleLocationChange = useCallback( (value: string) => { setValue('location', value); - const selectedOption = locationOptions.find((opt) => opt.value === value); + const selectedOption = LOCATION_OPTIONS.find( + (opt) => opt.value === value, + ); if (selectedOption) setLocationLabel(selectedOption.label); }, - [setValue, locationOptions], + [setValue], ); // 취소 핸들러 @@ -201,7 +157,7 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { onSubmit={handleSubmit(onSubmit)} className="w-full rounded-[16px] border border-Cgray300 p-[32px]" > -
+
{/* 이름 입력 필드 */}
- {/* 포지션 드롭다운 */} -
+ {/* 포지션 버튼 */} +
포지션
- ( - - )} - /> +
+
+ {POSITION_OPTIONS.map((option) => ( + + ))} +
+
{/* 성별 토글 버튼 */} -
+
성별
-
- {['남자', '여자', '비공개'].map((option) => ( +
+ {GENDER_OPTIONS.map((option) => ( ))}
{/* 연령대 드롭다운 */} -
+
연령대
{ render={() => ( )} @@ -308,7 +285,7 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => {
{/* 지역 드롭다운 */} -
+
지역
{ render={() => ( @@ -340,7 +317,9 @@ const BasicEdit = ({ onEditComplete }: BasicEditProps) => { diff --git a/src/app/(user-page)/mypage/_features/BasicInfo.tsx b/src/app/(user-page)/mypage/_features/BasicInfo.tsx index 5928e8f..f0fff01 100644 --- a/src/app/(user-page)/mypage/_features/BasicInfo.tsx +++ b/src/app/(user-page)/mypage/_features/BasicInfo.tsx @@ -36,7 +36,7 @@ const BasicInfo = ({ onEnableEdit }: BasicInfoProps) => { } return ( -
+
diff --git a/src/app/(user-page)/mypage/_features/TechStackEdit.tsx b/src/app/(user-page)/mypage/_features/TechStackEdit.tsx index 1e2aec6..55e4a2c 100644 --- a/src/app/(user-page)/mypage/_features/TechStackEdit.tsx +++ b/src/app/(user-page)/mypage/_features/TechStackEdit.tsx @@ -98,6 +98,8 @@ const TechStackEdit = ({ activeCategory={activeCategory} onCategoryChange={setActiveCategory} onReset={handleReset} + containerClassName="border border-none" + resetButtonClassName="" /> {/* 기술 버튼 목록 */} diff --git a/src/app/(user-page)/mypage/page.tsx b/src/app/(user-page)/mypage/page.tsx index 20f0de5..d567f73 100644 --- a/src/app/(user-page)/mypage/page.tsx +++ b/src/app/(user-page)/mypage/page.tsx @@ -1,11 +1,20 @@ +// page.tsx (서버 컴포넌트) +import { Suspense } from 'react'; + import MyPageClient from './MyPageClient'; import ProfileImage from './_features/ProfileImage'; +import SkeletonBasicInfo from './_features/skeletons/SkeletonBasicInfo'; export default function MyPage() { return ( -
- - +
+
+ +
+ + }> + +
); } diff --git a/src/components/ui/modal/Modal.tsx b/src/components/ui/modal/Modal.tsx index d9aaaf7..36a5202 100644 --- a/src/components/ui/modal/Modal.tsx +++ b/src/components/ui/modal/Modal.tsx @@ -13,6 +13,7 @@ interface AlertModalProps { buttonClassName?: string; closeOnly?: boolean; showOnly?: boolean; + disableConfirm?: boolean; } /** @@ -73,6 +74,7 @@ const Modal: React.FC = ({ buttonClassName = '', closeOnly = false, showOnly = false, + disableConfirm = false, }) => { const handleBackdropClick = (e: React.MouseEvent): void => { if (e.target === e.currentTarget) { @@ -116,7 +118,14 @@ const Modal: React.FC = ({ - diff --git a/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx b/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx index 38bc1af..b157eaf 100644 --- a/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx +++ b/src/components/ui/tech-stack/tech-stack-components/CategoryTabs.tsx @@ -1,5 +1,5 @@ import { RotateCcw } from 'lucide-react'; -import React from 'react'; +import React, { useState } from 'react'; import { CategoryType } from 'types/techStack'; import TabButton from './TabButton'; @@ -8,26 +8,46 @@ interface CategoryTabsProps { activeCategory: CategoryType; onCategoryChange: (category: CategoryType) => void; onReset: () => void; + containerClassName?: string; + resetButtonClassName?: string; } const CategoryTabs = ({ activeCategory, onCategoryChange, onReset, + containerClassName = '', + resetButtonClassName = '', }: CategoryTabsProps): JSX.Element => { + // 아이콘 회전 애니메이션 상태 관리 + const [isRotating, setIsRotating] = useState(false); + + // 초기화 버튼 클릭 핸들러 + const handleResetClick = () => { + setIsRotating(true); + onReset(); + + // 애니메이션 완료 후 상태 초기화 + setTimeout(() => { + setIsRotating(false); + }, 500); + }; + const categories: Array<{ id: CategoryType; label: string; smallText: string; }> = [ - { id: 'all', label: '전체', smallText: 'All' }, - { id: 'frontend', label: '프론트엔드', smallText: 'Front' }, - { id: 'backend', label: '백엔드', smallText: 'Back' }, - { id: 'design', label: '디자인', smallText: 'UI/UX' }, + { id: 'all', label: '전체', smallText: '전체' }, + { id: 'frontend', label: '프론트엔드', smallText: '프론트엔드' }, + { id: 'backend', label: '백엔드', smallText: '백엔드' }, + { id: 'design', label: '디자인', smallText: '디자인' }, ]; return ( -
+
{categories.map((category) => ( -
+
+ + {/* 애니메이션 스타일 정의 */} +
); }; diff --git a/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx b/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx index 64a4f06..3612e3c 100644 --- a/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx +++ b/src/components/ui/tech-stack/tech-stack-components/TabButton.tsx @@ -16,7 +16,7 @@ const TabButton = ({ return (