feat: implement infinite scroll
This commit is contained in:
parent
44c733681a
commit
057908f3c3
|
@ -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 = () => {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -1,82 +1,99 @@
|
||||||
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 }) => {
|
||||||
return (
|
const [itemsCount, setItemsCount] = useState(18)
|
||||||
<StyledOperatorGrid>
|
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||||
{operatorImages.map((operator, index) => (
|
const lastElementRef = useRef<HTMLDivElement | null>(null)
|
||||||
<Link
|
|
||||||
href={`/operators/${index + 1}`}
|
// Infinite scroll logic
|
||||||
key={'explore-operator-' + index}
|
const handleObserver = useCallback(
|
||||||
>
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
<GridItem>
|
const target = entries[0]
|
||||||
<img
|
if (target.isIntersecting && itemsCount < data.length) {
|
||||||
key={index}
|
setItemsCount((prevCount) => prevCount + OFFSET)
|
||||||
src={operator.image}
|
}
|
||||||
alt={`Operator ${index + 1}`}
|
},
|
||||||
loading="lazy"
|
[itemsCount, data?.length],
|
||||||
/>
|
)
|
||||||
</GridItem>
|
|
||||||
</Link>
|
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>
|
||||||
))}
|
))}
|
||||||
</StyledOperatorGrid>
|
</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 (
|
||||||
|
<Link
|
||||||
|
href={`/operators/${index + 1}`}
|
||||||
|
key={'explore-operator-' + index}
|
||||||
|
>
|
||||||
|
<GridItem>
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={operator.image}
|
||||||
|
alt={`Operator ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</GridItem>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue