diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4388ebb4..0041bd4a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -27,7 +27,8 @@ "Bash(timeout 30 pnpm exec tsc --noEmit --pretty)", "Bash(identify:*)", "Bash(sips:*)", - "Bash(awk:*)" + "Bash(awk:*)", + "Bash(pnpm dlx:*)" ], "deny": [] } diff --git a/app/admin/stats/components/TransactionsTable.tsx b/app/admin/stats/components/TransactionsTable.tsx index 050d5eb0..210601be 100644 --- a/app/admin/stats/components/TransactionsTable.tsx +++ b/app/admin/stats/components/TransactionsTable.tsx @@ -1,10 +1,10 @@ import React, { useState, useMemo } from 'react'; import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; +import { TablePagination } from '@/components/common/TablePagination'; import { SupportedNetworks } from '@/utils/networks'; import { Transaction } from '@/utils/statsUtils'; import { findToken } from '@/utils/tokens'; import { Market } from '@/utils/types'; -import { Pagination } from '../../../markets/components/Pagination'; import { TransactionTableBody } from './TransactionTableBody'; type TransactionsTableProps = { @@ -241,12 +241,13 @@ export function TransactionsTable({ />
- 0} + isLoading={false} />
diff --git a/app/global.css b/app/global.css index 1fbb0b3b..1144cfa7 100644 --- a/app/global.css +++ b/app/global.css @@ -5,6 +5,15 @@ @tailwind components; @tailwind utilities; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + :root { --quick-nav-display: none; --component-highlights-item-width: calc(100vw - 100px); @@ -23,6 +32,28 @@ --color-text: #16181a; --color-text-secondary: #8e8e8e; + /* shadcn variables */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 14 100% 57%; /* Morpho orange #f45f2d */ + --primary-foreground: 0 0% 100%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 100%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 14 100% 57%; + --radius: 0.5rem; + color-scheme: light; } @@ -43,6 +74,27 @@ --color-text: #fff; --color-text-secondary: #8e8e8e; + /* shadcn dark variables */ + --background: 222.2 84% 4.9%; + --foreground: 0 0% 100%; + --card: 222.2 84% 4.9%; + --card-foreground: 0 0% 100%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 0 0% 100%; + --primary: 14 100% 57%; /* Morpho orange #f45f2d */ + --primary-foreground: 0 0% 100%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 0 0% 100%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 0 0% 100%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 100%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 14 100% 57%; + color-scheme: dark; } diff --git a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx index 19f7c37f..0a3bfb27 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx @@ -1,49 +1,57 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { - Pagination, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, + Tooltip, } from '@heroui/react'; import moment from 'moment'; +import { FiFilter } from 'react-icons/fi'; import { Address } from 'viem'; import { formatUnits } from 'viem'; +import { Button } from '@/components/common'; import { AccountIdentity } from '@/components/common/AccountIdentity'; import { Badge } from '@/components/common/Badge'; +import { Spinner } from '@/components/common/Spinner'; +import { TablePagination } from '@/components/common/TablePagination'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; +import { TooltipContent } from '@/components/TooltipContent'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketBorrows } from '@/hooks/useMarketBorrows'; +import { formatSimple } from '@/utils/balance'; import { Market } from '@/utils/types'; type BorrowsTableProps = { chainId: number; market: Market; + minAssets: string; + onOpenFiltersModal: () => void; }; -export function BorrowsTable({ chainId, market }: BorrowsTableProps) { +export function BorrowsTable({ chainId, market, minAssets, onOpenFiltersModal }: BorrowsTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; const { - data: borrows, + data: paginatedData, isLoading, + isFetching, error, - } = useMarketBorrows(market?.uniqueKey, market.loanAsset.id, chainId); + } = useMarketBorrows(market?.uniqueKey, market.loanAsset.id, chainId, minAssets, currentPage, pageSize); - const totalPages = Math.ceil((borrows ?? []).length / pageSize); + const borrows = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; - const paginatedBorrows = useMemo(() => { - const sliced = (borrows ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); - return sliced; - }, [currentPage, borrows, pageSize]); - + const hasActiveFilter = minAssets !== '0'; const tableKey = `borrows-table-${currentPage}`; if (error) { @@ -56,80 +64,117 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { return (
-

Borrow & Repay

- - 1 ? ( -
- +

Borrow & Repay

+
+ } + /> + } + > +
- ) : null - } - > - - USER - TYPE - AMOUNT - TIME - - TRANSACTION - - - + +
+ + +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + +
- {paginatedBorrows.map((borrow) => ( - - - - - - - {borrow.type === 'MarketBorrow' ? 'Borrow' : 'Repay'} - - - - {formatUnits(BigInt(borrow.amount), market.loanAsset.decimals)} - {market?.loanAsset?.symbol && ( - - - - )} - - {moment.unix(borrow.timestamp).fromNow()} - - - - - ))} - -
+ + ACCOUNT + TYPE + AMOUNT + TIME + + TRANSACTION + + + + {borrows.map((borrow) => ( + + + + + + + {borrow.type === 'MarketBorrow' ? 'Borrow' : 'Repay'} + + + + {formatSimple(Number(formatUnits(BigInt(borrow.amount), market.loanAsset.decimals)))} + {market?.loanAsset?.symbol && ( + + + + )} + + {moment.unix(borrow.timestamp).fromNow()} + + + + + ))} + + +
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index e6a6097a..dc3b5665 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from 'react'; import { - Pagination, Table, TableHeader, TableBody, @@ -11,6 +10,8 @@ import { import moment from 'moment'; import { Address, formatUnits } from 'viem'; import { AccountIdentity } from '@/components/common/AccountIdentity'; +import { Spinner } from '@/components/common/Spinner'; +import { TablePagination } from '@/components/common/TablePagination'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; import { useMarketLiquidations } from '@/hooks/useMarketLiquidations'; @@ -31,7 +32,8 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { error, } = useMarketLiquidations(market?.uniqueKey, chainId); - const totalPages = Math.ceil((liquidations ?? []).length / pageSize); + const totalCount = (liquidations ?? []).length; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); @@ -56,28 +58,22 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) {

Liquidations

- 1 ? ( -
- -
- ) : null - } - > +
+ {/* Loading overlay */} + {isLoading && ( +
+ +
+ )} + +
LIQUIDATOR REPAID ({market?.loanAsset?.symbol ?? 'Loan'}) @@ -169,6 +165,18 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { })}
+
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx index f8dbd675..f488dfee 100644 --- a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx +++ b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx @@ -1,79 +1,114 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { - Pagination, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, + Tooltip, } from '@heroui/react'; import moment from 'moment'; +import { FiFilter } from 'react-icons/fi'; import { Address } from 'viem'; import { formatUnits } from 'viem'; +import { Button } from '@/components/common'; import { AccountIdentity } from '@/components/common/AccountIdentity'; import { Badge } from '@/components/common/Badge'; +import { Spinner } from '@/components/common/Spinner'; +import { TablePagination } from '@/components/common/TablePagination'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; +import { TooltipContent } from '@/components/TooltipContent'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; import useMarketSupplies from '@/hooks/useMarketSupplies'; +import { formatSimple } from '@/utils/balance'; import { Market } from '@/utils/types'; type SuppliesTableProps = { chainId: number; market: Market; + minAssets: string; + onOpenFiltersModal: () => void; }; -export function SuppliesTable({ chainId, market }: SuppliesTableProps) { +export function SuppliesTable({ chainId, market, minAssets, onOpenFiltersModal }: SuppliesTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; - const { data: supplies, isLoading } = useMarketSupplies( + const { data: paginatedData, isLoading, isFetching } = useMarketSupplies( market?.uniqueKey, market.loanAsset.id, chainId, + minAssets, + currentPage, + pageSize, ); - const totalPages = Math.ceil((supplies ?? []).length / pageSize); + const supplies = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; - const paginatedSupplies = useMemo(() => { - const sliced = (supplies ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); - return sliced; - }, [currentPage, supplies, pageSize]); - + const hasActiveFilter = minAssets !== '0'; const tableKey = `supplies-table-${currentPage}`; return (
-

Supply & Withdraw

- - 1 ? ( -
- +

Supply & Withdraw

+
+ } /> -
- ) : null - } - > + } + > + + +
+ + +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + +
- USER + ACCOUNT TYPE AMOUNT TIME @@ -86,8 +121,8 @@ export function SuppliesTable({ chainId, market }: SuppliesTableProps) { emptyContent={isLoading ? 'Loading...' : 'No supply activities found for this market'} isLoading={isLoading} > - {paginatedSupplies.map((supply) => ( - + {supplies.map((supply) => ( + - {formatUnits(BigInt(supply.amount), market.loanAsset.decimals)} + {formatSimple(Number(formatUnits(BigInt(supply.amount), market.loanAsset.decimals)))} {market?.loanAsset?.symbol && (
+
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx b/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx new file mode 100644 index 00000000..c33847f0 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { Input, Divider } from '@heroui/react'; +import { FiSliders } from 'react-icons/fi'; +import { Button } from '@/components/common'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; + +type TransactionFiltersModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + minSupplyAmount: string; + minBorrowAmount: string; + onMinSupplyChange: (value: string) => void; + onMinBorrowChange: (value: string) => void; + loanAssetSymbol: string; +}; + +function SettingItem({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +export default function TransactionFiltersModal({ + isOpen, + onOpenChange, + minSupplyAmount, + minBorrowAmount, + onMinSupplyChange, + onMinBorrowChange, + loanAssetSymbol, +}: TransactionFiltersModalProps) { + const handleSupplyChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Allow decimals and empty string + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onMinSupplyChange(value); + } + }; + + const handleBorrowChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Allow decimals and empty string + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onMinBorrowChange(value); + } + }; + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Minimum Amounts

+

+ Filter transactions to show only those above the specified minimum amount. +

+ + + + + + + +
+
+ + + + + )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 502f0a85..5d50b451 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -8,7 +8,7 @@ import { ExternalLinkIcon, ChevronLeftIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import Link from 'next/link'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { formatUnits } from 'viem'; +import { formatUnits, parseUnits } from 'viem'; import { useAccount } from 'wagmi'; import { BorrowModal } from '@/components/BorrowModal'; import { Button } from '@/components/common'; @@ -20,6 +20,7 @@ import { TokenIcon } from '@/components/TokenIcon'; import { useMarketData } from '@/hooks/useMarketData'; import { useMarketHistoricalData } from '@/hooks/useMarketHistoricalData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; +import { useTransactionFilters } from '@/hooks/useTransactionFilters'; import useUserPositions from '@/hooks/useUserPosition'; import MORPHO_LOGO from '@/imgs/tokens/morpho.svg'; import { getExplorerURL, getMarketURL } from '@/utils/external'; @@ -32,6 +33,7 @@ import { CampaignBadge } from './components/CampaignBadge'; import { LiquidationsTable } from './components/LiquidationsTable'; import { PositionStats } from './components/PositionStats'; import { SuppliesTable } from './components/SuppliesTable'; +import TransactionFiltersModal from './components/TransactionFiltersModal'; import RateChart from './RateChart'; import VolumeChart from './VolumeChart'; @@ -80,6 +82,7 @@ function MarketContent() { ); const [volumeView, setVolumeView] = useState<'USD' | 'Asset'>('Asset'); const [isRefreshing, setIsRefreshing] = useState(false); + const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); // 4. Data fetching hooks - use unified time range const { @@ -89,6 +92,14 @@ function MarketContent() { refetch: refetchMarket, } = useMarketData(marketid as string, network); + // Transaction filters with localStorage persistence (per symbol) + const { + minSupplyAmount, + minBorrowAmount, + setMinSupplyAmount, + setMinBorrowAmount, + } = useTransactionFilters(market?.loanAsset?.symbol ?? ''); + const { data: historicalData, isLoading: isHistoricalLoading, @@ -118,6 +129,29 @@ function MarketContent() { return formatUnits(adjusted, 36); }, [oraclePrice, market]); + // convert to token amounts + const scaledMinSupplyAmount = useMemo(() => { + if (!market || !minSupplyAmount || minSupplyAmount === '0' || minSupplyAmount === '') { + return '0'; + } + try { + return parseUnits(minSupplyAmount, market.loanAsset.decimals).toString(); + } catch { + return '0'; + } + }, [minSupplyAmount, market]); + + const scaledMinBorrowAmount = useMemo(() => { + if (!market || !minBorrowAmount || minBorrowAmount === '0' || minBorrowAmount === '') { + return '0'; + } + try { + return parseUnits(minBorrowAmount, market.loanAsset.decimals).toString(); + } catch { + return '0'; + } + }, [minBorrowAmount, market]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -233,6 +267,18 @@ function MarketContent() { /> )} + {showTransactionFiltersModal && ( + + )} +

{market.loanAsset.symbol}/{market.collateralAsset.symbol} Market @@ -372,7 +418,7 @@ function MarketContent() { />

-

Volume

+

Volume

-

Rates

+

Rates

-

Activities

+

Activities

{/* divider */}
- - + setShowTransactionFiltersModal(true)} + /> + setShowTransactionFiltersModal(true)} + />
diff --git a/app/markets/components/Pagination.tsx b/app/markets/components/Pagination.tsx deleted file mode 100644 index d1bb035b..00000000 --- a/app/markets/components/Pagination.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Pagination as NextUIPagination } from '@heroui/react'; - -type PaginationProps = { - totalPages: number; - currentPage: number; - onPageChange: (page: number) => void; - entriesPerPage: number; - isDataLoaded: boolean; - size?: "lg"| "md" |"sm" -}; - -export function Pagination({ - totalPages, - currentPage, - onPageChange, - entriesPerPage, - isDataLoaded, - size = "md" -}: PaginationProps) { - if (!isDataLoaded || totalPages === 0) { - return null; - } - - return ( -
-
- -
-
- ); -} diff --git a/app/markets/components/marketsTable.tsx b/app/markets/components/marketsTable.tsx index 4c04ef29..1b37ea1e 100644 --- a/app/markets/components/marketsTable.tsx +++ b/app/markets/components/marketsTable.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { FaRegStar, FaStar } from 'react-icons/fa'; +import { TablePagination } from '@/components/common/TablePagination'; import { type TrustedVault } from '@/constants/vaults/known_vaults'; import { Market } from '@/utils/types'; import { buildTrustedVaultMap } from '@/utils/vaults'; @@ -7,7 +8,6 @@ import { ColumnVisibility } from './columnVisibility'; import { SortColumn } from './constants'; import { MarketTableBody } from './MarketTableBody'; import { HTSortable } from './MarketTableUtils'; -import { Pagination } from './Pagination'; type MarketsTableProps = { sortColumn: number; @@ -64,7 +64,7 @@ function MarketsTable({ const totalPages = Math.ceil(markets.length / entriesPerPage); - const containerClassName = ['flex flex-col gap-4 pb-4', className] + const containerClassName = ['flex flex-col gap-2 pb-4', className] .filter((value): value is string => Boolean(value)) .join(' '); const tableWrapperClassName = ['overflow-x-auto', wrapperClassName] @@ -205,12 +205,14 @@ function MarketsTable({ /> - 0} + isLoading={false} + showEntryCount={false} /> ); diff --git a/components.json b/components.json new file mode 100644 index 00000000..d3d7e306 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/global.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/docs/Styling.md b/docs/Styling.md index 5b007b21..90cbd7b5 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -437,6 +437,80 @@ Add an action link (like explorer) in the top-right corner: - Render token avatars with `TokenIcon` (`@/components/TokenIcon`) so chain-specific fallbacks, glyph sizing, and tooltips stay consistent. - Display oracle provenance data with `OracleVendorBadge` (`@/components/OracleVendorBadge`) instead of plain text to benefit from vendor icons, warnings, and tooltips. +### TablePagination Component + +**TablePagination** (`@/components/common/TablePagination`) +- Unified pagination component for all tables in the app +- Provides consistent styling, smart page numbers with ellipsis, and jump-to-page functionality +- All text uses `font-zen !font-normal` (no bold styling) + +**Features:** +- Smart page numbers with ellipsis for large page counts (shows 7 pages max) +- Jump-to-page search icon (appears when >10 pages) +- Optional entry count display ("Showing X-Y of Z entries") +- Loading states with disabled buttons +- Rounded-md styling with bg-surface +- Tighter spacing (gap-2) when used in layouts + +**Props:** +- `currentPage`: Current active page (1-indexed) +- `totalPages`: Total number of pages +- `totalEntries`: Total number of items across all pages +- `pageSize`: Number of items per page +- `onPageChange`: Callback when page changes +- `isLoading?`: Show loading state (default: false) +- `showEntryCount?`: Display entry count below controls (default: true) + +**Usage Examples:** + +```tsx +import { TablePagination } from '@/components/common/TablePagination'; + +// Basic usage with entry count + + +// Without entry count (e.g., main markets table) + + +// In a table layout with tighter spacing +
+ + {/* table content */} +
+ +
+``` + +**Styling Notes:** +- Uses `font-zen !font-normal` throughout (overrides button's default font-medium) +- All buttons have consistent 8px height (h-8) +- Rounded-md container with bg-surface and shadow-sm +- Primary color for active page button +- Jump-to-page popover with Input and Go button +- Entry count uses text-xs text-secondary + ### Account Identity Component **AccountIdentity** (`@/components/common/AccountIdentity`) diff --git a/package.json b/package.json index fe7e69eb..10532995 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-slot": "^1.2.4", "@rainbow-me/rainbowkit": "2", "@react-aria/i18n": "^3.12.11", "@react-spring/web": "^9.7.3", @@ -45,6 +46,7 @@ "@uniswap/permit2-sdk": "^1.2.1", "abitype": "^0.10.3", "animejs": "^4.2.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "downshift": "^9.0.8", "framer-motion": "^11.2.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41402378..23e288d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@radix-ui/react-navigation-menu': specifier: ^1.1.4 version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@18.3.23)(react@18.3.1) '@rainbow-me/rainbowkit': specifier: '2' version: 2.2.8(@tanstack/react-query@5.85.3(react@18.3.1))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)(viem@2.40.2(bufferutil@4.0.9)(typescript@5.6.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.16.4(@tanstack/query-core@5.85.3)(@tanstack/react-query@5.85.3(react@18.3.1))(@types/react@18.3.23)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.6.3)(utf-8-validate@5.0.10)(viem@2.40.2(bufferutil@4.0.9)(typescript@5.6.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)) @@ -83,6 +86,9 @@ importers: animejs: specifier: ^4.2.2 version: 4.2.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 clsx: specifier: ^2.1.0 version: 2.1.1 @@ -2707,6 +2713,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -4248,6 +4263,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -11194,6 +11212,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + '@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)': dependencies: react: 18.3.1 @@ -13842,6 +13867,10 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + client-only@0.0.1: {} cliui@6.0.0: diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index 38dcfa2b..44eb7b52 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -8,6 +8,7 @@ import { IoHelpCircleOutline } from 'react-icons/io5'; import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common'; import { SuppliedAssetFilterCompactSwitch } from '@/components/common/SuppliedAssetFilterCompactSwitch'; +import { TablePagination } from '@/components/common/TablePagination'; import { useTokens } from '@/components/providers/TokenProvider'; import TrustedVaultsModal from '@/components/settings/TrustedVaultsModal'; import { TrustedByCell } from '@/components/vaults/TrustedVaultBadges'; @@ -27,7 +28,6 @@ import { Market } from '@/utils/types'; import { buildTrustedVaultMap } from '@/utils/vaults'; import { DEFAULT_COLUMN_VISIBILITY, ColumnVisibility } from 'app/markets/components/columnVisibility'; import MarketSettingsModal from 'app/markets/components/MarketSettingsModal'; -import { Pagination } from '../../../app/markets/components/Pagination'; import { MarketIdBadge } from '../MarketIdBadge'; import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; import { MarketIndicators } from '../MarketIndicators'; @@ -1074,13 +1074,13 @@ export function MarketsTableWithSameLoanAsset({ {/* Pagination */} - {/* Settings Modal */} diff --git a/src/components/common/TablePagination.tsx b/src/components/common/TablePagination.tsx new file mode 100644 index 00000000..8c71dcc7 --- /dev/null +++ b/src/components/common/TablePagination.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react'; +import { Input, Popover, PopoverTrigger, PopoverContent, Tooltip } from '@heroui/react'; +import { ChevronLeftIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { TooltipContent } from '@/components/TooltipContent'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type TablePaginationProps = { + currentPage: number; + totalPages: number; + totalEntries: number; + pageSize: number; + onPageChange: (page: number) => void; + isLoading?: boolean; + showEntryCount?: boolean; +}; + +export function TablePagination({ + currentPage, + totalPages, + totalEntries, + pageSize, + onPageChange, + isLoading = false, + showEntryCount = true, +}: TablePaginationProps) { + const [jumpPage, setJumpPage] = useState(''); + const [isJumpOpen, setIsJumpOpen] = useState(false); + + if (totalPages === 0) { + return null; + } + + const startEntry = (currentPage - 1) * pageSize + 1; + const endEntry = Math.min(currentPage * pageSize, totalEntries); + + const handleJumpToPage = () => { + const page = parseInt(jumpPage, 10); + if (page >= 1 && page <= totalPages) { + onPageChange(page); + setJumpPage(''); + setIsJumpOpen(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleJumpToPage(); + } + }; + + // Generate smart page numbers with ellipsis + const getPageNumbers = () => { + const pages: (number | 'ellipsis')[] = []; + const delta = 1; // Number of pages to show on each side of current + + if (totalPages <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + // Calculate range around current page + const start = Math.max(2, currentPage - delta); + const end = Math.min(totalPages - 1, currentPage + delta); + + // Add ellipsis after first page if needed + if (start > 2) { + pages.push('ellipsis'); + } + + // Add pages around current + for (let i = start; i <= end; i++) { + pages.push(i); + } + + // Add ellipsis before last page if needed + if (end < totalPages - 1) { + pages.push('ellipsis'); + } + + // Always show last page + pages.push(totalPages); + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + return ( +
+ {/* Page controls */} +
+ {/* Previous button */} + + + {/* Page numbers */} +
+ {pageNumbers.map((page, idx) => + page === 'ellipsis' ? ( + 1000 ? 'w-10 text-xs' : 'w-8 text-sm' + )}> + ... + + ) : ( + + ), + )} +
+ + {/* Next button */} + + + {/* Jump to page - only show if more than 10 pages */} + {totalPages > 10 && ( +
+ + } + /> + } + > + + + + + +
+

Jump to page

+
+ setJumpPage(e.target.value)} + onKeyPress={handleKeyPress} + className="w-24" + classNames={{ + input: 'text-center rounded-sm px-3', + inputWrapper: 'rounded-sm', + }} + /> + +
+
+
+
+
+ )} +
+ + {/* Entry count - below controls */} + {showEntryCount && ( +
+ Showing {startEntry}-{endEntry} of {totalEntries} entries +
+ )} +
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 00000000..1259983d --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 px-3 text-xs", + lg: "h-10 px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export type ButtonProps = { + asChild?: boolean +} & React.ButtonHTMLAttributes & VariantProps + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 00000000..063a9da0 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,119 @@ +import * as React from "react" +import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { ButtonProps, buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return