11import { useEffect , useState } from 'react' ;
2+ import type { ReactNode } from 'react' ;
23import { useParams , Link } from 'react-router-dom' ;
34import { useNftToken , useNftContract , useNftTokenTransfers } from '../hooks' ;
45import { AddressLink , CopyButton , Pagination , EmptyState } from '../components' ;
@@ -14,12 +15,35 @@ import {
1415 truncateHash ,
1516} from '../utils' ;
1617
18+ const SKIP_METADATA_KEYS = new Set ( [ 'image' , 'image_url' , 'imageUrl' , 'image_data' , 'description' , 'attributes' , 'name' ] ) ;
19+
20+ function renderMetadataValue ( value : unknown ) : ReactNode {
21+ if ( typeof value === 'string' ) {
22+ if ( / ^ h t t p s ? : \/ \/ / . test ( value ) ) {
23+ return < a href = { value } target = "_blank" rel = "noopener noreferrer" className = "text-accent-primary hover:underline break-all text-sm" > { value } </ a > ;
24+ }
25+ return < span className = "text-fg text-sm" > { value } </ span > ;
26+ }
27+ if ( typeof value === 'number' ) {
28+ return < span className = "font-mono text-fg text-sm" > { value } </ span > ;
29+ }
30+ if ( typeof value === 'boolean' ) {
31+ return < span className = "font-mono text-fg text-sm" > { value ? 'true' : 'false' } </ span > ;
32+ }
33+ return (
34+ < pre className = "text-xs font-mono text-fg bg-dark-800 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all" >
35+ { JSON . stringify ( value , null , 2 ) }
36+ </ pre >
37+ ) ;
38+ }
39+
1740export default function NFTTokenPage ( ) {
1841 const { contract : contractAddress , tokenId } = useParams < { contract : string ; tokenId : string } > ( ) ;
1942
2043 const { contract } = useNftContract ( contractAddress ) ;
2144 const { token, loading : tokenLoading , error : tokenError , refetch } = useNftToken ( contractAddress , tokenId ) ;
2245 const [ txPage , setTxPage ] = useState ( 1 ) ;
46+ const [ metadataView , setMetadataView ] = useState < 'formatted' | 'raw' > ( 'formatted' ) ;
2347 const { transfers, pagination, loading } = useNftTokenTransfers ( contractAddress , tokenId , { page : txPage , limit : 20 } ) ;
2448
2549 const metadataPending = isNftMetadataPending ( token ) ;
@@ -49,6 +73,11 @@ export default function NFTTokenPage() {
4973 const attributes = getNftAttributes ( token ) ;
5074 const displayName = token ?. name || `${ contract ?. name || contract ?. symbol || 'NFT' } #${ token ?. token_id || tokenId || '' } ` ;
5175
76+ const extraMetadataEntries = token ?. metadata_status === 'fetched' && token . metadata
77+ ? Object . entries ( token . metadata ) . filter ( ( [ k ] ) => ! SKIP_METADATA_KEYS . has ( k ) )
78+ : [ ] ;
79+ const hasExtraMetadata = token ?. metadata_status === 'fetched' && token . metadata !== null ;
80+
5281 return (
5382 < div >
5483 { /* Breadcrumb */ }
@@ -207,6 +236,56 @@ export default function NFTTokenPage() {
207236 </ div >
208237 </ div >
209238
239+ { /* Metadata */ }
240+ { hasExtraMetadata && (
241+ < div className = "card mt-6" >
242+ < div className = "flex items-center justify-between mb-4" >
243+ < h3 className = "text-lg font-semibold text-fg" > Metadata</ h3 >
244+ < div className = "flex" >
245+ < button
246+ type = "button"
247+ onClick = { ( ) => setMetadataView ( 'formatted' ) }
248+ className = { `px-3 py-1 text-sm border rounded-l-lg ${
249+ metadataView === 'formatted'
250+ ? 'border-accent-primary text-accent-primary bg-accent-primary/10'
251+ : 'border-dark-500 text-gray-400 hover:border-gray-400'
252+ } `}
253+ >
254+ Formatted
255+ </ button >
256+ < button
257+ type = "button"
258+ onClick = { ( ) => setMetadataView ( 'raw' ) }
259+ className = { `px-3 py-1 text-sm border rounded-r-lg ${
260+ metadataView === 'raw'
261+ ? 'border-accent-primary text-accent-primary bg-accent-primary/10'
262+ : 'border-dark-500 text-gray-400 hover:border-gray-400'
263+ } `}
264+ >
265+ Raw
266+ </ button >
267+ </ div >
268+ </ div >
269+
270+ { metadataView === 'raw' ? (
271+ < pre className = "text-xs font-mono text-fg bg-dark-800 rounded p-3 overflow-auto max-h-96 whitespace-pre-wrap break-all" >
272+ { JSON . stringify ( token ! . metadata , null , 2 ) }
273+ </ pre >
274+ ) : extraMetadataEntries . length > 0 ? (
275+ < dl className = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-3" >
276+ { extraMetadataEntries . map ( ( [ key , value ] ) => (
277+ < div key = { key } className = "flex flex-col gap-0.5" >
278+ < dt className = "text-fg-subtle text-xs uppercase tracking-wider" > { key . replace ( / _ / g, ' ' ) } </ dt >
279+ < dd > { renderMetadataValue ( value ) } </ dd >
280+ </ div >
281+ ) ) }
282+ </ dl >
283+ ) : (
284+ < p className = "text-fg-subtle text-sm" > No additional fields.</ p >
285+ ) }
286+ </ div >
287+ ) }
288+
210289 { /* Transfers */ }
211290 < div className = "card mt-6 overflow-hidden" >
212291 < div className = "flex items-center justify-between mb-4" >
0 commit comments