├── Procfile ├── .prettierignore ├── src ├── react-app-env.d.ts ├── App.css ├── pages │ ├── Token.css │ ├── Token.tsx │ └── Home.tsx ├── setupTests.ts ├── components │ ├── Loader.tsx │ ├── RarityModeContext.tsx │ ├── ThemeSwitch.tsx │ ├── Sidebar.tsx │ ├── Header.tsx │ ├── MenuModal.tsx │ ├── TraitContext.tsx │ └── TraitMenu.tsx ├── utils.ts ├── index.css ├── reportWebVitals.ts ├── rarityConfig.ts ├── hooks │ ├── useTraitData.ts │ ├── useMeta.ts │ └── useOpenSeaOrders.ts ├── index.tsx ├── types.ts ├── App.tsx ├── queries.ts └── opensea.svg ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── .vscode └── settings.json ├── craco.config.js ├── .gitignore ├── tailwind.config.js ├── tsconfig.json ├── README.md ├── LICENSE └── package.json /Procfile: -------------------------------------------------------------------------------- 1 | web: yarn start -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | public 2 | node_modules -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1koj/rarity-interface/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1koj/rarity-interface/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1koj/rarity-interface/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/target": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .layout { 2 | width: 100vw; 3 | min-height: 100vh; 4 | display: grid; 5 | grid-template-rows: 4.5rem auto; 6 | } 7 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: [require('tailwindcss'), require('autoprefixer')], 5 | }, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/Token.css: -------------------------------------------------------------------------------- 1 | @media only screen and (min-width: 1024px) { 2 | .container { 3 | height: calc(100vh - 4.5rem); 4 | } 5 | } 6 | 7 | .container { 8 | min-height: calc(100vh - 4.5rem); 9 | } 10 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | const Loader: React.FC = () => { 2 | return ( 3 |
4 |
7 |
8 | ) 9 | } 10 | 11 | export default Loader 12 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import numbro from 'numbro' 2 | 3 | export const formatURL = (url: string) => { 4 | if (url.substring(0, 7) === 'ipfs://') 5 | return 'https://ipfs.io/ipfs/' + url.substring(7) 6 | else return url 7 | } 8 | 9 | export const formatNumber = (score: number) => 10 | numbro(score).format({ 11 | optionalMantissa: true, 12 | mantissa: 1, 13 | }) 14 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .h-full-content { 7 | height: calc(100% - 3rem); 8 | } 9 | .min-h-full-content { 10 | minheight: calc(100% - 3rem); 11 | } 12 | .grid-rows-content { 13 | grid-template-rows: 8rem auto; 14 | } 15 | } 16 | 17 | .content { 18 | min-height: calc(100vh - 4.5rem); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals' 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors') 2 | 3 | module.exports = { 4 | purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], 5 | darkMode: 'class', 6 | theme: { 7 | extend: { 8 | colors: { 9 | sky: colors.sky, 10 | emerald: colors.emerald, 11 | }, 12 | }, 13 | }, 14 | variants: { 15 | extend: { 16 | scale: ['hover'], 17 | border: ['hover'], 18 | cursor: ['hover'], 19 | }, 20 | }, 21 | plugins: [require('@tailwindcss/forms')], 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /src/rarityConfig.ts: -------------------------------------------------------------------------------- 1 | interface Config { 2 | tokenBackgroundColor?: string 3 | contractAddress: string 4 | headerText?: string 5 | headerTextStyle?: React.CSSProperties 6 | logoURL?: string 7 | logoStyle?: React.CSSProperties 8 | listImageKey?: 'image' | 'image_data' 9 | mainImageKey?: 'image' | 'image_data' | 'animation_url' 10 | links?: { 11 | website?: string 12 | twitter?: string 13 | discord?: string 14 | openSea?: string 15 | etherscan?: string 16 | } 17 | } 18 | 19 | const config: Config = { 20 | contractAddress: '', 21 | } 22 | 23 | export default config 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useTraitData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client' 2 | import { useMemo } from 'react' 3 | import { GET_META } from '../queries' 4 | import { Meta } from '../types' 5 | 6 | const useTraitData = () => { 7 | const { data, error } = useQuery<{ meta: Meta }>(GET_META) 8 | 9 | const attributes = useMemo(() => { 10 | if (!data) return 11 | const { 12 | meta: { traitTypes }, 13 | } = data 14 | return Object.fromEntries( 15 | traitTypes.map(({ trait_type, attributes }) => [trait_type, attributes]) 16 | ) 17 | }, [data]) 18 | 19 | return { attributes } 20 | } 21 | 22 | export default useTraitData 23 | -------------------------------------------------------------------------------- /src/hooks/useMeta.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client' 2 | import { useMemo } from 'react' 3 | import { GET_META } from '../queries' 4 | import { Meta } from '../types' 5 | 6 | const useMeta = () => { 7 | const { data } = useQuery<{ meta: Meta }>(GET_META) 8 | 9 | const datamap = useMemo(() => { 10 | if (!data) return undefined 11 | 12 | return data.meta.traitTypes.reduce( 13 | (acc, type) => ({ 14 | ...acc, 15 | [type.trait_type]: type.stats.max, 16 | }), 17 | {} as { [traitType: string]: number } 18 | ) 19 | }, [data]) 20 | 21 | const getMax = (traitType: string) => 22 | datamap ? datamap[traitType] : undefined 23 | 24 | return { max: getMax } 25 | } 26 | 27 | export default useMeta 28 | -------------------------------------------------------------------------------- /src/components/RarityModeContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from 'react' 2 | 3 | interface RarityMode { 4 | normalized: boolean 5 | toggle: () => void 6 | } 7 | 8 | export const RarityModeContext = createContext({ 9 | normalized: true, 10 | toggle: () => {}, 11 | }) 12 | 13 | interface RarityModeProviderProps { 14 | children: React.ReactNode 15 | } 16 | 17 | export const RarityModeProvider: React.FC = ({ 18 | children, 19 | }) => { 20 | const [normalized, setNormalized] = useState(true) 21 | const toggle = () => setNormalized((n) => !n) 22 | return ( 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Switch } from '@headlessui/react' 3 | import useDarkMode from 'use-dark-mode' 4 | 5 | function ThemeSwitch() { 6 | const { value, toggle } = useDarkMode(true, { 7 | classNameDark: 'dark', 8 | classNameLight: 'light', 9 | }) 10 | 11 | return ( 12 | toggle()} 15 | className={`${ 16 | value ? 'bg-background-dark' : 'bg-background' 17 | } relative inline-flex items-center h-6 rounded-full w-11`} 18 | > 19 | 24 | 25 | ) 26 | } 27 | 28 | export default ThemeSwitch 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Rarity interface 2 | 3 | User interface for [rarity-analyser](https://github.com/mikko-o/rarity-analyser) data. 4 | 5 | ### Getting started 6 | 7 | Create an .env.local file and add your rarity-analyser server URL 8 | 9 | ``` 10 | REACT_APP_API_URL=http://localhost:4000 11 | ``` 12 | 13 | Start the app with `yarn start`. 14 | 15 | ### Configuration 16 | 17 | The app can be configured according to the following structure in `src/rarityConfig.ts`. 18 | 19 | Setting `contractAddress` is required to get order data from OpenSea. 20 | 21 | ```typescript 22 | interface Config { 23 | contractAddress: string 24 | tokenBackgroundColor?: string 25 | headerText?: string 26 | headerTextStyle?: React.CSSProperties 27 | logoURL?: string 28 | logoStyle?: React.CSSProperties 29 | listImageKey?: 'image' | 'image_data' 30 | mainImageKey?: 'image' | 'image_data' | 'animation_url' 31 | links?: { 32 | website?: string 33 | twitter?: string 34 | discord?: string 35 | openSea?: string 36 | etherscan?: string 37 | } 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import reportWebVitals from './reportWebVitals' 6 | import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client' 7 | import { SelectedTraitsProvider } from './components/TraitContext' 8 | import { RarityModeProvider } from './components/RarityModeContext' 9 | 10 | const client = new ApolloClient({ 11 | uri: process.env.REACT_APP_API_URL, 12 | cache: new InMemoryCache(), 13 | }) 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | , 25 | document.getElementById('root') 26 | ) 27 | 28 | // If you want to start measuring performance in your app, pass a function 29 | // to log results (for example: reportWebVitals(console.log)) 30 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 31 | reportWebVitals() 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mikko-o 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Token { 2 | id: number 3 | name: string 4 | rarity_score: number 5 | rarity_score_normalized: number 6 | rank: number 7 | rank_normalized: number 8 | attributes: Attribute[] 9 | image?: string 10 | image_data?: string 11 | description?: string 12 | external_url?: string 13 | animation_url?: string 14 | background_color?: string 15 | } 16 | 17 | export interface Attribute { 18 | trait_type: string 19 | value: string 20 | rarity_score: number 21 | rarity_score_normalized: number 22 | percentile: number 23 | count: number 24 | } 25 | 26 | export interface Meta { 27 | numTokens: number 28 | traitTypes: TraitsMeta[] 29 | stats: Stats 30 | } 31 | 32 | export interface TraitsMeta { 33 | trait_type: string 34 | attributes: Attribute[] 35 | stats: Stats 36 | numValues: number 37 | } 38 | 39 | interface Stats { 40 | mean: number 41 | std: number 42 | min: number 43 | max: number 44 | } 45 | 46 | export interface OpenSeaOrder { 47 | asset: { 48 | token_id: 'string' 49 | } 50 | base_price: number 51 | side: number 52 | payment_token_contract: { 53 | symbol: string 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' 2 | import './App.css' 3 | import Home from './pages/Home' 4 | import Token from './pages/Token' 5 | import Sidebar from './components/Sidebar' 6 | import Header from './components/Header' 7 | import MenuModal from './components/MenuModal' 8 | import { FaGithub } from 'react-icons/fa' 9 | 10 | function App() { 11 | return ( 12 |
13 | 14 |
15 |
16 | 17 |
18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 |
34 | 42 |
43 | ) 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@headlessui/react' 2 | import TraitMenu from './TraitMenu' 3 | import { useContext } from 'react' 4 | import { RarityModeContext } from './RarityModeContext' 5 | 6 | const Sidebar: React.FC = () => { 7 | return ( 8 |
9 |
10 | 11 | 12 |
13 |
14 | ) 15 | } 16 | 17 | export default Sidebar 18 | 19 | export const RarityMode: React.FC = () => { 20 | const { toggle, normalized } = useContext(RarityModeContext) 21 | return ( 22 |
23 |
24 | Trait normalization 25 |
26 | toggle()} 29 | className={`${normalized ? 'bg-blue-400' : 'bg-gray-400'} 30 | mr-3 relative inline-flex flex-shrink-0 h-6 w-12 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75`} 31 | > 32 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | Rarity 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rarity-interface", 3 | "version": "0.1.0", 4 | "private": false, 5 | "dependencies": { 6 | "@apollo/client": "^3.4.11", 7 | "@craco/craco": "^6.3.0", 8 | "@headlessui/react": "^1.4.1", 9 | "@heroicons/react": "^1.0.4", 10 | "@tailwindcss/forms": "^0.3.3", 11 | "@testing-library/jest-dom": "^5.11.4", 12 | "@testing-library/react": "^11.1.0", 13 | "@testing-library/user-event": "^12.1.10", 14 | "@types/jest": "^26.0.15", 15 | "@types/node": "^12.0.0", 16 | "@types/react": "^17.0.0", 17 | "@types/react-dom": "^17.0.0", 18 | "@types/react-paginate": "^7.1.1", 19 | "@types/react-router-dom": "^5.1.9", 20 | "graphql": "^15.5.3", 21 | "numbro": "^2.3.5", 22 | "prettier": "^2.4.1", 23 | "query-string": "^7.0.1", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "react-icons": "^4.2.0", 27 | "react-paginate": "^7.1.3", 28 | "react-router-dom": "^5.3.0", 29 | "react-scripts": "4.0.3", 30 | "react-spring": "^9.2.4", 31 | "typescript": "^4.1.2", 32 | "use-dark-mode": "^2.3.1", 33 | "web-vitals": "^1.0.1" 34 | }, 35 | "scripts": { 36 | "start": "craco start", 37 | "build": "craco build", 38 | "test": "craco test", 39 | "eject": "react-scripts eject", 40 | "format": "yarn prettier --write . --no-semi --single-quote" 41 | }, 42 | "eslintConfig": { 43 | "extends": [ 44 | "react-app", 45 | "react-app/jest" 46 | ] 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "devDependencies": { 61 | "autoprefixer": "^9", 62 | "postcss": "^7", 63 | "tailwindcss": "npm:@tailwindcss/postcss7-compat" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/queries.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const GET_TOKENS = gql` 4 | query getTokens( 5 | $first: Int 6 | $skip: Int 7 | $orderBy: String 8 | $orderDirection: String 9 | $id: Int 10 | $traits: [Trait] 11 | ) { 12 | tokens( 13 | first: $first 14 | skip: $skip 15 | orderBy: $orderBy 16 | orderDirection: $orderDirection 17 | id: $id 18 | traits: $traits 19 | ) { 20 | id 21 | name 22 | rarity_score 23 | rarity_score_normalized 24 | image 25 | image_data 26 | animation_url 27 | rank 28 | rank_normalized 29 | description 30 | background_color 31 | attributes { 32 | trait_type 33 | value 34 | rarity_score 35 | rarity_score_normalized 36 | percentile 37 | count 38 | } 39 | } 40 | } 41 | ` 42 | 43 | export const GET_META = gql` 44 | { 45 | meta { 46 | numTokens 47 | traitTypes { 48 | trait_type 49 | attributes { 50 | trait_type 51 | rarity_score 52 | value 53 | percentile 54 | count 55 | } 56 | stats { 57 | max 58 | } 59 | } 60 | } 61 | } 62 | ` 63 | 64 | export const GET_TOKEN = gql` 65 | query getToken($id: Int) { 66 | token(id: $id) { 67 | id 68 | name 69 | rarity_score 70 | rarity_score_normalized 71 | image 72 | image_data 73 | animation_url 74 | rank 75 | rank_normalized 76 | description 77 | background_color 78 | attributes { 79 | trait_type 80 | value 81 | rarity_score 82 | rarity_score_normalized 83 | percentile 84 | count 85 | } 86 | } 87 | } 88 | ` 89 | 90 | export const GET_NUM_TOKENS = gql` 91 | { 92 | meta { 93 | numTokens 94 | } 95 | } 96 | ` 97 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import config from '../rarityConfig' 2 | import { FaTwitter, FaDiscord } from 'react-icons/fa' 3 | import openSea from '../opensea.svg' 4 | import { Link } from 'react-router-dom' 5 | 6 | const Header: React.FC = () => { 7 | return ( 8 |
9 |
10 | 11 |

12 | {config.headerText} 13 |

14 | 15 | 16 | {config.logoURL ? ( 17 | 18 | logo 26 | 27 | ) : null} 28 |
29 | 30 |
31 | 32 |
33 |
34 | ) 35 | } 36 | 37 | export default Header 38 | 39 | const ProjectLinks: React.FC = () => { 40 | if (!config.links) return null 41 | return ( 42 |
43 | {config.links.website ? Website : null} 44 | {config.links.twitter ? ( 45 | 46 | 47 | 48 | ) : null} 49 | {config.links.discord ? ( 50 | 51 | 52 | 53 | ) : null} 54 | {config.links.openSea ? ( 55 | 56 | OpenSea logo 57 | 58 | ) : null} 59 | {config.links.etherscan ? ( 60 | Etherscan 61 | ) : null} 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useOpenSeaOrders.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import config from '../rarityConfig' 3 | import { OpenSeaOrder } from '../types' 4 | 5 | const idsToQuery = (ids: number[]) => 6 | ids.reduce((qs, id) => `${qs}&token_ids=${id}`, '') 7 | 8 | const options = { method: 'GET', headers: { Accept: 'application/json' } } 9 | 10 | export const getOpenSeaOrders = (tokenIds: number[]) => 11 | fetch( 12 | `https://api.opensea.io/wyvern/v1/orders?asset_contract_address=${ 13 | config.contractAddress 14 | }&bundled=false&include_bundled=false&include_invalid=false${idsToQuery( 15 | tokenIds 16 | )}&side=1&limit=20&offset=0&order_by=created_date&order_direction=desc`, 17 | options 18 | ) 19 | .then((response) => response.json()) 20 | .then((data) => data.orders as OpenSeaOrder[]) 21 | 22 | const useOpenSeaOrders = (tokenIds: number[] | undefined) => { 23 | const [orders, setOrders] = useState([]) 24 | 25 | useEffect(() => { 26 | if (!tokenIds || tokenIds.length === 0) return 27 | getOpenSeaOrders(tokenIds) 28 | .then((data) => 29 | data.filter((order) => order.payment_token_contract.symbol !== 'WETH') 30 | ) 31 | .then(setOrders) 32 | }, [tokenIds]) 33 | 34 | return orders.reduce( 35 | (result, order) => ({ ...result, [Number(order.asset.token_id)]: order }), 36 | {} as { [id: number]: OpenSeaOrder } 37 | ) 38 | } 39 | 40 | export default useOpenSeaOrders 41 | 42 | export const getOpenSeaOrder = (tokenId: number) => { 43 | return fetch( 44 | `https://api.opensea.io/wyvern/v1/orders?asset_contract_address=${config.contractAddress}&bundled=false&side=1&include_bundled=false&include_invalid=false&token_id=${tokenId}&limit=20&offset=0&order_by=created_date&order_direction=desc`, 45 | options 46 | ) 47 | .then((response) => response.json()) 48 | .then((data) => data.orders as OpenSeaOrder[]) 49 | .then((orders) => 50 | orders.filter((x) => x?.payment_token_contract.symbol !== 'WETH') 51 | ) 52 | .then((orders) => (orders.length > 0 ? orders[0] : null)) 53 | } 54 | 55 | export const useOpenSeaOrder = (tokenId: number | undefined | null) => { 56 | const [order, setOrder] = useState(undefined) 57 | 58 | useEffect(() => { 59 | if (!tokenId) return 60 | getOpenSeaOrder(tokenId).then((order) => order && setOrder(order)) 61 | }, [tokenId]) 62 | 63 | return order 64 | } 65 | -------------------------------------------------------------------------------- /src/components/MenuModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, Transition } from '@headlessui/react' 2 | import { MenuIcon } from '@heroicons/react/outline' 3 | import { Fragment, useState } from 'react' 4 | import { RarityMode } from './Sidebar' 5 | import TraitMenu from './TraitMenu' 6 | 7 | const MenuModal: React.FC = () => { 8 | let [isOpen, setIsOpen] = useState(false) 9 | 10 | function closeModal() { 11 | setIsOpen(false) 12 | } 13 | 14 | function openModal() { 15 | setIsOpen(true) 16 | } 17 | 18 | return ( 19 |
20 | 23 | 24 | 25 | 30 |
31 | 40 | 41 | 42 | 43 | {/* This element is to trick the browser into centering the modal contents. */} 44 | 50 | 59 |
60 | 61 | 62 |
63 |
64 |
65 |
66 |
67 |
68 | ) 69 | } 70 | 71 | export default MenuModal 72 | -------------------------------------------------------------------------------- /src/components/TraitContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useMemo, useState } from 'react' 2 | import { Attribute } from '../types' 3 | 4 | interface TraitContextIterface { 5 | traits: Attribute[] 6 | add: (t: Attribute) => void 7 | remove: (t: Attribute) => void 8 | clear: () => void 9 | isSelected: (traitType: string, value: string | number) => boolean 10 | queryParams: { trait_type: string; value: string }[] | undefined 11 | counts: { [traitType: string]: number } 12 | tokenCount?: number 13 | } 14 | 15 | const init: TraitContextIterface = { 16 | traits: [], 17 | add: (t: Attribute) => {}, 18 | remove: (t: Attribute) => {}, 19 | clear: () => {}, 20 | isSelected: (traitType: string, value: string | number) => false, 21 | queryParams: undefined, 22 | counts: {}, 23 | tokenCount: undefined, 24 | } 25 | 26 | export const TraitContext = createContext(init) 27 | 28 | export const SelectedTraitsProvider: React.FC<{ children: React.ReactNode }> = 29 | ({ children }) => { 30 | const [traits, setTraits] = useState<{ 31 | [trait_type: string]: { [value: string]: Attribute } 32 | }>({}) 33 | const add = (trait: Attribute) => { 34 | const updated = { ...traits[trait.trait_type], [trait.value]: trait } 35 | setTraits({ ...traits, [trait.trait_type]: updated }) 36 | } 37 | const remove = (trait: Attribute) => { 38 | const newTraits = { ...traits } 39 | delete newTraits[trait.trait_type][trait.value] 40 | setTraits(newTraits) 41 | } 42 | const clear = () => setTraits({}) 43 | const isSelected = (traitType: string, value: string | number) => 44 | traitType in traits && value in traits[traitType] 45 | 46 | const counts = useMemo( 47 | () => 48 | Object.fromEntries( 49 | Object.entries(traits).map(([k, v]) => [k, Object.values(v).length]) 50 | ), 51 | [traits] 52 | ) 53 | 54 | const traitArray = useMemo( 55 | () => 56 | Object.values(traits) 57 | .map((v) => Object.values(v)) 58 | .flat(), 59 | [traits] 60 | ) 61 | 62 | const tokenCount = useMemo( 63 | () => 64 | traitArray.length > 0 65 | ? Math.max(...traitArray.map((x) => x.count)) 66 | : undefined, 67 | [traitArray] 68 | ) 69 | 70 | return ( 71 | ({ 79 | trait_type, 80 | value: String(value), 81 | })), 82 | counts, 83 | tokenCount, 84 | }} 85 | > 86 | {children} 87 | 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/opensea.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/TraitMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client' 2 | import { Disclosure, Transition } from '@headlessui/react' 3 | import { ChevronUpIcon } from '@heroicons/react/solid' 4 | import { createRef, forwardRef, useContext, useEffect, useMemo } from 'react' 5 | import { GET_META } from '../queries' 6 | import { Attribute, Meta, TraitsMeta } from '../types' 7 | import { TraitContext } from './TraitContext' 8 | 9 | const TraitMenu: React.FC = () => { 10 | const { traits, add, remove, clear, counts } = useContext(TraitContext) 11 | const { data } = useQuery<{ meta: Meta }>(GET_META) 12 | const count = traits.length 13 | const refs = useMemo( 14 | () => data?.meta.traitTypes.map(() => createRef()), 15 | [data?.meta.traitTypes] 16 | ) 17 | // When traits are added, open disclosure if closed 18 | useEffect(() => { 19 | traits.forEach((trait) => { 20 | const ref = refs?.find( 21 | (x) => x.current?.outerText.split(' (')[0] === trait.trait_type 22 | ) 23 | if (ref && ref.current?.ariaExpanded === 'false') ref.current.click() 24 | }) 25 | }, [traits, refs]) 26 | if (!data) return null 27 | return ( 28 |
29 |
30 |

31 | Traits 32 | 33 | {count ? `(${count})` : ''} 34 | 35 |

36 | 37 | 51 |
52 | 53 | {data.meta.traitTypes.map((trait, i) => ( 54 | 60 | ))} 61 |
62 | ) 63 | } 64 | 65 | export default TraitMenu 66 | 67 | interface ItemProps { 68 | trait: TraitsMeta 69 | add: (t: Attribute) => void 70 | remove: (t: Attribute) => void 71 | count: number | undefined 72 | } 73 | 74 | const Item = forwardRef( 75 | ({ trait, add, remove, count }, ref) => { 76 | if (!trait) throw new Error('no trait') 77 | return ( 78 |
79 | 80 | {({ open }) => ( 81 | <> 82 | 86 | 87 | {trait.trait_type}{' '} 88 | 89 | {count ? `(${count})` : ''} 90 | 91 | 92 | 97 | 98 | 106 | 107 | 108 | 109 | 110 | 111 | )} 112 | 113 |
114 | ) 115 | } 116 | ) 117 | 118 | interface ValuesProps { 119 | attributes: Attribute[] 120 | } 121 | 122 | const Values: React.FC = ({ attributes }) => { 123 | const { add, remove, isSelected } = useContext(TraitContext) 124 | return ( 125 |
126 | {attributes.map((attribute) => ( 127 | 144 | ))} 145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /src/pages/Token.tsx: -------------------------------------------------------------------------------- 1 | import { useHistory, useLocation } from 'react-router' 2 | import queryString from 'query-string' 3 | import { useSprings, animated } from 'react-spring' 4 | import { Attribute, Token as TokenInterface } from '../types' 5 | import './Token.css' 6 | import { formatNumber, formatURL } from '../utils' 7 | import { TraitContext } from '../components/TraitContext' 8 | import { useCallback, useContext, useEffect } from 'react' 9 | import useMeta from '../hooks/useMeta' 10 | import { RarityModeContext } from '../components/RarityModeContext' 11 | import config from '../rarityConfig' 12 | import { useQuery } from '@apollo/client' 13 | import { GET_TOKEN } from '../queries' 14 | import Loader from '../components/Loader' 15 | import { useOpenSeaOrder } from '../hooks/useOpenSeaOrders' 16 | 17 | const Token: React.FC = () => { 18 | const { search } = useLocation<{ token: TokenInterface }>() 19 | const history = useHistory() 20 | const { id } = queryString.parse(search) 21 | const { normalized } = useContext(RarityModeContext) 22 | 23 | const { data } = useQuery(GET_TOKEN, { 24 | variables: { 25 | id: Number(id), 26 | }, 27 | }) 28 | 29 | const order = useOpenSeaOrder(Number(id)) 30 | 31 | const token = data?.token 32 | 33 | if (!id) { 34 | history.replace('/') 35 | return null 36 | } 37 | 38 | if (!token) 39 | return ( 40 |
41 | 42 |
43 | ) 44 | 45 | return ( 46 |
47 |
48 |
49 |
53 |

{token.name}

54 | 55 |

56 | {token.description} 57 |

58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 |
66 | Rank: #{normalized ? token?.rank_normalized : token?.rank} 67 |
68 |
69 |
70 | + 71 | {formatNumber( 72 | normalized 73 | ? token?.rarity_score_normalized 74 | : token?.rarity_score 75 | )} 76 |
77 |
78 | 79 | {order ? ( 80 | 84 | {order.base_price / 1e18} ETH 85 | 86 | ) : null} 87 | 88 |
89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default Token 96 | 97 | interface AttributesProps { 98 | attributes?: Attribute[] 99 | } 100 | 101 | const Attributes: React.FC = ({ attributes }) => { 102 | const { max } = useMeta() 103 | 104 | const percentage = useCallback( 105 | (attribute: Attribute, normalized: boolean) => { 106 | const highest = max(attribute.trait_type) 107 | const score = normalized ? attribute.rarity_score_normalized : attribute.rarity_score 108 | return highest ? Math.round((score / highest) * 100) : 0 109 | }, 110 | [max] 111 | ) 112 | 113 | const { normalized } = useContext(RarityModeContext) 114 | 115 | const [springs, api] = useSprings( 116 | attributes?.length ?? 0, 117 | (index) => ({ width: '0%' }) 118 | ) 119 | 120 | useEffect(() => { 121 | if (!attributes) return 122 | api.start(i => ({ width: `${percentage(attributes[i], normalized)}%` })) 123 | }, [normalized, percentage, attributes, api]) 124 | 125 | if (!attributes || !max) return null 126 | return ( 127 |
128 | {attributes.map((attribute, i) => ( 129 |
133 |
134 |
135 |
136 | 137 |
138 |
139 | + 140 | {formatNumber( 141 | normalized 142 | ? attribute.rarity_score_normalized 143 | : attribute.rarity_score 144 | )} 145 |
146 |
147 | 148 |
149 | 153 |
154 |
155 |
156 | ))} 157 |
158 | ) 159 | } 160 | 161 | interface TraitProps { 162 | trait: Attribute 163 | } 164 | 165 | const Trait: React.FC = ({ trait }) => { 166 | const { add, remove, isSelected } = useContext(TraitContext) 167 | const selected = isSelected(trait.trait_type, trait.value) 168 | return ( 169 | 182 | ) 183 | } 184 | 185 | interface ImageProps { 186 | token: TokenInterface 187 | } 188 | 189 | const Image: React.FC = ({ token }) => { 190 | if (!token) return null 191 | const src = token[config.mainImageKey ?? 'image'] 192 | if (token.animation_url && config.mainImageKey === 'animation_url') { 193 | return ( 194 |
195 |