diff --git a/.changeset/dry-feet-go.md b/.changeset/dry-feet-go.md new file mode 100644 index 000000000..7c47752b2 --- /dev/null +++ b/.changeset/dry-feet-go.md @@ -0,0 +1,5 @@ +--- +"@namehash/namehash-ui": minor +--- + +Populates `namehash-ui` package with shared UI components. diff --git a/packages/ensnode-sdk/src/shared/types.ts b/packages/ensnode-sdk/src/shared/types.ts index 6d3252f63..7a35434fc 100644 --- a/packages/ensnode-sdk/src/shared/types.ts +++ b/packages/ensnode-sdk/src/shared/types.ts @@ -7,6 +7,8 @@ import type { DEFAULT_EVM_CHAIN_ID } from "../ens"; * * Represents a unique identifier for a chain. * Guaranteed to be a positive integer. + * + * Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains **/ export type ChainId = number; diff --git a/packages/namehash-ui/README.md b/packages/namehash-ui/README.md index 59f8a4879..6d50047bb 100644 --- a/packages/namehash-ui/README.md +++ b/packages/namehash-ui/README.md @@ -7,10 +7,10 @@ For UI component libraries intended for the general public, we recommend [ensnod ## Installation ```bash -npm install @namehash/namehash-ui @ensnode/ensnode-react +npm install @namehash/namehash-ui @ensnode/ensnode-react sonner ``` -Note: `@ensnode/ensnode-react` is necessary only for some components. It might happen that you won't need it. +Note: `@ensnode/ensnode-react` and `sonner` are package's peer dependencies. The former is necessary only for some components and same goes for `sonner` which is only necessary for `CopyButton` component. It might happen that you won't need these installed, depending on which components you want to use. ## Setup diff --git a/packages/namehash-ui/biome.jsonc b/packages/namehash-ui/biome.jsonc index 887097697..5cb7459a5 100644 --- a/packages/namehash-ui/biome.jsonc +++ b/packages/namehash-ui/biome.jsonc @@ -2,8 +2,6 @@ "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "extends": "//", "files": { - "includes": [ - "!src/styles.css" - ] + "includes": ["**/*", "!src/styles.css"] } } diff --git a/packages/namehash-ui/components.json b/packages/namehash-ui/components.json new file mode 100644 index 000000000..599d3091f --- /dev/null +++ b/packages/namehash-ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "nhui" + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/cn", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/packages/namehash-ui/package.json b/packages/namehash-ui/package.json index 2020a23af..5d85e4bbd 100644 --- a/packages/namehash-ui/package.json +++ b/packages/namehash-ui/package.json @@ -53,20 +53,36 @@ "peerDependencies": { "@ensnode/ensnode-react": "workspace:*", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "sonner": "^2.0.3" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "postcss": "^8.5.6", "react": "19.2.1", + "tailwindcss": "catalog:", "tsup": "^8.3.6", "typescript": "catalog:", - "@tailwindcss/postcss": "^4.1.18", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18" + "viem": "catalog:" }, - "dependencies": {} + "dependencies": { + "@ensnode/datasources": "workspace:*", + "@ensnode/ensnode-sdk": "workspace:*", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", + "boring-avatars": "^2.0.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "catalog:", + "lucide-react": "catalog:", + "tailwind-merge": "catalog:", + "tailwindcss-animate": "catalog:", + "tw-animate-css": "^1.4.0" + } } diff --git a/packages/namehash-ui/src/components/chains/ChainIcon.tsx b/packages/namehash-ui/src/components/chains/ChainIcon.tsx new file mode 100644 index 000000000..0fdfca51c --- /dev/null +++ b/packages/namehash-ui/src/components/chains/ChainIcon.tsx @@ -0,0 +1,70 @@ +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + linea, + lineaSepolia, + mainnet, + optimism, + optimismSepolia, + scroll, + scrollSepolia, + sepolia, +} from "viem/chains"; + +import { ensTestEnvL1Chain } from "@ensnode/datasources"; +import type { ChainId } from "@ensnode/ensnode-sdk"; + +import { ArbitrumIcon } from "./icons/ArbitrumIcon.tsx"; +import { ArbitrumTestnetIcon } from "./icons/ArbitrumTestnetIcon.tsx"; +import { BaseIcon } from "./icons/BaseIcon.tsx"; +import { BaseTestnetIcon } from "./icons/BaseTestnetIcon.tsx"; +import { EthereumIcon } from "./icons/EthereumIcon.tsx"; +import { EthereumLocalIcon } from "./icons/EthereumLocalIcon.tsx"; +import { EthereumTestnetIcon } from "./icons/EthereumTestnetIcon.tsx"; +import { LineaIcon } from "./icons/LineaIcon.tsx"; +import { LineaTestnetIcon } from "./icons/LineaTestnetIcon.tsx"; +import { OptimismIcon } from "./icons/OptimismIcon.tsx"; +import { OptimismTestnetIcon } from "./icons/OptimismTestnetIcon.tsx"; +import { ScrollIcon } from "./icons/ScrollIcon.tsx"; +import { ScrollTestnetIcon } from "./icons/ScrollTestnetIcon.tsx"; +import { UnrecognizedChainIcon } from "./icons/UnrecognizedChainIcon.tsx"; + +export interface ChainIconProps { + chainId: ChainId; + width?: number; + height?: number; +} + +/** + * Mapping of {@link ChainId} to chain icon. + */ +const chainIcons = new Map>>([ + // mainnet + [mainnet.id, EthereumIcon], + [base.id, BaseIcon], + [linea.id, LineaIcon], + [optimism.id, OptimismIcon], + [arbitrum.id, ArbitrumIcon], + [scroll.id, ScrollIcon], + + // sepolia + [sepolia.id, EthereumTestnetIcon], + [baseSepolia.id, BaseTestnetIcon], + [lineaSepolia.id, LineaTestnetIcon], + [optimismSepolia.id, OptimismTestnetIcon], + [arbitrumSepolia.id, ArbitrumTestnetIcon], + [scrollSepolia.id, ScrollTestnetIcon], + + // ens-test-env + [ensTestEnvL1Chain.id, EthereumLocalIcon], +]); + +/** + * Renders an icon for the provided chain ID. + */ +export function ChainIcon({ chainId, width = 20, height = 20 }: ChainIconProps) { + const Icon = chainIcons.get(chainId) || UnrecognizedChainIcon; + return ; +} diff --git a/packages/namehash-ui/src/components/chains/ChainName.tsx b/packages/namehash-ui/src/components/chains/ChainName.tsx new file mode 100644 index 000000000..3dc0f3aba --- /dev/null +++ b/packages/namehash-ui/src/components/chains/ChainName.tsx @@ -0,0 +1,15 @@ +import type { ChainId } from "@ensnode/ensnode-sdk"; + +import { getChainName } from "@/utils/chains.ts"; + +export interface ChainNameProps { + chainId: ChainId; + className: string; +} + +/** + * Renders a prettified chain name for the provided {@link ChainId}. + */ +export const ChainName = ({ chainId, className }: ChainNameProps) => ( +

{getChainName(chainId)}

+); diff --git a/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx new file mode 100644 index 000000000..0518a4316 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const ArbitrumIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx new file mode 100644 index 000000000..fe5beb5ee --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const ArbitrumTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx b/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx new file mode 100644 index 000000000..a0fed3979 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx @@ -0,0 +1,18 @@ +import type React from "react"; + +export const BaseIcon = (props: React.SVGProps) => ( + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx new file mode 100644 index 000000000..841bf885e --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const BaseTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx new file mode 100644 index 000000000..4510ab0de --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx @@ -0,0 +1,17 @@ +import type React from "react"; + +export const EthereumIcon = (props: React.SVGProps) => ( + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx new file mode 100644 index 000000000..a2231c729 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const EthereumLocalIcon = (props: React.SVGProps) => ( + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx new file mode 100644 index 000000000..15c6e7fce --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const EthereumTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx b/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx new file mode 100644 index 000000000..d4c268d76 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx @@ -0,0 +1,34 @@ +import type React from "react"; + +export const LineaIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx new file mode 100644 index 000000000..ef4ebe483 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const LineaTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx b/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx new file mode 100644 index 000000000..e231c4eba --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const OptimismIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx new file mode 100644 index 000000000..793e1669d --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const OptimismTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx new file mode 100644 index 000000000..1e6f00617 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx @@ -0,0 +1,40 @@ +import type React from "react"; + +export const ScrollIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx new file mode 100644 index 000000000..ef752793c --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx @@ -0,0 +1,40 @@ +import type React from "react"; + +export const ScrollTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx b/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx new file mode 100644 index 000000000..5125ea551 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx @@ -0,0 +1,20 @@ +import type React from "react"; + +export const UnrecognizedChainIcon = (props: React.SVGProps) => ( + + + +); diff --git a/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx b/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx new file mode 100644 index 000000000..4a568ecd2 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx @@ -0,0 +1,24 @@ +import { fromUnixTime, intlFormat } from "date-fns"; +import { useEffect, useState } from "react"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +/** + * Client-only absolute time component + */ +export function AbsoluteTime({ + timestamp, + options, +}: { + timestamp: UnixTimestamp; + options: Intl.DateTimeFormatOptions; +}) { + const date = fromUnixTime(timestamp); + const [absoluteTime, setAbsoluteTime] = useState(""); + + useEffect(() => { + setAbsoluteTime(intlFormat(date, options)); + }, [date, options]); + + return <>{absoluteTime}; +} diff --git a/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx b/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx new file mode 100644 index 000000000..134cade25 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx @@ -0,0 +1,23 @@ +import { formatDistanceStrict, fromUnixTime } from "date-fns"; +import { useEffect, useState } from "react"; + +import type { Duration } from "@ensnode/ensnode-sdk"; + +/** + * Display Duration component + */ +export function DisplayDuration({ duration }: { duration: Duration }) { + const [timeDistance, setTimeDistance] = useState(""); + + // formatDistanceStrict needs two UnixTimestamp values + // so we create `beginsAt` and `endsAt` timestamps + // where `beginsAt = endsAt - duration` + const beginsAt = fromUnixTime(0); + const endsAt = fromUnixTime(duration); + + useEffect(() => { + setTimeDistance(formatDistanceStrict(beginsAt, endsAt)); + }, [beginsAt, endsAt]); + + return timeDistance; +} diff --git a/packages/namehash-ui/src/components/datetime/RelativeTime.tsx b/packages/namehash-ui/src/components/datetime/RelativeTime.tsx new file mode 100644 index 000000000..32df4c3e2 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/RelativeTime.tsx @@ -0,0 +1,144 @@ +import { formatDistance, formatDistanceStrict, fromUnixTime } from "date-fns"; +import { millisecondsInSecond } from "date-fns/constants"; +import type * as React from "react"; +import { useEffect, useState } from "react"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { AbsoluteTime } from "@/components/datetime/AbsoluteTime.tsx"; +import { Tooltip, TooltipArrow, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { cn } from "@/utils/cn.ts"; + +/** + * Formats a Unix timestamp as its relative distance with now + * + * @param timestamp - the timestamp to format as a relative time + * @param enforcePast - if true, enforces that the return value won't relate to the future. + * Helpful for UI contexts where it's nonsensical for a value to relate to the future. Ex: how long ago an event happened. + * Note how different systems may have misaligned clocks. `enforcePast` aims to protect from UI confusion when + * the client's clock is set incorrectly in the past, such that events happening "now" might otherwise appear to + * be coming from the future. + * @param includeSeconds - if true includes seconds in the result + * @param conciseFormatting - if true removes special prefixes / suffixes such as "about ..." or "in almost ..." + * @param relativeTo - if defined represents the timestamp to compare with. Otherwise, the timestamp param is compared with the present. + */ +export function formatRelativeTime( + timestamp: UnixTimestamp, + enforcePast = false, + includeSeconds = false, + conciseFormatting = false, + relativeTo?: UnixTimestamp, +): string { + const date = fromUnixTime(timestamp); + const compareWith = typeof relativeTo !== "undefined" ? fromUnixTime(relativeTo) : new Date(); + + if ( + (enforcePast && date >= compareWith) || + (!includeSeconds && + !conciseFormatting && + Math.abs(date.getTime() - compareWith.getTime()) < 60 * millisecondsInSecond) + ) { + return "just now"; + } + + if (conciseFormatting) { + return formatDistanceStrict(date, compareWith, { addSuffix: true }); + } + + return formatDistance(date, compareWith, { + addSuffix: true, + includeSeconds, + }); +} + +/** + * Client-only relative time component + */ +export function RelativeTime({ + timestamp, + enforcePast = false, + includeSeconds = false, + conciseFormatting = false, + tooltipPosition = "top", + tooltipStyles, + relativeTo, + prefix, + contentWrapper, +}: { + timestamp: UnixTimestamp; + enforcePast?: boolean; + includeSeconds?: boolean; + conciseFormatting?: boolean; + tooltipPosition?: React.ComponentProps["side"]; + tooltipStyles?: string; + relativeTo?: UnixTimestamp; + prefix?: string; + /** + * A component to be rendered as a wrapper for the Relative Time component content. + */ + contentWrapper?: ({ children }: React.PropsWithChildren) => React.ReactNode; +}) { + const [relativeTime, setRelativeTime] = useState(""); + + useEffect(() => { + setRelativeTime( + formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), + ); + }, [timestamp, conciseFormatting, enforcePast, includeSeconds, relativeTo]); + + const tooltipTriggerContent = ( + <> + {prefix} + {relativeTime} + + ); + + return ( + + + {typeof contentWrapper === "function" + ? contentWrapper({ children: tooltipTriggerContent }) + : tooltipTriggerContent} + + + {" "} + (Local time) +
+ {" "} + (UTC) + +
+
+ ); +} + +// TODO: Copied from ENSAwards - some alignment made but further changes may be needed diff --git a/packages/namehash-ui/src/components/footer/Footer.tsx b/packages/namehash-ui/src/components/footer/Footer.tsx new file mode 100644 index 000000000..0108d5d05 --- /dev/null +++ b/packages/namehash-ui/src/components/footer/Footer.tsx @@ -0,0 +1,196 @@ +import { EnsServiceProviderIcon } from "@/components/icons/ens/EnsServiceProviderIcon.tsx"; +import { NameHashLabsIcon } from "@/components/icons/namehash/NameHashLabsIcon.tsx"; +import { EfpIcon } from "@/components/icons/socials/EfpIcon.tsx"; +import { EmailIcon } from "@/components/icons/socials/EmailIcon.tsx"; +import { FarcasterIcon } from "@/components/icons/socials/FarcasterIcon.tsx"; +import { GitHubIcon } from "@/components/icons/socials/GitHubIcon.tsx"; +import { TelegramIcon } from "@/components/icons/socials/TelegramIcon.tsx"; +import { TwitterIcon } from "@/components/icons/socials/TwitterIcon.tsx"; + +const footerProducts = [ + { + name: "ENSNode", + href: "https://ensnode.io/", + }, + { + name: "ENSRainbow", + href: "https://ensrainbow.io", + }, + { + name: "ENSAdmin", + href: "https://ensadmin.io", + }, + { + name: "ENS Referral Program", + href: "/ens-referral-awards", + }, + { + name: "ENSAwards", + href: "https://ensawards.org/", + }, + { + name: "NameGraph", + href: "https://namegraph.dev", + }, + { + name: "NameAI", + href: "https://nameai.io/", + }, + { + name: "NameGuard", + href: "https://nameguard.io", + }, + { + name: "NameKit", + href: "https://namekit.io", + }, +]; + +const footerResources = [ + { + name: "Contact us", + href: "https://namehashlabs.org/contact", + }, + { + name: "Careers", + href: "https://namehashlabs.org/careers", + }, + { + name: "Partners", + href: "https://namehashlabs.org/partners", + }, + { + name: "Brand assets", + href: "https://namehashlabs.org/brand-assets", + }, +]; + +export function Footer() { + return ( +
+
+
+
+ + +

+ Founded in 2022, Namehash Labs is dedicated to developing open source infrastructure + that helps the Ethereum Name Service (ENS) Protocol grow. +

+ + +
+ +
+
+ Products + +
+
+ Resources + +
+
+
+ +
+

+ © NameHash Labs. All Rights Reserved +

+ + + +
+ + Made with + {"❤️"} + by + + + NameHash Labs + +
+
+
+
+ ); +} diff --git a/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx b/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx new file mode 100644 index 000000000..3602f4270 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx @@ -0,0 +1,27 @@ +import type { LucideIcon, LucideProps } from "lucide-react"; +import * as React from "react"; + +export const ChainExplorerIcon: LucideIcon = React.forwardRef( + (props, ref) => { + return ( + + + + + ); + }, +); diff --git a/packages/namehash-ui/src/components/icons/InfoIcon.tsx b/packages/namehash-ui/src/components/icons/InfoIcon.tsx new file mode 100644 index 000000000..2964ad049 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/InfoIcon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export const InfoIcon = (props: SVGProps) => ( + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/icons/ens/EnsIcon.tsx b/packages/namehash-ui/src/components/icons/ens/EnsIcon.tsx new file mode 100644 index 000000000..515c0eaf4 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/ens/EnsIcon.tsx @@ -0,0 +1,37 @@ +import type { LucideIcon, LucideProps } from "lucide-react"; +import * as React from "react"; + +export const EnsIcon: LucideIcon = React.forwardRef((props, ref) => { + return ( + + + + + + + ); +}); diff --git a/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx b/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx new file mode 100644 index 000000000..1733d0468 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx @@ -0,0 +1,108 @@ +import type React from "react"; + +export const EnsServiceProviderIcon = (props: React.SVGAttributes) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx b/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx new file mode 100644 index 000000000..22018e0ac --- /dev/null +++ b/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx @@ -0,0 +1,67 @@ +import type React from "react"; + +export const NameHashLabsIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx b/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx new file mode 100644 index 000000000..77b18e584 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx @@ -0,0 +1,30 @@ +import type React from "react"; + +export const EfpIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx b/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx new file mode 100644 index 000000000..8441cd67b --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx @@ -0,0 +1,24 @@ +import type React from "react"; + +export const EmailIcon = (props: React.SVGProps) => { + return ( + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx b/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx new file mode 100644 index 000000000..6f80800bd --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx @@ -0,0 +1,27 @@ +import type React from "react"; + +export const FarcasterIcon = (props: React.SVGProps) => { + return ( + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx b/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx new file mode 100644 index 000000000..3331767c5 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx @@ -0,0 +1,21 @@ +import type React from "react"; + +export const GitHubIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx b/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx new file mode 100644 index 000000000..316c9bc97 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx @@ -0,0 +1,19 @@ +import type React from "react"; + +export const TelegramIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx b/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx new file mode 100644 index 000000000..171446c53 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx @@ -0,0 +1,12 @@ +import type React from "react"; + +export const TwitterIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/identity/Address.tsx b/packages/namehash-ui/src/components/identity/Address.tsx new file mode 100644 index 000000000..47cefc2dc --- /dev/null +++ b/packages/namehash-ui/src/components/identity/Address.tsx @@ -0,0 +1,17 @@ +import type { Address } from "viem"; +import { getAddress } from "viem"; + +interface AddressDisplayProps { + address: Address; + className?: string; +} + +/** + * Displays a truncated checksummed address without any navigation. + * Pure display component for showing addresses. + */ +export function AddressDisplay({ address, className }: AddressDisplayProps) { + const checksummedAddress = getAddress(address); + const truncatedAddress = `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`; + return {truncatedAddress}; +} diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx new file mode 100644 index 000000000..5741b6fcd --- /dev/null +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -0,0 +1,78 @@ +import BoringAvatar from "boring-avatars"; +import * as React from "react"; + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import type { Name } from "@ensnode/ensnode-sdk"; + +import { Avatar, AvatarImage } from "@/components/ui/avatar.tsx"; +import { cn } from "@/utils/cn.ts"; +import { getEnsMetadataServiceAvatarUrl } from "@/utils/ensMetadata.ts"; + +interface EnsAvatarProps { + name: Name; + namespaceId: ENSNamespaceId; + className?: string; + isSquare?: boolean; +} + +type ImageLoadingStatus = Parameters< + NonNullable["onLoadingStatusChange"]> +>[0]; + +export const EnsAvatar = ({ name, namespaceId, className, isSquare = false }: EnsAvatarProps) => { + const [loadingStatus, setLoadingStatus] = React.useState("idle"); + const avatarUrl = getEnsMetadataServiceAvatarUrl(name, namespaceId); + + if (avatarUrl === null) { + return ( + + + + ); + } + + return ( + + { + setLoadingStatus(status); + }} + /> + {loadingStatus === "error" && } + {(loadingStatus === "idle" || loadingStatus === "loading") && ( + + )} + + ); +}; + +interface EnsAvatarFallbackProps { + name: Name; + isSquare: boolean; +} + +const avatarFallbackColors = ["#000000", "#bedbff", "#5191c1", "#1e6495", "#0a4b75"]; + +const EnsAvatarFallback = ({ name, isSquare }: EnsAvatarFallbackProps) => ( + +); + +type EnsAvatarLoadingProps = Omit; +const AvatarLoading = ({ className }: EnsAvatarLoadingProps) => ( +
+); + +// TODO: Copied from ENSAwards (as a newer version) - further alignment might be needed diff --git a/packages/namehash-ui/src/components/identity/Identity.tsx b/packages/namehash-ui/src/components/identity/Identity.tsx new file mode 100644 index 000000000..703fff704 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/Identity.tsx @@ -0,0 +1,157 @@ +import type { PropsWithChildren } from "react"; + +import type { ENSNamespaceId, Identity } from "@ensnode/ensnode-sdk"; +import { + DEFAULT_EVM_CHAIN_ID, + isResolvedIdentity, + ResolutionStatusIds, + translateDefaultableChainIdToChainId, +} from "@ensnode/ensnode-sdk"; + +import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; +import { ChainExplorerIcon } from "@/components/icons/ChainExplorerIcon.tsx"; +import { EnsIcon } from "@/components/icons/ens/EnsIcon.tsx"; +import { AddressDisplay } from "@/components/identity/Address.tsx"; +import { CopyButton } from "@/components/special-buttons/CopyButton.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { getBlockExplorerAddressDetailsUrl } from "@/utils/blockExplorers.ts"; +import { getChainName } from "@/utils/chains.ts"; +import { cn } from "@/utils/cn.ts"; +import { getEnsManagerAddressDetailsUrl } from "@/utils/ensManager.ts"; + +export interface IdentityLinkDetails { + isExternal: boolean; + link: URL; +} +interface IdentityLinkProps { + linkDetails: IdentityLinkDetails; + className?: string; +} + +/** + * Displays an identifier (address or name) with a link to the identity details URL. + * If the ENS namespace has a known ENS Manager App, + * includes a link to the view details of the address within that ENS namespace. + * + * Can take other components (ex.ChainIcon) as children + * and display them alongside the link as one common interaction area. + */ +export function IdentityLink({ + linkDetails, + className, + children, +}: PropsWithChildren) { + return ( + + {children} + + ); +} + +export interface IdentityTooltipProps { + identity: Identity; + namespaceId: ENSNamespaceId; +} + +/** + * On hover displays details on how the primary name for + * the address of the identity was resolved. + */ +export const IdentityTooltip = ({ + identity, + namespaceId, + children, +}: PropsWithChildren) => { + if (!isResolvedIdentity(identity)) { + // identity is still loading, don't build any tooltip components yet. + return children; + } + + const chainDescription = + identity.chainId === DEFAULT_EVM_CHAIN_ID + ? 'the "default" EVM Chain' + : getChainName(identity.chainId); + + let header: string; + + switch (identity.resolutionStatus) { + case ResolutionStatusIds.Named: + header = `Primary name on ${chainDescription} for address:`; + break; + case ResolutionStatusIds.Unnamed: + header = `Unnamed address on ${chainDescription}:`; + break; + case ResolutionStatusIds.Unknown: + header = `Error resolving address on ${chainDescription}:`; + break; + } + + const ensAppAddressDetailsUrl = getEnsManagerAddressDetailsUrl(identity.address, namespaceId); + + const body = ( + + + + ); + + const effectiveChainId = translateDefaultableChainIdToChainId(identity.chainId, namespaceId); + const chainExplorerUrl = getBlockExplorerAddressDetailsUrl(effectiveChainId, identity.address); + + return ( + + {children} + +
+
+ +
+
+ {header} +
+ {body} +
+
+ + {chainExplorerUrl && ( + + + + )} + {ensAppAddressDetailsUrl && ( + + + + )} +
+
+
+
+ ); +}; + +// TODO: Copied from ENSAwards - some alignment made but further changes may be needed diff --git a/packages/namehash-ui/src/components/identity/Name.tsx b/packages/namehash-ui/src/components/identity/Name.tsx new file mode 100644 index 000000000..69e5e55c9 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/Name.tsx @@ -0,0 +1,18 @@ +import type { Name } from "@ensnode/ensnode-sdk"; +import { beautifyName } from "@ensnode/ensnode-sdk"; + +interface NameDisplayProps { + name: Name; + className?: string; +} + +/** + * Displays an ENS name in beautified form. + * + * @param name - The name to display in beautified form. + * + */ +export function NameDisplay({ name, className = "nhui:font-medium" }: NameDisplayProps) { + const beautifiedName = beautifyName(name); + return {beautifiedName}; +} diff --git a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx new file mode 100644 index 000000000..10bd5a0f1 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx @@ -0,0 +1,231 @@ +import type * as React from "react"; + +import { useResolvedIdentity } from "@ensnode/ensnode-react"; +import { + type ENSNamespaceId, + type Identity, + isResolvedIdentity, + ResolutionStatusIds, + translateDefaultableChainIdToChainId, + type UnresolvedIdentity, +} from "@ensnode/ensnode-sdk"; + +import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; +import { AddressDisplay } from "@/components/identity/Address.tsx"; +import { + IdentityLink, + type IdentityLinkDetails, + IdentityTooltip, +} from "@/components/identity/Identity.tsx"; +import { NameDisplay } from "@/components/identity/Name.tsx"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIsMobile } from "@/hooks/useIsMobile.tsx"; +import { cn } from "@/utils/cn.ts"; + +import { EnsAvatar } from "./EnsAvatar.tsx"; + +export interface ResolveAndDisplayIdentityProps { + identity: UnresolvedIdentity; + namespaceId: ENSNamespaceId; + accelerate?: boolean; + withLink?: boolean; + identityLinkDetails?: IdentityLinkDetails; + withTooltip?: boolean; + withAvatar?: boolean; + withIdentifier?: boolean; + className?: string; +} + +/** + * Resolves the provided `UnresolvedIdentity` through ENSNode and displays the result. + * + * @param identity - The `UnresolvedIdentity` to resolve and display. + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') + * @param accelerate - Whether to attempt Protocol Acceleration (default: false) + * when resolving the primary name. + * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. + * @param identityLinkDetails - If the `withLink` is true, provides info on where should it lead and should it be an external link. + * @param withTooltip - Whether to wrap the displayed identity in an `IdentityInfoTooltip` component. + * @param withAvatar - Whether to display an avatar image. + * @param withIdentifier - Whether to display identity's textual identifier (address or name). + * @param className - The class name to apply to the displayed identity. + */ +export function ResolveAndDisplayIdentity({ + identity, + namespaceId, + accelerate = true, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: ResolveAndDisplayIdentityProps) { + // resolve the primary name for `identity` using ENSNode + // TODO: extract out the concept of resolving an `Identity` into a provider that child + // components can then hook into. + const { identity: identityResult } = useResolvedIdentity({ + identity, + accelerate, + }); + + return ( + + ); +} + +interface DisplayIdentityProps { + identity: Identity; + namespaceId: ENSNamespaceId; + withLink?: boolean; + identityLinkDetails?: IdentityLinkDetails; + withTooltip?: boolean; + withAvatar?: boolean; + withIdentifier?: boolean; + className?: string; +} + +/** + * Displays the provided `Identity`. + * + * Performs _NO_ resolution if the provided `identity` is not already a `ResolvedIdentity`. + * + * @param identity - The identity to display. May be a `ResolvedIdentity` or an `UnresolvedIdentity`. + * If not a `ResolvedIdentity` (and therefore just an `UnresolvedIdentity`) then displays a loading state. + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') + * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. + * @param identityLinkDetails - If the `withLink` is true, provides info on where should it lead and should it be an external link. + * @param withTooltip - Whether to wrap the displayed identity in an `IdentityInfoTooltip` component. + * @param withAvatar - Whether to display an avatar image. + * @param withIdentifier - Whether to display identity's textual identifier (address or name). + * @param className - The class name to apply to the displayed identity. + * + * @throws Error - if `withLink` is true, but no `identityLinkDetails` are provided. + */ +export function DisplayIdentity({ + identity, + namespaceId, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: DisplayIdentityProps) { + let avatar: React.ReactElement; + let identifier: React.ReactElement; + + const isMobile = useIsMobile(); + + if (!isResolvedIdentity(identity)) { + // identity is an `UnresolvedIdentity` which represents that it hasn't been resolved yet + // display loading state + avatar = ( + + ); + identifier = ( + + ); + } else if ( + identity.resolutionStatus === ResolutionStatusIds.Unnamed || + identity.resolutionStatus === ResolutionStatusIds.Unknown + ) { + avatar = ( +
+ +
+ ); + identifier = ( + + ); + } else { + avatar = ( + + ); + identifier = ( + + ); + } + + let result = ( +
+ {/* TODO: extract the `EnsAvatar` / `ChainIcon` out of this component and remove the + `withAvatar` prop. */} + {withAvatar && avatar} + {withIdentifier && identifier} +
+ ); + + // TODO: extract the `IdentityInfoTooltip` out of this component and remove the `withTooltip` prop. + if (withTooltip) { + result = ( + + {result} + + ); + } + + // TODO: extract the `IdentityLink` out of this component and remove the `withLink` prop. + if (withLink) { + if (identityLinkDetails === undefined) { + throw new Error("Invariant(ResolveAndDisplayIdentity): Expected identity link details"); + } + result = ( + + {result} + + ); + } + + return result; +} + +// TODO: Copied from ENSAwards (as a newer version) - performed some refactor actions +// to make the component usable across all our apps, but further alignment might be needed diff --git a/packages/namehash-ui/src/components/placeholder/Placeholder.tsx b/packages/namehash-ui/src/components/placeholder/Placeholder.tsx deleted file mode 100644 index 16f400cbb..000000000 --- a/packages/namehash-ui/src/components/placeholder/Placeholder.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const Placeholder = () => { - return ( -

