From 708da8a58a961c665e911c791edb02ede9022f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20Nak=C4=B1=C5=9F=C3=A7=C4=B1?= Date: Mon, 25 Jul 2022 11:29:52 +0300 Subject: [PATCH] Add collection actions page (#3) * Add collection actions page * Add collections index page and update sidebar --- components/Sidebar.tsx | 15 +- .../collections/actions/Combobox.hooks.ts | 8 + components/collections/actions/Combobox.tsx | 93 ++++++ components/collections/actions/actions.ts | 169 ++++++++++ pages/collection/index.tsx | 1 - pages/collection/upload.tsx | 315 ------------------ pages/collections/actions.tsx | 172 ++++++++++ pages/collections/index.tsx | 49 +++ 8 files changed, 496 insertions(+), 326 deletions(-) create mode 100644 components/collections/actions/Combobox.hooks.ts create mode 100644 components/collections/actions/Combobox.tsx create mode 100644 components/collections/actions/actions.ts delete mode 100644 pages/collection/index.tsx delete mode 100644 pages/collection/upload.tsx create mode 100644 pages/collections/actions.tsx create mode 100644 pages/collections/index.tsx diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 1fc0244..39ad531 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -3,13 +3,14 @@ import { Anchor } from 'components/Anchor' import { useWallet } from 'contexts/wallet' import { useRouter } from 'next/router' // import BrandText from 'public/brand/brand-text.svg' -import { footerLinks, links, socialsLinks } from 'utils/links' +import { footerLinks, socialsLinks } from 'utils/links' import { SidebarLayout } from './SidebarLayout' import { WalletLoader } from './WalletLoader' const routes = [ - { text: 'Create Collection', href: `/collection/` }, + { text: 'Create Collection', href: `/collections/` }, + { text: 'Collections', href: `/collections` }, { text: 'Contract Dashboards', href: `/contracts/` }, ] @@ -53,7 +54,7 @@ export const Sidebar = () => {
{/* Stargaze network status */} -
Network: {wallet.network}
+
Network: {wallet.network}
{/* footer reference links */} {/* footer attribution */} -
- StargazeTools {process.env.APP_VERSION}
- Made by{' '} - - deus labs - -
+
Stargaze Studio {process.env.APP_VERSION}
{/* footer social links */}
diff --git a/components/collections/actions/Combobox.hooks.ts b/components/collections/actions/Combobox.hooks.ts new file mode 100644 index 0000000..98ad95e --- /dev/null +++ b/components/collections/actions/Combobox.hooks.ts @@ -0,0 +1,8 @@ +import { useState } from 'react' + +import type { ActionListItem } from './actions' + +export const useActionsComboboxState = () => { + const [value, setValue] = useState(null) + return { value, onChange: (item: ActionListItem) => setValue(item) } +} diff --git a/components/collections/actions/Combobox.tsx b/components/collections/actions/Combobox.tsx new file mode 100644 index 0000000..526cfa0 --- /dev/null +++ b/components/collections/actions/Combobox.tsx @@ -0,0 +1,93 @@ +import { Combobox, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { matchSorter } from 'match-sorter' +import { Fragment, useState } from 'react' +import { FaChevronDown, FaInfoCircle } from 'react-icons/fa' + +import type { ActionListItem } from './actions' +import { ACTION_LIST } from './actions' + +export interface ActionsComboboxProps { + value: ActionListItem | null + onChange: (item: ActionListItem) => void +} + +export const ActionsCombobox = ({ value, onChange }: ActionsComboboxProps) => { + const [search, setSearch] = useState('') + + const filtered = + search === '' ? ACTION_LIST : matchSorter(ACTION_LIST, search, { keys: ['id', 'name', 'description'] }) + + return ( + +
+ val?.name ?? ''} + id="message-type" + onChange={(event) => setSearch(event.target.value)} + placeholder="Select action" + /> + + + {({ open }) => + + setSearch('')} as={Fragment}> + + {filtered.length < 1 && ( + + Action found. + + )} + {filtered.map((entry) => ( + + clsx('flex relative flex-col py-2 px-4 space-y-1 cursor-pointer', { 'bg-plumbus-70': active }) + } + value={entry} + > + {entry.name} + {entry.description} + + ))} + + +
+ + {value && ( +
+
+ +
+ {value.description} +
+ )} +
+ ) +} diff --git a/components/collections/actions/actions.ts b/components/collections/actions/actions.ts new file mode 100644 index 0000000..b02c93f --- /dev/null +++ b/components/collections/actions/actions.ts @@ -0,0 +1,169 @@ +import type { MinterInstance } from 'contracts/minter' +import { useMinterContract } from 'contracts/minter' +import type { SG721Instance } from 'contracts/sg721' +import { useSG721Contract } from 'contracts/sg721' + +export type ActionType = typeof ACTION_TYPES[number] + +export const ACTION_TYPES = [ + 'mint_to', + 'mint_for', + 'set_whitelist', + 'update_start_time', + 'update_per_address_limit', + 'withdraw', + 'transfer', + 'shuffle', +] as const + +export interface ActionListItem { + id: ActionType + name: string + description?: string +} + +export const ACTION_LIST: ActionListItem[] = [ + { + id: 'mint_to', + name: 'Mint To', + description: `Mint a token to a user`, + }, + { + id: 'mint_for', + name: 'Mint For', + description: `Mint a token for a user with given token ID`, + }, + { + id: 'set_whitelist', + name: 'Set Whitelist', + description: `Set whitelist contract address`, + }, + { + id: 'update_start_time', + name: 'Update Start Time', + description: `Update start time for minting`, + }, + { + id: 'update_per_address_limit', + name: 'Update Tokens Per Address Limit', + description: `Update token per address limit`, + }, + { + id: 'withdraw', + name: 'Withdraw Tokens', + description: `Withdraw tokens from the contract`, + }, + { + id: 'transfer', + name: 'Transfer Tokens', + description: `Transfer tokens from one address to another`, + }, + { + id: 'shuffle', + name: 'Shuffle Tokens', + description: 'Shuffle the token IDs', + }, +] + +export interface DispatchExecuteProps { + type: ActionType + [k: string]: unknown +} + +type Select = T + +/** @see {@link MinterInstance} */ +export type DispatchExecuteArgs = { + minterContract: string + sg721Contract: string + minterMessages?: MinterInstance + sg721Messages?: SG721Instance + txSigner: string +} & ( + | { type: undefined } + | { type: Select<'mint_to'>; recipient: string } + | { type: Select<'mint_for'>; recipient: string; tokenId: number } + | { type: Select<'set_whitelist'>; whitelist: string } + | { type: Select<'update_start_time'>; startTime: string } + | { type: Select<'update_per_address_limit'>; limit: number } + | { type: Select<'shuffle'> } + | { type: Select<'withdraw'> } + | { type: Select<'transfer'>; recipient: string; tokenId: number } +) + +export const dispatchExecute = async (args: DispatchExecuteArgs) => { + const { minterMessages, sg721Messages, txSigner } = args + if (!minterMessages || !sg721Messages) { + throw new Error('Cannot execute actions') + } + switch (args.type) { + case 'mint_to': { + return minterMessages.mintTo(txSigner, args.recipient) + } + case 'mint_for': { + return minterMessages.mintFor(txSigner, args.recipient, args.tokenId) + } + case 'set_whitelist': { + return minterMessages.setWhitelist(txSigner, args.whitelist) + } + case 'update_start_time': { + return minterMessages.updateStartTime(txSigner, args.startTime) + } + case 'update_per_address_limit': { + return minterMessages.updatePerAddressLimit(txSigner, args.limit) + } + case 'shuffle': { + return minterMessages.shuffle(txSigner) + } + case 'withdraw': { + return minterMessages.withdraw(txSigner) + } + case 'transfer': { + return sg721Messages.transferNft(args.recipient, args.tokenId.toString()) + } + default: { + throw new Error('Unknown action') + } + } +} + +export const previewExecutePayload = (args: DispatchExecuteArgs) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { messages: minterMessages } = useMinterContract() + // eslint-disable-next-line react-hooks/rules-of-hooks + const { messages: sg721Messages } = useSG721Contract() + const { minterContract, sg721Contract } = args + switch (args.type) { + case 'mint_to': { + return minterMessages()?.mintTo(minterContract, args.recipient) + } + case 'mint_for': { + return minterMessages()?.mintFor(minterContract, args.recipient, args.tokenId) + } + case 'set_whitelist': { + return minterMessages()?.setWhitelist(minterContract, args.whitelist) + } + case 'update_start_time': { + return minterMessages()?.updateStartTime(minterContract, args.startTime) + } + case 'update_per_address_limit': { + return minterMessages()?.updatePerAddressLimit(minterContract, args.limit) + } + case 'shuffle': { + return minterMessages()?.shuffle(minterContract) + } + case 'withdraw': { + return minterMessages()?.withdraw(minterContract) + } + case 'transfer': { + return sg721Messages(sg721Contract)?.transferNft(args.recipient, args.tokenId.toString()) + } + default: { + return {} + } + } +} + +export const isEitherType = (type: unknown, arr: T[]): type is T => { + return arr.some((val) => type === val) +} diff --git a/pages/collection/index.tsx b/pages/collection/index.tsx deleted file mode 100644 index 6e7a0cd..0000000 --- a/pages/collection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './upload' diff --git a/pages/collection/upload.tsx b/pages/collection/upload.tsx deleted file mode 100644 index 33afa8f..0000000 --- a/pages/collection/upload.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import clsx from 'clsx' -import Anchor from 'components/Anchor' -import AnchorButton from 'components/AnchorButton' -import Button from 'components/Button' -import { useCollectionStore } from 'contexts/collection' -import { setBaseTokenUri, setImage } from 'contexts/collection' -import { useWallet } from 'contexts/wallet' -import { NextPage } from 'next' -import { NextSeo } from 'next-seo' -import { Blob, File, NFTStorage } from 'nft.storage' -import { useEffect, useRef, useState } from 'react' -import toast from 'react-hot-toast' -import { FaArrowRight } from 'react-icons/fa' -import { withMetadata } from 'utils/layout' -import { links } from 'utils/links' -import { naturalCompare } from 'utils/sort' - -const UploadPage: NextPage = () => { - const wallet = useWallet() - - const baseTokenURI = useCollectionStore().base_token_uri - const [baseImageURI, setBaseImageURI] = useState('') - const [uploadMethod, setUploadMethod] = useState('New') - - const [imageFiles, setImageFiles] = useState([]) - const [metadataFiles, setMetadataFiles] = useState([]) - const [updatedMetadataFiles, setUpdatedMetadataFiles] = useState([]) - let imageFilesArray: File[] = [] - let metadataFilesArray: File[] = [] - let updatedMetadataFilesArray: File[] = [] - - const imageFilesRef = useRef(null) - const metadataFilesRef = useRef(null) - - const NFT_STORAGE_TOKEN = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6ZXRocjoweDJBODk5OGI4ZkE2YTM1NzMyYmMxQTRDQzNhOUU2M0Y2NUM3ZjA1RWIiLCJpc3MiOiJuZnQtc3RvcmFnZSIsImlhdCI6MTY1NTE5MTcwNDQ2MiwibmFtZSI6IlRlc3QifQ.IbdV_26bkPHSdd81sxox5AoG-5a4CCEY4aCrdbCXwAE' - const client = new NFTStorage({ token: NFT_STORAGE_TOKEN }) - - const handleChangeBaseTokenUri = (event: { - target: { value: React.SetStateAction } - }) => { - setBaseTokenUri(event.target.value.toString()) - } - - const handleChangeImage = (event: { - target: { value: React.SetStateAction } - }) => { - setImage(event.target.value.toString()) - } - - const selectImages = async () => { - imageFilesArray = [] - let reader: FileReader - if (!imageFilesRef.current?.files) return toast.error('No files selected.') - for (let i = 0; i < imageFilesRef.current.files.length; i++) { - reader = new FileReader() - reader.onload = function (e) { - if (!e.target?.result) return toast.error('Error parsing file.') - if (!imageFilesRef.current?.files) - return toast.error('No files selected.') - let imageFile = new File( - [e.target.result], - imageFilesRef.current.files[i].name, - { type: 'image/jpg' } - ) - imageFilesArray.push(imageFile) - if (i === imageFilesRef.current.files.length - 1) { - imageFilesArray.sort((a, b) => naturalCompare(a.name, b.name)) - console.log(imageFilesArray) - selectMetadata() - } - } - if (!imageFilesRef.current.files) return toast.error('No file selected.') - reader.readAsArrayBuffer(imageFilesRef.current.files[i]) - //reader.onloadend = function (e) { ... - } - } - const selectMetadata = async () => { - metadataFilesArray = [] - - let reader: FileReader - if (!metadataFilesRef.current?.files) - return toast.error('No files selected.') - for (let i = 0; i < metadataFilesRef.current.files.length; i++) { - reader = new FileReader() - reader.onload = function (e) { - if (!e.target?.result) return toast.error('Error parsing file.') - if (!metadataFilesRef.current?.files) - return toast.error('No files selected.') - let metadataFile = new File( - [e.target.result], - metadataFilesRef.current.files[i].name, - { type: 'image/jpg' } - ) - metadataFilesArray.push(metadataFile) - if (i === metadataFilesRef.current.files.length - 1) { - metadataFilesArray.sort((a, b) => naturalCompare(a.name, b.name)) - console.log(metadataFilesArray) - updateMetadata() - } - - } - if (!metadataFilesRef.current?.files) - return toast.error('No file selected.') - reader.readAsText(metadataFilesRef.current.files[i], 'utf8') - //reader.onloadend = function (e) { ... - } - } - const updateMetadata = async () => { - const imageURI = await client.storeDirectory(imageFilesArray) - console.log(imageURI) - updatedMetadataFilesArray = [] - let reader: FileReader - for (let i = 0; i < metadataFilesArray.length; i++) { - reader = new FileReader() - reader.onload = function (e) { - let metadataJSON = JSON.parse(e.target?.result as string) - metadataJSON.image = `ipfs://${imageURI}/${imageFilesArray[i].name}` - let metadataFileBlob = new Blob([JSON.stringify(metadataJSON)], { - type: 'application/json', - }) - let updatedMetadataFile = new File( - [metadataFileBlob], - metadataFilesArray[i].name, - { type: 'application/json' } - ) - updatedMetadataFilesArray.push(updatedMetadataFile) - console.log(updatedMetadataFile.name + ' => ' + metadataJSON.image) - if (i === metadataFilesArray.length - 1) { - upload() - } - } - reader.readAsText(metadataFilesArray[i], 'utf8') - //reader.onloadend = function (e) { ... - } - } - const upload = async () => { - const baseTokenURI = await client.storeDirectory(updatedMetadataFilesArray) - console.log(baseTokenURI) - } - - return ( -
- - -
-

