Merge branch 'fe/feat/infinit-query' into 'fe/develop'
Feat: 리뷰 인피니트 쿼리 See merge request s11-s-project/S11P21S002!186
This commit is contained in:
commit
f53f849641
@ -13,7 +13,7 @@ export default function AdminLayout() {
|
||||
<AdminProjectSidebar />
|
||||
|
||||
<ResizablePanel className="flex w-full items-center">
|
||||
<main className="h-full grow">
|
||||
<main className="h-full grow overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
|
46
frontend/src/components/Header/UserProfileForm.tsx
Normal file
46
frontend/src/components/Header/UserProfileForm.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import useLogoutQuery from '@/queries/auth/useLogoutQuery';
|
||||
export default function UserProfileForm({ onClose }: { onClose: () => void }) {
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
const { nickname, profileImage } = profile || { nickname: '', profileImage: '' };
|
||||
|
||||
const logoutMutation = useLogoutQuery();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState<boolean>(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-4">
|
||||
{profileImage ? (
|
||||
<img
|
||||
src={profileImage}
|
||||
alt={`${nickname}'s profile`}
|
||||
className="h-16 w-16 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-full bg-gray-300"></div>
|
||||
)}
|
||||
|
||||
<div className="text-lg font-bold">{nickname || 'Guest'}</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="outlinePrimary"
|
||||
className="mt-4"
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{isLoggingOut ? '로그아웃 중...' : '로그아웃'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
31
frontend/src/components/Header/UserProfileModal.tsx
Normal file
31
frontend/src/components/Header/UserProfileModal.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '../ui/dialogCustom';
|
||||
import { User } from 'lucide-react';
|
||||
import UserProfileForm from './UserProfileForm';
|
||||
|
||||
export default function UserProfileModal() {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const handleOpen = () => setIsOpen(true);
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center p-2"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader title="프로필" />
|
||||
<UserProfileForm onClose={handleClose} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Bell, User } from 'lucide-react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useLocation, Link, useParams } from 'react-router-dom';
|
||||
import UserProfileModal from './UserProfileModal';
|
||||
|
||||
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
@ -60,7 +61,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
{!isHomePage && (
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-5" />
|
||||
<User className="h-4 w-4 text-black sm:h-5 sm:w-5" />
|
||||
<UserProfileModal />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
@ -125,19 +125,22 @@ export const reviewHandlers = [
|
||||
const projectId = Array.isArray(params.projectId)
|
||||
? parseInt(params.projectId[0], 10)
|
||||
: parseInt(params.projectId as string, 10);
|
||||
console.log(projectId);
|
||||
|
||||
const reviewStatus = Array.isArray(params.reviewStatus) ? params.reviewStatus[0] : params.reviewStatus;
|
||||
|
||||
const lastReviewId = Array.isArray(params.lastReviewId) ? params.lastReviewId[0] : params.lastReviewId;
|
||||
const lastReviewId = Array.isArray(params.lastReviewId)
|
||||
? parseInt(params.lastReviewId[0], 10)
|
||||
: parseInt(params.lastReviewId as string, 10) || 0;
|
||||
|
||||
const limitPage = Array.isArray(params.limitPage)
|
||||
? parseInt(params.limitPage[0], 10)
|
||||
: parseInt(params.limitPage as string, 10) || 10;
|
||||
|
||||
const reviews: ReviewResponse[] = Array.from({ length: limitPage }, (_, index) => ({
|
||||
// 총 100개의 리뷰를 생성
|
||||
const totalReviews = 100;
|
||||
const reviews: ReviewResponse[] = Array.from({ length: totalReviews }, (_, index) => ({
|
||||
projectId,
|
||||
reviewId: lastReviewId ? parseInt(lastReviewId, 10) + index : index + 1,
|
||||
reviewId: index + 1,
|
||||
title: `Review ${index + 1}`,
|
||||
content: `Review content ${index + 1}`,
|
||||
status: (reviewStatus || 'REQUESTED') as 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||
@ -146,6 +149,10 @@ export const reviewHandlers = [
|
||||
author: { id: 1, nickname: 'Author', profileImage: '', email: 'author@example.com' },
|
||||
}));
|
||||
|
||||
return HttpResponse.json(reviews);
|
||||
// 마지막 리뷰 ID 기준으로 데이터를 잘라서 반환
|
||||
const startIndex = lastReviewId > 0 ? lastReviewId : 0;
|
||||
const slicedReviews = reviews.slice(startIndex, startIndex + limitPage);
|
||||
|
||||
return HttpResponse.json(slicedReviews);
|
||||
}),
|
||||
];
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import useReviewByStatusQuery from '@/queries/reviews/useReviewByStatusQuery';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
@ -14,15 +14,44 @@ export default function ProjectReviewList() {
|
||||
const [, setSearchQuery] = useState('');
|
||||
const [sortValue, setSortValue] = useState('latest');
|
||||
|
||||
const { data: projectReviews = [] } = useReviewByStatusQuery(
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useReviewByStatusQuery(
|
||||
Number(projectId),
|
||||
memberId,
|
||||
activeTab !== 'all' ? activeTab : undefined
|
||||
);
|
||||
|
||||
const projectReviews = data?.pages.flat() || [];
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
const currentLoadMoreRef = loadMoreRef.current;
|
||||
if (currentLoadMoreRef) {
|
||||
observer.observe(currentLoadMoreRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentLoadMoreRef) {
|
||||
observer.unobserve(currentLoadMoreRef);
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<div>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
|
||||
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-white px-4">
|
||||
<h1 className="text-xl font-semibold">프로젝트 리뷰</h1>
|
||||
<Link
|
||||
to={`/admin/${workspaceId}/reviews/request`}
|
||||
@ -41,6 +70,14 @@ export default function ProjectReviewList() {
|
||||
setSortValue={setSortValue}
|
||||
workspaceId={Number(workspaceId)}
|
||||
/>
|
||||
|
||||
{isFetchingNextPage}
|
||||
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import useWorkspaceReviewsQuery from '@/queries/workspaces/useWorkspaceReviewsQuery';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import ReviewList from '@/components/ReviewList';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
export default function WorkspaceReviewList() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const profile = useAuthStore((state) => state.profile);
|
||||
@ -14,15 +14,44 @@ export default function WorkspaceReviewList() {
|
||||
const [, setSearchQuery] = useState('');
|
||||
const [sortValue, setSortValue] = useState('latest');
|
||||
|
||||
const { data: workspaceReviews = [] } = useWorkspaceReviewsQuery(
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useWorkspaceReviewsQuery(
|
||||
Number(workspaceId),
|
||||
memberId,
|
||||
activeTab !== 'all' ? activeTab : undefined
|
||||
);
|
||||
|
||||
const workspaceReviews = data?.pages.flat() || [];
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
);
|
||||
|
||||
const currentLoadMoreRef = loadMoreRef.current;
|
||||
if (currentLoadMoreRef) {
|
||||
observer.observe(currentLoadMoreRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentLoadMoreRef) {
|
||||
observer.unobserve(currentLoadMoreRef);
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<div>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b px-4">
|
||||
<header className="sticky top-0 z-10 flex h-[57px] items-center gap-1 border-b bg-white px-4">
|
||||
<h1 className="text-xl font-semibold">워크스페이스 리뷰</h1>
|
||||
<Link
|
||||
to={`/admin/${workspaceId}/reviews/request`}
|
||||
@ -31,6 +60,7 @@ export default function WorkspaceReviewList() {
|
||||
<Button variant="outlinePrimary">리뷰 요청</Button>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<ReviewList
|
||||
reviews={workspaceReviews}
|
||||
activeTab={activeTab}
|
||||
@ -40,6 +70,14 @@ export default function WorkspaceReviewList() {
|
||||
setSortValue={setSortValue}
|
||||
workspaceId={Number(workspaceId)}
|
||||
/>
|
||||
|
||||
{isFetchingNextPage}
|
||||
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
16
frontend/src/queries/auth/useLogoutQuery.ts
Normal file
16
frontend/src/queries/auth/useLogoutQuery.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { logout } from '@/api/authApi';
|
||||
|
||||
export default function useLogoutQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
const { clearAuth } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: logout,
|
||||
onSuccess: () => {
|
||||
clearAuth();
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
},
|
||||
});
|
||||
}
|
@ -1,13 +1,22 @@
|
||||
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||
import { getReviewByStatus } from '@/api/reviewApi';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ReviewResponse } from '@/types';
|
||||
|
||||
export default function useReviewByStatusQuery(
|
||||
projectId: number,
|
||||
memberId: number,
|
||||
reviewStatus: 'REQUESTED' | 'APPROVED' | 'REJECTED' | undefined
|
||||
) {
|
||||
return useSuspenseQuery({
|
||||
return useSuspenseInfiniteQuery<ReviewResponse[]>({
|
||||
queryKey: ['reviewByStatus', projectId, reviewStatus],
|
||||
queryFn: () => getReviewByStatus(projectId, memberId, reviewStatus),
|
||||
queryFn: ({ pageParam = undefined }) => {
|
||||
return getReviewByStatus(projectId, memberId, reviewStatus, pageParam as number | undefined);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.length === 0) return undefined;
|
||||
const lastReview = lastPage[lastPage.length - 1];
|
||||
return lastReview.reviewId;
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
});
|
||||
}
|
||||
|
@ -1,15 +1,24 @@
|
||||
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
|
||||
import { getWorkspaceReviews } from '@/api/workspaceApi';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ReviewResponse } from '@/types';
|
||||
|
||||
export default function useWorkspaceReviewsQuery(
|
||||
workspaceId: number,
|
||||
memberId: number,
|
||||
reviewStatus?: 'REQUESTED' | 'APPROVED' | 'REJECTED',
|
||||
lastReviewId?: number,
|
||||
limitPage?: number
|
||||
limitPage: number = 10
|
||||
) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: ['workspaceReviews', workspaceId, reviewStatus, lastReviewId],
|
||||
queryFn: () => getWorkspaceReviews(workspaceId, memberId, reviewStatus, lastReviewId, limitPage),
|
||||
return useSuspenseInfiniteQuery<ReviewResponse[]>({
|
||||
queryKey: ['workspaceReviews', workspaceId, reviewStatus],
|
||||
queryFn: ({ pageParam = undefined }) =>
|
||||
getWorkspaceReviews(workspaceId, memberId, reviewStatus, pageParam as number | undefined, limitPage),
|
||||
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.length === 0) return undefined;
|
||||
const lastReview = lastPage[lastPage.length - 1];
|
||||
return lastReview.reviewId;
|
||||
},
|
||||
|
||||
initialPageParam: undefined,
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user