diff --git a/apps/frontend/app/(main)/problem/_components/ProblemInfiniteTable.tsx b/apps/frontend/app/(main)/problem/_components/ProblemInfiniteTable.tsx new file mode 100644 index 0000000000..264feae833 --- /dev/null +++ b/apps/frontend/app/(main)/problem/_components/ProblemInfiniteTable.tsx @@ -0,0 +1,57 @@ +'use client' + +import DataTable from '@/components/DataTable' +import SearchBar from '@/components/SearchBar' +import { Skeleton } from '@/components/ui/skeleton' +import { useInfiniteScroll } from '@/lib/useInfiniteScroll' +import type { Problem } from '@/types/type' +import { useSearchParams } from 'next/navigation' +import { columns } from './Columns' + +export default function ProblemInfiniteTable() { + const searchParams = useSearchParams() + const search = searchParams.get('search') ?? '' + const order = searchParams.get('order') ?? 'id-asc' + const newSearchParams = new URLSearchParams() + newSearchParams.set('search', search) + newSearchParams.set('order', order) + + const { items, total, ref, isFetchingNextPage } = useInfiniteScroll({ + pathname: 'problem', + query: newSearchParams + }) + + return ( + <> +
+
+

All

+

{total}

+
+ +
+
+ + {isFetchingNextPage && ( + <> + {[...Array(5)].map((_, i) => ( + + ))} + + )} +
+
+ + ) +} diff --git a/apps/frontend/app/(main)/problem/_components/ProblemTable.tsx b/apps/frontend/app/(main)/problem/_components/ProblemTable.tsx deleted file mode 100644 index 26dc7aa4aa..0000000000 --- a/apps/frontend/app/(main)/problem/_components/ProblemTable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import DataTable from '@/components/DataTable' -import { fetcher } from '@/lib/utils' -import type { Problem } from '@/types/type' -import { columns } from './Columns' - -interface Props { - search: string - order: string -} - -export default async function ProblemTable({ search, order }: Props) { - const res: { problems: Problem[] } | object = await fetcher - .get('problem', { - searchParams: { - take: 10, - search, - order - } - }) - .json() - - if (!('problems' in res)) { - console.dir(res) - throw new Error('Error occurred while fetching problems.') - } - return ( - - ) -} diff --git a/apps/frontend/app/(main)/problem/page.tsx b/apps/frontend/app/(main)/problem/page.tsx index 4d875c9592..5ef8cb1bdd 100644 --- a/apps/frontend/app/(main)/problem/page.tsx +++ b/apps/frontend/app/(main)/problem/page.tsx @@ -1,50 +1,48 @@ -import SearchBar from '@/components/SearchBar' +'use client' + import { Skeleton } from '@/components/ui/skeleton' import type { Problem } from '@/types/type' +import { QueryClientProvider, QueryClient } from '@tanstack/react-query' import { Suspense } from 'react' -import ProblemTable from './_components/ProblemTable' - -interface ProblemProps { - searchParams: { search: string; tag: string; order: string } -} - -export default function Problem({ searchParams }: ProblemProps) { - const search = searchParams?.search ?? '' - const order = searchParams?.order ?? 'id-asc' +import ProblemInfiniteTable from './_components/ProblemInfiniteTable' +export default function Problem() { + const queryClient = new QueryClient() return ( <> -
- -
- -
- - - - - - - - - - - - - - - -
- {[...Array(5)].map((_, i) => ( - - ))} - - } - > - -
+ + +
+ + + + + + + + + + + + + + + +
+ {[...Array(5)].map((_, i) => ( + + ))} + + } + > + +
+
) } diff --git a/apps/frontend/lib/useInfiniteScroll.ts b/apps/frontend/lib/useInfiniteScroll.ts new file mode 100644 index 0000000000..f4ddd7e27c --- /dev/null +++ b/apps/frontend/lib/useInfiniteScroll.ts @@ -0,0 +1,118 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useInView } from 'react-intersection-observer' +import type { URLSearchParams } from 'url' +import { fetcher, fetcherWithAuth } from './utils' + +interface Item { + id: number +} + +interface DataSet { + problems: T[] //TODO: 백엔드 get류 api return type 통일되면 problems -> data로 고치기 + total: number +} + +interface UseInfiniteScrollProps { + pathname: string + query: URLSearchParams + itemsPerPage?: number + withAuth?: boolean +} + +/** + * + * @param pathname + * The path or endpoint from which data will be fetched. + * + * @param query + * The URLSearchParams object representing the query parameters for data fetching. + * + * @param itemsPerPage + * The number of items to fetch per page. Default is 5. + * + * @param withAuth + * A boolean indicating whether authentication is required for data fetching. Default is false. + * + */ + +export const useInfiniteScroll = ({ + pathname, + query, + itemsPerPage = 10, + withAuth = false +}: UseInfiniteScrollProps) => { + //fetch datas with pageParams and url + const getInfiniteData = async ({ + pageParam + }: { + pageParam?: number + }): Promise> => { + if (!query.has('take')) query.append('take', String(itemsPerPage)) + pageParam && pageParam > 0 && query.set('cursor', pageParam.toString()) + let dataSet: DataSet + withAuth + ? (dataSet = await fetcherWithAuth + .get(pathname, { + searchParams: query + }) + .json()) + : (dataSet = await fetcher + .get(pathname, { + searchParams: query + }) + .json()) + return dataSet + } + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useSuspenseInfiniteQuery({ + queryKey: [pathname, query.toString()], + staleTime: 0, + queryFn: getInfiniteData, + initialPageParam: 0, + getNextPageParam: (lastPage: DataSet) => { + return lastPage?.problems.length === 0 || //TODO: fix problem to data + lastPage?.problems.length < itemsPerPage + ? undefined //페이지에 있는 아이템 수가 0이거나 itemsPerPage보다 작으면 undefined를 반환합니다. + : lastPage.problems.at(-1)?.id //cursor를 getData의 params로 넘겨줍니다. + } + }) + + const { ref, inView } = useInView() + + useEffect(() => { + if (inView && !isFetchingNextPage && hasNextPage) { + fetchNextPage() + } + }, [inView, isFetchingNextPage, hasNextPage, fetchNextPage, data]) + + /* + + 5번 이상 바닥에 닿으면 자동 페칭을 멈추고, loadmore 버튼을 보이게 합니다. + const scrollCounter = useRef(0) // 바닥에 닿은 횟수를 세는 카운터 + const [isLoadButton, setIsLoadButton] = useState(false) + + useEffect(() => { + if (inView && !isFetchingNextPage) { + if (hasNextPage) { + if (scrollCounter.current < 5) { + setIsLoadButton(false) + fetchNextPage() + scrollCounter.current += 1 + } else { + setIsLoadButton(true) + } + } + } else setIsLoadButton(false) + }, [inView, isFetchingNextPage, hasNextPage, fetchNextPage, data]) +*/ + + return { + items: data.pages.flat().flatMap((page) => page.problems), + total: data.pages.at(0)?.total, + fetchNextPage, + ref, + isFetchingNextPage + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 65822df2d9..aff9005639 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^7.106.0", "@tailwindcss/typography": "^0.5.10", + "@tanstack/react-query": "^5.27.5", "@tanstack/react-table": "^8.13.2", "@tiptap/core": "^2.2.4", "@tiptap/extension-link": "^2.2.4", @@ -69,6 +70,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.51.0", "react-icons": "^5.0.1", + "react-intersection-observer": "^9.8.1", "react-resizable-panels": "^2.0.12", "react-use": "^17.5.0", "sharp": "^0.33.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 527071a54f..b1c140d2c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.1) + '@tanstack/react-query': + specifier: ^5.24.1 + version: 5.24.1(react@18.2.0) '@tanstack/react-table': specifier: ^8.13.2 version: 8.13.2(react-dom@18.2.0)(react@18.2.0) @@ -498,6 +501,9 @@ importers: react-icons: specifier: ^5.0.1 version: 5.0.1(react@18.2.0) + react-intersection-observer: + specifier: ^9.8.1 + version: 9.8.1(react-dom@18.2.0)(react@18.2.0) react-resizable-panels: specifier: ^2.0.12 version: 2.0.12(react-dom@18.2.0)(react@18.2.0) @@ -746,7 +752,6 @@ packages: /@apollo/server@4.10.1(graphql@16.8.1): resolution: {integrity: sha512-XGMOgTyzV4EBHQq0xQVKFry9hZF7AA/6nxxGLamqdxodhdSdGbU9jrlb5/XDveeGuXP3+5JDdrB2HcziVLJcMA==} - engines: {node: '>=14.16.0'} peerDependencies: graphql: ^16.6.0 dependencies: @@ -2457,7 +2462,6 @@ packages: /@commitlint/cli@19.1.0(@types/node@20.11.26)(typescript@5.4.2): resolution: {integrity: sha512-SYGm8HGbVzrlSYeB6oo6pG1Ec6bOMJcDsXgNGa4vgZQsPj6nJkcbTWlIRmtmIk0tHi0d5sCljGuQ+g/0NCPv7w==} - engines: {node: '>=v18'} hasBin: true dependencies: '@commitlint/format': 19.0.3 @@ -3137,7 +3141,6 @@ packages: /@graphql-tools/code-file-loader@8.1.1(graphql@16.8.1): resolution: {integrity: sha512-q4KN25EPSUztc8rA8YUU3ufh721Yk12xXDbtUA+YstczWS7a1RJlghYMFEfR1HsHSYbF7cUqkbnTKSGM3o52bQ==} - engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: @@ -3315,7 +3318,6 @@ packages: /@graphql-tools/import@7.0.1(graphql@16.8.1): resolution: {integrity: sha512-935uAjAS8UAeXThqHfYVr4HEAp6nHJ2sximZKO1RzUTq5WoALMAhhGARl0+ecm6X+cqNUwIChJbjtaa6P/ML0w==} - engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: @@ -3836,7 +3838,6 @@ packages: /@jridgewell/resolve-uri@3.1.2: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} /@jridgewell/set-array@1.2.1: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} @@ -6850,7 +6851,6 @@ packages: /@sentry/types@7.106.1: resolution: {integrity: sha512-g3OcyAHGugBwkQP4fZYCCZqF2ng9K7yQc9FVngKq/y7PwHm84epXdYYGDGgfQOIC1d5/GMaPxmzI5IIrZexzkg==} - engines: {node: '>=8'} dev: false /@sentry/utils@7.106.1: @@ -7345,6 +7345,19 @@ packages: tailwindcss: 3.4.1(ts-node@10.9.2) dev: false + /@tanstack/query-core@5.24.1: + resolution: {integrity: sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==} + dev: false + + /@tanstack/react-query@5.24.1(react@18.2.0): + resolution: {integrity: sha512-4+09JEdO4d6+Gc8Y/g2M/MuxDK5IY0QV8+2wL2304wPKJgJ54cBbULd3nciJ5uvh/as8rrxx6s0mtIwpRuGd1g==} + peerDependencies: + react: ^18.0.0 + dependencies: + '@tanstack/query-core': 5.24.1 + react: 18.2.0 + dev: false + /@tanstack/react-table@8.13.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-b6mR3mYkjRtJ443QZh9sc7CvGTce81J35F/XMr0OoWbx0KIM7TTTdyNP2XKObvkLpYnLpCrYDwI3CZnLezWvpg==} engines: {node: '>=12'} @@ -7505,7 +7518,6 @@ packages: /@tiptap/extension-horizontal-rule@2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4): resolution: {integrity: sha512-iCRHjFQQHApWg3R4fkKkJQhWEOdu1Fdc4YEAukdOXPSg3fg36IwjvsMXjt9SYBtVZ+iio3rORCZGXyMvgCH9uw==} - peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: @@ -7523,7 +7535,6 @@ packages: /@tiptap/extension-link@2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4): resolution: {integrity: sha512-Qsx0cFZm4dxbkToXs5TcXbSoUdicv8db1gV1DYIZdETqjBm4wFjlzCUP7hPHFlvNfeSy1BzAMRt+RpeuiwvxWQ==} - peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: @@ -10722,7 +10733,6 @@ packages: /eslint-config-next@14.1.3(eslint@8.57.0)(typescript@5.4.2): resolution: {integrity: sha512-sUCpWlGuHpEhI0pIT0UtdSLJk5Z8E2DYinPTwsBiWaSYQomchdl0i60pjynY48+oXvtyWMQ7oE+G3m49yrfacg==} - peerDependencies: eslint: ^7.23.0 || ^8.0.0 typescript: '>=3.3.1' peerDependenciesMeta: @@ -12326,7 +12336,6 @@ packages: /internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} - engines: {node: '>= 0.4'} dependencies: es-errors: 1.3.0 hasown: 2.0.2 @@ -15905,6 +15914,19 @@ packages: react: 18.2.0 dev: false + /react-intersection-observer@9.8.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-QzOFdROX8D8MH3wE3OVKH0f3mLjKTtEN1VX/rkNuECCff+aKky0pIjulDhr3Ewqj5el/L+MhBkM3ef0Tbt+qUQ==} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react-dom: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}