diff --git a/apps/insights/package.json b/apps/insights/package.json index a3c34f9500..48643cda90 100644 --- a/apps/insights/package.json +++ b/apps/insights/package.json @@ -44,6 +44,7 @@ "react-aria-components": "catalog:", "react-dom": "catalog:", "recharts": "catalog:", + "superjson": "catalog:", "swr": "catalog:", "zod": "catalog:" }, diff --git a/apps/insights/src/app/price-feeds/[slug]/layout.ts b/apps/insights/src/app/price-feeds/[slug]/layout.ts index 3bc3a95f54..586c20005d 100644 --- a/apps/insights/src/app/price-feeds/[slug]/layout.ts +++ b/apps/insights/src/app/price-feeds/[slug]/layout.ts @@ -1,6 +1,6 @@ import type { Metadata } from "next"; -import { client } from "../../../services/pyth"; +import { getData } from "../../../services/pyth"; export { PriceFeedLayout as default } from "../../../components/PriceFeed/layout"; export const metadata: Metadata = { @@ -8,6 +8,6 @@ export const metadata: Metadata = { }; export const generateStaticParams = async () => { - const data = await client.getData(); - return data.symbols.map((symbol) => ({ slug: encodeURIComponent(symbol) })); + const data = await getData(); + return data.map(({ symbol }) => ({ slug: encodeURIComponent(symbol) })); }; diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx new file mode 100644 index 0000000000..7fa2197792 --- /dev/null +++ b/apps/insights/src/app/price-feeds/[slug]/price-components/[componentId]/page.tsx @@ -0,0 +1,11 @@ +type Props = { + params: Promise<{ + componentId: string; + }>; +}; + +const PriceFeedComponent = async ({ params }: Props) => { + const { componentId } = await params; + return componentId; +}; +export default PriceFeedComponent; diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx new file mode 100644 index 0000000000..cd1074e5ff --- /dev/null +++ b/apps/insights/src/app/price-feeds/[slug]/price-components/layout.tsx @@ -0,0 +1 @@ +export { PriceComponents as default } from "../../../../components/PriceFeed/price-components"; diff --git a/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx b/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx index cd1074e5ff..657fc7d676 100644 --- a/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx +++ b/apps/insights/src/app/price-feeds/[slug]/price-components/page.tsx @@ -1 +1,3 @@ -export { PriceComponents as default } from "../../../../components/PriceFeed/price-components"; +// eslint-disable-next-line unicorn/no-null +const Page = () => null; +export default Page; diff --git a/apps/insights/src/cache.ts b/apps/insights/src/cache.ts new file mode 100644 index 0000000000..2e48aefae8 --- /dev/null +++ b/apps/insights/src/cache.ts @@ -0,0 +1,16 @@ +import { unstable_cache } from "next/cache"; +import { parse, stringify } from "superjson"; + +export const cache = ( + fn: (...params: P) => Promise, + keys?: Parameters[1], + opts?: Parameters[2], +) => { + const cachedFn = unstable_cache( + async (params: P): Promise => stringify(await fn(...params)), + keys, + opts, + ); + + return async (...params: P): Promise => parse(await cachedFn(params)); +}; diff --git a/apps/insights/src/components/CopyButton/index.tsx b/apps/insights/src/components/CopyButton/index.tsx index 0def672482..3a38047103 100644 --- a/apps/insights/src/components/CopyButton/index.tsx +++ b/apps/insights/src/components/CopyButton/index.tsx @@ -3,9 +3,12 @@ import { Check } from "@phosphor-icons/react/dist/ssr/Check"; import { Copy } from "@phosphor-icons/react/dist/ssr/Copy"; import { useLogger } from "@pythnetwork/app-logger"; -import { Button } from "@pythnetwork/component-library/Button"; +import { + type Props as ButtonProps, + Button, +} from "@pythnetwork/component-library/Button"; import clsx from "clsx"; -import { type ComponentProps, useCallback, useEffect, useState } from "react"; +import { type ElementType, useCallback, useEffect, useState } from "react"; import styles from "./index.module.scss"; @@ -13,13 +16,18 @@ type OwnProps = { text: string; }; -type Props = Omit< - ComponentProps, +type Props = Omit< + ButtonProps, keyof OwnProps | "onPress" | "afterIcon" > & OwnProps; -export const CopyButton = ({ text, children, className, ...props }: Props) => { +export const CopyButton = ({ + text, + children, + className, + ...props +}: Props) => { const [isCopied, setIsCopied] = useState(false); const logger = useLogger(); const copy = useCallback(() => { diff --git a/apps/insights/src/components/FeedKey/index.tsx b/apps/insights/src/components/FeedKey/index.tsx index fc3215596c..ec89d90665 100644 --- a/apps/insights/src/components/FeedKey/index.tsx +++ b/apps/insights/src/components/FeedKey/index.tsx @@ -1,6 +1,6 @@ -import base58 from "bs58"; import { useMemo, type ComponentProps } from "react"; +import { toHex, truncateHex } from "../../hex"; import { CopyButton } from "../CopyButton"; type OwnProps = { @@ -22,10 +22,7 @@ export const FeedKey = ({ feed, ...props }: Props) => { () => toHex(feed.product.price_account), [feed.product.price_account], ); - const truncatedKey = useMemo( - () => toTruncatedHex(feed.product.price_account), - [feed.product.price_account], - ); + const truncatedKey = useMemo(() => truncateHex(key), [key]); return ( @@ -33,13 +30,3 @@ export const FeedKey = ({ feed, ...props }: Props) => { ); }; - -const toHex = (value: string) => toHexString(base58.decode(value)); - -const toTruncatedHex = (value: string) => { - const hex = toHex(value); - return `${hex.slice(0, 6)}...${hex.slice(-4)}`; -}; - -const toHexString = (byteArray: Uint8Array) => - `0x${Array.from(byteArray, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; diff --git a/apps/insights/src/components/FormattedNumber/index.tsx b/apps/insights/src/components/FormattedNumber/index.tsx new file mode 100644 index 0000000000..3fadfd2950 --- /dev/null +++ b/apps/insights/src/components/FormattedNumber/index.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useMemo } from "react"; +import { useNumberFormatter } from "react-aria"; + +type Props = Parameters[0] & { + value: number; +}; + +export const FormattedNumber = ({ value, ...args }: Props) => { + const numberFormatter = useNumberFormatter(args); + return useMemo(() => numberFormatter.format(value), [numberFormatter, value]); +}; diff --git a/apps/insights/src/components/LayoutTransition/index.tsx b/apps/insights/src/components/LayoutTransition/index.tsx index 6883a8cf73..65910a308e 100644 --- a/apps/insights/src/components/LayoutTransition/index.tsx +++ b/apps/insights/src/components/LayoutTransition/index.tsx @@ -53,7 +53,7 @@ export const LayoutTransition = ({ children, ...props }: Props) => { return ( (

Not Found

{"The page you're looking for isn't here"}

- Go Home +
); diff --git a/apps/insights/src/components/PriceFeed/layout.tsx b/apps/insights/src/components/PriceFeed/layout.tsx index 97002a6ab8..4dd992d762 100644 --- a/apps/insights/src/components/PriceFeed/layout.tsx +++ b/apps/insights/src/components/PriceFeed/layout.tsx @@ -4,20 +4,26 @@ import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes"; import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert"; import { Badge } from "@pythnetwork/component-library/Badge"; import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs"; -import { Button, ButtonLink } from "@pythnetwork/component-library/Button"; +import { Button } from "@pythnetwork/component-library/Button"; import { Drawer, DrawerTrigger } from "@pythnetwork/component-library/Drawer"; import { StatCard } from "@pythnetwork/component-library/StatCard"; +import { notFound } from "next/navigation"; import type { ReactNode } from "react"; -import { z } from "zod"; import styles from "./layout.module.scss"; +import { PriceFeedSelect } from "./price-feed-select"; import { ReferenceData } from "./reference-data"; import { TabPanel, TabRoot, Tabs } from "./tabs"; -import { client } from "../../services/pyth"; +import { toHex } from "../../hex"; +import { getData } from "../../services/pyth"; import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent"; import { FeedKey } from "../FeedKey"; -import { LivePrice, LiveConfidence, LiveLastUpdated } from "../LivePrices"; -import { NotFound } from "../NotFound"; +import { + LivePrice, + LiveConfidence, + LiveLastUpdated, + LiveValue, +} from "../LivePrices"; import { PriceFeedTag } from "../PriceFeedTag"; type Props = { @@ -28,8 +34,9 @@ type Props = { }; export const PriceFeedLayout = async ({ children, params }: Props) => { - const { slug } = await params; - const feed = await getPriceFeed(decodeURIComponent(slug)); + const [{ slug }, data] = await Promise.all([params, getData()]); + const symbol = decodeURIComponent(slug); + const feed = data.find((item) => item.symbol === symbol); return feed ? (
@@ -50,7 +57,24 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
- + feed.symbol !== symbol) + .map((feed) => ({ + id: encodeURIComponent(feed.symbol), + key: toHex(feed.product.price_account), + displaySymbol: feed.product.display_symbol, + name: , + assetClassText: feed.product.asset_type, + assetClass: ( + + {feed.product.asset_type.toUpperCase()} + + ), + }))} + > + +
{ of the confidence of individual quoters and how well individual quoters agree with each other.

- Learn more - + } @@ -134,7 +158,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
Price Components - {feed.price.numComponentPrices} +
), @@ -145,49 +169,6 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
) : ( - + notFound() ); }; - -const getPriceFeed = async (symbol: string) => { - const data = await client.getData(); - const priceFeeds = priceFeedsSchema.parse( - data.symbols.map((symbol) => ({ - symbol, - product: data.productFromSymbol.get(symbol), - price: data.productPrice.get(symbol), - })), - ); - return priceFeeds.find((feed) => feed.symbol === symbol); -}; - -const priceFeedsSchema = z.array( - z.object({ - symbol: z.string(), - product: z.object({ - display_symbol: z.string(), - asset_type: z.string(), - description: z.string(), - price_account: z.string(), - base: z.string().optional(), - country: z.string().optional(), - quote_currency: z.string().optional(), - tenor: z.string().optional(), - cms_symbol: z.string().optional(), - cqs_symbol: z.string().optional(), - nasdaq_symbol: z.string().optional(), - generic_symbol: z.string().optional(), - weekly_schedule: z.string().optional(), - schedule: z.string().optional(), - contract_id: z.string().optional(), - }), - price: z.object({ - exponent: z.number(), - numComponentPrices: z.number(), - numQuoters: z.number(), - minPublishers: z.number(), - lastSlot: z.bigint(), - validSlot: z.bigint(), - }), - }), -); diff --git a/apps/insights/src/components/PriceFeed/price-component-drawer.tsx b/apps/insights/src/components/PriceFeed/price-component-drawer.tsx new file mode 100644 index 0000000000..a17b6595a9 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/price-component-drawer.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Drawer } from "@pythnetwork/component-library/Drawer"; +import { + useSelectedLayoutSegment, + usePathname, + useRouter, +} from "next/navigation"; +import { type ReactNode, useMemo, useCallback } from "react"; + +type Props = { + children: ReactNode; +}; + +export const PriceComponentDrawer = ({ children }: Props) => { + const pathname = usePathname(); + const segment = useSelectedLayoutSegment(); + const prevUrl = useMemo( + () => + segment ? pathname.replace(new RegExp(`/${segment}$`), "") : pathname, + [pathname, segment], + ); + const router = useRouter(); + + const onOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + router.push(prevUrl); + } + }, + [router, prevUrl], + ); + + return ( + + {children} + + ); +}; diff --git a/apps/insights/src/components/PriceFeed/price-components-card.tsx b/apps/insights/src/components/PriceFeed/price-components-card.tsx new file mode 100644 index 0000000000..af5296eb6b --- /dev/null +++ b/apps/insights/src/components/PriceFeed/price-components-card.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { Card } from "@pythnetwork/component-library/Card"; +import { Paginator } from "@pythnetwork/component-library/Paginator"; +import { type RowConfig, Table } from "@pythnetwork/component-library/Table"; +import { type ReactNode, Suspense, useMemo } from "react"; +import { useFilter } from "react-aria"; + +import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; + +type Props = { + className?: string | undefined; + priceComponents: PriceComponent[]; + nameLoadingSkeleton: ReactNode; + scoreLoadingSkeleton: ReactNode; + scoreWidth: number; + slug: string; +}; + +type PriceComponent = { + id: string; + publisherNameAsString: string | undefined; + score: ReactNode; + name: ReactNode; + uptimeScore: ReactNode; + deviationPenalty: ReactNode; + deviationScore: ReactNode; + stalledPenalty: ReactNode; + stalledScore: ReactNode; +}; + +export const PriceComponentsCard = ({ + priceComponents, + slug, + ...props +}: Props) => ( + }> + + +); + +const ResolvedPriceComponentsCard = ({ + priceComponents, + slug, + ...props +}: Props) => { + const filter = useFilter({ sensitivity: "base", usage: "search" }); + const { + search, + page, + pageSize, + updateSearch, + updatePage, + updatePageSize, + paginatedItems, + numResults, + numPages, + mkPageLink, + } = useQueryParamFilterPagination( + priceComponents, + (priceComponent, search) => + filter.contains(priceComponent.id, search) || + (priceComponent.publisherNameAsString !== undefined && + filter.contains(priceComponent.publisherNameAsString, search)), + { defaultPageSize: 20 }, + ); + + const rows = useMemo( + () => + paginatedItems.map(({ id, ...data }) => ({ + id, + href: `/price-feeds/${slug}/price-components/${id}`, + data, + })), + [paginatedItems, slug], + ); + + return ( + + ); +}; + +type PriceComponentsCardProps = Pick< + Props, + "className" | "nameLoadingSkeleton" | "scoreLoadingSkeleton" | "scoreWidth" +> & + ( + | { isLoading: true } + | { + isLoading?: false; + numResults: number; + search: string; + numPages: number; + page: number; + pageSize: number; + onSearchChange: (newSearch: string) => void; + onPageSizeChange: (newPageSize: number) => void; + onPageChange: (newPage: number) => void; + mkPageLink: (page: number) => string; + rows: RowConfig< + | "score" + | "name" + | "uptimeScore" + | "deviationScore" + | "deviationPenalty" + | "stalledScore" + | "stalledPenalty" + >[]; + } + ); + +const PriceComponentsCardContents = ({ + className, + scoreWidth, + scoreLoadingSkeleton, + nameLoadingSkeleton, + ...props +}: PriceComponentsCardProps) => ( + + ), + })} + > + + +); diff --git a/apps/insights/src/components/PriceFeed/price-components.module.scss b/apps/insights/src/components/PriceFeed/price-components.module.scss new file mode 100644 index 0000000000..1c329ac5e9 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/price-components.module.scss @@ -0,0 +1,8 @@ +@use "@pythnetwork/component-library/theme"; + +.publisherName { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: theme.spacing(6); +} diff --git a/apps/insights/src/components/PriceFeed/price-components.tsx b/apps/insights/src/components/PriceFeed/price-components.tsx index 6cd9d818a5..f1cbd173d5 100644 --- a/apps/insights/src/components/PriceFeed/price-components.tsx +++ b/apps/insights/src/components/PriceFeed/price-components.tsx @@ -1 +1,91 @@ -export const PriceComponents = () =>

