Skip to content

Commit

Permalink
Add asset page and carousel component
Browse files Browse the repository at this point in the history
  • Loading branch information
kattylucy committed Jan 29, 2025
1 parent eb95230 commit 61215ea
Show file tree
Hide file tree
Showing 5 changed files with 346 additions and 27 deletions.
151 changes: 145 additions & 6 deletions centrifuge-app/src/pages/Dashboard/AssetsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,149 @@
import { Pool } from '@centrifuge/centrifuge-js'
import { Loan, Pool } from '@centrifuge/centrifuge-js'
import { useCentrifuge } from '@centrifuge/centrifuge-react'
import { Box, Carousel, Checkbox, Grid, Text, Thumbnail } from '@centrifuge/fabric'
import { useEffect, useRef, useState } from 'react'
import { useTheme } from 'styled-components'
import { Spinner } from '../../../src/components/Spinner'
import { useLoans } from '../../../src/utils/useLoans'
import { usePoolMetadata } from '../../../src/utils/usePools'

type TransformedLoan = {
poolUri: string | undefined
poolName: string | undefined
id: string
loans: Loan[]
}

export const PoolCard = ({
poolUri,
poolName,
id,
children,
active,
onClick,
}: {
poolUri: string | undefined
poolName: string | undefined
id: string
children: React.ReactNode
active: boolean
onClick: () => void
}) => {
const theme = useTheme()
return (
<Box
display="flex"
backgroundColor={active ? theme.colors.backgroundInverted : theme.colors.backgroundSecondary}
borderRadius={4}
height="36px"
padding="4px"
alignItems="center"
key={id}
justifyContent="space-between"
onClick={onClick}
style={{ cursor: 'pointer' }}
>
<Box display="flex" alignItems="center">
{poolUri ? (
<Box as="img" src={poolUri} alt="" height={24} width={24} borderRadius={4} mr={1} />
) : (
<Thumbnail type="pool" label="LP" size="small" />
)}
<Text variant="body2" color={active ? theme.colors.textInverted : theme.colors.textPrimary}>
{poolName}
</Text>
</Box>
{children}
</Box>
)
}

export default function AssetsPage({ pools }: { pools: Pool[] }) {
// Use a single hook that handles multiple pool IDs
const allLoans = useLoans(pools.map((pool) => pool.id))
console.log(allLoans)
export default function AssetsPage() {
return <></>
const cent = useCentrifuge()
const ids = pools.map((pool) => pool.id)
const { data: loans, isLoading } = useLoans(pools ? ids : [])
const [selectedPools, setSelectedPools] = useState<string[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const [useCarousel, setUseCarousel] = useState(false)

const loansByPool = loans?.reduce((acc: Record<string, Record<string, (typeof loans)[0]>>, loan) => {
acc[loan.poolId] = acc[loan.poolId] || {}
acc[loan.poolId][loan.id] = loan
return acc
}, {})

const transformedLoans: TransformedLoan[] = pools
.map((pool) => {
const { data: poolMetadata } = usePoolMetadata(pool)

Check failure on line 77 in centrifuge-app/src/pages/Dashboard/AssetsPage.tsx

View workflow job for this annotation

GitHub Actions / build-app

React Hook "usePoolMetadata" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function

Check failure on line 77 in centrifuge-app/src/pages/Dashboard/AssetsPage.tsx

View workflow job for this annotation

GitHub Actions / ff-prod / build-app

React Hook "usePoolMetadata" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function
const poolUri = poolMetadata?.pool?.icon?.uri
? cent.metadata.parseMetadataUrl(poolMetadata?.pool?.icon?.uri)
: undefined
return {
...pool,
loans: loansByPool?.[pool.id] ? Object.values(loansByPool[pool.id]) : [],
poolName: poolMetadata?.pool?.name,
poolUri,
}
})
.slice(0, 3)

const renderPoolCards = (loans: TransformedLoan[]) => {
return loans.map((loan, index) => {
const { poolUri, poolName, id } = loan

const selectedPool = () => {
const past = selectedPools.find((pool) => pool === id)
if (past) {
setSelectedPools(selectedPools.filter((pool) => pool !== id))
} else {
setSelectedPools([...selectedPools, id])
}
}

return (
<PoolCard
key={index}
poolUri={poolUri}
poolName={poolName}
id={id}
active={selectedPools.includes(id)}
children={<Checkbox variant="secondary" checked={selectedPools.includes(id)} />}
onClick={selectedPool}
/>
)
})
}

useEffect(() => {
const checkWrapping = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth
const totalCardWidth = transformedLoans.length * 300
setUseCarousel(totalCardWidth > containerWidth)
}
}

checkWrapping()
window.addEventListener('resize', checkWrapping)
return () => window.removeEventListener('resize', checkWrapping)
}, [transformedLoans])

if (isLoading) return <Spinner />

return (
<Box py={3} px={3}>
<Text variant="heading1">Dashboard</Text>
<Box mt={5} mb={2} ref={containerRef}>
{useCarousel ? (
<Carousel visibleItems={5} itemWidth={300} gap={16}>
{renderPoolCards(transformedLoans)}
</Carousel>
) : (
<Grid gap={2} gridTemplateColumns="repeat(auto-fill, minmax(250px, 1fr))">
{renderPoolCards(transformedLoans)}
</Grid>
)}
</Box>
</Box>
)
}
19 changes: 11 additions & 8 deletions centrifuge-app/src/utils/useLoans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { Dec } from './Decimal'
import { useTinlakeLoans } from './tinlake/useTinlakePools'

