Compare commits

..

No commits in common. "edce32888094b2002b62a3f36d3551de1c10895d" and "7ca78280b348fe54bc8dc01a10121e9a94e489fe" have entirely different histories.

2 changed files with 276 additions and 392 deletions

View File

@ -1,5 +1,13 @@
import { useEffect, useState, useContext, useMemo } from 'react' import { useEffect, useState, useContext, useMemo } from 'react'
import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd' import {
Card,
Segmented,
Flex,
Popover,
DatePicker,
Button,
Space
} from 'antd'
import { import {
ResponsiveContainer, ResponsiveContainer,
BarChart, BarChart,
@ -17,9 +25,9 @@ import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import LoadingPlaceholder from './LoadingPlaceholder'
import MissingPlaceholder from './MissingPlaceholder' import MissingPlaceholder from './MissingPlaceholder'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import { LoadingOutlined } from '@ant-design/icons'
const HistoryDisplay = ({ const HistoryDisplay = ({
objectType, objectType,
@ -283,17 +291,15 @@ const HistoryDisplay = ({
}) })
const chartRows = Array.from( const chartRows = Array.from(
chartData chartData.reduce((acc, item) => {
.reduce((acc, item) => { const existing = acc.get(item.date) || {
const existing = acc.get(item.date) || { date: item.date,
date: item.date, dateFormatted: item.dateFormatted
dateFormatted: item.dateFormatted }
} existing[item.category] = item.value
existing[item.category] = item.value acc.set(item.date, existing)
acc.set(item.date, existing) return acc
return acc }, new Map()).values()
}, new Map())
.values()
).sort((a, b) => new Date(a.date) - new Date(b.date)) ).sort((a, b) => new Date(a.date) - new Date(b.date))
const customTimeRangeContent = ( const customTimeRangeContent = (
@ -328,99 +334,89 @@ const HistoryDisplay = ({
style={{ width: '100%' }} style={{ width: '100%' }}
styles={{ body: { padding: '12px', ...styles } }} styles={{ body: { padding: '12px', ...styles } }}
> >
<Flex gap='small' vertical> {!startDate && !endDate && (
{!startDate && !endDate && ( <Flex justify='space-between'>
<Flex justify='space-between'> <Flex align='center' gap='5px'>
<Flex align='center' gap='5px'> <Popover
<Popover content={customTimeRangeContent}
content={customTimeRangeContent} trigger='hover'
trigger='hover' arrow={false}
arrow={false} placement='bottomLeft'
placement='bottomLeft' styles={{ body: { borderRadius: '22.5px' } }}
styles={{ body: { borderRadius: '22.5px' } }} >
>
<Segmented
size='small'
options={[{ label: 'Custom', value: 'custom' }]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Popover>
</Flex>
<Flex gap='middle'>
{loading == true && <LoadingOutlined />}
<Segmented <Segmented
size='small' size='small'
options={[ options={[{ label: 'Custom', value: 'custom' }]}
{ label: '24hr', value: '24hrs' },
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' }
]}
value={timeRange} value={timeRange}
onChange={setTimeRange} onChange={setTimeRange}
disabled={loading} disabled={loading}
/> />
</Flex> </Popover>
</Flex> </Flex>
)}
{chartRows.length > 0 && ( <Segmented
<div style={{ width: '100%', height: `${height}px` }}> size='small'
<ResponsiveContainer width='100%' height='100%'> options={[
<BarChart data={chartRows}> { label: '24hr', value: '24hrs' },
<CartesianGrid { label: '12hr', value: '12hrs' },
strokeDasharray='3 3' { label: '8hr', value: '8hrs' },
stroke={isDarkMode ? '#303030' : '#f0f0f0'} { label: '4hr', value: '4hrs' },
/> { label: '1hr', value: '1hrs' }
<XAxis ]}
dataKey='dateFormatted' value={timeRange}
tick={{ onChange={setTimeRange}
fill: isDarkMode ? '#d9d9d9' : '#595959', disabled={loading}
fontSize: 12 />
}} </Flex>
/> )}
<YAxis {loading == true && (
tick={{ <Flex justify='center' align='center' style={{ height: `${height}px` }}>
fill: isDarkMode ? '#d9d9d9' : '#595959', <LoadingPlaceholder message='Loading history data...' />
fontSize: 12 </Flex>
}} )}
/> {chartRows.length > 0 && (
<Tooltip <div style={{ width: '100%', height: `${height}px` }}>
contentStyle={{ <ResponsiveContainer width='100%' height='100%'>
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff', <BarChart data={chartRows}>
border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`, <CartesianGrid
borderRadius: 8 strokeDasharray='3 3'
}} stroke={isDarkMode ? '#303030' : '#f0f0f0'}
/> />
<Legend /> <XAxis
{modelStats.map((statDef, index) => { dataKey='dateFormatted'
const category = statDef.label || statDef.name tick={{ fill: isDarkMode ? '#d9d9d9' : '#595959', fontSize: 12 }}
return ( />
<Bar <YAxis
key={category} tick={{ fill: isDarkMode ? '#d9d9d9' : '#595959', fontSize: 12 }}
dataKey={category} />
stackId='history' <Tooltip
fill={colorRange[index] || colors.default} contentStyle={{
/> backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
) border: `1px solid ${isDarkMode ? '#303030' : '#f0f0f0'}`,
})} borderRadius: 8
</BarChart> }}
</ResponsiveContainer> />
</div> <Legend />
)} {modelStats.map((statDef, index) => {
{loading == false && chartRows.length == 0 && ( const category = statDef.label || statDef.name
<Flex return (
justify='center' <Bar
align='center' key={category}
style={{ height: `${height}px` }} dataKey={category}
> stackId='history'
<MissingPlaceholder message='No data available.' /> fill={colorRange[index] || colors.default}
</Flex> />
)} )
</Flex> })}
</BarChart>
</ResponsiveContainer>
</div>
)}
{loading == false && chartRows.length == 0 && (
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
</Card> </Card>
) )
} }

View File

@ -3,7 +3,6 @@ import {
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useEffect, useEffect,
useLayoutEffect,
useState, useState,
useCallback, useCallback,
useMemo, useMemo,
@ -50,7 +49,6 @@ const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50 const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49
const RowForm = ({ record, isEditing, onRegister, children }) => { const RowForm = ({ record, isEditing, onRegister, children }) => {
const [form] = Form.useForm() const [form] = Form.useForm()
@ -154,12 +152,10 @@ const ObjectTable = forwardRef(
// Table state // Table state
const [pages, setPages] = useState([]) const [pages, setPages] = useState([])
const pagesRef = useRef(pages) const pagesRef = useRef(pages)
const tableData = useMemo( const [hasMore, setHasMore] = useState(true)
() => pages.flatMap((page) => page.items),
[pages]
)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false) const [lazyLoading, setLazyLoading] = useState(false)
const [tableData, setTableData] = useState([])
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
@ -186,26 +182,37 @@ const ObjectTable = forwardRef(
const rowActions = const rowActions =
model.actions?.filter((action) => action.row == true) || [] model.actions?.filter((action) => action.row == true) || []
const createSkeletonData = useCallback((pageNum) => { const createSkeletonData = useCallback(() => {
return Array(pageSize) return Array(pageSize)
.fill(null) .fill(null)
.map((_, index) => ({ .map(() => ({
_id: `skeleton-${pageNum}-${index}`, _id: `skeleton-${Math.random().toString(36).substring(2, 15)}`,
isSkeleton: true isSkeleton: true
})) }))
}, [pageSize]) }, [pageSize])
const createSkeletonPage = useCallback( const scrollOperationTimingOut = useRef(false)
(pageNum) => ({ const scrollOperationTimeout = useRef(null)
pageNum,
items: createSkeletonData(pageNum),
isSkeletonPage: true
}),
[createSkeletonData]
)
const loadingPagesRef = useRef(new Set()) const runScrollOperation = useCallback((operation) => {
const pendingScrollAnchorRef = useRef(null) if (scrollOperationTimingOut.current) return
scrollOperationTimingOut.current = true
operation()
scrollOperationTimeout.current = setTimeout(() => {
scrollOperationTimingOut.current = false
scrollOperationTimeout.current = null
}, 150)
}, [])
useEffect(() => {
return () => {
if (scrollOperationTimeout.current) {
clearTimeout(scrollOperationTimeout.current)
}
}
}, [])
const renderActions = (objectData) => { const renderActions = (objectData) => {
return ( return (
@ -248,7 +255,7 @@ const ObjectTable = forwardRef(
) )
} }
const fetchPage = useCallback( const fetchData = useCallback(
async (pageNum = 1, filter = null, sorter = null) => { async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) { if (filter == null) {
filter = activeFilterRef.current filter = activeFilterRef.current
@ -263,266 +270,148 @@ const ObjectTable = forwardRef(
order: sorter.order order: sorter.order
} }
} }
return fetchObjects(type, { try {
page: pageNum, const result = await fetchObjects(type, {
limit: pageSize, page: pageNum,
filter: { ...filter, ...masterFilter }, limit: pageSize,
sorter, filter: { ...filter, ...masterFilter },
onDataChange sorter,
}) onDataChange
})
setHasMore(result.hasMore)
setPages((prev) => {
const existingPageIndex = prev.findIndex(
(p) => p.pageNum === pageNum
)
if (existingPageIndex !== -1) {
// Update existing page
const newPages = [...prev]
newPages[existingPageIndex] = { pageNum, items: result.data }
return newPages
}
// If page doesn't exist, return unchanged
return prev
})
setLoading(false)
setLazyLoading(false)
return result.data || []
} catch (error) {
setPages((prev) =>
prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
)
setLoading(false)
setLazyLoading(false)
throw error
}
}, },
[type, masterFilter, pageSize, onDataChange, fetchObjects] [type, masterFilter, pageSize, onDataChange, fetchObjects]
) )
const skeletonsHeight = useMemo(() => { const loadNextPage = useCallback(() => {
return SKELETON_HEIGHT * pageSize const highestPage = Math.max(...pages.map((p) => p.pageNum))
}, [pageSize]) const nextPage = highestPage + 1
if (hasMore && lazyLoading == false) {
const createPageWindow = useCallback( setPages((prev) => {
(loadedPages, direction) => { const filteredPages = prev.map((page) => ({
const sortedPages = [...loadedPages].sort( ...page,
(a, b) => a.pageNum - b.pageNum items: page.items.filter((item) => !item.isSkeleton)
) }))
const visiblePages = const minPage = Math.min(...filteredPages.map((p) => p.pageNum))
direction === 'previous' const relevantPages = filteredPages.filter(
? sortedPages.slice(0, 2) (p) => p.pageNum !== minPage
: sortedPages.slice(-2)
const firstPage = visiblePages[0]
const lastPage = visiblePages[visiblePages.length - 1]
const nextPages = []
if (firstPage?.pageNum > 1) {
nextPages.push(createSkeletonPage(firstPage.pageNum - 1))
}
nextPages.push(...visiblePages)
if (lastPage?.hasMore) {
nextPages.push(createSkeletonPage(lastPage.pageNum + 1))
}
return nextPages
},
[createSkeletonPage]
)
const fetchData = useCallback(
async (pageNum = 1, filter = null, sorter = null) => {
try {
const result = await fetchPage(pageNum, filter, sorter)
const loadedPage = {
pageNum,
items: result.data || [],
hasMore: result.hasMore
}
setPages((prev) =>
prev.map((page) =>
page.pageNum === pageNum ? loadedPage : page
)
) )
setLoading(false) return [
return result.data || [] ...relevantPages,
} catch (error) { { pageNum: nextPage, items: createSkeletonData() }
setLoading(false) ]
throw error })
} fetchData(nextPage)
}, }
[fetchPage] }, [pages, createSkeletonData, fetchData, hasMore, lazyLoading])
)
const findRenderedRow = useCallback((scrollTarget, id) => { const loadPreviousPage = useCallback(() => {
if (!scrollTarget || id == null) return null const lowestPage = Math.min(...pages.map((p) => p.pageNum))
return Array.from( const prevPage = lowestPage - 1
scrollTarget.querySelectorAll('tr[data-row-key]')
).find((row) => row.getAttribute('data-row-key') === String(id))
}, [])
const captureScrollAnchor = useCallback( if (prevPage > 0 && lazyLoading == false) {
(scrollTarget, placeholderPage, direction, currentPages) => { setPages((prev) => {
if (!scrollTarget) return null const filteredPages = prev.map((page) => ({
...page,
items: page.items.filter((item) => !item.isSkeleton)
}))
const maxPage = Math.max(...filteredPages.map((p) => p.pageNum))
const relevantPages = filteredPages.filter(
(p) => p.pageNum !== maxPage
)
return [
{ pageNum: prevPage, items: createSkeletonData() },
...relevantPages
]
})
fetchData(prevPage)
}
}, [pages, createSkeletonData, fetchData, lazyLoading])
const targetTop = scrollTarget.getBoundingClientRect().top const scrollDown = useCallback(
const closestRow = (items) => { (target, scrollHeight) => {
return items setTimeout(() => {
.map((item, index) => { target.scrollTop = scrollHeight / 2 - target.clientHeight
const element = findRenderedRow(scrollTarget, item._id) }, 0)
return element
? {
index,
id: item._id,
top: element.getBoundingClientRect().top
}
: null
})
.filter(Boolean)
.sort(
(a, b) =>
Math.abs(a.top - targetTop) - Math.abs(b.top - targetTop)
)[0]
}
const placeholderAnchor = closestRow(placeholderPage.items)
const loadedPages = currentPages.filter(
(page) => !page.isSkeletonPage
)
const retainedPage =
direction === 'previous'
? loadedPages[0]
: loadedPages[loadedPages.length - 1]
const fallbackAnchor = retainedPage
? closestRow(retainedPage.items)
: null
return { placeholderAnchor, fallbackAnchor }
},
[findRenderedRow]
)
const restoreScrollAnchor = useCallback(
(scrollTarget, anchor, loadedPage) => {
if (!scrollTarget || !anchor) return
const loadedItem =
loadedPage.items[anchor.placeholderAnchor?.index]
const anchorId = loadedItem?._id ?? anchor.fallbackAnchor?.id
const previousTop = loadedItem
? anchor.placeholderAnchor?.top
: anchor.fallbackAnchor?.top
if (anchorId == null || previousTop == null) return
const element = findRenderedRow(scrollTarget, anchorId)
if (!element) return
const renderedTop = element.getBoundingClientRect().top
scrollTarget.scrollTop += renderedTop - previousTop
},
[findRenderedRow]
)
useLayoutEffect(() => {
const pendingAnchor = pendingScrollAnchorRef.current
if (!pendingAnchor) return
pendingScrollAnchorRef.current = null
restoreScrollAnchor(
pendingAnchor.scrollTarget,
pendingAnchor.anchor,
pendingAnchor.loadedPage
)
}, [pages, restoreScrollAnchor])
const loadBoundaryPage = useCallback(
async (pageNum, direction, scrollTarget) => {
if (loadingPagesRef.current.size > 0) return
const currentPages = pagesRef.current
const placeholderPage = currentPages.find(
(page) => page.pageNum === pageNum && page.isSkeletonPage
)
if (!placeholderPage) return
const scrollAnchor = captureScrollAnchor(
scrollTarget,
placeholderPage,
direction,
currentPages
)
loadingPagesRef.current.add(pageNum)
setLazyLoading(true) setLazyLoading(true)
logger.debug(`Loading ${direction} page...`) logger.debug('Loading next page...')
loadNextPage()
try {
const result = await fetchPage(pageNum)
const loadedPage = {
pageNum,
items: result.data || [],
hasMore: result.hasMore
}
pendingScrollAnchorRef.current = {
scrollTarget,
anchor: scrollAnchor,
loadedPage
}
setPages((prev) => {
if (
!prev.some(
(page) => page.pageNum === pageNum && page.isSkeletonPage
)
) {
return prev
}
const loadedPages = prev
.filter((page) => !page.isSkeletonPage)
.concat(loadedPage)
return createPageWindow(loadedPages, direction)
})
} catch (error) {
logger.error(`Error loading page ${pageNum}:`, error)
} finally {
loadingPagesRef.current.delete(pageNum)
if (loadingPagesRef.current.size === 0) {
setLazyLoading(false)
}
}
}, },
[ [loadNextPage]
captureScrollAnchor,
createPageWindow,
fetchPage
]
) )
const loadNextPage = useCallback( const scrollUp = useCallback(
(scrollTarget) => { (target, scrollHeight) => {
const nextPage = pagesRef.current[pagesRef.current.length - 1] setTimeout(() => {
if (nextPage?.isSkeletonPage) { target.scrollTop = scrollHeight / 2
loadBoundaryPage(nextPage.pageNum, 'next', scrollTarget) }, 0)
} setLazyLoading(true)
logger.debug('Loading previous page...')
loadPreviousPage()
}, },
[loadBoundaryPage] [loadPreviousPage]
)
const loadPreviousPage = useCallback(
(scrollTarget) => {
const previousPage = pagesRef.current[0]
if (previousPage?.isSkeletonPage) {
loadBoundaryPage(previousPage.pageNum, 'previous', scrollTarget)
}
},
[loadBoundaryPage]
) )
const handleScroll = useCallback( const handleScroll = useCallback(
(e) => { (e) => {
const { target } = e const { target } = e
const firstPage = pagesRef.current[0] const scrollHeight = target.scrollHeight
const lastPage = pagesRef.current[pagesRef.current.length - 1] const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
// Load more data when scrolling down
if ( if (
lastPage?.isSkeletonPage && scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD &&
target.scrollHeight - target.scrollTop - target.clientHeight <= hasMore
skeletonsHeight + SCROLL_THRESHOLD
) { ) {
loadNextPage(target) runScrollOperation(() => scrollDown(target, scrollHeight))
} else if ( }
firstPage?.isSkeletonPage &&
target.scrollTop <= skeletonsHeight + SCROLL_THRESHOLD // Load previous data when scrolling up
) { if (scrollTop < SCROLL_THRESHOLD && prevPage > 0) {
loadPreviousPage(target) runScrollOperation(() => scrollUp(target, scrollHeight))
} }
}, },
[loadNextPage, loadPreviousPage, skeletonsHeight] [hasMore, pages, runScrollOperation, scrollDown, scrollUp]
) )
const reload = useCallback(async () => { const reload = useCallback(async () => {
setLazyLoading(true) setLazyLoading(true)
try { for (let i = 0; i < pagesRef.current.length; i++) {
const loadedPages = pagesRef.current.filter( const page = pagesRef.current[i]
(page) => !page.isSkeletonPage await fetchData(page.pageNum)
)
for (let i = 0; i < loadedPages.length; i++) {
await fetchData(loadedPages[i].pageNum)
}
} finally {
setLazyLoading(false)
} }
}, [fetchData]) }, [fetchData])
@ -712,41 +601,24 @@ const ObjectTable = forwardRef(
const loadPage = useCallback( const loadPage = useCallback(
async (pageNum, filter = null, sorter = null) => { async (pageNum, filter = null, sorter = null) => {
setPages([createSkeletonPage(pageNum)]) // Create initial page with skeletons
setLoading(true) setPages([{ pageNum: pageNum, items: createSkeletonData() }])
try { const items = await fetchData(pageNum, filter, sorter)
const firstResult = await fetchPage(pageNum, filter, sorter)
const loadedPages = [
{
pageNum,
items: firstResult.data || [],
hasMore: firstResult.hasMore
}
]
if (firstResult.hasMore) { if (items.length >= 25) {
setPages([ setPages((prev) => {
...loadedPages, // Remove any existing page with the same pageNum
createSkeletonPage(pageNum + 1) const filtered = prev.filter((p) => p.pageNum !== pageNum + 1)
]) return [
const secondResult = await fetchPage(pageNum + 1) ...filtered,
loadedPages.push({ { pageNum: pageNum + 1, items: createSkeletonData() }
pageNum: pageNum + 1, ]
items: secondResult.data || [], })
hasMore: secondResult.hasMore await fetchData(pageNum + 1, filter, sorter)
})
}
setPages(createPageWindow(loadedPages, 'next'))
} catch (error) {
logger.error(`Error loading page ${pageNum}:`, error)
setPages([])
} finally {
setLoading(false)
} }
}, },
[createPageWindow, createSkeletonPage, fetchPage] [createSkeletonData, fetchData]
) )
const loadInitialPage = useCallback(async () => { const loadInitialPage = useCallback(async () => {
@ -795,6 +667,7 @@ const ObjectTable = forwardRef(
setInitialized(false) setInitialized(false)
setLoading(true) setLoading(true)
setLazyLoading(false) setLazyLoading(false)
setHasMore(true)
prevValuesRef.current = { type, masterFilter } prevValuesRef.current = { type, masterFilter }
} }
}, [type, masterFilter]) }, [type, masterFilter])
@ -854,6 +727,8 @@ const ObjectTable = forwardRef(
} }
}) })
console.log('filters--', filters)
setSidebarFilter(next) setSidebarFilter(next)
setPages([]) setPages([])
setLoading(true) setLoading(true)
@ -891,6 +766,11 @@ const ObjectTable = forwardRef(
pagesRef.current = pages pagesRef.current = pages
}, [pages]) }, [pages])
// Flatten pages array for table display
useEffect(() => {
setTableData(pages.flatMap((page) => page.items))
}, [pages])
useEffect(() => { useEffect(() => {
if (!expandHeight || cards) return if (!expandHeight || cards) return
@ -1031,27 +911,35 @@ const ObjectTable = forwardRef(
const handleCardsScroll = (e) => { const handleCardsScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target const { scrollTop, scrollHeight, clientHeight } = e.target
const firstPage = pagesRef.current[0] const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const lastPage = pagesRef.current[pagesRef.current.length - 1] const prevPage = lowestPage - 1
// Load more data when scrolling down
if ( if (
lastPage?.isSkeletonPage &&
scrollHeight - scrollTop - clientHeight < 100 && scrollHeight - scrollTop - clientHeight < 100 &&
hasMore &&
!lazyLoading !lazyLoading
) { ) {
setTimeout(() => {
e.target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
loadNextPage() loadNextPage()
} else if ( }
firstPage?.isSkeletonPage &&
scrollTop < 100 && // Load previous data when scrolling up
!lazyLoading if (scrollTop < 100 && prevPage > 0 && !lazyLoading) {
) { setTimeout(() => {
e.target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
loadPreviousPage() loadPreviousPage()
} }
} }
container.addEventListener('scroll', handleCardsScroll) container.addEventListener('scroll', handleCardsScroll)
return () => container.removeEventListener('scroll', handleCardsScroll) return () => container.removeEventListener('scroll', handleCardsScroll)
}, [cards, pages, lazyLoading, loadNextPage, loadPreviousPage]) }, [cards, pages, hasMore, lazyLoading, loadNextPage, loadPreviousPage])
const renderCards = () => { const renderCards = () => {
return ( return (