Price Components

; +import { Badge } from "@pythnetwork/component-library/Badge"; +import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; + +import { PriceComponentDrawer } from "./price-component-drawer"; +import { PriceComponentsCard } from "./price-components-card"; +import styles from "./price-components.module.scss"; +import { getRankings } from "../../services/clickhouse"; +import { getData } from "../../services/pyth"; +import { FormattedNumber } from "../FormattedNumber"; +import { PublisherTag } from "../PublisherTag"; +import { Score } from "../Score"; + +const PUBLISHER_SCORE_WIDTH = 24; + +type Props = { + children: ReactNode; + params: Promise<{ + slug: string; + }>; +}; + +export const PriceComponents = async ({ children, params }: Props) => { + const { slug } = await params; + const symbol = decodeURIComponent(slug); + const [data, rankings] = await Promise.all([getData(), getRankings(symbol)]); + const feed = data.find((feed) => feed.symbol === symbol); + + return feed ? ( + <> + ({ + id: ranking.publisher, + publisherNameAsString: lookupPublisher(ranking.publisher)?.name, + score: ( + + ), + name: ( +
+ + {ranking.cluster === "pythtest-conformance" && ( + + test + + )} +
+ ), + uptimeScore: ( + + ), + deviationPenalty: ranking.deviation_penalty ? ( + + ) : // eslint-disable-next-line unicorn/no-null + null, + deviationScore: ( + + ), + stalledPenalty: ( + + ), + stalledScore: ( + + ), + }))} + nameLoadingSkeleton={} + scoreLoadingSkeleton={} + scoreWidth={PUBLISHER_SCORE_WIDTH} + /> + {children} + + ) : ( + notFound() + ); +}; diff --git a/apps/insights/src/components/PriceFeed/price-feed-select.module.scss b/apps/insights/src/components/PriceFeed/price-feed-select.module.scss new file mode 100644 index 0000000000..a0fcd9d599 --- /dev/null +++ b/apps/insights/src/components/PriceFeed/price-feed-select.module.scss @@ -0,0 +1,203 @@ +@use "@pythnetwork/component-library/theme"; + +.priceFeedSelect { + .trigger { + background-color: transparent; + border: 1px solid theme.color("button", "outline", "border"); + outline-offset: 0; + outline: theme.spacing(1) solid transparent; + cursor: pointer; + border-radius: theme.border-radius("xl"); + padding: theme.spacing(2); + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: theme.spacing(4); + transition-property: background-color, color, border-color, outline-color; + transition-duration: 100ms; + transition-timing-function: linear; + text-align: left; + + .caret { + width: theme.spacing(8); + height: theme.spacing(8); + transition: transform 300ms ease; + } + + &[data-hovered] { + background-color: theme.color("button", "outline", "background", "hover"); + } + + &[data-pressed] { + background-color: theme.color( + "button", + "outline", + "background", + "active" + ); + } + + &[data-focus-visible] { + border-color: theme.color("focus"); + outline-color: theme.color("focus-dim"); + } + } + + &[data-open] { + .trigger .caret { + transform: rotate(-180deg); + } + } +} + +.popover { + min-width: var(--trigger-width); + background-color: theme.color("background", "modal"); + border-radius: theme.border-radius("lg"); + border: 1px solid theme.color("border"); + color: theme.color("paragraph"); + display: flex; + font-size: theme.font-size("sm"); + box-shadow: + 0 4px 6px -4px rgb(from black r g b / 10%), + 0 10px 15px -3px rgb(from black r g b / 10%); + + .dialog { + display: flex; + flex-flow: column nowrap; + flex-grow: 1; + max-height: theme.spacing(100); + outline: none; + + .searchField { + flex: 0; + z-index: 1; + + .searchInput { + width: 100%; + padding: theme.spacing(3); + border: none; + border-top-left-radius: theme.border-radius("lg"); + border-top-right-radius: theme.border-radius("lg"); + border-bottom: 1px solid theme.color("border"); + outline: theme.spacing(1) solid transparent; + outline-offset: 0; + transition: + border-color 100ms linear, + outline-color 100ms linear, + color 100ms linear; + + &[data-hovered] { + border-color: theme.color("forms", "input", "hover", "border"); + } + + &[data-focused] { + border-color: theme.color("focus"); + } + + &[data-focus-visible] { + outline-color: theme.color("focus-dim"); + } + + &::placeholder { + color: theme.color("button", "disabled", "foreground"); + } + } + } + + .listbox { + outline: none; + overflow: auto; + width: theme.spacing(110); + flex-grow: 1; + + .priceFeed { + padding: theme.spacing(3) theme.spacing(4); + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + width: 100%; + cursor: pointer; + transition: background-color 100ms linear; + outline: none; + text-decoration: none; + border-top: 1px solid theme.color("background", "secondary"); + + &[data-is-first] { + border-top: none; + } + + & > *:last-child { + flex-shrink: 0; + } + + &[data-focused] { + background-color: theme.color( + "button", + "outline", + "background", + "hover" + ); + } + + &[data-pressed] { + background-color: theme.color( + "button", + "outline", + "background", + "active" + ); + } + } + } + } + + &[data-placement="top"] { + --origin: translateY(8px); + --scale: 1, 0.8; + + transform-origin: bottom; + } + + &[data-placement="bottom"] { + --origin: translateY(-8px); + --scale: 1, 0.8; + + transform-origin: top; + } + + &[data-placement="right"] { + --origin: translateX(-8px); + --scale: 0.8, 1; + + transform-origin: left; + } + + &[data-placement="left"] { + --origin: translateX(8px); + --scale: 0.8, 1; + + transform-origin: right; + } + + &[data-entering] { + animation: popover-slide 200ms; + } + + &[data-exiting] { + animation: popover-slide 200ms reverse ease-in; + } +} + +@keyframes popover-slide { + from { + transform: scale(var(--scale)) var(--origin); + opacity: 0; + } + + to { + transform: scale(1, 1) translateY(0); + opacity: 1; + } +} diff --git a/apps/insights/src/components/PriceFeed/price-feed-select.tsx b/apps/insights/src/components/PriceFeed/price-feed-select.tsx new file mode 100644 index 0000000000..99f007cd3a --- /dev/null +++ b/apps/insights/src/components/PriceFeed/price-feed-select.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { DropdownCaretDown } from "@pythnetwork/component-library/DropdownCaretDown"; +import { type ReactNode, useMemo, useState } from "react"; +import { useCollator, useFilter } from "react-aria"; +import { + Select, + Button, + Popover, + Dialog, + ListBox, + ListBoxItem, + UNSTABLE_Virtualizer as Virtualizer, + UNSTABLE_ListLayout as ListLayout, + TextField, + Input, +} from "react-aria-components"; + +import styles from "./price-feed-select.module.scss"; + +type Props = { + children: ReactNode; + feeds: { + id: string; + key: string; + displaySymbol: string; + name: ReactNode; + assetClass: ReactNode; + assetClassText: string; + }[]; +}; + +export const PriceFeedSelect = ({ children, feeds }: Props) => { + const collator = useCollator(); + const filter = useFilter({ sensitivity: "base", usage: "search" }); + const [search, setSearch] = useState(""); + const sortedFeeds = useMemo( + () => + feeds.sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol)), + [feeds, collator], + ); + const filteredFeeds = useMemo( + () => + search === "" + ? sortedFeeds + : sortedFeeds.filter( + (feed) => + filter.contains(feed.displaySymbol, search) || + filter.contains(feed.assetClassText, search) || + filter.contains(feed.key, search), + ), + [sortedFeeds, search, filter], + ); + return ( + + + + + {({ name, assetClass, id, displaySymbol }) => ( + + {name} + {assetClass} + + )} + + + + + + ); +}; diff --git a/apps/insights/src/components/PriceFeedTag/index.tsx b/apps/insights/src/components/PriceFeedTag/index.tsx index 2e09bb4c04..200e5d8121 100644 --- a/apps/insights/src/components/PriceFeedTag/index.tsx +++ b/apps/insights/src/components/PriceFeedTag/index.tsx @@ -8,46 +8,53 @@ import styles from "./index.module.scss"; type OwnProps = { compact?: boolean | undefined; - feed?: - | { +} & ( + | { isLoading: true } + | { + isLoading?: false; + feed: { product: { display_symbol: string; description: string; }; - } - | undefined; -}; + }; + } +); + type Props = Omit, keyof OwnProps> & OwnProps; -export const PriceFeedTag = ({ feed, className, compact, ...props }: Props) => ( +export const PriceFeedTag = ({ className, compact, ...props }: Props) => (
- {feed === undefined ? ( + {props.isLoading ? ( ) : ( - + )}
- {feed === undefined ? ( + {props.isLoading ? (
) : ( )} {!compact && (
- {feed === undefined ? ( + {props.isLoading ? ( ) : ( - feed.product.description.split("/")[0] + props.feed.product.description.split("/")[0] )}
)} diff --git a/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx b/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx index ba8097256d..59d6edd374 100644 --- a/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx +++ b/apps/insights/src/components/PriceFeeds/asset-classes-drawer.tsx @@ -1,5 +1,6 @@ "use client"; +import { useLogger } from "@pythnetwork/app-logger"; import { Badge } from "@pythnetwork/component-library/Badge"; import { CLOSE_DURATION_IN_MS, @@ -8,11 +9,15 @@ import { } from "@pythnetwork/component-library/Drawer"; import { Table } from "@pythnetwork/component-library/Table"; import { usePathname } from "next/navigation"; +import { + parseAsString, + parseAsInteger, + useQueryStates, + createSerializer, +} from "nuqs"; import { type ReactNode, useMemo } from "react"; import { useCollator } from "react-aria"; -import { serialize, useQueryParams } from "./query-params"; - type Props = { numFeedsByAssetClass: Record; children: ReactNode; @@ -38,10 +43,10 @@ export const AssetClassesDrawer = ({ } > - {({ close }) => ( + {({ state }) => ( )} @@ -51,48 +56,59 @@ export const AssetClassesDrawer = ({ type AssetClassTableProps = { numFeedsByAssetClass: Record; - closeDrawer: () => void; + state: { close: () => void }; }; const AssetClassTable = ({ numFeedsByAssetClass, - closeDrawer, + state, }: AssetClassTableProps) => { + const logger = useLogger(); const collator = useCollator(); const pathname = usePathname(); - const { updateAssetClass, updateSearch } = useQueryParams(); + const queryStates = { + page: parseAsInteger.withDefault(1), + search: parseAsString.withDefault(""), + assetClass: parseAsString.withDefault(""), + }; + const serialize = createSerializer(queryStates); + const [, setQuery] = useQueryStates(queryStates); const assetClassRows = useMemo( () => Object.entries(numFeedsByAssetClass) .sort(([a], [b]) => collator.compare(a, b)) - .map(([assetClass, count]) => ({ - id: assetClass, - href: `${pathname}${serialize({ assetClass })}`, - onAction: () => { - closeDrawer(); - setTimeout(() => { - updateAssetClass(assetClass); - updateSearch(""); - }, CLOSE_DURATION_IN_MS); - }, - data: { - assetClass, - count: {count}, - }, - })), + .map(([assetClass, count]) => { + const newQuery = { assetClass, search: "", page: 1 }; + return { + id: assetClass, + href: `${pathname}${serialize(newQuery)}`, + onAction: () => { + state.close(); + setTimeout(() => { + setQuery(newQuery).catch((error: unknown) => { + logger.error("Failed to update query", error); + }); + }, CLOSE_DURATION_IN_MS); + }, + data: { + assetClass, + count: {count}, + }, + }; + }), [ numFeedsByAssetClass, collator, - closeDrawer, + state, pathname, - updateAssetClass, - updateSearch, + setQuery, + serialize, + logger, ], ); return (
{
{ /> } + nameLoadingSkeleton={} priceFeeds={priceFeeds.activeFeeds.map((feed) => ({ symbol: feed.symbol, id: feed.product.price_account, @@ -203,14 +202,7 @@ const FeaturedFeedsCard = ({ ); const getPriceFeeds = async () => { - const data = await client.getData(); - const priceFeeds = priceFeedsSchema.parse( - data.symbols.map((symbol) => ({ - symbol, - product: data.productFromSymbol.get(symbol), - price: data.productPrice.get(symbol), - })), - ); + const priceFeeds = await getData(); const activeFeeds = priceFeeds.filter((feed) => isActive(feed)); const comingSoon = priceFeeds.filter((feed) => !isActive(feed)); return { activeFeeds, comingSoon }; @@ -243,24 +235,6 @@ const filterFeeds = ( const isActive = (feed: { price: { minPublishers: number } }) => feed.price.minPublishers <= 50; -const priceFeedsSchema = z.array( - z.object({ - symbol: z.string(), - product: z.object({ - display_symbol: z.string(), - asset_type: z.string(), - description: z.string(), - price_account: z.string(), - weekly_schedule: z.string().optional(), - }), - price: z.object({ - exponent: z.number(), - numQuoters: z.number(), - minPublishers: z.number(), - }), - }), -); - class NoSuchFeedError extends Error { constructor(symbol: string) { super(`No feed exists named ${symbol}`); diff --git a/apps/insights/src/components/PriceFeeds/layout.tsx b/apps/insights/src/components/PriceFeeds/layout.tsx index 276b191a8a..1474386b37 100644 --- a/apps/insights/src/components/PriceFeeds/layout.tsx +++ b/apps/insights/src/components/PriceFeeds/layout.tsx @@ -13,11 +13,11 @@ export const PriceFeedsLayout = ({ children }: Props) => ( variants={{ initial: (custom) => ({ opacity: 0, - scale: isGoingToIndex(custom) ? 1.04 : 0.96, + scale: getInitialScale(custom), }), exit: (custom) => ({ opacity: 0, - scale: isGoingToIndex(custom) ? 0.96 : 1.04, + scale: getExitScale(custom), transition: { scale: { type: "spring", bounce: 0 }, }, @@ -38,4 +38,22 @@ export const PriceFeedsLayout = ({ children }: Props) => ( ); -const isGoingToIndex = ({ segment }: VariantArg) => segment === null; +const getInitialScale = ({ segment, prevSegment }: VariantArg) => { + if (segment === null) { + return 1.04; + } else if (prevSegment === null) { + return 0.96; + } else { + return 1; + } +}; + +const getExitScale = ({ segment, prevSegment }: VariantArg) => { + if (segment === null) { + return 0.96; + } else if (prevSegment === null) { + return 1.04; + } else { + return 1; + } +}; diff --git a/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx index 09795b473d..c978a9176d 100644 --- a/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx +++ b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx @@ -1,17 +1,18 @@ "use client"; import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine"; +import { useLogger } from "@pythnetwork/app-logger"; import { Badge } from "@pythnetwork/component-library/Badge"; import { Card } from "@pythnetwork/component-library/Card"; import { Paginator } from "@pythnetwork/component-library/Paginator"; import { SearchInput } from "@pythnetwork/component-library/SearchInput"; import { Select } from "@pythnetwork/component-library/Select"; import { type RowConfig, Table } from "@pythnetwork/component-library/Table"; -import { usePathname } from "next/navigation"; +import { useQueryState, parseAsString } from "nuqs"; import { type ReactNode, Suspense, useCallback, useMemo } from "react"; import { useFilter, useCollator } from "react-aria"; -import { serialize, useQueryParams } from "./query-params"; +import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; import { SKELETON_WIDTH } from "../LivePrices"; type Props = { @@ -41,70 +42,70 @@ export const PriceFeedsCard = ({ priceFeeds, ...props }: Props) => ( ); const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { - const { - search, - page, - pageSize, - assetClass, - updateSearch, - updatePage, - updatePageSize, - updateAssetClass, - } = useQueryParams(); - - const filter = useFilter({ sensitivity: "base", usage: "search" }); + const logger = useLogger(); const collator = useCollator(); - const sortedFeeds = useMemo( + const sortedPriceFeeds = useMemo( () => priceFeeds.sort((a, b) => collator.compare(a.displaySymbol, b.displaySymbol), ), [priceFeeds, collator], ); + const filter = useFilter({ sensitivity: "base", usage: "search" }); + const [assetClass, setAssetClass] = useQueryState( + "assetClass", + parseAsString.withDefault(""), + ); const feedsFilteredByAssetClass = useMemo( () => assetClass - ? sortedFeeds.filter((feed) => feed.assetClassAsString === assetClass) - : sortedFeeds, - [assetClass, sortedFeeds], + ? sortedPriceFeeds.filter( + (feed) => feed.assetClassAsString === assetClass, + ) + : sortedPriceFeeds, + [assetClass, sortedPriceFeeds], ); - const filteredFeeds = useMemo(() => { - if (search === "") { - return feedsFilteredByAssetClass; - } else { + const { + search, + page, + pageSize, + updateSearch, + updatePage, + updatePageSize, + paginatedItems, + numResults, + numPages, + mkPageLink, + } = useQueryParamFilterPagination( + feedsFilteredByAssetClass, + (priceFeed, search) => { const searchTokens = search .split(" ") .flatMap((item) => item.split(",")) .filter(Boolean); - return feedsFilteredByAssetClass.filter((feed) => - searchTokens.some((token) => filter.contains(feed.symbol, token)), + return searchTokens.some((token) => + filter.contains(priceFeed.symbol, token), ); - } - }, [search, feedsFilteredByAssetClass, filter]); - const paginatedFeeds = useMemo( - () => filteredFeeds.slice((page - 1) * pageSize, page * pageSize), - [page, pageSize, filteredFeeds], + }, ); const rows = useMemo( () => - paginatedFeeds.map(({ id, symbol, ...data }) => ({ + paginatedItems.map(({ id, symbol, ...data }) => ({ id, href: `/price-feeds/${encodeURIComponent(symbol)}`, data, })), - [paginatedFeeds], + [paginatedItems], ); - const numPages = useMemo( - () => Math.ceil(filteredFeeds.length / pageSize), - [filteredFeeds.length, pageSize], - ); - - const pathname = usePathname(); - - const mkPageLink = useCallback( - (page: number) => `${pathname}${serialize({ page, pageSize })}`, - [pathname, pageSize], + const updateAssetClass = useCallback( + (newAssetClass: string) => { + updatePage(1); + setAssetClass(newAssetClass).catch((error: unknown) => { + logger.error("Failed to update asset class", error); + }); + }, + [updatePage, setAssetClass, logger], ); const assetClasses = useMemo( @@ -117,7 +118,7 @@ const ResolvedPriceFeedsCard = ({ priceFeeds, ...props }: Props) => { return ( diff --git a/apps/insights/src/components/PriceFeeds/query-params.ts b/apps/insights/src/components/PriceFeeds/query-params.ts deleted file mode 100644 index 75d17b5930..0000000000 --- a/apps/insights/src/components/PriceFeeds/query-params.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useLogger } from "@pythnetwork/app-logger"; -import { - parseAsString, - parseAsInteger, - useQueryStates, - createSerializer, -} from "nuqs"; -import { useCallback } from "react"; - -const queryParams = { - assetClass: parseAsString.withDefault(""), - page: parseAsInteger.withDefault(1), - pageSize: parseAsInteger.withDefault(30), - search: parseAsString.withDefault(""), -}; - -export const serialize = createSerializer(queryParams); - -export const useQueryParams = () => { - const logger = useLogger(); - - const [{ search, page, pageSize, assetClass }, setQuery] = - useQueryStates(queryParams); - - const updateQuery = useCallback( - (...params: Parameters) => { - setQuery(...params).catch((error: unknown) => { - logger.error("Failed to update query", error); - }); - }, - [setQuery, logger], - ); - - const updateSearch = useCallback( - (newSearch: string) => { - updateQuery({ page: 1, search: newSearch }); - }, - [updateQuery], - ); - - const updatePage = useCallback( - (newPage: number) => { - updateQuery({ page: newPage }); - }, - [updateQuery], - ); - - const updatePageSize = useCallback( - (newPageSize: number) => { - updateQuery({ page: 1, pageSize: newPageSize }); - }, - [updateQuery], - ); - - const updateAssetClass = useCallback( - (newAssetClass: string) => { - updateQuery({ page: 1, assetClass: newAssetClass }); - }, - [updateQuery], - ); - - return { - search, - page, - pageSize, - assetClass, - updateSearch, - updatePage, - updatePageSize, - updateAssetClass, - }; -}; diff --git a/apps/insights/src/components/PublisherTag/index.module.scss b/apps/insights/src/components/PublisherTag/index.module.scss new file mode 100644 index 0000000000..b4f98f782f --- /dev/null +++ b/apps/insights/src/components/PublisherTag/index.module.scss @@ -0,0 +1,52 @@ +@use "@pythnetwork/component-library/theme"; + +.publisherTag { + display: flex; + flex-flow: row nowrap; + gap: theme.spacing(4); + align-items: center; + + .icon { + width: theme.spacing(9); + height: theme.spacing(9); + } + + .key { + margin: 0 -#{theme.button-padding("sm", true)}; + } + + .nameAndKey { + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(1); + align-items: flex-start; + + .name { + color: theme.color("heading"); + } + + .key { + margin: -#{theme.spacing(1)} -#{theme.button-padding("xs", true)}; + margin-bottom: -#{theme.spacing(2)}; + } + } + + .undisclosedIconWrapper { + background: theme.color("button", "disabled", "background"); + border-radius: theme.border-radius("full"); + display: grid; + place-content: center; + + .undisclosedIcon { + width: theme.spacing(4); + height: theme.spacing(4); + color: theme.color("button", "disabled", "foreground"); + } + } + + &[data-loading] { + .icon { + border-radius: theme.border-radius("full"); + } + } +} diff --git a/apps/insights/src/components/PublisherTag/index.tsx b/apps/insights/src/components/PublisherTag/index.tsx new file mode 100644 index 0000000000..5e4492d352 --- /dev/null +++ b/apps/insights/src/components/PublisherTag/index.tsx @@ -0,0 +1,64 @@ +import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast"; +import { Skeleton } from "@pythnetwork/component-library/Skeleton"; +import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; +import clsx from "clsx"; +import { type ComponentProps, useMemo } from "react"; + +import styles from "./index.module.scss"; +import { CopyButton } from "../CopyButton"; + +type Props = { isLoading: true } | { isLoading?: false; publisherKey: string }; + +export const PublisherTag = (props: Props) => { + const knownPublisher = useMemo( + () => (props.isLoading ? undefined : lookupPublisher(props.publisherKey)), + [props], + ); + const Icon = knownPublisher?.icon.color ?? UndisclosedIcon; + return ( +
+ {props.isLoading ? ( + + ) : ( + + )} + {props.isLoading ? ( + + ) : ( + <> + {knownPublisher ? ( +
+
{knownPublisher.name}
+ + {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`} + +
+ ) : ( + + {`${props.publisherKey.slice(0, 4)}...${props.publisherKey.slice(-4)}`} + + )} + + )} +
+ ); +}; + +const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => ( +
+ +
+); diff --git a/apps/insights/src/components/Publishers/index.module.scss b/apps/insights/src/components/Publishers/index.module.scss index 9ab9b38cb4..c70256d1e3 100644 --- a/apps/insights/src/components/Publishers/index.module.scss +++ b/apps/insights/src/components/Publishers/index.module.scss @@ -133,53 +133,3 @@ theme.pallette-color("steel", 700) ); } - -.publisherName { - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(4); - align-items: center; - - .publisherIcon { - width: theme.spacing(9); - height: theme.spacing(9); - } - - &.publisherNamePlaceholder .publisherIcon { - border-radius: theme.border-radius("full"); - } - - .key { - margin: 0 -#{theme.button-padding("sm", true)}; - } - - .nameAndKey { - display: flex; - flex-flow: column nowrap; - gap: theme.spacing(1); - align-items: flex-start; - - .name { - color: theme.color("heading"); - } - - .key { - margin: 0 -#{theme.button-padding("xs", true)}; - } - } - - &[data-is-undisclosed] { - .undisclosedIconWrapper { - background: theme.color("button", "disabled", "background"); - border-radius: theme.border-radius("full"); - display: grid; - place-content: center; - - .undisclosedIcon { - width: theme.spacing(4); - height: theme.spacing(4); - color: theme.color("button", "disabled", "foreground"); - } - } - } -} diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index 8d229542ae..563907229b 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -1,9 +1,8 @@ import { ArrowSquareOut } from "@phosphor-icons/react/dist/ssr/ArrowSquareOut"; -import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast"; import { Info } from "@phosphor-icons/react/dist/ssr/Info"; import { Lightbulb } from "@phosphor-icons/react/dist/ssr/Lightbulb"; import { Alert, AlertTrigger } from "@pythnetwork/component-library/Alert"; -import { ButtonLink, Button } from "@pythnetwork/component-library/Button"; +import { Button } from "@pythnetwork/component-library/Button"; import { Card } from "@pythnetwork/component-library/Card"; import { Skeleton } from "@pythnetwork/component-library/Skeleton"; import { StatCard } from "@pythnetwork/component-library/StatCard"; @@ -17,13 +16,15 @@ import { PublishersCard } from "./publishers-card"; import { SemicircleMeter, Label } from "./semicircle-meter"; import { client as clickhouseClient } from "../../services/clickhouse"; import { client as hermesClient } from "../../services/hermes"; -import { CLUSTER, client as pythClient } from "../../services/pyth"; +import { CLUSTER, getData } from "../../services/pyth"; import { client as stakingClient } from "../../services/staking"; -import { CopyButton } from "../CopyButton"; import { FormattedTokens } from "../FormattedTokens"; +import { PublisherTag } from "../PublisherTag"; +import { Score } from "../Score"; import { TokenIcon } from "../TokenIcon"; const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n; +const PUBLISHER_SCORE_WIDTH = 24; export const Publishers = async () => { const [publishers, totalFeeds, oisStats] = await Promise.all([ @@ -69,14 +70,14 @@ export const Publishers = async () => { Median Scores of all publishers who contribute to the Pyth Network.

- Learn more - + } @@ -91,7 +92,7 @@ export const Publishers = async () => { title="Oracle Integrity Staking (OIS)" className={styles.oisCard} toolbar={ - { afterIcon={ArrowSquareOut} > Staking App - + } > { rankingLoadingSkeleton={ } - nameLoadingSkeleton={ -
- -
-
- -
- -
-
+ nameLoadingSkeleton={} + scoreLoadingSkeleton={ + } + scoreWidth={PUBLISHER_SCORE_WIDTH} publishers={publishers.map( ({ key, rank, numSymbols, medianScore }) => ({ id: key, nameAsString: lookupPublisher(key)?.name, - name: , + name: , ranking: {rank}, activeFeeds: numSymbols, inactiveFeeds: totalFeeds - numSymbols, - medianScore, + medianScore: ( + + ), }), )} /> @@ -195,48 +187,6 @@ const Ranking = ({ className, ...props }: ComponentProps<"span">) => ( ); -const PublisherName = ({ publisherKey }: { publisherKey: string }) => { - const knownPublisher = lookupPublisher(publisherKey); - const Icon = knownPublisher?.icon.color ?? UndisclosedIcon; - const name = knownPublisher?.name ?? "Undisclosed"; - return ( -
- - {knownPublisher ? ( -
-
{name}
- - {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`} - -
- ) : ( - - {`${publisherKey.slice(0, 4)}...${publisherKey.slice(-4)}`} - - )} -
- ); -}; - -const UndisclosedIcon = ({ className, ...props }: ComponentProps<"div">) => ( -
- -
-); - const getPublishers = async () => { const rows = await clickhouseClient.query({ query: @@ -258,11 +208,8 @@ const publishersSchema = z.array( ); const getTotalFeedCount = async () => { - const pythData = await pythClient.getData(); - return pythData.symbols.filter( - (symbol) => - (pythData.productPrice.get(symbol)?.numComponentPrices ?? 0) > 0, - ).length; + const pythData = await getData(); + return pythData.filter(({ price }) => price.numComponentPrices > 0).length; }; const getOisStats = async () => { diff --git a/apps/insights/src/components/Publishers/publishers-card.module.scss b/apps/insights/src/components/Publishers/publishers-card.module.scss deleted file mode 100644 index 3bc10c54e1..0000000000 --- a/apps/insights/src/components/Publishers/publishers-card.module.scss +++ /dev/null @@ -1,66 +0,0 @@ -@use "@pythnetwork/component-library/theme"; - -.publishersCard { - .publisherScore { - width: calc(theme.spacing(1) * var(--width)); - height: theme.spacing(4); - border-radius: theme.border-radius("3xl"); - position: relative; - - .fill { - position: absolute; - top: 0; - bottom: 0; - left: 0; - border-radius: theme.border-radius("3xl"); - color: theme.color("background", "primary"); - display: grid; - place-content: center; - text-shadow: - 0 1px 2px rgb(0 0 0 / 10%), - 0 1px 3px rgb(0 0 0 / 10%); - font-size: theme.font-size("xxs"); - font-weight: theme.font-weight("semibold"); - } - - &[data-size-class="bad"] { - background: theme.color("states", "error", "background"); - - .fill { - background: theme.color("states", "error", "color"); - } - } - - &[data-size-class="weak"] { - background: theme.color("states", "warning", "background"); - - .fill { - background: theme.color("states", "warning", "normal"); - } - } - - &[data-size-class="warn"] { - background: theme.color("states", "yellow", "background"); - - .fill { - background: theme.color("states", "yellow", "normal"); - } - } - - &[data-size-class="ok"] { - background: theme.color("states", "lime", "background"); - - .fill { - background: theme.color("states", "lime", "normal"); - } - } - - &[data-size-class="good"] { - background: theme.color("states", "success", "background"); - - .fill { - background: theme.color("states", "success", "normal"); - } - } - } -} diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 158d9fb351..50d35c31a0 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/apps/insights/src/components/Publishers/publishers-card.tsx @@ -1,39 +1,22 @@ "use client"; import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast"; -import { useLogger } from "@pythnetwork/app-logger"; import { Badge } from "@pythnetwork/component-library/Badge"; import { Card } from "@pythnetwork/component-library/Card"; import { Paginator } from "@pythnetwork/component-library/Paginator"; import { SearchInput } from "@pythnetwork/component-library/SearchInput"; -import { Skeleton } from "@pythnetwork/component-library/Skeleton"; import { type RowConfig, Table } from "@pythnetwork/component-library/Table"; -import clsx from "clsx"; -import { usePathname } from "next/navigation"; -import { - parseAsString, - parseAsInteger, - useQueryStates, - createSerializer, -} from "nuqs"; -import { - type ReactNode, - type CSSProperties, - Suspense, - useCallback, - useMemo, -} from "react"; +import { type ReactNode, Suspense, useMemo } from "react"; import { useFilter } from "react-aria"; -import { Meter } from "react-aria-components"; -import styles from "./publishers-card.module.scss"; - -const PUBLISHER_SCORE_WIDTH = 24; +import { useQueryParamFilterPagination } from "../../use-query-param-filter-pagination"; type Props = { className?: string | undefined; rankingLoadingSkeleton: ReactNode; nameLoadingSkeleton: ReactNode; + scoreLoadingSkeleton: ReactNode; + scoreWidth: number; publishers: Publisher[]; }; @@ -44,7 +27,7 @@ type Publisher = { ranking: ReactNode; activeFeeds: ReactNode; inactiveFeeds: ReactNode; - medianScore: number; + medianScore: ReactNode; }; export const PublishersCard = ({ publishers, ...props }: Props) => ( @@ -54,89 +37,34 @@ export const PublishersCard = ({ publishers, ...props }: Props) => ( ); const ResolvedPublishersCard = ({ publishers, ...props }: Props) => { - const logger = useLogger(); - - const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams); - - const updateQuery = useCallback( - (...params: Parameters) => { - setQuery(...params).catch((error: unknown) => { - logger.error("Failed to update query", error); - }); - }, - [setQuery, logger], - ); - - const updateSearch = useCallback( - (newSearch: string) => { - updateQuery({ page: 1, search: newSearch }); - }, - [updateQuery], - ); - - const updatePage = useCallback( - (newPage: number) => { - updateQuery({ page: newPage }); - }, - [updateQuery], - ); - - const updatePageSize = useCallback( - (newPageSize: number) => { - updateQuery({ page: 1, pageSize: newPageSize }); - }, - [updateQuery], - ); - const filter = useFilter({ sensitivity: "base", usage: "search" }); - const filteredPublishers = useMemo( - () => - search === "" - ? publishers - : publishers.filter( - (publisher) => - filter.contains(publisher.id, search) || - (publisher.nameAsString !== undefined && - filter.contains(publisher.nameAsString, search)), - ), - [publishers, search, filter], - ); - const paginatedPublishers = useMemo( - () => filteredPublishers.slice((page - 1) * pageSize, page * pageSize), - [page, pageSize, filteredPublishers], - ); - - const numPages = useMemo( - () => Math.ceil(filteredPublishers.length / pageSize), - [filteredPublishers.length, pageSize], - ); - - const pathname = usePathname(); - - const mkPageLink = useCallback( - (page: number) => { - const serialize = createSerializer(queryParams); - return `${pathname}${serialize({ page, pageSize })}`; - }, - [pathname, pageSize], + const { + search, + page, + pageSize, + updateSearch, + updatePage, + updatePageSize, + paginatedItems, + numResults, + numPages, + mkPageLink, + } = useQueryParamFilterPagination( + publishers, + (publisher, search) => + filter.contains(publisher.id, search) || + (publisher.nameAsString !== undefined && + filter.contains(publisher.nameAsString, search)), ); const rows = useMemo( - () => - paginatedPublishers.map(({ id, medianScore, ...data }) => ({ - id, - href: "#", - data: { - ...data, - medianScore: , - }, - })), - [paginatedPublishers], + () => paginatedItems.map(({ id, ...data }) => ({ id, href: "#", data })), + [paginatedItems], ); return ( { ); }; -const queryParams = { - page: parseAsInteger.withDefault(1), - pageSize: parseAsInteger.withDefault(30), - search: parseAsString.withDefault(""), -}; - type PublishersCardContentsProps = Pick< Props, - "className" | "rankingLoadingSkeleton" | "nameLoadingSkeleton" + | "className" + | "rankingLoadingSkeleton" + | "nameLoadingSkeleton" + | "scoreLoadingSkeleton" + | "scoreWidth" > & ( | { isLoading: true } @@ -184,10 +110,12 @@ const PublishersCardContents = ({ className, rankingLoadingSkeleton, nameLoadingSkeleton, + scoreLoadingSkeleton, + scoreWidth, ...props }: PublishersCardContentsProps) => ( } title={ <> @@ -206,7 +134,7 @@ const PublishersCardContents = ({ {...(props.isLoading ? { isPending: true, isDisabled: true } : { - defaultValue: props.search, + value: props.search, onChange: props.onSearchChange, })} /> @@ -259,15 +187,9 @@ const PublishersCardContents = ({ { id: "medianScore", name: "MEDIAN SCORE", - width: PUBLISHER_SCORE_WIDTH, - alignment: "center", - loadingSkeleton: ( - - ), + alignment: "right", + width: scoreWidth, + loadingSkeleton: scoreLoadingSkeleton, }, ]} {...(props.isLoading @@ -281,44 +203,3 @@ const PublishersCardContents = ({ /> ); - -type PublisherScoreProps = { - score: number; -}; - -const PublisherScore = ({ score }: PublisherScoreProps) => ( - - {({ percentage }) => ( -
-
- {score.toFixed(2)} -
-
- )} -
-); - -const getSizeClass = (percentage: number) => { - if (percentage < 60) { - return "bad"; - } else if (percentage < 70) { - return "weak"; - } else if (percentage < 80) { - return "warn"; - } else if (percentage < 90) { - return "ok"; - } else { - return "good"; - } -}; diff --git a/apps/insights/src/components/Root/footer.tsx b/apps/insights/src/components/Root/footer.tsx index f7254d4ce1..25eb2b77aa 100644 --- a/apps/insights/src/components/Root/footer.tsx +++ b/apps/insights/src/components/Root/footer.tsx @@ -3,9 +3,12 @@ import { GithubLogo } from "@phosphor-icons/react/dist/ssr/GithubLogo"; import { TelegramLogo } from "@phosphor-icons/react/dist/ssr/TelegramLogo"; import { XLogo } from "@phosphor-icons/react/dist/ssr/XLogo"; import { YoutubeLogo } from "@phosphor-icons/react/dist/ssr/YoutubeLogo"; -import { ButtonLink } from "@pythnetwork/component-library/Button"; +import { + type Props as ButtonProps, + Button, +} from "@pythnetwork/component-library/Button"; import { Link } from "@pythnetwork/component-library/Link"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ElementType } from "react"; import styles from "./footer.module.scss"; import Wordmark from "./wordmark.svg"; @@ -64,15 +67,18 @@ export const Footer = () => ( ); -type SocialLinkProps = Omit< - ComponentProps, +type SocialLinkProps = Omit< + ButtonProps, "target" | "variant" | "size" | "beforeIcon" | "hideText" > & { - icon: ComponentProps["beforeIcon"]; + icon: ComponentProps["beforeIcon"]; }; -const SocialLink = ({ icon, ...props }: SocialLinkProps) => ( - ({ + icon, + ...props +}: SocialLinkProps) => ( + - Dev Docs - + diff --git a/apps/insights/src/components/Root/theme-switch.tsx b/apps/insights/src/components/Root/theme-switch.tsx index 6c903e105c..846d9b6718 100644 --- a/apps/insights/src/components/Root/theme-switch.tsx +++ b/apps/insights/src/components/Root/theme-switch.tsx @@ -4,12 +4,15 @@ import type { IconProps } from "@phosphor-icons/react"; import { Desktop } from "@phosphor-icons/react/dist/ssr/Desktop"; import { Moon } from "@phosphor-icons/react/dist/ssr/Moon"; import { Sun } from "@phosphor-icons/react/dist/ssr/Sun"; -import { Button } from "@pythnetwork/component-library/Button"; +import { + type Props as ButtonProps, + Button, +} from "@pythnetwork/component-library/Button"; import clsx from "clsx"; import { motion } from "motion/react"; import { useTheme } from "next-themes"; import { - type ComponentProps, + type ElementType, useCallback, useRef, useMemo, @@ -19,12 +22,15 @@ import { useIsSSR } from "react-aria"; import styles from "./theme-switch.module.scss"; -type Props = Omit< - ComponentProps, +type Props = Omit< + ButtonProps, "beforeIcon" | "variant" | "size" | "hideText" | "children" | "onPress" >; -export const ThemeSwitch = ({ className, ...props }: Props) => { +export const ThemeSwitch = ({ + className, + ...props +}: Props) => { const { theme, setTheme } = useTheme(); const toggleTheme = useCallback(() => { @@ -70,7 +76,6 @@ type IconMovementProps = Omit & { const IconMovement = ({ icon: Icon, offset, ...props }: IconMovementProps) => ( + props.isLoading ? ( + + ) : ( + + {({ percentage }) => ( +
+
+ {props.score.toFixed(2)} +
+
+ )} +
+ ); + +const getSizeClass = (percentage: number) => { + if (percentage < 60) { + return "bad"; + } else if (percentage < 70) { + return "weak"; + } else if (percentage < 80) { + return "warn"; + } else if (percentage < 90) { + return "ok"; + } else { + return "good"; + } +}; diff --git a/apps/insights/src/hex.ts b/apps/insights/src/hex.ts new file mode 100644 index 0000000000..251392fe80 --- /dev/null +++ b/apps/insights/src/hex.ts @@ -0,0 +1,7 @@ +import base58 from "bs58"; + +export const toHex = (value: string) => + `0x${Array.from(base58.decode(value), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; + +export const truncateHex = (value: string) => + `${value.slice(0, 6)}...${value.slice(-4)}`; diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index 43e72786bf..bd6afd0ff3 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -1,7 +1,49 @@ import "server-only"; import { createClient } from "@clickhouse/client"; +import { z } from "zod"; +import { cache } from "../cache"; import { CLICKHOUSE } from "../config/server"; export const client = createClient(CLICKHOUSE); + +export const getRankings = cache(async (symbol: string) => { + const rows = await client.query({ + query: ` + SELECT + cluster, + publisher, + uptime_score, + uptime_rank, + deviation_penalty, + deviation_score, + deviation_rank, + stalled_penalty, + stalled_score, + stalled_rank, + final_score + FROM insights_feed_component_rankings(symbol={symbol: String}) + `, + query_params: { symbol }, + }); + const result = await rows.json(); + + return rankingsSchema.parse(result.data); +}); + +const rankingsSchema = z.array( + z.strictObject({ + cluster: z.enum(["pythnet", "pythtest-conformance"]), + publisher: z.string(), + uptime_score: z.number(), + uptime_rank: z.number(), + deviation_penalty: z.number().nullable(), + deviation_score: z.number(), + deviation_rank: z.number(), + stalled_penalty: z.number(), + stalled_score: z.number(), + stalled_rank: z.number(), + final_score: z.number(), + }), +); diff --git a/apps/insights/src/services/pyth.ts b/apps/insights/src/services/pyth.ts index 6915afc00c..31f322e8b5 100644 --- a/apps/insights/src/services/pyth.ts +++ b/apps/insights/src/services/pyth.ts @@ -6,12 +6,57 @@ import { } from "@pythnetwork/client"; import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection"; import { Connection, PublicKey } from "@solana/web3.js"; +import { z } from "zod"; + +import { cache } from "../cache"; export const CLUSTER = "pythnet"; const connection = new Connection(getPythClusterApiUrl(CLUSTER)); const programKey = getPythProgramKeyForCluster(CLUSTER); export const client = new PythHttpClient(connection, programKey); +export const getData = cache(async () => { + const data = await client.getData(); + return priceFeedsSchema.parse( + data.symbols.map((symbol) => ({ + symbol, + product: data.productFromSymbol.get(symbol), + price: data.productPrice.get(symbol), + })), + ); +}); + +const priceFeedsSchema = z.array( + z.object({ + symbol: z.string(), + product: z.object({ + display_symbol: z.string(), + asset_type: z.string(), + description: z.string(), + price_account: z.string(), + base: z.string().optional(), + country: z.string().optional(), + quote_currency: z.string().optional(), + tenor: z.string().optional(), + cms_symbol: z.string().optional(), + cqs_symbol: z.string().optional(), + nasdaq_symbol: z.string().optional(), + generic_symbol: z.string().optional(), + weekly_schedule: z.string().optional(), + schedule: z.string().optional(), + contract_id: z.string().optional(), + }), + price: z.object({ + exponent: z.number(), + numComponentPrices: z.number(), + numQuoters: z.number(), + minPublishers: z.number(), + lastSlot: z.bigint(), + validSlot: z.bigint(), + }), + }), +); + export const subscribe = (feeds: PublicKey[], cb: PythPriceCallback) => { const pythConn = new PythConnection( connection, diff --git a/apps/insights/src/use-query-param-filter-pagination.ts b/apps/insights/src/use-query-param-filter-pagination.ts new file mode 100644 index 0000000000..f2c12957e5 --- /dev/null +++ b/apps/insights/src/use-query-param-filter-pagination.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useLogger } from "@pythnetwork/app-logger"; +import { usePathname } from "next/navigation"; +import { + parseAsString, + parseAsInteger, + useQueryStates, + createSerializer, +} from "nuqs"; +import { useCallback, useMemo } from "react"; + +export const useQueryParamFilterPagination = ( + items: T[], + predicate: (item: T, term: string) => boolean, + options?: { defaultPageSize: number }, +) => { + const logger = useLogger(); + + const queryParams = useMemo( + () => ({ + page: parseAsInteger.withDefault(1), + pageSize: parseAsInteger.withDefault(options?.defaultPageSize ?? 30), + search: parseAsString.withDefault(""), + }), + [options], + ); + + const [{ search, page, pageSize }, setQuery] = useQueryStates(queryParams); + + const updateQuery = useCallback( + (...params: Parameters) => { + setQuery(...params).catch((error: unknown) => { + logger.error("Failed to update query", error); + }); + }, + [setQuery, logger], + ); + + const updateSearch = useCallback( + (newSearch: string) => { + updateQuery({ page: 1, search: newSearch }); + }, + [updateQuery], + ); + + const updatePage = useCallback( + (newPage: number) => { + updateQuery({ page: newPage }); + }, + [updateQuery], + ); + + const updatePageSize = useCallback( + (newPageSize: number) => { + updateQuery({ page: 1, pageSize: newPageSize }); + }, + [updateQuery], + ); + + const filteredItems = useMemo( + () => + search === "" ? items : items.filter((item) => predicate(item, search)), + [items, search, predicate], + ); + const paginatedItems = useMemo( + () => filteredItems.slice((page - 1) * pageSize, page * pageSize), + [page, pageSize, filteredItems], + ); + + const numPages = useMemo( + () => Math.ceil(filteredItems.length / pageSize), + [filteredItems.length, pageSize], + ); + + const pathname = usePathname(); + + const mkPageLink = useCallback( + (page: number) => { + const serialize = createSerializer(queryParams); + return `${pathname}${serialize({ page, pageSize })}`; + }, + [pathname, pageSize, queryParams], + ); + + return { + search, + page, + pageSize, + updateSearch, + updatePage, + updatePageSize, + paginatedItems, + numPages, + mkPageLink, + numResults: filteredItems.length, + }; +}; diff --git a/apps/insights/src/zod-utils.ts b/apps/insights/src/zod-utils.ts deleted file mode 100644 index ae53e58472..0000000000 --- a/apps/insights/src/zod-utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type ZodSchema, type ZodTypeDef, z } from "zod"; - -export const singletonArray = ( - schema: ZodSchema, -) => - z - .array(schema) - .length(1) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .transform((value) => value[0]!); - -export const optionalSingletonArray = ( - schema: ZodSchema, -) => - z - .array(schema) - .max(1) - .transform((value) => value[0]); diff --git a/packages/component-library/src/Alert/index.module.scss b/packages/component-library/src/Alert/index.module.scss index 7ec4520637..009d35a3c6 100644 --- a/packages/component-library/src/Alert/index.module.scss +++ b/packages/component-library/src/Alert/index.module.scss @@ -5,59 +5,54 @@ inset: 0; z-index: 1; - .modal { + .alert { position: fixed; bottom: theme.spacing(8); right: theme.spacing(8); outline: none; + background: theme.color("states", "info", "background-opaque"); + border-radius: theme.border-radius("3xl"); + backdrop-filter: blur(32px); + width: theme.spacing(156); + padding: theme.spacing(6); + padding-right: theme.spacing(16); + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + + .closeButton { + position: absolute; + right: theme.spacing(2); + top: theme.spacing(2); + } - .dialog { - background: theme.color("states", "info", "background-opaque"); - border-radius: theme.border-radius("3xl"); - backdrop-filter: blur(32px); - width: theme.spacing(156); - outline: none; - position: relative; - padding: theme.spacing(6); - padding-right: theme.spacing(16); - display: flex; - flex-flow: column nowrap; - gap: theme.spacing(4); - - .closeButton { - position: absolute; - right: theme.spacing(2); - top: theme.spacing(2); - } - - .title { - @include theme.h4; - - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(3); - align-items: center; - color: theme.color("heading"); - line-height: 1; + .title { + @include theme.h4; - .icon { - color: theme.color("states", "info", "normal"); - flex: none; - display: grid; - place-content: center; - font-size: theme.spacing(6); - } + display: flex; + flex-flow: row nowrap; + gap: theme.spacing(3); + align-items: center; + color: theme.color("heading"); + line-height: 1; + + .icon { + color: theme.color("states", "info", "normal"); + flex: none; + display: grid; + place-content: center; + font-size: theme.spacing(6); } + } - .body { - color: theme.color("paragraph"); - font-size: theme.font-size("sm"); - line-height: 140%; - display: flex; - flex-flow: column nowrap; - gap: theme.spacing(4); - align-items: flex-start; - } + .body { + color: theme.color("paragraph"); + font-size: theme.font-size("sm"); + line-height: 140%; + display: flex; + flex-flow: column nowrap; + gap: theme.spacing(4); + align-items: flex-start; } } } diff --git a/packages/component-library/src/Alert/index.tsx b/packages/component-library/src/Alert/index.tsx index 546eb6ee47..e331882c1f 100644 --- a/packages/component-library/src/Alert/index.tsx +++ b/packages/component-library/src/Alert/index.tsx @@ -3,23 +3,27 @@ import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle"; import clsx from "clsx"; import type { ComponentProps, ReactNode } from "react"; -import { Dialog, Heading } from "react-aria-components"; +import { Heading } from "react-aria-components"; import styles from "./index.module.scss"; import { Button } from "../Button/index.js"; -import { Modal } from "../Modal/index.js"; +import { ModalDialog } from "../ModalDialog/index.js"; -export { DialogTrigger as AlertTrigger } from "react-aria-components"; +export { ModalDialogTrigger as AlertTrigger } from "../ModalDialog/index.js"; const CLOSE_DURATION_IN_S = 0.1; export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000; -type OwnProps = Pick, "children"> & { +type OwnProps = Pick, "children"> & { icon?: ReactNode | undefined; title: ReactNode; }; -type Props = Omit, keyof OwnProps> & OwnProps; +type Props = Omit< + ComponentProps, + keyof OwnProps | "overlayClassName" +> & + OwnProps; export const Alert = ({ icon, @@ -28,23 +32,23 @@ export const Alert = ({ className, ...props }: Props) => ( - - {(state) => ( - + {(...args) => ( + <> + )} - + ); diff --git a/packages/component-library/src/Breadcrumbs/index.tsx b/packages/component-library/src/Breadcrumbs/index.tsx index c066810366..e22e3e6e02 100644 --- a/packages/component-library/src/Breadcrumbs/index.tsx +++ b/packages/component-library/src/Breadcrumbs/index.tsx @@ -6,7 +6,7 @@ import clsx from "clsx"; import type { ComponentProps } from "react"; import styles from "./index.module.scss"; -import { ButtonLink } from "../Button/index.js"; +import { Button } from "../Button/index.js"; import { Link } from "../Link/index.js"; import { UnstyledBreadcrumbs, @@ -41,7 +41,7 @@ export const Breadcrumbs = ({ label, className, items, ...props }: Props) => ( {"href" in item ? ( <> {item.href === "/" ? ( - ( href="/" > {item.label} - + ) : ( {item.label} diff --git a/packages/component-library/src/Button/button-link.stories.tsx b/packages/component-library/src/Button/button-link.stories.tsx deleted file mode 100644 index 2cc3eb14fc..0000000000 --- a/packages/component-library/src/Button/button-link.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { ButtonLink as ButtonLinkComponent } from "./index.js"; -import buttonMeta from "./index.stories.js"; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const { onPress, isPending, ...argTypes } = buttonMeta.argTypes; -const meta = { - component: ButtonLinkComponent, - title: "Button/ButtonLink", - argTypes: { - ...argTypes, - href: { - control: "text", - table: { - category: "Link", - }, - }, - target: { - control: "text", - table: { - category: "Link", - }, - }, - }, -} satisfies Meta; -export default meta; - -export const ButtonLink = { - args: { - children: "Link", - href: "https://www.pyth.network", - target: "_blank", - variant: "primary", - size: "md", - isDisabled: false, - rounded: false, - hideText: false, - }, -} satisfies StoryObj; diff --git a/packages/component-library/src/Button/index.stories.tsx b/packages/component-library/src/Button/index.stories.tsx index 6ea27759fc..89ad08d7d1 100644 --- a/packages/component-library/src/Button/index.stories.tsx +++ b/packages/component-library/src/Button/index.stories.tsx @@ -71,6 +71,18 @@ const meta = { category: "State", }, }, + href: { + control: "text", + table: { + category: "Link", + }, + }, + target: { + control: "text", + table: { + category: "Link", + }, + }, }, } satisfies Meta; export default meta; diff --git a/packages/component-library/src/Button/index.tsx b/packages/component-library/src/Button/index.tsx index d067ec2c71..843083fea7 100644 --- a/packages/component-library/src/Button/index.tsx +++ b/packages/component-library/src/Button/index.tsx @@ -1,9 +1,10 @@ import clsx from "clsx"; -import type { ComponentType, ReactNode } from "react"; -import { - type ButtonProps as BaseButtonProps, - type LinkProps as BaseLinkProps, -} from "react-aria-components"; +import type { + ComponentProps, + ElementType, + ComponentType, + ReactNode, +} from "react"; import styles from "./index.module.scss"; import { UnstyledButton } from "../UnstyledButton/index.js"; @@ -30,17 +31,20 @@ type OwnProps = { afterIcon?: Icon | undefined; }; -export type ButtonProps = Omit & OwnProps; +export type Props = Omit< + ComponentProps, + keyof OwnProps +> & + OwnProps; -export const Button = (props: ButtonProps) => ( - -); - -export type ButtonLinkProps = Omit & OwnProps; - -export const ButtonLink = (props: ButtonLinkProps) => ( - -); +export const Button = ( + props: Props | Props, +) => + "href" in props ? ( + + ) : ( + + ); type ButtonImplProps = OwnProps & { className?: Parameters[0]; diff --git a/packages/component-library/src/Card/index.module.scss b/packages/component-library/src/Card/index.module.scss index 1bb701a6c5..2f127ce2a6 100644 --- a/packages/component-library/src/Card/index.module.scss +++ b/packages/component-library/src/Card/index.module.scss @@ -33,19 +33,19 @@ } .header { + display: flex; padding: theme.spacing(3) theme.spacing(4); position: relative; .title { - margin: 0; - font-size: theme.font-size("base"); - font-weight: theme.font-weight("medium"); color: theme.color("heading"); display: inline-flex; flex-flow: row nowrap; gap: theme.spacing(3); align-items: center; + @include theme.text("lg", "medium"); + .icon { font-size: theme.spacing(6); height: theme.spacing(6); diff --git a/packages/component-library/src/Drawer/index.module.scss b/packages/component-library/src/Drawer/index.module.scss index 455c8c4066..fe46004e9a 100644 --- a/packages/component-library/src/Drawer/index.module.scss +++ b/packages/component-library/src/Drawer/index.module.scss @@ -6,49 +6,44 @@ background: rgba(from black r g b / 30%); z-index: 1; - .modal { + .drawer { position: fixed; top: theme.spacing(4); bottom: theme.spacing(4); right: theme.spacing(4); - width: 40%; + width: 60%; max-width: theme.spacing(160); outline: none; + background: theme.color("background", "primary"); + border: 1px solid theme.color("border"); + border-radius: theme.border-radius("3xl"); + display: flex; + flex-flow: column nowrap; + overflow-y: hidden; + padding-bottom: theme.border-radius("3xl"); - .dialog { - background: theme.color("background", "primary"); - border: 1px solid theme.color("border"); - border-radius: theme.border-radius("3xl"); - outline: none; + .heading { + padding: theme.spacing(4); + padding-left: theme.spacing(6); display: flex; - flex-flow: column nowrap; - height: 100%; - overflow-y: hidden; - padding-bottom: theme.border-radius("3xl"); + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + color: theme.color("heading"); + flex: none; + + .title { + @include theme.h4; - .heading { - padding: theme.spacing(4); - padding-left: theme.spacing(6); display: flex; flex-flow: row nowrap; - justify-content: space-between; - align-items: center; - color: theme.color("heading"); - flex: none; - - .title { - @include theme.h4; - - display: flex; - flex-flow: row nowrap; - gap: theme.spacing(3); - } + gap: theme.spacing(3); } + } - .body { - flex: 1; - overflow-y: auto; - } + .body { + flex: 1; + overflow-y: auto; } } } diff --git a/packages/component-library/src/Drawer/index.tsx b/packages/component-library/src/Drawer/index.tsx index e542242b48..b7db865c5e 100644 --- a/packages/component-library/src/Drawer/index.tsx +++ b/packages/component-library/src/Drawer/index.tsx @@ -3,45 +3,56 @@ import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle"; import clsx from "clsx"; import type { ComponentProps, ReactNode } from "react"; -import { Dialog, Heading } from "react-aria-components"; +import { Heading } from "react-aria-components"; import styles from "./index.module.scss"; import { Button } from "../Button/index.js"; -import { Modal } from "../Modal/index.js"; +import { ModalDialog } from "../ModalDialog/index.js"; -export { DialogTrigger as DrawerTrigger } from "react-aria-components"; +export { ModalDialogTrigger as DrawerTrigger } from "../ModalDialog/index.js"; const CLOSE_DURATION_IN_S = 0.15; export const CLOSE_DURATION_IN_MS = CLOSE_DURATION_IN_S * 1000; -type OwnProps = Pick, "children"> & { +type OwnProps = { title: ReactNode; + closeHref?: string | undefined; }; -type Props = Omit, keyof OwnProps> & OwnProps; +type Props = Omit< + ComponentProps, + keyof OwnProps | "overlayVariants" | "overlayClassName" | "variants" +> & + OwnProps; -export const Drawer = ({ title, children, className, ...props }: Props) => ( - ( + - {(state) => ( - + {(...args) => ( + <>
{title} @@ -54,14 +65,15 @@ export const Drawer = ({ title, children, className, ...props }: Props) => ( rounded variant="ghost" size="sm" + {...(closeHref && { href: closeHref })} > Close
- {typeof children === "function" ? children(state) : children} + {typeof children === "function" ? children(...args) : children}
-
+ )} -
+ ); diff --git a/packages/component-library/src/DropdownCaretDown/index.tsx b/packages/component-library/src/DropdownCaretDown/index.tsx new file mode 100644 index 0000000000..6723c47fe2 --- /dev/null +++ b/packages/component-library/src/DropdownCaretDown/index.tsx @@ -0,0 +1,14 @@ +import type { ComponentProps } from "react"; + +export const DropdownCaretDown = ( + props: Omit, "xmlns" | "viewBox" | "fill">, +) => ( + + + +); diff --git a/packages/component-library/src/Html/base.scss b/packages/component-library/src/Html/base.scss index bbd385ba29..f4bc030dcc 100644 --- a/packages/component-library/src/Html/base.scss +++ b/packages/component-library/src/Html/base.scss @@ -29,6 +29,7 @@ html { &[data-overlay-visible] { scrollbar-gutter: auto; padding-right: var(--scrollbar-width) !important; + overflow: hidden; } } diff --git a/packages/component-library/src/MainNavTabs/index.tsx b/packages/component-library/src/MainNavTabs/index.tsx index a48f0eeef7..501d42152a 100644 --- a/packages/component-library/src/MainNavTabs/index.tsx +++ b/packages/component-library/src/MainNavTabs/index.tsx @@ -36,7 +36,6 @@ export const MainNavTabs = ({ className, pathname, ...props }: Props) => ( {args.isSelected && ( , - "isOpen" | "isDismissable" | "onOpenChange" - >; - children: - | ReactNode - | (( - state: NonNullable>, - ) => ReactNode); -}; - -type Props = Omit, keyof OwnProps> & - OwnProps; - -export const Modal = ({ overlayProps, children, ...props }: Props) => { - const state = use(OverlayTriggerStateContext); - const { hideOverlay, showOverlay } = useSetOverlayVisible(); - - useEffect(() => { - if (state?.isOpen) { - showOverlay(); - } - }, [state, showOverlay]); - - const onOpenChange = useCallback( - (newValue: boolean) => { - state?.setOpen(newValue); - }, - [state], - ); - - return ( - - {state?.isOpen && ( - - - {typeof children === "function" ? children(state) : children} - - - )} - - ); -}; diff --git a/packages/component-library/src/ModalDialog/index.tsx b/packages/component-library/src/ModalDialog/index.tsx new file mode 100644 index 0000000000..1dbc8a633c --- /dev/null +++ b/packages/component-library/src/ModalDialog/index.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { motion } from "motion/react"; +import { + type ComponentProps, + type Dispatch, + type SetStateAction, + createContext, + use, + useCallback, + useState, + useEffect, +} from "react"; +import { + Modal, + ModalOverlay, + Dialog, + DialogTrigger, +} from "react-aria-components"; + +import { useSetOverlayVisible } from "../overlay-visible-context.js"; + +const MotionModalOverlay = motion.create(ModalOverlay); +const MotionDialog = motion.create(Dialog); + +export const ModalDialogTrigger = ( + props: ComponentProps, +) => { + const [animation, setAnimation] = useState("unmounted"); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + setAnimation(isOpen ? "visible" : "hidden"); + }, + [setAnimation], + ); + + return ( + + + + ); +}; + +const ModalAnimationContext = createContext< + [AnimationState, Dispatch>] | undefined +>(undefined); + +type OwnProps = Pick, "children"> & + Pick, "isOpen" | "onOpenChange"> & { + overlayClassName?: + | ComponentProps["className"] + | undefined; + overlayVariants?: + | ComponentProps["variants"] + | undefined; + }; + +type Props = Omit, keyof OwnProps> & + OwnProps; + +export const ModalDialog = ({ + isOpen, + onOpenChange, + overlayClassName, + overlayVariants, + children, + ...props +}: Props) => { + const contextAnimationState = use(ModalAnimationContext); + const localAnimationState = useState("unmounted"); + const [animation, setAnimation] = + contextAnimationState ?? localAnimationState; + const { hideOverlay, showOverlay } = useSetOverlayVisible(); + + const startAnimation = (animation: AnimationState) => { + if (animation === "visible") { + showOverlay(); + } + }; + + const endAnimation = (animation: AnimationState) => { + if (animation === "hidden") { + hideOverlay(); + } + setAnimation((a) => { + return animation === "hidden" && a === "hidden" ? "unmounted" : a; + }); + }; + + useEffect(() => { + if (isOpen !== undefined) { + setAnimation((a) => { + if (isOpen) { + return "visible"; + } else { + return a === "visible" ? "hidden" : a; + } + }); + } + }, [isOpen, setAnimation]); + + return ( + + + {(...args) => ( + + {typeof children === "function" ? children(...args) : children} + + )} + + + ); +}; + +type AnimationState = "unmounted" | "hidden" | "visible"; diff --git a/packages/component-library/src/Paginator/index.tsx b/packages/component-library/src/Paginator/index.tsx index 26df77ffec..56063931c9 100644 --- a/packages/component-library/src/Paginator/index.tsx +++ b/packages/component-library/src/Paginator/index.tsx @@ -2,9 +2,10 @@ import { CaretLeft } from "@phosphor-icons/react/dist/ssr/CaretLeft"; import { CaretRight } from "@phosphor-icons/react/dist/ssr/CaretRight"; import clsx from "clsx"; import { type ComponentProps, useMemo, useCallback } from "react"; +import type { Link } from "react-aria-components"; import styles from "./index.module.scss"; -import { Button, ButtonLink } from "../Button/index.js"; +import { type Props as ButtonProps, Button } from "../Button/index.js"; import buttonStyles from "../Button/index.module.scss"; import { Select } from "../Select/index.js"; import { UnstyledToolbar } from "../UnstyledToolbar/index.js"; @@ -153,7 +154,7 @@ const PaginatorToolbar = ({ }; type PageSelectorProps = Pick< - ComponentProps, + ComponentProps, "hideText" | "beforeIcon" | "isDisabled" | "children" > & { page: number; @@ -169,7 +170,7 @@ const PageSelector = ({ mkPageLink, ...props }: PageSelectorProps) => ); type PageLinkProps = Omit< - ComponentProps, + ButtonProps, "variant" | "size" | "href" | "onPress" > & { page: number; @@ -190,7 +191,7 @@ const PageLink = ({ }, [onPageChange, page]); return ( - , + ButtonProps, "variant" | "size" | "href" | "onPress" > & { page: number; diff --git a/packages/component-library/src/Select/index.module.scss b/packages/component-library/src/Select/index.module.scss index ad00c3ce24..9efba9f487 100644 --- a/packages/component-library/src/Select/index.module.scss +++ b/packages/component-library/src/Select/index.module.scss @@ -2,9 +2,7 @@ .select { .caret { - transition-property: transform; - transition-duration: 300ms; - transition-timing-function: ease; + transition: transform 300ms ease; } &[data-open] { diff --git a/packages/component-library/src/Select/index.tsx b/packages/component-library/src/Select/index.tsx index 63dadd4119..a91f50f1b2 100644 --- a/packages/component-library/src/Select/index.tsx +++ b/packages/component-library/src/Select/index.tsx @@ -3,6 +3,7 @@ import clsx from "clsx"; import type { ComponentProps, ReactNode } from "react"; import { type PopoverProps, + type Button as BaseButton, Label, Select as BaseSelect, Popover, @@ -15,14 +16,15 @@ import { } from "react-aria-components"; import styles from "./index.module.scss"; -import { Button } from "../Button/index.js"; +import { type Props as ButtonProps, Button } from "../Button/index.js"; +import { DropdownCaretDown } from "../DropdownCaretDown/index.js"; type Props = Omit< ComponentProps, "defaultSelectedKey" | "selectedKey" | "onSelectionChange" > & Pick< - ComponentProps, + ButtonProps, "variant" | "size" | "rounded" | "hideText" | "isPending" > & Pick & { @@ -142,16 +144,3 @@ const Item = ({ children, show }: ItemProps) => ( ); - -const DropdownCaretDown = ( - props: Omit, "xmlns" | "viewBox" | "fill">, -) => ( - - - -); diff --git a/packages/component-library/src/Table/index.module.scss b/packages/component-library/src/Table/index.module.scss index 2db4fb9ca3..28e0dc65e2 100644 --- a/packages/component-library/src/Table/index.module.scss +++ b/packages/component-library/src/Table/index.module.scss @@ -39,8 +39,7 @@ border-spacing: 0; .cell { - padding-left: theme.spacing(3); - padding-right: theme.spacing(3); + padding: theme.spacing(3) theme.spacing(4); white-space: nowrap; border: 0; outline: theme.spacing(0.5) solid transparent; @@ -49,14 +48,6 @@ background-color: theme.color("background", "primary"); transition: outline-color 100ms linear; - &:first-child { - padding-left: theme.spacing(4); - } - - &:last-child { - padding-right: theme.spacing(4); - } - &[data-alignment="left"] { text-align: left; } @@ -91,10 +82,8 @@ color: theme.color("muted"); .cell { - border-bottom: 1px solid theme.color("background", "secondary"); + border-bottom: 1px solid theme.color("border"); font-weight: theme.font-weight("medium"); - padding-top: theme.spacing(3); - padding-bottom: theme.spacing(3); position: sticky; top: 0; z-index: 1; @@ -106,7 +95,7 @@ } .tableBody { - @include theme.text("sm", "medium"); + @include theme.text("base", "medium"); color: theme.color("paragraph"); font-weight: theme.font-weight("medium"); @@ -125,9 +114,8 @@ } .cell { - padding-top: theme.spacing(4); - padding-bottom: theme.spacing(4); transition: background-color 100ms linear; + border-bottom: 1px solid theme.color("background", "secondary"); } &[data-hovered] .cell { @@ -155,23 +143,6 @@ width: 100%; } - &[data-divide] { - .table { - // This rule has lower specificity than a rule above which applies the - // background color to hovered / pressed body cells, but csslint has no - // way to understand that .tableHeader and .tableBody are mutually - // exclusive and so these rules will never override other other. - // stylelint-disable-next-line no-descending-specificity - .tableHeader .cell { - border-color: theme.color("border"); - } - - .tableBody .row .cell { - border-bottom: 1px solid theme.color("background", "secondary"); - } - } - } - &[data-rounded] { border-radius: theme.border-radius("xl"); diff --git a/packages/component-library/src/Table/index.stories.tsx b/packages/component-library/src/Table/index.stories.tsx index a4ea136be7..057776b1cb 100644 --- a/packages/component-library/src/Table/index.stories.tsx +++ b/packages/component-library/src/Table/index.stories.tsx @@ -42,12 +42,6 @@ const meta = { category: "State", }, }, - divide: { - control: "boolean", - table: { - category: "Variant", - }, - }, fill: { control: "boolean", table: { @@ -75,7 +69,6 @@ export const Table = { isUpdating: false, isLoading: false, fill: true, - divide: false, rounded: true, columns: [ { diff --git a/packages/component-library/src/Table/index.tsx b/packages/component-library/src/Table/index.tsx index 147cca1b10..8f13338871 100644 --- a/packages/component-library/src/Table/index.tsx +++ b/packages/component-library/src/Table/index.tsx @@ -22,7 +22,6 @@ import { type TableProps = { className?: string | undefined; fill?: boolean | undefined; - divide?: boolean | undefined; rounded?: boolean | undefined; label: string; columns: ColumnConfig[]; @@ -60,7 +59,6 @@ export type RowConfig = Omit< export const Table = ({ className, fill, - divide, rounded, label, rows, @@ -73,7 +71,6 @@ export const Table = ({
{isUpdating && ( diff --git a/packages/component-library/src/Tabs/index.tsx b/packages/component-library/src/Tabs/index.tsx index 3c9e9b854b..b51ef7345d 100644 --- a/packages/component-library/src/Tabs/index.tsx +++ b/packages/component-library/src/Tabs/index.tsx @@ -40,7 +40,6 @@ export const Tabs = ({ label, className, pathname, ...props }: Props) => ( {args.isSelected && ( = 0.6'} + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -15053,12 +15063,12 @@ packages: react-dom: optional: true - framer-motion@11.11.17: - resolution: {integrity: sha512-O8QzvoKiuzI5HSAHbcYuL6xU+ZLXbrH7C8Akaato4JzQbX2ULNeniqC2Vo5eiCtFktX9XsJ+7nUhxcl2E2IjpA==} + framer-motion@11.15.0: + resolution: {integrity: sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==} peerDependencies: '@emotion/is-prop-valid': '*' - react: ^18.0.0 - react-dom: ^18.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/is-prop-valid': optional: true @@ -16286,6 +16296,10 @@ packages: resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} engines: {node: '>= 0.4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -17928,15 +17942,21 @@ packages: moment@2.29.4: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + motion-dom@11.14.3: + resolution: {integrity: sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==} + + motion-utils@11.14.3: + resolution: {integrity: sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==} + motion@10.16.2: resolution: {integrity: sha512-p+PurYqfUdcJZvtnmAqu5fJgV2kR0uLFQuBKtLeFVTrYEVllI99tiOTSefVNYuip9ELTEkepIIDftNdze76NAQ==} - motion@11.11.17: - resolution: {integrity: sha512-y6mXYElvJ5HHwPBUpYG/5wclKVGW4hJhqPkTjWccib5/WrcRM185adg3+4aSmG5iD10XKFt5uBOAiKwuzMHPPQ==} + motion@11.14.4: + resolution: {integrity: sha512-ZIaw6ko88B8rSmBEFzqbTCQMbo9xMu8f4PSXSGdb9DTDy8R0sXcbwMEKmTEYkrj9TmZ4n+Ebd0KYjtqHgzRkRQ==} peerDependencies: '@emotion/is-prop-valid': '*' - react: ^18.0.0 - react-dom: ^18.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@emotion/is-prop-valid': optional: true @@ -21128,6 +21148,11 @@ packages: sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + superjson@2.2.2: + resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} + engines: {node: '>=16'} superstruct@0.14.2: resolution: {integrity: sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ==} @@ -44429,6 +44454,10 @@ snapshots: cookie@0.6.0: {} + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -47970,16 +47999,20 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - framer-motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0): + framer-motion@11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0): dependencies: + motion-dom: 11.14.3 + motion-utils: 11.14.3 tslib: 2.8.0 optionalDependencies: '@emotion/is-prop-valid': 1.2.2 react: 19.0.0 react-dom: 18.3.1(react@18.3.1) - framer-motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + framer-motion@11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: + motion-dom: 11.14.3 + motion-utils: 11.14.3 tslib: 2.8.0 optionalDependencies: '@emotion/is-prop-valid': 1.2.2 @@ -49515,6 +49548,8 @@ snapshots: call-bind: 1.0.7 get-intrinsic: 1.2.4 + is-what@4.1.16: {} + is-windows@1.0.2: {} is-wsl@1.1.0: {} @@ -53558,6 +53593,10 @@ snapshots: moment@2.29.4: {} + motion-dom@11.14.3: {} + + motion-utils@11.14.3: {} + motion@10.16.2: dependencies: '@motionone/animation': 10.18.0 @@ -53567,18 +53606,18 @@ snapshots: '@motionone/utils': 10.18.0 '@motionone/vue': 10.16.4 - motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0): + motion@11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0): dependencies: - framer-motion: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0) + framer-motion: 11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@19.0.0) tslib: 2.8.0 optionalDependencies: '@emotion/is-prop-valid': 1.2.2 react: 19.0.0 react-dom: 18.3.1(react@18.3.1) - motion@11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + motion@11.14.4(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - framer-motion: 11.11.17(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + framer-motion: 11.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) tslib: 2.8.0 optionalDependencies: '@emotion/is-prop-valid': 1.2.2 @@ -57962,6 +58001,10 @@ snapshots: sudo-prompt@9.2.1: {} + superjson@2.2.2: + dependencies: + copy-anything: 3.0.5 + superstruct@0.14.2: {} superstruct@0.15.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 65447e87c1..2a5964356b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -69,7 +69,7 @@ catalog: framer-motion: 11.11.10 jest: 29.7.0 modern-normalize: 3.0.1 - motion: 11.11.17 + motion: 11.14.4 next-themes: 0.3.0 next: 15.1.0 nuqs: 2.1.2 @@ -88,6 +88,7 @@ catalog: style-loader: 4.0.0 stylelint-config-standard-scss: 13.1.0 stylelint: 16.10.0 + superjson: 2.2.2 swr: 2.2.5 tailwindcss-animate: 1.0.7 tailwindcss-react-aria-components: 1.1.6