Skip to content

Commit

Permalink
Add cNFT support to Explorer (#338)
Browse files Browse the repository at this point in the history
- [x] account header

<img width="1131" alt="Screenshot 2024-05-24 at 12 15 31 PM"
src="https://github.com/solana-labs/explorer/assets/7481857/594bb3e1-104c-4d59-9b1d-d356dc6b5cd1">

- [x] account data
- [x] generic cache
- [x] cnft tabs (metadata, attributes)

- [x] additional compression tab to show relevant proof data

<img width="1119" alt="Screenshot 2024-05-24 at 12 11 25 PM"
src="https://github.com/solana-labs/explorer/assets/7481857/3ff82c36-3173-4407-ab33-243a510246f3">

<img width="1126" alt="Screenshot 2024-05-24 at 12 07 49 PM"
src="https://github.com/solana-labs/explorer/assets/7481857/c7fe7b50-5d22-4291-8c1f-5c06259789f5">


Examples:
-
https://explorer.solana.com/address/4MupsdStMNTVwYzGUq75BkPMNGFLdircExr1aGT7eAiG
-
https://explorer.solana.com/address/2qGxZpFqCKtgcXNhMJtxAv5CRMHzUEvzHB6ae3bK4MZq
  • Loading branch information
ngundotra authored May 29, 2024
1 parent e2e1106 commit 261f5c8
Show file tree
Hide file tree
Showing 13 changed files with 766 additions and 99 deletions.
15 changes: 8 additions & 7 deletions app/address/[address]/attributes/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import { MetaplexNFTAttributesCard } from '@components/account/MetaplexNFTAttributesCard';
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import { isTokenProgramData } from '@providers/accounts';
import React from 'react';
import React, { Suspense } from 'react';

import { LoadingCard } from '@/app/components/common/LoadingCard';

type Props = Readonly<{
params: {
Expand All @@ -15,11 +16,11 @@ function MetaplexNFTAttributesCardRenderer({
account,
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
const parsedData = account?.data?.parsed;
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
return onNotFound();
}
return <MetaplexNFTAttributesCard nftData={parsedData.nftData} />;
return (
<Suspense fallback={<LoadingCard />}>
{<MetaplexNFTAttributesCard account={account} onNotFound={onNotFound} />}
</Suspense>
);
}

export default function MetaplexNFTAttributesPageClient({ params: { address } }: Props) {
Expand Down
28 changes: 28 additions & 0 deletions app/address/[address]/compression/page-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import React, { Suspense } from 'react';

import { CompressedNFTInfoCard } from '@/app/components/account/CompressedNFTInfoCard';
import { LoadingCard } from '@/app/components/common/LoadingCard';

type Props = Readonly<{
params: {
address: string;
};
}>;

function CompressionCardRenderer({
account,
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
return (
<Suspense fallback={<LoadingCard />}>
{<CompressedNFTInfoCard account={account} onNotFound={onNotFound} />}
</Suspense>
);
}

export default function CompressionPageClient({ params: { address } }: Props) {
return <ParsedAccountRenderer address={address} renderComponent={CompressionCardRenderer} />;
}
21 changes: 21 additions & 0 deletions app/address/[address]/compression/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
import { Metadata } from 'next/types';

import CompressionPageClient from './page-client';

type Props = Readonly<{
params: {
address: string;
};
}>;

export async function generateMetadata(props: AddressPageMetadataProps): Promise<Metadata> {
return {
description: `Information about the Compressed NFT with address ${props.params.address} on Solana`,
title: `Compression Information | ${await getReadableTitleFromAddress(props)} | Solana`,
};
}

export default function CompressionPage(props: Props) {
return <CompressionPageClient {...props} />;
}
93 changes: 81 additions & 12 deletions app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ import { FEATURE_PROGRAM_ID } from '@utils/parseFeatureAccount';
import { useClusterPath } from '@utils/url';
import Link from 'next/link';
import { redirect, useSelectedLayoutSegment } from 'next/navigation';
import React, { PropsWithChildren } from 'react';
import React, { PropsWithChildren, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import useSWRImmutable from 'swr/immutable';
import { Base58EncodedAddress } from 'web3js-experimental';

import { CompressedNftAccountHeader, CompressedNftCard } from '@/app/components/account/CompressedNftCard';
import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info';

const IDENTICON_WIDTH = 64;
Expand Down Expand Up @@ -192,7 +194,9 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
const infoParsed = info?.data?.data.parsed;

const { data: fullTokenInfo, isLoading: isFullTokenInfoLoading } = useSWRImmutable(
infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey ? ['get-full-token-info', address, cluster, url] : null,
infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey
? ['get-full-token-info', address, cluster, url]
: null,
fetchFullTokenInfo
);

Expand All @@ -207,13 +211,23 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
<div className="container mt-n3">
<div className="header">
<div className="header-body">
<AccountHeader address={address} account={info?.data} tokenInfo={fullTokenInfo} isTokenInfoLoading={isFullTokenInfoLoading} />
<AccountHeader
address={address}
account={info?.data}
tokenInfo={fullTokenInfo}
isTokenInfoLoading={isFullTokenInfoLoading}
/>
</div>
</div>
{!pubkey ? (
<ErrorCard text={`Address "${address}" is not valid`} />
) : (
<DetailsSections info={info} pubkey={pubkey} tokenInfo={fullTokenInfo} isTokenInfoLoading={isFullTokenInfoLoading}>
<DetailsSections
info={info}
pubkey={pubkey}
tokenInfo={fullTokenInfo}
isTokenInfoLoading={isFullTokenInfoLoading}
>
{children}
</DetailsSections>
)}
Expand All @@ -229,7 +243,17 @@ export default function AddressLayout({ children, params }: Props) {
);
}

function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { address: string; account?: Account, tokenInfo?: FullTokenInfo, isTokenInfoLoading: boolean }) {
function AccountHeader({
address,
account,
tokenInfo,
isTokenInfoLoading,
}: {
address: string;
account?: Account;
tokenInfo?: FullTokenInfo;
isTokenInfoLoading: boolean;
}) {
const mintInfo = useMintAccountInfo(address);

const parsedData = account?.data.parsed;
Expand Down Expand Up @@ -300,12 +324,22 @@ function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { ad
);
}

return (
const fallback = (
<>
<h6 className="header-pretitle">Details</h6>
<h2 className="header-title">Account</h2>
</>
);
if (account) {
return (
<ErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<CompressedNftAccountHeader account={account} />
</Suspense>
</ErrorBoundary>
);
}
return fallback;
}

function DetailsSections({
Expand All @@ -314,7 +348,7 @@ function DetailsSections({
tab,
info,
tokenInfo,
isTokenInfoLoading
isTokenInfoLoading,
}: {
children: React.ReactNode;
pubkey: PublicKey;
Expand Down Expand Up @@ -348,7 +382,7 @@ function DetailsSections({
);
}

function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: FullTokenInfo }) {
function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: FullTokenInfo }) {
const parsedData = account.data.parsed;
const rawData = account.data.raw;

Expand Down Expand Up @@ -392,7 +426,14 @@ function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: Ful
} else if (account.owner.toBase58() === FEATURE_PROGRAM_ID) {
return <FeatureAccountSection account={account} />;
} else {
return <UnknownAccountCard account={account} />;
const fallback = <UnknownAccountCard account={account} />;
return (
<ErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<CompressedNftCard account={account} />
</Suspense>
</ErrorBoundary>
);
}
}

Expand Down Expand Up @@ -425,7 +466,8 @@ export type MoreTabs =
| 'anchor-program'
| 'anchor-account'
| 'entries'
| 'concurrent-merkle-tree';
| 'concurrent-merkle-tree'
| 'compression';

function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) {
return (
Expand Down Expand Up @@ -467,15 +509,42 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
}

// Add the key for address lookup tables
if (account.data.raw && isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)) {
if (
account.data.raw &&
isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)
) {
tabs.push(...TABS_LOOKUP['address-lookup-table']);
}

// Add the key for Metaplex NFTs
if (parsedData && (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && (parsedData as TokenProgramData).nftData) {
if (
parsedData &&
(programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') &&
(parsedData as TokenProgramData).nftData
) {
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
}

if (!tabs.find(tab => tab.slug === 'metadata')) {
tabs.push(
{
path: 'metadata',
slug: 'metadata',
title: 'Metadata',
},
{
path: 'attributes',
slug: 'attributes',
title: 'Attributes',
}
);
tabs.push({
path: 'compression',
slug: 'compression',
title: 'Compression',
});
}

const isNFToken = account && isNFTokenAccount(account);
if (isNFToken) {
const collection = parseNFTokenCollectionAccount(account);
Expand Down
15 changes: 8 additions & 7 deletions app/address/[address]/metadata/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import { MetaplexMetadataCard } from '@components/account/MetaplexMetadataCard';
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import { isTokenProgramData } from '@providers/accounts';
import React from 'react';
import React, { Suspense } from 'react';

import { LoadingCard } from '@/app/components/common/LoadingCard';

type Props = Readonly<{
params: {
Expand All @@ -15,11 +16,11 @@ function MetaplexMetadataCardRenderer({
account,
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
const parsedData = account?.data?.parsed;
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
return onNotFound();
}
return <MetaplexMetadataCard nftData={parsedData.nftData} />;
return (
<Suspense fallback={<LoadingCard />}>
{<MetaplexMetadataCard account={account} onNotFound={onNotFound} />}
</Suspense>
);
}

export default function MetaplexNFTMetadataPageClient({ params: { address } }: Props) {
Expand Down
Loading

0 comments on commit 261f5c8

Please sign in to comment.