- Upload Assets & Metadata -

- -

- Make sure you check our{' '} - - documentation - {' '} - on how to create your collection -

-
- -
- -
-
- { - setUploadMethod('Existing') - }} - onChange={() => { }} - checked={uploadMethod === 'Existing'} - /> - -
-
- { - setUploadMethod('New') - }} - onChange={() => { }} - checked={uploadMethod === 'New'} - /> - -
-
- -
- - {uploadMethod == 'Existing' && ( -
-

- Though Stargaze's sg721 contract allows for off-chain metadata - storage, it is recommended to use a decentralized storage solution, - such as IPFS.
You may head over to{' '} - - NFT Storage - {' '} - and upload your assets & metadata manually to get a base URI for - your collection. -

-
- - -
-
- - -
-
- )} - {uploadMethod == 'New' && ( -
- -
- { }} - ref={imageFilesRef} - type="file" - multiple - /> -
- - -
- { }} - ref={metadataFilesRef} - type="file" - multiple - /> -
- -
- -
-
- )} -
- ) -} - -export default withMetadata(UploadPage, { center: false }) diff --git a/pages/collections/actions.tsx b/pages/collections/actions.tsx new file mode 100644 index 0000000..7828881 --- /dev/null +++ b/pages/collections/actions.tsx @@ -0,0 +1,172 @@ +import { Button } from 'components/Button' +import type { DispatchExecuteArgs } from 'components/collections/actions/actions' +import { dispatchExecute, isEitherType, previewExecutePayload } from 'components/collections/actions/actions' +import { ActionsCombobox } from 'components/collections/actions/Combobox' +import { useActionsComboboxState } from 'components/collections/actions/Combobox.hooks' +import { Conditional } from 'components/Conditional' +import { ContractPageHeader } from 'components/ContractPageHeader' +import { FormControl } from 'components/FormControl' +import { AddressInput, NumberInput } from 'components/forms/FormInput' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import { InputDateTime } from 'components/InputDateTime' +import { JsonPreview } from 'components/JsonPreview' +import { TransactionHash } from 'components/TransactionHash' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' +import type { NextPage } from 'next' +import { NextSeo } from 'next-seo' +import type { FormEvent } from 'react' +import { useMemo, useState } from 'react' +import { toast } from 'react-hot-toast' +import { FaArrowRight } from 'react-icons/fa' +import { useMutation } from 'react-query' +import { withMetadata } from 'utils/layout' +import { links } from 'utils/links' + +const CollectionActionsPage: NextPage = () => { + const { minter: minterContract, sg721: sg721Contract } = useContracts() + const wallet = useWallet() + const [lastTx, setLastTx] = useState('') + + const [timestamp, setTimestamp] = useState(undefined) + + const comboboxState = useActionsComboboxState() + const type = comboboxState.value?.id + + const sg721ContractState = useInputState({ + id: 'sg721-contract-address', + name: 'sg721-contract-address', + title: 'Sg721 Address', + subtitle: 'Address of the Sg721 contract', + }) + + const minterContractState = useInputState({ + id: 'minter-contract-address', + name: 'minter-contract-address', + title: 'Minter Address', + subtitle: 'Address of the Minter contract', + }) + + const limitState = useNumberInputState({ + id: 'per-address-limi', + name: 'perAddressLimit', + title: 'Per Address Limit', + subtitle: 'Enter the per address limit', + }) + + const tokenIdState = useNumberInputState({ + id: 'token-id', + name: 'tokenId', + title: 'Token ID', + subtitle: 'Enter the token ID', + }) + + const recipientState = useInputState({ + id: 'recipient-address', + name: 'recipient', + title: 'Recipient Address', + subtitle: 'Address of the recipient', + }) + + const whitelistState = useInputState({ + id: 'whitelist-address', + name: 'whitelistAddress', + title: 'Whitelist Address', + subtitle: 'Address of the whitelist contract', + }) + + const showWhitelistField = type === 'set_whitelist' + const showDateField = type === 'update_start_time' + const showLimitField = type === 'update_per_address_limit' + const showTokenIdField = isEitherType(type, ['transfer', 'mint_for']) + const showRecipientField = isEitherType(type, ['transfer', 'mint_to', 'mint_for']) + + const minterMessages = useMemo( + () => minterContract?.use(minterContractState.value), + [minterContract, minterContractState.value], + ) + const sg721Messages = useMemo( + () => sg721Contract?.use(sg721ContractState.value), + [sg721Contract, sg721ContractState.value], + ) + const payload: DispatchExecuteArgs = { + whitelist: whitelistState.value, + startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '', + limit: limitState.value, + minterContract: minterContractState.value, + sg721Contract: sg721ContractState.value, + tokenId: tokenIdState.value, + minterMessages, + sg721Messages, + recipient: recipientState.value, + txSigner: wallet.address, + type, + } + const { isLoading, mutate } = useMutation( + async (event: FormEvent) => { + event.preventDefault() + if (!type) { + throw new Error('Please select an action!') + } + if (minterContractState.value === '' && sg721ContractState.value === '') { + throw new Error('Please enter minter and sg721 contract addresses!') + } + const txHash = await toast.promise(dispatchExecute(payload), { + error: `${type.charAt(0).toUpperCase() + type.slice(1)} execute failed!`, + loading: 'Executing message...', + success: (tx) => `Transaction ${tx} success!`, + }) + if (txHash) { + setLastTx(txHash) + } + }, + { + onError: (error) => { + toast.error(String(error)) + }, + }, + ) + + return ( +
+ + + +
+
+ + + + {showRecipientField && } + {showWhitelistField && } + {showLimitField && } + {showTokenIdField && } + + + setTimestamp(date)} value={timestamp} /> + + +
+
+
+ + + + +
+ + + +
+
+
+ ) +} + +export default withMetadata(CollectionActionsPage, { center: false }) diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx new file mode 100644 index 0000000..d971195 --- /dev/null +++ b/pages/collections/index.tsx @@ -0,0 +1,49 @@ +import { HomeCard } from 'components/HomeCard' +import type { NextPage } from 'next' +// import Brand from 'public/brand/brand.svg' +import { withMetadata } from 'utils/layout' + +const HomePage: NextPage = () => { + return ( +
+
+ {/* */} +
+

Collections

+

+ Here you can create a collection, execute actions and query the results. +
+

+ +
+ +
+ +
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +
+
+ ) +} + +export default withMetadata(HomePage, { center: false })