- NameHash-UI -

- ); -}; diff --git a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx new file mode 100644 index 000000000..8b36bf17f --- /dev/null +++ b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx @@ -0,0 +1,358 @@ +import { Info as InfoIcon, CircleQuestionMark as QuestionmarkIcon } from "lucide-react"; +import { memo, type PropsWithChildren, type ReactNode } from "react"; +import { zeroAddress } from "viem"; + +import type { + DefaultableChainId, + ENSNamespaceId, + NamedRegistrarAction, + RegistrarActionReferral, + UnixTimestamp, +} from "@ensnode/ensnode-sdk"; +import { + buildUnresolvedIdentity, + isRegistrarActionReferralAvailable, + RegistrarActionTypes, + ZERO_ENCODED_REFERRER, +} from "@ensnode/ensnode-sdk"; + +import { DisplayDuration } from "@/components/datetime/DisplayDuration.tsx"; +import { RelativeTime } from "@/components/datetime/RelativeTime.tsx"; +import type { IdentityLinkDetails } from "@/components/identity/Identity.tsx"; +import { NameDisplay } from "@/components/identity/Name.tsx"; +import { + ResolveAndDisplayIdentity, + type ResolveAndDisplayIdentityProps, +} from "@/components/identity/ResolveAndDisplayIdentity.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { useIsMobile } from "@/hooks/useIsMobile.tsx"; +import { getBlockExplorerTransactionDetailsUrl } from "@/utils/blockExplorers.ts"; +import { cn } from "@/utils/cn.ts"; + +interface LabeledFieldProps { + fieldLabel: string; + className?: string; +} + +/** + * Display a labeled field. + */ +function LabeledField({ fieldLabel, className, children }: PropsWithChildren) { + return ( +
+

