diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/App.tsx" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/App.tsx" index 5d224f1..0a7b811 100644 --- "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/App.tsx" +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/App.tsx" @@ -11,6 +11,7 @@ import ProtectedLayout from './layouts/ProtectedLayout.tsx'; import GoogleLoginRedirectPage from './pages/GoogleLoginRedirectPage.tsx'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { LpDetailPage } from './pages/LpDetailPage.tsx'; //1. 홈페이지 @@ -28,6 +29,7 @@ const publicRoutes: RouteObject[] = [ { path: "login", element: }, { path: "signup", element: }, { path: "v1/auth/google/callback", element: }, + { path: "lps/:lpid", element: }, ], }, ]; diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/apis/lp.ts" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/apis/lp.ts" index feabe9e..4ca4f9c 100644 --- "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/apis/lp.ts" +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/apis/lp.ts" @@ -1,5 +1,5 @@ import { PaginationDto } from "../types/common.ts"; -import { ResponseLpListDto } from "../types/lp.ts"; +import { ResponseLpListDto, RequestLpDto, ResponseLpDto, ResponseLikeLpDto } from "../types/lp.ts"; import { axiosInstance } from "./axios.ts"; export const getLpList = async (paginationDto: PaginationDto, @@ -11,3 +11,25 @@ export const getLpList = async (paginationDto: PaginationDto, return data; }; + +export const getLpDetail = async ( + { lpId }: RequestLpDto +): Promise => { + const { data } = await axiosInstance.get(`/v1/lps/${lpId}`); + return data; +}; + +export const postLike = async ( + { lpId }: RequestLpDto +): Promise => { + const { data } = await axiosInstance.post(`/v1/lps/${lpId}/likes`); + return data; +}; + +export const deleteLike = async ( + { lpId }: RequestLpDto +): Promise => { + const { data } = await axiosInstance.delete(`/v1/lps/${lpId}/likes`); + return data; +}; + diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/components/LpCard/LpCard.tsx" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/components/LpCard/LpCard.tsx" index 9cf25cb..de2e296 100644 --- "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/components/LpCard/LpCard.tsx" +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/components/LpCard/LpCard.tsx" @@ -1,3 +1,4 @@ +import { NavigateFunction, useNavigate } from "react-router-dom"; import { Lp } from "../../types/lp.ts"; interface LpCardProps { @@ -5,9 +6,11 @@ interface LpCardProps { } const LpCard = ({ lp }: LpCardProps) => { + const navigate: NavigateFunction = useNavigate(); return (
navigate(`/lps/${lp.id}`)} + className="relative rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300 cursor-pointer" > { + // 1. 기존 요청 중단 + await queryClient.cancelQueries({ queryKey: [QUERY_KEY.lps, lpId] }); + + // 2. 이전 캐시 상태 저장 + const previousLpPost = queryClient.getQueryData([QUERY_KEY.lps, lpId]); + + // 3. 복사해서 newLpPost 만들기 + const newLpPost: ResponseLpDto | undefined = previousLpPost + ? { ...previousLpPost, status: previousLpPost.status ?? false } + : undefined; + + // 4. 현재 로그인한 사용자 정보 + const me = queryClient.getQueryData([QUERY_KEY.myInfo]); + + const userId: number = Number((me as ResponseMyInfoDto)?.data.id); + + // 5. 좋아요 배열에서 해당 userId의 위치 찾기 + const likedIndex: number = + previousLpPost?.data.likes.findIndex((like: Likes) => like.userId === userId) ?? -1; + + // 6. 있으면 삭제, 없으면 추가 (토글 로직) + if (likedIndex >= 0) { + previousLpPost?.data.likes.splice(likedIndex, 1); + } else { + const newLike: Likes = { userId, lpId: lpId } as Likes; + previousLpPost?.data.likes.push(newLike); + } +// 업데이트된 게시글 데이터를 캐시에 저장 +queryClient.setQueryData([QUERY_KEY.lps, lpId], newLpPost); + +return { previousLpPost, newLpPost }; +}, + +onError: ( + error: Error, + newLp: RequestLpDto, + context: { previousLpPost: ResponseLpDto | undefined; newLpPost: ResponseLpDto | undefined } | undefined +) => { + console.log(error, newLp); + if (context?.previousLpPost) { + queryClient.setQueryData( + [QUERY_KEY.lps, newLp.lpId], + context.previousLpPost + ); + } +}, + +onSettled: async ( + data: ResponseLikeLpDto | undefined, + error: Error | null, + variables: RequestLpDto, + context: { previousLpPost: ResponseLpDto | undefined; newLpPost: ResponseLpDto | undefined } | undefined +) => await queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.lps, variables.lpId], +}) + }); +} + + + +export default useDeleteLike; \ No newline at end of file diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/mutations/usePostLike.ts" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/mutations/usePostLike.ts" new file mode 100644 index 0000000..0fdfd71 --- /dev/null +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/mutations/usePostLike.ts" @@ -0,0 +1,71 @@ +import { useMutation } from "@tanstack/react-query"; +import { postLike } from "../../apis/lp"; +import { Likes, RequestLpDto, ResponseLikeLpDto, ResponseLpDto } from "../../types/lp"; +import { queryClient } from "../../App"; +import { QUERY_KEY } from "../../constants/key"; +import { ResponseMyInfoDto } from "../../types/auth"; + +function usePostLike() { + return useMutation({ + mutationFn: postLike, + // onMutate → 요청 전에 optimistic update + onMutate: async ({ lpId }: RequestLpDto) => { + // 1. 기존 요청 중단 + await queryClient.cancelQueries({ queryKey: [QUERY_KEY.lps, lpId] }); + + // 2. 이전 캐시 상태 저장 + const previousLpPost = queryClient.getQueryData([QUERY_KEY.lps, lpId]); + + // 3. 복사해서 newLpPost 만들기 + const newLpPost: ResponseLpDto | undefined = previousLpPost + ? { ...previousLpPost, status: previousLpPost.status ?? false } + : undefined; + + // 4. 현재 로그인한 사용자 정보 + const me = queryClient.getQueryData([QUERY_KEY.myInfo]); + + const userId: number = Number((me as ResponseMyInfoDto)?.data.id); + + // 5. 좋아요 배열에서 해당 userId의 위치 찾기 + const likedIndex: number = + previousLpPost?.data.likes.findIndex((like: Likes) => like.userId === userId) ?? -1; + + // 6. 있으면 삭제, 없으면 추가 (토글 로직) + if (likedIndex >= 0) { + previousLpPost?.data.likes.splice(likedIndex, 1); + } else { + const newLike: Likes = { userId, lpId: lpId } as Likes; + previousLpPost?.data.likes.push(newLike); + } + // 업데이트된 게시글 데이터를 캐시에 저장 + queryClient.setQueryData([QUERY_KEY.lps, lpId], newLpPost); + + return { previousLpPost, newLpPost }; + }, + + onError: ( + error: Error, + newLp: RequestLpDto, + context: { previousLpPost: ResponseLpDto | undefined; newLpPost: ResponseLpDto | undefined } | undefined + ) => { + console.log(error, newLp); + if (context?.previousLpPost) { + queryClient.setQueryData( + [QUERY_KEY.lps, newLp.lpId], + context.previousLpPost + ); + } + }, + + onSettled: async ( + data: ResponseLikeLpDto | undefined, + error: Error | null, + variables: RequestLpDto, + context: { previousLpPost: ResponseLpDto | undefined; newLpPost: ResponseLpDto | undefined } | undefined + ) => await queryClient.invalidateQueries({ + queryKey: [QUERY_KEY.lps, variables.lpId], + }) +}); +} + +export default usePostLike; diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetLpDetail.ts" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetLpDetail.ts" new file mode 100644 index 0000000..61ddf25 --- /dev/null +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetLpDetail.ts" @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEY } from "../../constants/key.ts"; +import { RequestLpDto } from "../../types/lp.ts"; +import { getLpDetail } from "../../apis/lp.ts"; + +function useGetLpDetail({ lpId }: RequestLpDto) { + return useQuery({ + queryKey: [QUERY_KEY.lps, lpId], + queryFn: () => getLpDetail({ lpId }), + }); +} + +export default useGetLpDetail; diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetMyInfo.ts" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetMyInfo.ts" new file mode 100644 index 0000000..a330669 --- /dev/null +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/hooks/queries/useGetMyInfo.ts" @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEY } from "../../constants/key.ts"; +import { getMyInfo } from "../../apis/auth.ts"; + +function useGetMyInfo(accessToken: string|null) { + return useQuery({ + queryKey: [QUERY_KEY.myInfo], + queryFn: getMyInfo, + enabled: !!accessToken, + }); +} + +export default useGetMyInfo; diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/pages/LpDetailPage.tsx" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/pages/LpDetailPage.tsx" new file mode 100644 index 0000000..ea6e324 --- /dev/null +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/pages/LpDetailPage.tsx" @@ -0,0 +1,61 @@ +import { useParams } from "react-router-dom"; +import useGetLpDetail from "../hooks/queries/useGetLpDetail.ts"; +import { Likes, ResponseLpDto } from "../types/lp.ts"; +import {Heart} from "lucide-react" +import { useAuth } from "../context/AuthContext.tsx"; +import usePostLike from "../hooks/mutations/usePostLike.ts"; +import useDeleteLike from "../hooks/mutations/useDeleteLike.ts"; +import useGetMyInfo from "../hooks/queries/useGetMyInfo.ts"; + +export const LpDetailPage = () => { + const lpId = useParams().lpId; + const {accessToken} = useAuth(); + + const { + data: lp, + isPending, + isError, + }: { + data: ResponseLpDto | undefined; + isPending: boolean; + isError: boolean; + } = useGetLpDetail({ lpId: Number(lpId) }); + + const { data: me } = useGetMyInfo(accessToken); + +const { mutate: likeMutate } = usePostLike(); +const { mutate: disLikeMutate } = useDeleteLike(); + +const isLiked: boolean | undefined = lp?.data.likes.some( + (like: Likes) => like.userId === me?.data.id + ); + + + const handleLikeLp = async() => { + likeMutate({lpId: Number(lpId)}) + } + +const handleDislikeLp = async() => { + disLikeMutate({lpId: Number(lpId)}); +}; + + + if (isPending && isError) { + return <>; + } + + return ( +
+

{lp?.data.title}

+ {lp?.data.title} +

{lp?.data.content}

+ + +
+ ); +}; + +export default LpDetailPage; diff --git "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/types/lp.ts" "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/types/lp.ts" index bea438a..2659126 100644 --- "a/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/types/lp.ts" +++ "b/\354\236\254\354\210\234-\354\235\264\354\236\254\355\230\204/LP project/src/types/lp.ts" @@ -1,4 +1,4 @@ -import { CursorBasedResponse } from "./common.ts"; +import { CommonResponse, CursorBasedResponse } from "./common.ts"; export type Tag = { id: number; @@ -24,4 +24,14 @@ export type Lp = { likes: Likes[]; } -export type ResponseLpListDto = CursorBasedResponse; \ No newline at end of file +export type RequestLpDto = { + lpId: number; +} + +export type ResponseLpDto = CommonResponse; +export type ResponseLpListDto = CursorBasedResponse; +export type ResponseLikeLpDto = CommonResponse<{ + id: number; + userId: number; + lpId: number; +}>; \ No newline at end of file