export function useLoans(poolIds: string[]) {
const isTinlakePool = poolIds.length === 1 && poolIds[0].startsWith('0x')
const isTinlakePool = poolIds.length === 1 && poolIds[0]?.startsWith('0x')

if (isTinlakePool) {
const { data: tinlakeLoans, isLoading: isLoadingTinlake } = useTinlakeLoans(poolIds[0])
return { data: tinlakeLoans, isLoading: isLoadingTinlake }
} else {
const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolIds], (cent) => cent.pools.getLoans({ poolIds }))
console.log('centLoans', centLoans)
return { data: centLoans, isLoading }
if (poolIds.length === 0) {
return { data: null, isLoading: false }
}

const { data: tinlakeLoans, isLoading: isLoadingTinlake } = useTinlakeLoans(poolIds[0])

Check failure on line 12 in centrifuge-app/src/utils/useLoans.ts

View workflow job for this annotation

GitHub Actions / build-app

React Hook "useTinlakeLoans" is called conditionally. React Hooks must be called in the exact same order in every component render

Check failure on line 12 in centrifuge-app/src/utils/useLoans.ts

View workflow job for this annotation

GitHub Actions / ff-prod / build-app

React Hook "useTinlakeLoans" is called conditionally. React Hooks must be called in the exact same order in every component render

const [centLoans, isLoading] = useCentrifugeQuery(['loans', poolIds], (cent) => cent.pools.getLoans({ poolIds }), {

Check failure on line 14 in centrifuge-app/src/utils/useLoans.ts

View workflow job for this annotation

GitHub Actions / build-app

React Hook "useCentrifugeQuery" is called conditionally. React Hooks must be called in the exact same order in every component render

Check failure on line 14 in centrifuge-app/src/utils/useLoans.ts

View workflow job for this annotation

GitHub Actions / ff-prod / build-app

React Hook "useCentrifugeQuery" is called conditionally. React Hooks must be called in the exact same order in every component render
suspense: true,
enabled: !isTinlakePool,
})
return { data: isTinlakePool ? tinlakeLoans : centLoans, isLoading: isTinlakePool ? isLoadingTinlake : isLoading }
}