+ {fieldLabel} +

+ {children} +
+ ); +} + +interface ResolveAndDisplayReferrerIdentityProps + extends Omit { + chainId: DefaultableChainId; + referral: RegistrarActionReferral; +} + +/** + * Resolve and Display Referrer Identity + * + * Resolves and displays the identity of the decoded referrer, or a fallback UI. + */ +function ResolveAndDisplayReferrerIdentity({ + namespaceId, + chainId, + referral, + accelerate = true, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: ResolveAndDisplayReferrerIdentityProps) { + // if encoded referrer is not available or is the zero encoded referrer then + if ( + !isRegistrarActionReferralAvailable(referral) || + referral.encodedReferrer === ZERO_ENCODED_REFERRER + ) { + // when we only want to display avatar (without textual identifier) don't display anything (return an empty placeholder). + // Otherwise, display a hyphen with no avatar + return withAvatar && !withIdentifier ? ( +
+ ) : ( +

-

+ ); + } + + // if the encoded referrer was not the zeroEncodedReferrer but couldn't be + // decoded according to the subjective interpretation rules of + // the current ENS Referral Awards program then display a tooltip with details + if (referral.decodedReferrer === zeroAddress) { + // when we only want to display avatar (without textual identifier) use a dedicated placeholder. + // Otherwise, display "unknown" plus the placeholder. + const unknownAvatarPlaceholder = (className?: string, iconSize = 24) => ( +
+ +
+ ); + + return withAvatar && !withIdentifier ? ( + unknownAvatarPlaceholder("nhui:w-10 nhui:h-10", 24) + ) : ( + + {unknownAvatarPlaceholder("nhui:w-5 nhui:h-5", 16)} + Unknown + + + + + + Encoded referrer + {referral.encodedReferrer} does not follow the + formatting requirements of incentive programs. + + + + ); + } + + // resolve and display identity for the decodedReferrer address + const referrerIdentity = buildUnresolvedIdentity(referral.decodedReferrer, namespaceId, chainId); + + return ( + + ); +} + +export interface RegistrarActionCardLoadingProps { + showReferrer?: boolean; +} + +/** + * Display Registrar Action Card loading state + */ +export function RegistrarActionCardLoading({ + showReferrer = true, +}: RegistrarActionCardLoadingProps) { + const isMobile = useIsMobile(); + + return ( +
+ +
+ + + +
+ + + +
+ + +
+ {!isMobile && ( +
+ )} + +
+ {isMobile && ( +
+ )} +
+
+ +
+ + {showReferrer && ( +
+ {!isMobile && ( +
+ )} + +
+ {isMobile && ( +
+ )} +
+
+ +
+ )} + + +
+ +
+ ); +} + +export interface RegistrarActionCardProps { + namespaceId: ENSNamespaceId; + namedRegistrarAction: NamedRegistrarAction; + now: UnixTimestamp; + links: { + name: IdentityLinkDetails; + registrant: IdentityLinkDetails; + referrer?: IdentityLinkDetails; + }; + showReferrer?: boolean; + referralProgramField?: ReactNode; +} + +/** + * Display a single Registrar Action + */ +export function RegistrarActionCard({ + namespaceId, + namedRegistrarAction, + now, + links, + showReferrer = true, + referralProgramField, +}: RegistrarActionCardProps) { + const isMobile = useIsMobile(); + const { registrant, registrationLifecycle, type, referral, transactionHash } = + namedRegistrarAction.action; + const { chainId } = registrationLifecycle.subregistry.subregistryId; + + const transactionDetailUrl = getBlockExplorerTransactionDetailsUrl(chainId, transactionHash); + const withTransactionLink = ({ children }: PropsWithChildren) => + // wrap `children` content with a transaction link only if the URL is defined + transactionDetailUrl ? ( + + {children} + + ) : ( + children + ); + + const registrantIdentity = buildUnresolvedIdentity(registrant, namespaceId, chainId); + + return ( +
+ + + + + + + +

+ +

+
+ + +

+ +

+
+ +
+ {!isMobile && ( + + )} + + + +
+ + {showReferrer && ( +
+ {!isMobile && ( + + )} + + + +
+ )} + + {referralProgramField !== undefined && referralProgramField} +
+ ); +} + +export const RegistrarActionCardMemo = memo(RegistrarActionCard); + +// TODO: Copied from ENSAwards (as a newer version) - performed some refactor actions +// to make the component usable across all our apps, but further alignment might be needed diff --git a/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx b/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx new file mode 100644 index 000000000..1de9777ea --- /dev/null +++ b/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { Button, type ButtonProps } from "@/components/ui/button"; +import { cn } from "@/utils/cn.ts"; + +export interface CopyButtonProps extends Omit { + value: string; + message?: string; + showToast?: boolean; + icon?: React.ReactNode; + successIcon?: React.ReactNode; +} + +export function CopyButton({ + value, + message = "Copied to clipboard", + variant = "ghost", + size = "icon", + showToast = false, + icon, + successIcon, + className, + children, + ...props +}: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + const [isCopying, setIsCopying] = React.useState(false); + + React.useEffect(() => { + if (hasCopied) { + const timeout = setTimeout(() => setHasCopied(false), 2000); + return () => clearTimeout(timeout); + } + }, [hasCopied]); + + async function copyToClipboard() { + if (isCopying) return; + + try { + setIsCopying(true); + await navigator.clipboard.writeText(value); + setHasCopied(true); + + if (showToast) { + toast.success(message); + } + } catch (error) { + console.error("Failed to copy text:", error); + + if (showToast) { + toast.error("Failed to copy text to clipboard"); + } + } finally { + setIsCopying(false); + } + } + + return ( + + ); +} diff --git a/packages/namehash-ui/src/components/ui/README.md b/packages/namehash-ui/src/components/ui/README.md new file mode 100644 index 000000000..618cfbe2c --- /dev/null +++ b/packages/namehash-ui/src/components/ui/README.md @@ -0,0 +1,5 @@ +# Attention + +All the files in this directory are part of [shadcn/ui's suite of reusable components](https://ui.shadcn.com/docs/components), and **shouldn't be modified** at all. + +If you want to create a similar component with slightly different behavior/UI, create a separate file where you use the shadcn's components as base and introduce your changes there. For an example see the [EnsAvatar](../identity/EnsAvatar.tsx) component. \ No newline at end of file diff --git a/packages/namehash-ui/src/components/ui/avatar.tsx b/packages/namehash-ui/src/components/ui/avatar.tsx new file mode 100644 index 000000000..2fbf2fea6 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/avatar.tsx @@ -0,0 +1,45 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/namehash-ui/src/components/ui/button.tsx b/packages/namehash-ui/src/components/ui/button.tsx new file mode 100644 index 000000000..852b01443 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/button.tsx @@ -0,0 +1,63 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +const buttonVariants = cva( + "nhui:inline-flex nhui:items-center nhui:justify-center nhui:gap-2 nhui:whitespace-nowrap nhui:rounded-md nhui:text-sm nhui:font-medium nhui:transition-all nhui:disabled:pointer-events-none nhui:disabled:opacity-50 nhui:[&_svg]:pointer-events-none nhui:[&_svg:not([class*=size-])]:size-4 nhui:shrink-0 nhui:[&_svg]:shrink-0 nhui:outline-none nhui:focus-visible:border-ring nhui:focus-visible:ring-ring/50 nhui:focus-visible:ring-[3px] nhui:aria-invalid:ring-destructive/20 nhui:dark:aria-invalid:ring-destructive/40 nhui:aria-invalid:border-destructive", + { + variants: { + variant: { + default: "nhui:bg-primary nhui:text-primary-foreground nhui:hover:bg-primary/90", + destructive: + "nhui:bg-destructive nhui:text-white nhui:hover:bg-destructive/90 nhui:focus-visible:ring-destructive/20 nhui:dark:focus-visible:ring-destructive/40 nhui:dark:bg-destructive/60", + outline: + "nhui:border nhui:bg-background nhui:shadow-xs nhui:hover:bg-accent nhui:hover:text-accent-foreground nhui:dark:bg-input/30 nhui:dark:border-input nhui:dark:hover:bg-input/50", + secondary: "nhui:bg-secondary nhui:text-secondary-foreground nhui:hover:bg-secondary/80", + ghost: + "nhui:hover:bg-accent nhui:hover:text-accent-foreground nhui:dark:hover:bg-accent/50", + link: "nhui:text-primary nhui:underline-offset-4 nhui:hover:underline", + }, + size: { + default: "nhui:h-9 nhui:px-4 nhui:py-2 nhui:has-[>svg]:px-3", + sm: "nhui:h-8 nhui:rounded-md nhui:gap-1.5 nhui:px-3 nhui:has-[>svg]:px-2.5", + lg: "nhui:h-10 nhui:rounded-md nhui:px-6 nhui:has-[>svg]:px-4", + icon: "nhui:size-9", + "icon-sm": "nhui:size-8", + "icon-lg": "nhui:size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean; +} +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/namehash-ui/src/components/ui/skeleton.tsx b/packages/namehash-ui/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..008625c72 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/utils/cn"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/packages/namehash-ui/src/components/ui/tooltip.tsx b/packages/namehash-ui/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..765a95d44 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +const TooltipArrow = TooltipPrimitive.Arrow; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow }; diff --git a/packages/namehash-ui/src/hooks/useIsMobile.tsx b/packages/namehash-ui/src/hooks/useIsMobile.tsx new file mode 100644 index 000000000..39b38c10d --- /dev/null +++ b/packages/namehash-ui/src/hooks/useIsMobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 640; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index 58d5bf00e..6a74f7db0 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -1,3 +1,24 @@ import "./styles.css"; -export * from "./components/placeholder/Placeholder"; +export * from "./components/chains/ChainIcon"; +export * from "./components/chains/ChainName"; +export * from "./components/datetime/AbsoluteTime"; +export * from "./components/datetime/DisplayDuration"; +export * from "./components/datetime/RelativeTime"; +export * from "./components/footer/Footer"; +export * from "./components/icons/ChainExplorerIcon"; +export * from "./components/icons/ens/EnsIcon.tsx"; +export * from "./components/icons/ens/EnsServiceProviderIcon"; +export * from "./components/icons/InfoIcon"; +export * from "./components/icons/socials/EfpIcon"; +export * from "./components/icons/socials/EmailIcon"; +export * from "./components/icons/socials/FarcasterIcon"; +export * from "./components/icons/socials/GitHubIcon"; +export * from "./components/icons/socials/TelegramIcon"; +export * from "./components/icons/socials/TwitterIcon"; +export * from "./components/identity/Address"; +export * from "./components/identity/EnsAvatar"; +export * from "./components/identity/Identity"; +export * from "./components/identity/Name"; +export * from "./components/identity/ResolveAndDisplayIdentity"; +export * from "./components/registrar-actions/RegistrarActionCard"; diff --git a/packages/namehash-ui/src/styles.css b/packages/namehash-ui/src/styles.css index fa881cfa9..ac65af0a0 100644 --- a/packages/namehash-ui/src/styles.css +++ b/packages/namehash-ui/src/styles.css @@ -1,2 +1,123 @@ @import "tailwindcss" prefix(nhui); -/*directly following https://tailwindcss.com/docs/upgrade-guide#using-a-prefix*/ +@import "tw-animate-css"; +@plugin "tailwindcss-animate"; +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply nhui:border-border nhui:outline-ring/50; + } + body { + @apply nhui:bg-background nhui:text-foreground; + } +} diff --git a/packages/namehash-ui/src/utils/blockExplorers.ts b/packages/namehash-ui/src/utils/blockExplorers.ts new file mode 100644 index 000000000..d118439bd --- /dev/null +++ b/packages/namehash-ui/src/utils/blockExplorers.ts @@ -0,0 +1,53 @@ +import type { Address, Hash } from "viem"; + +import type { ChainId } from "@ensnode/ensnode-sdk"; + +import { SUPPORTED_CHAINS } from "@/utils/chains.ts"; + +/** + * Gets the "base" block explorer URL for a given {@link ChainId} + * + * @returns default block explorer URL for the chain with the provided id, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerUrl = (chainId: ChainId): URL | null => { + const chain = SUPPORTED_CHAINS.find((chain) => chain.id === chainId); + if (!chain) return null; + + // NOTE: anvil/ens-test-env chain does not have a blockExplorer + if (!chain.blockExplorers) return null; + + return new URL(chain.blockExplorers.default.url); +}; + +/** + * Gets the block explorer URL for a specific address on a specific chainId + * + * @returns complete block explorer URL for a specific address on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerAddressDetailsUrl = ( + chainId: ChainId, + address: Address, +): URL | null => { + const chainBlockExplorer = getBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`address/${address}`, chainBlockExplorer.toString()); +}; + +/** + * Gets the block explorer URL for a specific transaction hash on a specific chainId + * + * @returns complete block explorer URL for a specific transaction hash on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerTransactionDetailsUrl = ( + chainId: ChainId, + transactionHash: Hash, +): URL | null => { + const chainBlockExplorer = getBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`tx/${transactionHash}`, chainBlockExplorer.toString()); +}; diff --git a/packages/namehash-ui/src/utils/chains.ts b/packages/namehash-ui/src/utils/chains.ts new file mode 100644 index 000000000..178519e07 --- /dev/null +++ b/packages/namehash-ui/src/utils/chains.ts @@ -0,0 +1,62 @@ +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + linea, + lineaSepolia, + mainnet, + optimism, + optimismSepolia, + scroll, + scrollSepolia, + sepolia, +} from "viem/chains"; + +import { ensTestEnvL1Chain } from "@ensnode/datasources"; +import type { ChainId } from "@ensnode/ensnode-sdk"; + +export const SUPPORTED_CHAINS = [ + ensTestEnvL1Chain, + mainnet, + sepolia, + base, + baseSepolia, + linea, + lineaSepolia, + optimism, + optimismSepolia, + arbitrum, + arbitrumSepolia, + scroll, + scrollSepolia, +]; + +/** + * Mapping of {@link ChainId} to prettified chain name. + * + * NOTE: We prefer our custom names here, rather than those provided by default in `Chain#name`. + */ +const CUSTOM_CHAIN_NAMES = new Map([ + [ensTestEnvL1Chain.id, "Ethereum Local (ens-test-env)"], + [mainnet.id, "Ethereum"], + [sepolia.id, "Ethereum Sepolia"], + [base.id, "Base"], + [baseSepolia.id, "Base Sepolia"], + [linea.id, "Linea"], + [lineaSepolia.id, "Linea Sepolia"], + [optimism.id, "Optimism"], + [optimismSepolia.id, "Optimism Sepolia"], + [arbitrum.id, "Arbitrum"], + [arbitrumSepolia.id, "Arbitrum Sepolia"], + [scroll.id, "Scroll"], + [scrollSepolia.id, "Scroll Sepolia"], +]); + +/** + * Returns a prettified chain name for the provided chain id. + */ +export function getChainName(chainId: ChainId): string { + const name = CUSTOM_CHAIN_NAMES.get(chainId); + return name || `Unknown Chain (${chainId})`; +} diff --git a/packages/namehash-ui/src/utils/cn.ts b/packages/namehash-ui/src/utils/cn.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/packages/namehash-ui/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/namehash-ui/src/utils/ensManager.ts b/packages/namehash-ui/src/utils/ensManager.ts new file mode 100644 index 000000000..bf0a310bf --- /dev/null +++ b/packages/namehash-ui/src/utils/ensManager.ts @@ -0,0 +1,52 @@ +import type { Address } from "viem"; + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +/** + * Get the ENS Manager App URL for the provided namespace. + * + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns ENS Manager App URL for the provided namespace, or null if the provided namespace + * doesn't have a known ENS Manager App + */ +export function getEnsManagerUrl(namespaceId: ENSNamespaceId): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(`https://app.ens.domains/`); + case ENSNamespaceIds.Sepolia: + return new URL(`https://sepolia.app.ens.domains/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by app.ens.domains + return null; + } +} + +/** + * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. + * + * @returns URL to the Profile page in the external ENS Manager App for a given name and ENS Namespace, + * or null if this URL is not known + */ +export function getEnsManagerNameDetailsUrl(name: Name, namespaceId: ENSNamespaceId): URL | null { + const baseUrl = getEnsManagerUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(name, baseUrl); +} + +/** + * Get the URL of the address details page in ENS Manager App for a given address and ENS Namespace. + * + * @returns URL to the address details page in the ENS Manager App for a given address and ENS + * Namespace, or null if this URL is not known + */ +export function getEnsManagerAddressDetailsUrl( + address: Address, + namespaceId: ENSNamespaceId, +): URL | null { + const baseUrl = getEnsManagerUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(address, baseUrl); +} diff --git a/packages/namehash-ui/src/utils/ensMetadata.ts b/packages/namehash-ui/src/utils/ensMetadata.ts new file mode 100644 index 000000000..7b3a6ab99 --- /dev/null +++ b/packages/namehash-ui/src/utils/ensMetadata.ts @@ -0,0 +1,32 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +/** + * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may + * be null. + * + * @param {Name} name - ENS name to build the avatar image URL for + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns avatar image URL for the name on the given ENS Namespace, or null if the given + * ENS namespace is not supported by the ENS Metadata Service + */ +export function getEnsMetadataServiceAvatarUrl( + name: Name, + namespaceId: ENSNamespaceId, +): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + case ENSNamespaceIds.Sepolia: + return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by metadata.ens.domains + // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 + return null; + } +} diff --git a/packages/namehash-ui/tsconfig.json b/packages/namehash-ui/tsconfig.json index 3e92d51dd..ec1ef20e1 100644 --- a/packages/namehash-ui/tsconfig.json +++ b/packages/namehash-ui/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "jsx": "react-jsx", "rootDir": ".", // necessary for 'The project root is ambiguous' - "types": ["react"] + "types": ["react"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["./**/*.ts", "./**/*.tsx"], "exclude": ["dist", "**/__tests__/**", "**/examples/**"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec08c782c..64c7110e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ catalogs: hono: specifier: ^4.10.2 version: 4.10.3 + lucide-react: + specifier: ^0.548.0 + version: 0.548.0 pg-connection-string: specifier: ^2.9.1 version: 2.9.1 @@ -57,6 +60,15 @@ catalogs: ponder: specifier: 0.16.1 version: 0.16.1 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7 tsup: specifier: ^8.3.6 version: 8.5.0 @@ -897,12 +909,54 @@ importers: packages/namehash-ui: dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../datasources '@ensnode/ensnode-react': specifier: workspace:* version: link:../ensnode-react + '@ensnode/ensnode-sdk': + specifier: workspace:* + version: link:../ensnode-sdk + '@radix-ui/react-avatar': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.2.7)(react@19.2.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + boring-avatars: + specifier: ^2.0.4 + version: 2.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: 'catalog:' + version: 4.1.0 + lucide-react: + specifier: 'catalog:' + version: 0.548.0(react@19.2.1) react-dom: specifier: ^19.0.0 version: 19.2.1(react@19.2.1) + sonner: + specifier: ^2.0.3 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + tailwind-merge: + specifier: 'catalog:' + version: 3.4.0 + tailwindcss-animate: + specifier: 'catalog:' + version: 1.0.7(tailwindcss@4.1.18) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: '@ensnode/shared-configs': specifier: workspace:* @@ -929,7 +983,7 @@ importers: specifier: 19.2.1 version: 19.2.1 tailwindcss: - specifier: ^4.1.18 + specifier: 'catalog:' version: 4.1.18 tsup: specifier: ^8.3.6 @@ -937,6 +991,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + viem: + specifier: 'catalog:' + version: 2.38.5(typescript@5.9.3)(zod@3.25.76) packages/ponder-metadata: dependencies: @@ -4616,6 +4673,12 @@ packages: boring-avatars@1.11.2: resolution: {integrity: sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A==} + boring-avatars@2.0.4: + resolution: {integrity: sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -8001,6 +8064,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -12654,6 +12720,22 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -13135,6 +13217,11 @@ snapshots: boring-avatars@1.11.2: {} + boring-avatars@2.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + bowser@2.13.1: {} boxen@7.0.0: @@ -17203,6 +17290,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tw-animate-css@1.4.0: {} + type-fest@0.7.1: {} type-fest@2.19.0: {} @@ -17597,7 +17686,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 @@ -17637,7 +17726,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@22.18.13)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f6a949d8d..58c67f562 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,9 +19,13 @@ catalog: date-fns: 4.1.0 drizzle-orm: 0.41.0 hono: ^4.10.2 + lucide-react: ^0.548.0 pg-connection-string: ^2.9.1 pino: 10.1.0 ponder: 0.16.1 + tailwindcss: ^4.1.18 + tailwindcss-animate: ^1.0.7 + tailwind-merge: ^3.4.0 tsup: ^8.3.6 typescript: ^5.7.3 viem: ^2.22.13