feat: implement infinite scroll

This commit is contained in:
jinhojang6 2024-09-27 23:46:45 +09:00
parent 44c733681a
commit 057908f3c3
No known key found for this signature in database
GPG Key ID: 1762F21FE8B543F8
9 changed files with 55203 additions and 153 deletions

View File

@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../common/api' import operators from '../../data/operators.json'
const useQueryOptions = { const useQueryOptions = {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@ -8,7 +8,8 @@ const useQueryOptions = {
} }
export const fetchData = async () => { export const fetchData = async () => {
return await api.get('/operators').then((res) => res.data) return operators
// return await api.get('/operators').then((res) => res.data)
} }
const useGetOperators = () => { const useGetOperators = () => {

55062
data/operators.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@
"next-mdx-remote": "^4.4.1", "next-mdx-remote": "^4.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-photo-album": "^2.3.1", "react-photo-album": "^2.3.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",

View File

@ -9,65 +9,6 @@ interface OperatorGridProps {
data: ProcessedOperator[] data: ProcessedOperator[]
} }
const operators: ProcessedOperator[] = [
{
id: '1',
image: '/dashboard/mock/operators/1.gif',
name: 'OP 1',
pointsPerHour: 304,
isStaked: false,
isPinned: false,
},
{
id: '2',
image: '/dashboard/mock/operators/2.gif',
name: 'OP 2',
pointsPerHour: 304,
isStaked: true,
isPinned: false,
},
{
id: '3',
image: '/dashboard/mock/operators/3.gif',
name: 'OP 3',
pointsPerHour: 304,
isStaked: true,
isPinned: true,
},
{
id: '4',
image: '/dashboard/mock/operators/4.gif',
name: 'OP 4',
pointsPerHour: 304,
isStaked: true,
isPinned: false,
},
{
id: '5',
image: '/dashboard/mock/operators/5.gif',
name: 'OP 5',
pointsPerHour: 304,
isStaked: true,
isPinned: false,
},
{
id: '6',
image: '/dashboard/mock/operators/6.gif',
name: 'OP 6',
pointsPerHour: 304,
isStaked: true,
isPinned: false,
},
{
id: '7',
image: '/dashboard/mock/operators/7.gif',
name: 'OP 7',
pointsPerHour: 304,
isStaked: true,
isPinned: false,
},
]
const OperatorGrid: React.FC<OperatorGridProps> = ({ const OperatorGrid: React.FC<OperatorGridProps> = ({
isLoading, isLoading,
data, data,
@ -111,7 +52,7 @@ const OperatorGrid: React.FC<OperatorGridProps> = ({
<Placeholder /> <Placeholder />
</OperatorCard> </OperatorCard>
)) ))
: data.map((operator) => ( : data?.map((operator) => (
<OperatorCard key={operator.id}> <OperatorCard key={operator.id}>
<Link href={`/operators/${operator.id}`} key={operator.id}> <Link href={`/operators/${operator.id}`} key={operator.id}>
<OperatorImage src={operator.image} alt={operator.name} /> <OperatorImage src={operator.image} alt={operator.name} />

View File

@ -1,62 +1,78 @@
import { breakpoints } from '@/configs/ui.configs' import { breakpoints } from '@/configs/ui.configs'
import { ProcessedOperator } from '@/containers/Dashboard/DashboardContainer'
import styled from '@emotion/styled' import styled from '@emotion/styled'
import Link from 'next/link' import Link from 'next/link'
import React from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
interface OperatorGridProps {} interface OperatorGridProps {
isLoading: boolean
data: ProcessedOperator[]
}
const operatorImages = [ const OFFSET = 18
{
image: '/dashboard/mock/operators/1.gif',
},
{
image: '/dashboard/mock/operators/2.gif',
},
{
image: '/dashboard/mock/operators/3.gif',
},
{
image: '/dashboard/mock/operators/4.gif',
},
{
image: '/dashboard/mock/operators/5.gif',
},
{
image: '/dashboard/mock/operators/6.gif',
},
{
image: '/dashboard/mock/operators/7.gif',
},
{
image: '/dashboard/mock/operators/1.gif',
},
{
image: '/dashboard/mock/operators/2.gif',
},
{
image: '/dashboard/mock/operators/3.gif',
},
{
image: '/dashboard/mock/operators/4.gif',
},
{
image: '/dashboard/mock/operators/5.gif',
},
{
image: '/dashboard/mock/operators/6.gif',
},
{
image: '/dashboard/mock/operators/7.gif',
},
{
image: '/dashboard/mock/operators/1.gif',
},
]
const OperatorGrid: React.FC<OperatorGridProps> = () => { const OperatorGrid: React.FC<OperatorGridProps> = ({ data, isLoading }) => {
const [itemsCount, setItemsCount] = useState(18)
const observerRef = useRef<IntersectionObserver | null>(null)
const lastElementRef = useRef<HTMLDivElement | null>(null)
// Infinite scroll logic
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0]
if (target.isIntersecting && itemsCount < data.length) {
setItemsCount((prevCount) => prevCount + OFFSET)
}
},
[itemsCount, data?.length],
)
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect()
observerRef.current = new IntersectionObserver(handleObserver, {
root: null,
rootMargin: '20px',
threshold: 1.0,
})
if (lastElementRef.current)
observerRef.current.observe(lastElementRef.current)
return () => {
if (observerRef.current) observerRef.current.disconnect()
}
}, [handleObserver])
return isLoading ? (
<GridContainer>
{Array.from({ length: 12 }).map((_, index) => (
<GridItem key={index}>
<Placeholder />
</GridItem>
))}
</GridContainer>
) : (
<GridContainer>
{data.slice(0, itemsCount).map((operator, index) => {
if (index === itemsCount - 1) {
return (
<Link
href={`/operators/${index + 1}`}
key={'explore-operator-' + index}
>
<GridItem ref={lastElementRef}>
<img
key={index}
src={operator.image}
alt={`Operator ${index + 1}`}
loading="lazy"
/>
</GridItem>
</Link>
)
} else {
return ( return (
<StyledOperatorGrid>
{operatorImages.map((operator, index) => (
<Link <Link
href={`/operators/${index + 1}`} href={`/operators/${index + 1}`}
key={'explore-operator-' + index} key={'explore-operator-' + index}
@ -70,13 +86,14 @@ const OperatorGrid: React.FC<OperatorGridProps> = () => {
/> />
</GridItem> </GridItem>
</Link> </Link>
))} )
</StyledOperatorGrid> }
})}
</GridContainer>
) )
} }
// grid: 6 responsive columns const GridContainer = styled.section`
const StyledOperatorGrid = styled.section`
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(216px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
gap: 16px; gap: 16px;
@ -101,8 +118,15 @@ const GridItem = styled.div`
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
` `
const Placeholder = styled.div`
width: 100%;
aspect-ratio: 1;
background-color: var(--grey-900);
border-radius: 8px;
opacity: 0.5;
`
export default OperatorGrid export default OperatorGrid

View File

@ -5,6 +5,7 @@ import { breakpoints } from '@/configs/ui.configs'
import styled from '@emotion/styled' import styled from '@emotion/styled'
import React from 'react' import React from 'react'
import useGetOperators from '../../../apis/operators/useGetOperators' import useGetOperators from '../../../apis/operators/useGetOperators'
import { getRandomSubset, processOperators } from '../../../utils/operators'
export type DashboardPageProps = React.DetailedHTMLProps< export type DashboardPageProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
@ -32,31 +33,13 @@ export interface ProcessedOperator {
isPinned: boolean isPinned: boolean
} }
function processOperators(data: Group[]): ProcessedOperator[] {
return data?.flatMap((group) =>
group.operators.map((operator) => ({
id: operator.id.toString(), // Convert ID to string
image: operator.image_400_url,
name: `OP ${operator.id}`,
pointsPerHour: Math.floor(Math.random() * 500), // Random value for points per hour
isStaked: false,
isPinned: false,
})),
)
}
const DashboardContainer: React.FC<DashboardPageProps> = ({ const DashboardContainer: React.FC<DashboardPageProps> = ({
children, children,
...props ...props
}) => { }) => {
const { data, isLoading } = useGetOperators() const { data, isLoading } = useGetOperators()
const processedOperators = processOperators(data) const processedOperators = processOperators(data as Group[])
function getRandomSubset<T>(array: T[], count: number): T[] {
const shuffled = array?.sort(() => 0.5 - Math.random())
return shuffled?.slice(0, count)
}
const random20Operators = getRandomSubset(processedOperators, 20) const random20Operators = getRandomSubset(processedOperators, 20)

View File

@ -2,15 +2,19 @@ import { OperatorFilter } from '@/components/Explore/OperatorFilter'
import { OperatorGrid } from '@/components/Explore/OperatorGrid' import { OperatorGrid } from '@/components/Explore/OperatorGrid'
import styled from '@emotion/styled' import styled from '@emotion/styled'
import React from 'react' import React from 'react'
import useGetOperators from '../../../apis/operators/useGetOperators'
import { processOperators } from '../../../utils/operators'
interface ExploreSectionProps {} interface ExploreSectionProps {}
const ExploreSection: React.FC<ExploreSectionProps> = () => { const ExploreSection: React.FC<ExploreSectionProps> = () => {
const { data, isLoading } = useGetOperators()
const processedOperators = processOperators(data as any)
return ( return (
<StyledExploreSection> <StyledExploreSection>
<h1 className="section-title">Explore Operators</h1> <h1 className="section-title">Explore Operators</h1>
<OperatorFilter /> <OperatorFilter />
<OperatorGrid /> <OperatorGrid data={processedOperators as any} isLoading={isLoading} />
</StyledExploreSection> </StyledExploreSection>
) )
} }

22
utils/operators.ts Normal file
View File

@ -0,0 +1,22 @@
import {
Group,
ProcessedOperator,
} from '@/containers/Dashboard/DashboardContainer'
export function processOperators(data: Group[]): ProcessedOperator[] {
return data?.flatMap((group) =>
group.operators.map((operator) => ({
id: operator.id.toString(), // Convert ID to string
image: operator.image_400_url,
name: `OP ${operator.id}`,
pointsPerHour: Math.floor(Math.random() * 500), // Random value for points per hour
isStaked: false,
isPinned: false,
})),
)
}
export function getRandomSubset<T>(array: T[], count: number): T[] {
const shuffled = array?.sort(() => 0.5 - Math.random())
return shuffled?.slice(0, count)
}

View File

@ -4075,6 +4075,13 @@ react-hot-toast@^2.4.1:
dependencies: dependencies:
goober "^2.1.10" goober "^2.1.10"
react-infinite-scroll-component@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -4683,6 +4690,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
throttle-debounce@^3.0.1: throttle-debounce@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"