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 */}
@@ -67,13 +68,7 @@ export const Sidebar = () => {
{/* 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
-
-
-
-
-
-
-
-
-
- {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.
-
-
-
- Collection Cover Image
-
-
-
-
-
- Base Token URI
-
-
-
-
- )}
- {uploadMethod == 'New' && (
-
-
- Image File Selection
-
-
- { }}
- ref={imageFilesRef}
- type="file"
- multiple
- />
-
-
-
- Metadata Selection
-
-
- { }}
- ref={metadataFilesRef}
- type="file"
- multiple
- />
-
-
-
-
- Upload
-
-
-
- )}
-
- )
-}
-
-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 (
+
+ )
+}
+
+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 })