diff --git a/src/api/resume/apis.ts b/src/api/resume/apis.ts new file mode 100644 index 00000000..1421d93a --- /dev/null +++ b/src/api/resume/apis.ts @@ -0,0 +1,228 @@ +import { + ResumeQueryParams, + ResumeDetail, + BestResumeResponse, + UserResumeResponse, + LikeBookmarkRequest, + ResumeUploadRequest, +} from './types' + +const RESUME_API_BASE = '/api/resumes' + +// 이력서 목록 조회 +export async function getResumeList({ + position = [], + year = [], + category = '전체', + cursorId, + limit = 10, + sortBy = 'CREATEDAT', +}: ResumeQueryParams) { + try { + // URLSearchParams를 사용하여 동적으로 쿼리 문자열 생성 + const params = new URLSearchParams() + + if (position.length > 0) + position.forEach((p) => params.append('position', p)) + if (year.length > 0) year.forEach((y) => params.append('year', y)) + // 카테고리 변환 (한글 -> 영어) + const categoryMap: { [key: string]: string } = { + 전체: '', + 이력서: 'RESUME', + 포트폴리오: 'PORTFOLIO', + ICT: 'ICT', + OTHER: 'OTHER', + } + + // category가 undefined이거나 null일 때 기본값 처리 + const safeCategory = category || '전체' + const mappedCategory = categoryMap[safeCategory] || '' + + // 카테고리가 빈 문자열이 아닐 때만 파라미터에 추가 + if (mappedCategory) { + params.append('category', mappedCategory) + } + + if (cursorId != undefined) { + params.append('cursorId', cursorId.toString()) + } + params.append('limit', limit.toString()) + params.append('sortBy', sortBy) + + const response = await fetch(`${RESUME_API_BASE}?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Error: 이력서 목록 조회 실패: ${response.status} ${response.statusText}`, + ) + } + + const result = await response.json() + return result + } catch (error) { + throw error // 에러를 호출한 함수에 다시 전달 + } +} + +// 이력서 상세 조회 +export const fetchResumeById = async ( + resumeId: number, +): Promise => { + try { + const response = await fetch(`${RESUME_API_BASE}/${resumeId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch resume with id ${resumeId}. Status: ${response.status}`, + ) + } + + const result = await response.json() + return result + } catch (error: any) { + throw error + } +} + +// 인기 이력서 조회 +export const fetchBestResumes = async ( + cursorId?: number, + limit: number = 12, + setAuthModalOpen?: (open: boolean) => void, +): Promise => { + try { + const params = new URLSearchParams() + params.append('limit', String(limit)) + + if (typeof cursorId === 'number') { + params.append('cursorId', String(cursorId)) + } + + const response = await fetch( + `${RESUME_API_BASE}/best?${params.toString()}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + // 401 Unauthorized 응답 처리 + if (response.status === 401) { + setAuthModalOpen?.(true) + throw new Error('로그인이 필요합니다.') + } + + if (!response.ok) { + throw new Error(`인기 이력서 조회 실패: ${response.status}`) + } + + const result = await response.json() + return result + } catch (error: any) { + throw error + } +} + +// 사용자 이력서 목록 조회 +export const fetchUserResumes = async ( + userId: number, + cursor?: number, + limit: number = 10, +): Promise => { + try { + // 쿼리 파라미터 구성 + const params = new URLSearchParams() + if (cursor !== undefined) { + params.append('cursor', cursor.toString()) + } + params.append('limit', limit.toString()) + + const queryString = params.toString() + const url = `${RESUME_API_BASE}/user/${userId}${queryString ? `?${queryString}` : ''}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + throw new Error( + `Failed to fetch resumes for user. Status: ${response.status}`, + ) + } + + const result = await response.json() + return result // 새로운 API 응답 구조 그대로 반환 + } catch (error: any) { + throw error + } +} + +// 북마크 처리 +export const postBookmark = async ( + data: LikeBookmarkRequest, +): Promise => { + const response = await fetch('/api/bookmarks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + contentId: data.contentId, + category: data.category, + bookmarkStatus: data.bookmarkStatus, + }), + credentials: 'include', + }) + + if (!response.ok) { + throw new Error('북마크 처리 중 오류가 발생했습니다.') + } +} + +// 이력서 업로드 +export const uploadResume = async ( + file: File, + data: ResumeUploadRequest, +): Promise => { + const formData = new FormData() + formData.append('file', file) + formData.append('request', JSON.stringify(data)) + + const response = await fetch(RESUME_API_BASE, { + method: 'POST', + body: formData, + credentials: 'include', + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + + if (response.status === 400) { + throw new Error('존재하지 않는 카테고리입니다.') + } + + if (response.status === 401) { + throw new Error(errorData?.message || '로그인이 필요합니다.') + } + + throw new Error(`이력서 업로드 실패: ${response.status}`) + } + + return response.json() +} diff --git a/src/api/resume/index.ts b/src/api/resume/index.ts new file mode 100644 index 00000000..c66edd96 --- /dev/null +++ b/src/api/resume/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './apis' +export * from './queries' +export * from './keys' +export * from './mutations' diff --git a/src/api/resume/keys.ts b/src/api/resume/keys.ts new file mode 100644 index 00000000..c259844b --- /dev/null +++ b/src/api/resume/keys.ts @@ -0,0 +1,23 @@ +// resume 관련 queryKey 생성 함수 +import type { ResumeQueryParams } from './types' + +export const resumeKeys = { + // 모든 이력서 관련 키의 기본값 + all: ['resumes'] as const, + + // 이력서 목록 키 + lists: () => [...resumeKeys.all, 'list'] as const, + list: (params: ResumeQueryParams) => [...resumeKeys.lists(), params] as const, + // 페이지별 쿼리 키 + page: (params: ResumeQueryParams, pageNumber: number) => + [...resumeKeys.list(params), 'page', pageNumber] as const, + + // 이력서 상세 키 + detail: (id: number) => [...resumeKeys.all, 'detail', id] as const, + + // 인기 이력서 목록 키 + bestList: () => [...resumeKeys.all, 'best'] as const, + + // 사용자 이력서 목록 키 + userList: (userId: number) => [...resumeKeys.all, 'user', userId] as const, +} diff --git a/src/api/resume/mutations.ts b/src/api/resume/mutations.ts new file mode 100644 index 00000000..6290350c --- /dev/null +++ b/src/api/resume/mutations.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { resumeKeys } from './keys' +import { postBookmark, uploadResume } from './apis' +import { LikeBookmarkRequest, ResumeUploadRequest } from './types' + +// 북마크 mutation +export const useResumeBookmarkMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationKey: [...resumeKeys.all, 'bookmark'], + mutationFn: postBookmark, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: resumeKeys.detail(variables.contentId), + }), + queryClient.invalidateQueries({ queryKey: resumeKeys.lists() }), + queryClient.invalidateQueries({ queryKey: resumeKeys.bestList() }), + ]) + }, + }) +} + +// 이력서 업로드 mutation +export const useResumeUploadMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ file, data }: { file: File; data: ResumeUploadRequest }) => + uploadResume(file, data), + onSuccess: () => { + // 이력서 업로드 성공 시 관련 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + }, + }) +} diff --git a/src/api/resume/queries.ts b/src/api/resume/queries.ts new file mode 100644 index 00000000..c334133b --- /dev/null +++ b/src/api/resume/queries.ts @@ -0,0 +1,88 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { + getResumeList, + fetchResumeById, + fetchBestResumes, + fetchUserResumes, +} from './apis' +import { ResumeQueryParams } from './types' +import { resumeKeys } from './keys' + +// 이력서 목록 조회 (무한 스크롤) +export const useResumeListQuery = ({ + position = [], + year = [], + category = '전체', + sortBy = 'CREATEDAT', + initialLimit = 10, + pageLimit = 12, +}: ResumeQueryParams & { + initialLimit?: number + pageLimit?: number +}) => { + return useInfiniteQuery({ + queryKey: [ + ...resumeKeys.list({ position, year, category, sortBy }), + { initialLimit, pageLimit }, + ], + queryFn: ({ pageParam }) => { + // 첫 페이지는 initialLimit, 이후 페이지는 pageLimit + const limit = !pageParam ? initialLimit : pageLimit + return getResumeList({ + position, + year, + category, + cursorId: pageParam, + limit, + sortBy, + }) + }, + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + }) +} + +// 이력서 상세 조회 +export const useResumeDetailQuery = (resumeId: number) => { + return useQuery({ + queryKey: resumeKeys.detail(resumeId), + queryFn: () => fetchResumeById(resumeId), + staleTime: 10 * 60 * 1000, // 10분 + gcTime: 30 * 60 * 1000, // 30분 + enabled: !!resumeId, // resumeId가 있을 때만 실행 + }) +} + +// 인기 이력서 목록 조회 (무한 스크롤) +export const useBestResumeListQuery = ( + setAuthModalOpen: (open: boolean) => void, + limit: number = 12, +) => { + return useInfiniteQuery({ + queryKey: [...resumeKeys.bestList(), limit], + queryFn: ({ pageParam }) => + fetchBestResumes(pageParam, limit, setAuthModalOpen), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + }) +} + +// 사용자 이력서 목록 조회 (무한 스크롤) +export const useUserResumeListQuery = (userId: number, limit: number = 10) => { + return useInfiniteQuery({ + queryKey: [...resumeKeys.userList(userId), limit], + queryFn: ({ pageParam }) => fetchUserResumes(userId, pageParam, limit), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + enabled: !!userId, // userId가 있을 때만 실행 + }) +} diff --git a/src/api/resume/types.ts b/src/api/resume/types.ts new file mode 100644 index 00000000..c2180d09 --- /dev/null +++ b/src/api/resume/types.ts @@ -0,0 +1,64 @@ +// 이력서 목록 기본 타입 +export interface ResumeQueryParams { + position?: string[] + year?: string[] + category?: string + cursorId?: number + limit?: number + sortBy?: string +} + +// 이력서 상세 정보 타입 +export interface ResumeDetail { + id: number + createdAt: number + title: string + url: string + category: string + position: string + likeCount: number + user: { + id: number + name: string + profileImage: string + year: number + mainPosition: string + } +} + +// 이력서 목록 응답 타입 +export interface ResumeListResponse { + data: any[] + hasNext: boolean + nextCursor?: number +} + +// 인기 이력서 응답 타입 +export interface BestResumeResponse { + data: any[] + hasNext: boolean + nextCursor?: number +} + +// 사용자 이력서 응답 타입 +export interface UserResumeResponse { + data: any[] + hasNext: boolean + nextCursor?: number +} + +// 좋아요/북마크 요청 타입 +export interface LikeBookmarkRequest { + contentId: number + category: 'SESSION' | 'BLOG' | 'RESUME' | 'PROJECT' | 'STUDY' + likeStatus?: boolean + bookmarkStatus?: boolean +} + +// 이력서 업로드 요청 타입 +export interface ResumeUploadRequest { + category: string + position: string + title: string + isMain: boolean +} diff --git a/src/app/(protected)/resume/@resumeList.tsx b/src/app/(protected)/resume/@resumeList.tsx index 98f9fcd8..d02c6d79 100644 --- a/src/app/(protected)/resume/@resumeList.tsx +++ b/src/app/(protected)/resume/@resumeList.tsx @@ -1,15 +1,13 @@ -import { useGetResumeQuery } from './query/useGetResumeQuery' +import { useResumeListQuery } from '@/api/resume/queries' import EmptyAnimation from '@/components/common/EmptyAnimation' -import { ResumeQueryParams } from '@/types/queryParams' +import { ResumeQueryParams } from '@/api/resume/types' import ResumeFolder from '@/components/resume/ResumeFolder' import SkeletonResumeFolder from '@/components/resume/SkeletonResume' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { useInView } from 'react-intersection-observer' import { useLike } from '../../blog/_lib/useLike' import { useBookmark } from '../../blog/_lib/useBookmark' -import { getResumeList } from './api/getResumeList' -// Resume 타입 가져오기 type Resume = { id: string createdAt: number @@ -25,34 +23,6 @@ type Resume = { year: number mainPosition: string } - likeList: string[] - bookmarkList: string[] - onLikeUpdate?: (id: string, newLikeCount: number) => void - onBookmarkUpdate?: (id: string, newBookmarkCount: number) => void -} - -interface ResumeItem { - id: string - createdAt: number - title: string - category: string - position: string - likeCount: number - viewCount: number - url: string - isMain: boolean - updatedAt: string - year?: string // 기존 호환성을 위해 optional로 유지 - user: { - id?: number // 기존 호환성을 위해 optional로 유지 - name: string - nickname: string - profileImage: string - year?: number // 기존 호환성을 위해 optional로 유지 - mainPosition?: string // 기존 호환성을 위해 optional로 유지 - } - likeList?: string[] - bookmarkList?: string[] } export default function ResumeList({ @@ -61,13 +31,7 @@ export default function ResumeList({ category = '전체', sortBy = 'CREATEDAT', }: ResumeQueryParams = {}) { - const [resumes, setResumes] = useState([]) - const [currentCursor, setCurrentCursor] = useState(0) - const [hasNext, setHasNext] = useState(true) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const [ref, inView] = useInView({ threshold: 0.1 }) - const [likeList, setLikeList] = useState([]) const [bookmarkList, setBookmarkList] = useState([]) @@ -75,20 +39,39 @@ export default function ResumeList({ const { fetchBookmarks } = useBookmark() const { - data: resumeResponse, + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, isLoading, isError, refetch, - } = useGetResumeQuery({ + } = useResumeListQuery({ position, year, - category: category || '전체', - cursorId: undefined, - limit: 10, + category, sortBy, }) - const checkLike = async () => { + const convertToResume = (item: any): Resume => ({ + id: String(item.id), + createdAt: new Date(item.createdAt).getTime(), + title: item.title, + category: item.category, + position: item.position, + likeCount: item.likeCount, + year: String(item.year), + user: { + id: Number(item.user.id), + name: item.user.name, + profileImage: item.user.profileImage, + year: item.user.year, + mainPosition: item.user.mainPosition, + }, + }) + + // checkLike, checkBookmark 함수를 useCallback으로 메모이제이션 + const checkLike = useCallback(async () => { try { const data = await fetchLikes('RESUME', 0, 50) setLikeList(data) @@ -97,9 +80,9 @@ export default function ResumeList({ console.error(err) return [] } - } + }, []) - const checkBookmark = async () => { + const checkBookmark = useCallback(async () => { try { const data = await fetchBookmarks('RESUME', 0, 50) setBookmarkList(data) @@ -108,19 +91,9 @@ export default function ResumeList({ console.error(err) return [] } - } + }, []) const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - // 현재 이력서 데이터에서 해당 ID를 가진 이력서 찾아 업데이트 - setResumes((prev) => - prev.map((resume) => - resume.id === resumeId - ? { ...resume, likeCount: newLikeCount } - : resume, - ), - ) - - // 탭 변경 시에도 좋아요 상태 유지를 위해 서버 데이터 갱신 setTimeout(() => { checkLike() refetch() @@ -128,100 +101,27 @@ export default function ResumeList({ } const handleBookmarkUpdate = (resumeId: string, newBookmarkCount: number) => { - // 현재 이력서 데이터에서 해당 ID를 가진 이력서 찾아 업데이트 - setResumes((prev) => - prev.map((resume) => - resume.id === resumeId - ? { ...resume, bookmarkCount: newBookmarkCount } - : resume, - ), - ) - - // 탭 변경 시에도 좋아요 상태 유지를 위해 서버 데이터 갱신 setTimeout(() => { checkBookmark() refetch() }, 500) } - // ResumeItem을 기존 Resume 타입으로 변환 - const convertToResume = (item: ResumeItem): Resume => ({ - id: item.id, - createdAt: item.createdAt, - title: item.title, - category: item.category, - position: item.position, - likeCount: item.likeCount, - year: item.year || '', // 새 API에서는 user.year를 사용하거나 기본값 - user: { - id: item.user.id || 0, - name: item.user.name, - profileImage: item.user.profileImage, - year: item.user.year || 0, - mainPosition: item.user.mainPosition || item.position, - }, - likeList: item.likeList || [], - bookmarkList: item.bookmarkList || [], - }) - - // 더 많은 데이터 로드하기 - const loadMoreResumes = useCallback(async () => { - if (!hasNext || isLoadingMore) return - - try { - setIsLoadingMore(true) - const response = await getResumeList({ - position, - year, - category: category || '전체', - cursorId: currentCursor, - limit: 12, - sortBy, - }) - - if (response.data && response.data.length > 0) { - setResumes((prev) => { - const existingIds = new Set(prev.map((p) => p.id)) - const newResumes = response.data.filter( - (p: ResumeItem) => !existingIds.has(p.id), - ) - return [...prev, ...newResumes] - }) - setCurrentCursor(response.nextCursor) - setHasNext(response.hasNext) - } - } catch (error) { - console.error('더 많은 이력서 로드 실패:', error) - } finally { - setIsLoadingMore(false) - } - }, [hasNext, isLoadingMore, position, year, category, currentCursor, sortBy]) - - // 필터 변경 시 초기화 + // 필터 변경 시 초기화 및 상태 체크 useEffect(() => { - setResumes([]) - setCurrentCursor(0) - setHasNext(true) checkLike() checkBookmark() - refetch() - }, [position, year, category, sortBy]) + }, [position, year, category, sortBy, checkLike, checkBookmark]) - // 초기 데이터 로드 + // 무한 스크롤 처리 useEffect(() => { - if (resumeResponse?.data) { - setResumes(resumeResponse.data) - setCurrentCursor(resumeResponse.nextCursor) - setHasNext(resumeResponse.hasNext) + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage() } - }, [resumeResponse]) + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) - // 무한 스크롤 트리거 - useEffect(() => { - if (inView && hasNext && !isLoadingMore) { - loadMoreResumes() - } - }, [inView, hasNext, isLoadingMore, loadMoreResumes]) + const resumes = + data?.pages.flatMap((page) => page.data.map(convertToResume)) ?? [] if (isLoading && resumes.length === 0) { return ( @@ -233,7 +133,7 @@ export default function ResumeList({ ) } - if (isError || (resumeResponse && resumes.length === 0)) { + if (isError || (!isLoading && resumes.length === 0)) { return (
- ) // 오류 발생 시 표시할 문구 + ) } return ( @@ -249,22 +149,22 @@ export default function ResumeList({ {resumes.map((resume) => ( ))} - {isLoadingMore && ( + {isFetchingNextPage && ( <> {Array.from({ length: 4 }).map((_, i) => ( ))} )} - {hasNext &&
} + {hasNextPage &&
}
) } diff --git a/src/app/(protected)/resume/[resumeId]/page.tsx b/src/app/(protected)/resume/[resumeId]/page.tsx index 25e5f99a..aa05a7ec 100644 --- a/src/app/(protected)/resume/[resumeId]/page.tsx +++ b/src/app/(protected)/resume/[resumeId]/page.tsx @@ -1,79 +1,27 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { useParams } from 'next/navigation' import Image from 'next/image' import ProfileBox from '@/components/profile/ProfileBox' import Other from '@/components/resume/OtherResume' -import { fetchResumeById } from '@/app/(protected)/resume/api/getResume' - +import { useResumeDetailQuery } from '@/api/resume/queries' import Skeleton from '@/components/resume/Skeleton' -interface ResumeData { - id: number - createdAt: number - title: string - url: string - category: string - position: string - likeCount: number - user: { - id: number - name: string - profileImage: string - year: number - mainPosition: string - } -} - -export default function Detail({ - params, -}: { - params: Promise<{ resumeId: string }> -}) { - const [resumeId, setResumeId] = useState('') - - // 로딩 / 에러 상태 - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - // 이력서 데이터 - const [resume, setResume] = useState(null) - const [profileData, setProfileData] = useState(null) +export default function Detail() { + const { resumeId } = useParams() as { resumeId: string } // OtherResume 표시 const [showOther, setShowOther] = useState(false) - // params에서 resumeId 추출 - useEffect(() => { - const getParams = async () => { - const resolvedParams = await params - setResumeId(resolvedParams.resumeId) - } - getParams() - }, [params]) - - // 2) 이력서 데이터 로드 - useEffect(() => { - const loadResume = async () => { - if (!resumeId) return - - try { - setIsLoading(true) - setError(null) - - const data = await fetchResumeById(Number(resumeId)) - setResume(data) - setProfileData(data.user) - } catch (err: any) { - console.error('이력서 가져오기 실패:', err) - setError(err.message || '이력서 불러오기 실패') - } finally { - setIsLoading(false) - } - } - loadResume() - }, [resumeId]) + // 이력서 상세 데이터 조회 + const { + data: resume, + isLoading, + isError, + error, + } = useResumeDetailQuery(Number(resumeId)) // Other 컴포넌트 표시 토글 const handleToggleOther = () => { @@ -82,7 +30,7 @@ export default function Detail({ // (A) 오른쪽 영역 렌더 함수 const renderRightSide = () => { - if (error || !resume) { + if (isError || !resume) { // 에러 상태 or resume가 null return (
@@ -153,7 +101,26 @@ export default function Detail({
{/* 왼쪽 영역 (항상 표시) */}
- +
- {showOther && profileData && ( - + {showOther && resume?.user && ( + )}
diff --git a/src/components/mypage/AddResume.tsx b/src/components/mypage/AddResume.tsx index b4109f9b..40940868 100644 --- a/src/components/mypage/AddResume.tsx +++ b/src/components/mypage/AddResume.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import Image from 'next/image' import Select from '../signup/Select' import InputField from '../common/InputField' +import { useResumeUploadMutation } from '@/api/resume/mutations' interface AddResumeProps { readonly setModal: (value: boolean) => void @@ -18,6 +19,8 @@ export default function AddResume({ setModal, fetchData }: AddResumeProps) { const router = useRouter() const [isLoading, setIsLoading] = useState(false) + const uploadMutation = useResumeUploadMutation() + const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (file) { @@ -64,12 +67,8 @@ export default function AddResume({ setModal, fetchData }: AddResumeProps) { try { setIsLoading(true) - const formDataToSend = new FormData() - if (formData.resumeFile) { - formDataToSend.append('file', formData.resumeFile) - } + setAddError('') - // 새로운 API v3 스펙에 맞춰 request 객체 구성 const requestData = { category: formData.resumeCategory, position: formData.resumePosition, @@ -77,38 +76,17 @@ export default function AddResume({ setModal, fetchData }: AddResumeProps) { isMain: formData.resumeIsMain, } - formDataToSend.append('request', JSON.stringify(requestData)) - - const response = await fetch('/api/resumes', { - method: 'POST', - body: formDataToSend, - credentials: 'include', + await uploadMutation.mutateAsync({ + file: formData.resumeFile, + data: requestData, }) - if (!response.ok) { - const errorData = await response.json().catch(() => null) - - if (response.status === 400) { - setAddError('존재하지 않는 카테고리입니다.') - return - } - - if (response.status === 401) { - setAddError(errorData?.message || '로그인이 필요합니다.') - return - } - - throw new Error(`API 오류: ${response.status}`) - } - - const result = await response.json() - console.log('이력서 생성 성공:', result) - + console.log('이력서 생성 성공') fetchData() setModal(false) } catch (err: any) { console.error('이력서 생성 실패:', err) - setAddError('네트워크 오류가 발생했습니다.') + setAddError(err.message || '네트워크 오류가 발생했습니다.') } finally { setIsLoading(false) } diff --git a/src/components/mypage/Resume.tsx b/src/components/mypage/Resume.tsx index 0fd167a8..476594f5 100644 --- a/src/components/mypage/Resume.tsx +++ b/src/components/mypage/Resume.tsx @@ -1,118 +1,40 @@ 'use client' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import ResumeFolder from './ResumeFolder' import AddResume from './AddResume' import Link from 'next/link' -import { ResumeQueryParams } from '@/types/queryParams' -import { fetchUserResumes } from '@/app/(protected)/resume/api/getUserResume' +import { useUserResumeListQuery } from '@/api/resume/queries' import { useInView } from 'react-intersection-observer' import { useLike } from '@/app/blog/_lib/useLike' import { useBookmark } from '@/app/blog/_lib/useBookmark' import SkeletonResumeFolder from '@/components/resume/SkeletonResume' import { usePathname } from 'next/navigation' -interface Resume { - id: string - createdAt: number - title: string - category: string - position: string - likeCount: number - year: string - user: { - id: number - name: string - profileImage: string - year: number - mainPosition: string - } - likeList: string[] - bookmarkList: string[] -} - -export default function Resume({ userId }) { - const [resumes, setResumes] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [isError, setIsError] = useState(false) +export default function Resume({ userId }: { userId: number }) { const [modal, setModal] = useState(false) - const [currentCursor, setCurrentCursor] = useState( - undefined, - ) - const [hasNext, setHasNext] = useState(true) - const [isLoadingMore, setIsLoadingMore] = useState(false) const [ref, inView] = useInView({ threshold: 0.1 }) + const [bookmarkList, setBookmarkList] = useState([]) const [likeList, setLikeList] = useState([]) const { fetchLikes } = useLike() - const [bookmarkList, setBookmarkList] = useState([]) const { fetchBookmarks } = useBookmark() const pathname = usePathname() const isMyPage = pathname === '/mypage' - // API 호출 - 초기 데이터 로드 - const fetchData = async (reset: boolean = false) => { - try { - if (reset) { - setIsLoading(true) - setResumes([]) - setCurrentCursor(undefined) - setHasNext(true) - } else { - setIsLoadingMore(true) - } - setIsError(false) - - const result = await fetchUserResumes( - userId, - reset ? undefined : currentCursor, - 10, - ) - - // 새로운 API 응답 구조에 맞춰 데이터 변환 - const convertedResumes: Resume[] = result.data.map((item) => ({ - id: item.id.toString(), - createdAt: new Date(item.createdAt).getTime(), - title: item.title, - category: item.category, - position: item.position, - likeCount: item.likeCount, - year: '', // API 응답에 year가 없으므로 빈 문자열로 설정 - user: { - id: userId, // userId 사용 - name: item.user.name, - profileImage: item.user.profileImage, - year: item.user.year, - mainPosition: item.position, // position을 mainPosition으로 사용 - }, - likeList: [], - bookmarkList: [], - })) - - if (reset) { - setResumes(convertedResumes) - } else { - setResumes((prev) => { - const existingIds = new Set(prev.map((p) => p.id)) - const newResumes = convertedResumes.filter( - (p) => !existingIds.has(p.id), - ) - return [...prev, ...newResumes] - }) - } - - setCurrentCursor(result.nextCursor) - setHasNext(result.hasNext) - } catch (error) { - setIsError(true) - console.error('이력서 데이터 로드 실패:', error) - } finally { - setIsLoading(false) - setIsLoadingMore(false) - } - } - - const checkLike = async () => { + // 사용자 이력서 목록 조회 + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + refetch, + } = useUserResumeListQuery(userId, 10) + + // 좋아요 상태 조회 + const checkLike = useCallback(async () => { try { const data = await fetchLikes('RESUME', 0, 50) setLikeList(data) @@ -121,9 +43,10 @@ export default function Resume({ userId }) { console.error(err) return [] } - } + }, [fetchLikes]) - const checkBookmark = async () => { + // 북마크 상태 조회 + const checkBookmark = useCallback(async () => { try { const data = await fetchBookmarks('RESUME', 0, 50) setBookmarkList(data) @@ -132,49 +55,62 @@ export default function Resume({ userId }) { console.error(err) return [] } - } + }, [fetchBookmarks]) const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - // 현재 이력서 데이터에서 해당 ID를 가진 이력서 찾아 업데이트 - setResumes((prev) => - prev.map((resume) => - resume.id === resumeId - ? { ...resume, likeCount: newLikeCount } - : resume, - ), - ) + setTimeout(() => { + checkLike() + refetch() + }, 500) } const handleBookmarkUpdate = (resumeId: string, newBookmarkCount: number) => { - // 현재 이력서 데이터에서 해당 ID를 가진 이력서 찾아 업데이트 - setResumes((prev) => - prev.map((resume) => - resume.id === resumeId - ? { ...resume, bookmarkCount: newBookmarkCount } - : resume, - ), - ) + setTimeout(() => { + checkBookmark() + refetch() + }, 500) } // 초기 데이터 로드 useEffect(() => { - fetchData(true) checkLike() checkBookmark() - }, [userId]) + }, [userId, checkLike, checkBookmark]) - // 무한 스크롤 - 추가 데이터 로드 + // 무한 스크롤 처리 useEffect(() => { - if (inView && hasNext && !isLoadingMore && !isLoading) { - fetchData(false) + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage() } - }, [inView, hasNext, isLoadingMore, isLoading]) + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) const handleClickAddResume = () => { setModal(!modal) } - // 렌더 + // 모든 페이지의 데이터를 평탄화하고 타입 변환 + const resumes = + data?.pages.flatMap((page) => + page.data.map((item) => ({ + id: item.id.toString(), + createdAt: new Date(item.createdAt).getTime(), + title: item.title, + category: item.category, + position: item.position, + likeCount: item.likeCount, + year: '', + user: { + id: userId, + name: item.user.name, + profileImage: item.user.profileImage, + year: item.user.year, + mainPosition: item.position, + }, + likeList: [], + bookmarkList: [], + })), + ) ?? [] + return (
{/* 상단 영역 */} @@ -187,9 +123,7 @@ export default function Resume({ userId }) { 이력서 추가 )} - {modal && ( - fetchData(true)} /> - )} + {modal && refetch()} />}
@@ -215,7 +149,7 @@ export default function Resume({ userId }) { ))} {/* 추가 로딩 스켈레톤 */} - {isLoadingMore && ( + {isFetchingNextPage && ( <> @@ -225,7 +159,7 @@ export default function Resume({ userId }) {
{/* 무한 스크롤 트리거 */} - {hasNext &&
} + {hasNextPage &&
} {/* 에러 상태 */} {isError && ( diff --git a/src/components/mypage/ResumeFolder.tsx b/src/components/mypage/ResumeFolder.tsx index 53619295..c5a2d286 100644 --- a/src/components/mypage/ResumeFolder.tsx +++ b/src/components/mypage/ResumeFolder.tsx @@ -1,21 +1,19 @@ 'use client' -import { useEffect, useState } from 'react' -import CareerTag from '../common/CareerTag' import PositionTag from '../common/PositionTag' import Link from 'next/link' import Image from 'next/image' import EmptyAnimation from '../common/EmptyAnimation' -import { useLike } from '@/app/blog/_lib/useLike' -import { useBookmark } from '@/app/blog/_lib/useBookmark' +import { useResumeLikeBookmark } from '@/hooks/resume/useResumeLikeBookmark' interface ResumeProps { likeCount: number resume: Resume - likeList: string[] // 좋아요 리스트 + likeList: string[] onLikeUpdate: (resumeId: string, newLikeCount: number) => void - bookmarkList: string[] // 북마크 리스트 + bookmarkList: string[] onBookmarkUpdate: (resumeId: string, newBookmarkCount: number) => void } + export default function ResumeFolder({ likeCount: initialLikeCount, resume, @@ -24,72 +22,15 @@ export default function ResumeFolder({ bookmarkList, onBookmarkUpdate, }: ResumeProps) { - // resume이 undefined일 경우 기본값을 설정합니다. - const { postLike } = useLike() - const { postBookmark } = useBookmark() - - // const [resumes, setResumes] = useState([]) - - const [isLike, setIsLike] = useState(false) - const [isBookmark, setIsBookmark] = useState(false) - - const [likeCount, setLikeCount] = useState(initialLikeCount) - const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) - - useEffect(() => { - if (Array.isArray(likeList)) { - setIsLike(likeList.some((bookmark: any) => bookmark.id === resume.id)) - } - if (Array.isArray(bookmarkList)) { - setIsBookmark( - bookmarkList.some((bookmark: any) => bookmark.id === resume.id), - ) - } - }, [likeList, bookmarkList, resume.id]) - - const clickLike = async (event: React.MouseEvent) => { - event.preventDefault() - try { - const newIsLike = !isLike - const newLikeCount = newIsLike ? likeCount + 1 : likeCount - 1 - // 낙관적 업데이트 - setIsLike(newIsLike) - setLikeCount(newLikeCount) - - await postLike(Number(resume.id), 'RESUME', newIsLike) - - if (resume.onLikeUpdate) { - onLikeUpdate(resume.id, newLikeCount) - } - } catch (err) { - setIsLike(!isLike) - setLikeCount(isLike ? likeCount : likeCount - 1) - console.error(err) - } - } - - const clickBookmark = async (event: React.MouseEvent) => { - event.preventDefault() - try { - const newIsBookmark = !isBookmark - const newBookmarkCount = newIsBookmark - ? bookmarkCount + 1 - : bookmarkCount - 1 - // 낙관적 업데이트 - setIsBookmark(newIsBookmark) - setBookmarkCount(newBookmarkCount) - - await postBookmark(Number(resume.id), 'RESUME', newIsBookmark) - - if (resume.onBookmarkUpdate) { - onBookmarkUpdate(resume.id, newBookmarkCount) - } - } catch (err) { - setIsBookmark(!isBookmark) - setBookmarkCount(isBookmark ? likeCount : likeCount - 1) - console.error(err) - } - } + const { isLike, isBookmark, likeCount, clickLike, clickBookmark } = + useResumeLikeBookmark( + resume, + initialLikeCount, + likeList, + bookmarkList, + onLikeUpdate, + onBookmarkUpdate, + ) if (!resume) { return ( diff --git a/src/components/resume/BestResume.tsx b/src/components/resume/BestResume.tsx index 28d398a5..ba633a60 100644 --- a/src/components/resume/BestResume.tsx +++ b/src/components/resume/BestResume.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { fetchBestResumes } from '@/app/(protected)/resume/api/getBestResume' +import { useBestResumeListQuery } from '@/api/resume/queries' import { useInView } from 'react-intersection-observer' interface ResumeData { @@ -21,50 +21,23 @@ interface ResumeData { interface ResumeFolderProps { setAuthModalOpen: (open: boolean) => void } -export default function BestResume({ setAuthModalOpen }: ResumeFolderProps) { - const [resumes, setResumes] = useState([]) // 빈 배열로 초기화 - const [cursorId, setCursorId] = useState(undefined) - const [hasNext, setHasNext] = useState(true) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const router = useRouter() // useRouter 훅 추가 +export default function BestResume({ setAuthModalOpen }: ResumeFolderProps) { + const router = useRouter() const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) const { ref, inView } = useInView({ threshold: 0.01 }) const limit = 12 - const loadMoreResumes = useCallback(async () => { - if (!hasNext || isLoadingMore) return - - try { - setIsLoadingMore(true) - const response = await fetchBestResumes( - cursorId || undefined, - limit, - setAuthModalOpen, - ) - const newResumes: ResumeData[] = response.data || [] - - setResumes((prev) => { - const existingIds = new Set(prev.map((r) => r.id)) - const filtered = newResumes.filter((r) => !existingIds.has(r.id)) - return [...prev, ...filtered] - }) - - if (response.hasNext !== undefined) setHasNext(response.hasNext) - if (response.nextCursor !== undefined) setCursorId(response.nextCursor) - } catch (error) { - console.error('인기 이력서 불러오기 실패:', error) - } finally { - setIsLoadingMore(false) - } - }, [cursorId, hasNext, isLoadingMore, setAuthModalOpen]) + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useBestResumeListQuery(setAuthModalOpen, limit) + // 무한 스크롤 처리 useEffect(() => { - if (inView && hasNext && !isLoadingMore && isOpen) { - loadMoreResumes() + if (inView && hasNextPage && !isFetchingNextPage && isOpen) { + fetchNextPage() } - }, [inView, hasNext, isOpen, isLoadingMore, loadMoreResumes]) + }, [inView, hasNextPage, isFetchingNextPage, isOpen, fetchNextPage]) const handleResumeClick = (id: number) => { router.push(`/resume/${id}`) @@ -72,6 +45,9 @@ export default function BestResume({ setAuthModalOpen }: ResumeFolderProps) { const toggleDropdown = () => setIsOpen(!isOpen) + // 모든 페이지의 데이터를 평탄화 + const resumes = data?.pages.flatMap((page) => page.data) ?? [] + return (