export function useLoan(poolId: string, assetId: string | undefined) {
Expand Down
168 changes: 168 additions & 0 deletions fabric/src/components/Carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { TouchEvent, useEffect, useState } from 'react'
import styled from 'styled-components'
import { IconChevronLeft, IconChevronRight } from '../../icon'
import { Box } from '../Box'

interface CarouselProps {
children: React.ReactNode[]
visibleItems?: number
itemWidth?: number
gap?: number
}

const CarouselContainer = styled(Box)`
position: relative;
display: flex;
align-items: center;
`

const CarouselViewport = styled(Box)`
overflow: hidden;
margin-left: 40px;
margin-right: 40px;
`

const CarouselItems = styled(Box)<{ translateX: number }>`
display: flex;
transform: ${({ translateX }) => `translateX(-${translateX}px)`};
transition: transform 0.3s ease-in-out;
margin-right: 16px;
`

const CarouselItem = styled(Box)<{ itemWidth: number; gap: number }>`
flex: 0 0 auto;
min-width: ${({ itemWidth }) => itemWidth}px;
margin-right: ${({ gap }) => gap}px;
&:last-child {
margin-right: 0;
}
`

const CarouselArrow = styled.button<{ position: 'left' | 'right' }>`
background-color: transparent;
color: ${({ theme }) => theme.colors.backgroundInverted};
border: none;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
position: absolute;
top: 50%;
${({ position }) => position}: 8px;
transform: translateY(-50%);
transition: background-color 0.3s;
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`

export const Carousel = ({ children, visibleItems = 3, itemWidth = 200, gap = 16 }: CarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0)
const [dynamicVisibleItems, setDynamicVisibleItems] = useState(visibleItems)
const totalItems = children.length
const maxIndex = Math.max(totalItems - dynamicVisibleItems, 0)

useEffect(() => {
const updateVisibleItems = () => {
const width = window.innerWidth
if (width < 600) {
setDynamicVisibleItems(1)
} else if (width >= 600 && width < 900) {
setDynamicVisibleItems(2)
} else {
setDynamicVisibleItems(3)
}
}

updateVisibleItems()

window.addEventListener('resize', updateVisibleItems)
return () => window.removeEventListener('resize', updateVisibleItems)
}, [])

useEffect(() => {
setCurrentIndex((prev) => Math.min(prev, Math.max(totalItems - dynamicVisibleItems, 0)))
}, [dynamicVisibleItems, totalItems])

const handlePrev = () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0))
}

const handleNext = () => {
setCurrentIndex((prev) => Math.min(prev + 1, maxIndex))
}

const [touchStartX, setTouchStartX] = useState<number | null>(null)
const [touchEndX, setTouchEndX] = useState<number | null>(null)

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
handlePrev()
} else if (e.key === 'ArrowRight') {
handleNext()
}
}

window.addEventListener('keydown', handleKeyDown)

return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [])

const onTouchStart = (e: TouchEvent) => {
setTouchStartX(e.changedTouches[0].screenX)
}

const onTouchMove = (e: TouchEvent) => {
setTouchEndX(e.changedTouches[0].screenX)
}

const onTouchEnd = () => {
if (touchStartX === null || touchEndX === null) return
const distance = touchStartX - touchEndX
const threshold = 50
if (distance > threshold) {
handleNext()
} else if (distance < -threshold) {
handlePrev()
}
setTouchStartX(null)
setTouchEndX(null)
}

return (
<CarouselContainer onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}>
{currentIndex > 0 && (
<CarouselArrow position="left" onClick={handlePrev} aria-label="Previous Slide">
<IconChevronLeft size={18} />
</CarouselArrow>
)}

<CarouselViewport>
<CarouselItems translateX={currentIndex * (itemWidth + gap)} id="carousel-items">
{children.map((child, index) => (
<CarouselItem key={index} itemWidth={itemWidth} gap={gap}>
{child}
</CarouselItem>
))}
</CarouselItems>
</CarouselViewport>

{currentIndex < maxIndex && (
<CarouselArrow position="right" onClick={handleNext} aria-label="Next Slide">
<IconChevronRight size={18} />
</CarouselArrow>
)}
</CarouselContainer>
)
}
Loading

0 comments on commit 61215ea

Please sign in to comment.