From 4ba58eca6d0e2e2e43c360c6c9dea060d634a57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20Nak=C4=B1=C5=9F=C3=A7=C4=B1?= Date: Thu, 4 Aug 2022 12:16:42 +0300 Subject: [PATCH] Refactoring collection creation logic (#20) * Split collection info component * Fix texts * Refactor components * Create upload details component * Add on change method to collection details * Add on change method to minting details * Add on change method to whitelist details * Add on change method to royalty details * Update create page name * Refactor code for collection creation logic --- components/CollectionInfo.tsx | 232 ----- .../creation/CollectionDetails.tsx | 108 +++ .../collections/creation/MintingDetails.tsx | 70 ++ .../collections/creation/RoyaltyDetails.tsx | 50 ++ .../collections/creation/UploadDetails.tsx | 587 +++++++++++++ .../collections/creation/WhitelistDetails.tsx | 96 +++ components/collections/creation/index.ts | 5 + pages/collections/create.tsx | 808 +++++------------- services/upload/pinata.ts | 3 +- 9 files changed, 1145 insertions(+), 814 deletions(-) delete mode 100644 components/CollectionInfo.tsx create mode 100644 components/collections/creation/CollectionDetails.tsx create mode 100644 components/collections/creation/MintingDetails.tsx create mode 100644 components/collections/creation/RoyaltyDetails.tsx create mode 100644 components/collections/creation/UploadDetails.tsx create mode 100644 components/collections/creation/WhitelistDetails.tsx create mode 100644 components/collections/creation/index.ts diff --git a/components/CollectionInfo.tsx b/components/CollectionInfo.tsx deleted file mode 100644 index 06be569..0000000 --- a/components/CollectionInfo.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import clsx from 'clsx' -import { Button } from 'components/Button' -import { FormControl } from 'components/FormControl' -import { FormGroup } from 'components/FormGroup' -import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' -import { InputDateTime } from 'components/InputDateTime' -import type { ChangeEvent } from 'react' -import React, { useState } from 'react' -import useCollapse from 'react-collapsed' -import { toast } from 'react-hot-toast' -import { useMutation } from 'react-query' - -import { Conditional } from './Conditional' -import { NumberInput, TextInput } from './forms/FormInput' -import { JsonPreview } from './JsonPreview' -import { WhitelistUpload } from './WhitelistUpload' - -export const CollectionInfo = () => { - const { getCollapseProps, getToggleProps } = useCollapse() - const [timestamp, setTimestamp] = useState() - - const toggleProps = getToggleProps() - const collapseProps = getCollapseProps() - - const [wlstartDate, setwlStartDate] = useState(undefined) - const [wlendDate, setwlEndDate] = useState(undefined) - const [whitelistArray, setWhitelistArray] = useState([]) - - const [coverImage, setCoverImage] = useState(null) - - const nameState = useInputState({ - id: 'name', - name: 'name', - title: 'Name', - subtitle: '', - }) - - const descriptionState = useInputState({ - id: 'description', - name: 'description', - title: 'Description', - subtitle: '', - }) - - const externalImageState = useInputState({ - id: 'externalImage', - name: 'externalImage', - title: 'External Image', - subtitle: '', - }) - - const numberOfTokensState = useNumberInputState({ - id: 'numberoftokens', - name: 'numberoftokens', - title: 'Number Of Tokens', - subtitle: '', - placeholder: '100', - }) - - const unitPriceState = useNumberInputState({ - id: 'unitPrice', - name: 'unitPrice', - title: 'Unit Price', - subtitle: '', - placeholder: '100', - }) - - const perAddressLimitState = useNumberInputState({ - id: 'peraddresslimit', - name: 'peraddresslimit', - title: 'Per Address Limit', - subtitle: '', - placeholder: '1', - }) - - const royaltyPaymentAddressState = useInputState({ - id: 'royalty-payment-address', - name: 'royaltyPaymentAddress', - title: 'Payment Address', - subtitle: 'Address to receive royalties', - placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...', - }) - - const royaltyShareState = useNumberInputState({ - id: 'royalty-share', - name: 'royaltyShare', - title: 'Share Percentage', - subtitle: 'Percentage of royalties to be paid', - placeholder: '8', - }) - - const wlunitPriceState = useNumberInputState({ - id: 'unit-price', - name: 'unitPrice', - title: 'Unit Price', - subtitle: 'Price of each tokens in collection', - placeholder: '500', - }) - - const wlmemberLimitState = useNumberInputState({ - id: 'member-limit', - name: 'memberLimit', - title: 'Member Limit', - subtitle: 'Limit of the whitelisted members', - placeholder: '1000', - }) - - const wlperAddressLimitState = useNumberInputState({ - id: 'per-address-limit', - name: 'perAddressLimit', - title: 'Per Address Limit', - subtitle: 'Limit of tokens per address', - placeholder: '5', - }) - - const { isLoading, mutate } = useMutation( - (event: FormEvent) => { - //event.preventDefault() - }, - { - onError: (error) => { - //toast.error(String(error)) - }, - }, - ) - - const whitelistFileOnChange = (data: string[]) => { - setWhitelistArray(data) - } - - const selectCoverImage = (event: ChangeEvent) => { - if (event.target.files === null) return toast.error('Error selecting cover image') - if (event.target.files.length === 0) { - setCoverImage(null) - return toast.error('No files selected.') - } - const reader = new FileReader() - reader.onload = (e) => { - if (!e.target?.result) return toast.error('Error parsing file.') - if (!event.target.files) return toast.error('No files selected.') - const imageFile = new File([e.target.result], event.target.files[0].name, { type: 'image/jpg' }) - setCoverImage(imageFile) - } - reader.readAsArrayBuffer(event.target.files[0]) - } - - return ( -
-
-
-
-
-
- - - -
- - - {coverImage !== null && ( -
- cover-preview -
- )} -
- -
-
-
- - - - - - setTimestamp(date)} value={timestamp} /> - - -
-
-
- -
-
-
- - -
- - - - - - - - setwlStartDate(date)} value={wlstartDate} /> - - - setwlEndDate(date)} value={wlendDate} /> - - - 0}> - - - -
- - - - -
-
-
- -
- ) -} diff --git a/components/collections/creation/CollectionDetails.tsx b/components/collections/creation/CollectionDetails.tsx new file mode 100644 index 0000000..3de023d --- /dev/null +++ b/components/collections/creation/CollectionDetails.tsx @@ -0,0 +1,108 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +import clsx from 'clsx' +import { FormControl } from 'components/FormControl' +import { FormGroup } from 'components/FormGroup' +import { useInputState } from 'components/forms/FormInput.hooks' +import type { ChangeEvent } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' + +import { TextInput } from '../../forms/FormInput' + +interface CollectionDetailsProps { + onChange: (data: CollectionDetailsDataProps) => void +} + +export interface CollectionDetailsDataProps { + name: string + description: string + imageFile: File[] + externalLink?: string +} + +export const CollectionDetails = ({ onChange }: CollectionDetailsProps) => { + const [coverImage, setCoverImage] = useState(null) + + const nameState = useInputState({ + id: 'name', + name: 'name', + title: 'Name', + placeholder: 'My Awesome Collection', + }) + + const descriptionState = useInputState({ + id: 'description', + name: 'description', + title: 'Description', + placeholder: 'My Awesome Collection Description', + }) + + const externalLinkState = useInputState({ + id: 'external-link', + name: 'externalLink', + title: 'External Link', + placeholder: 'https://my-collection...', + }) + + useEffect(() => { + try { + const data: CollectionDetailsDataProps = { + name: nameState.value, + description: descriptionState.value, + imageFile: coverImage ? [coverImage] : [], + externalLink: externalLinkState.value, + } + onChange(data) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + toast.error(error.message) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nameState.value, descriptionState.value, coverImage, externalLinkState.value]) + + const selectCoverImage = (event: ChangeEvent) => { + if (event.target.files === null) return toast.error('Error selecting cover image') + if (event.target.files.length === 0) { + setCoverImage(null) + return toast.error('No files selected.') + } + const reader = new FileReader() + reader.onload = (e) => { + if (!e.target?.result) return toast.error('Error parsing file.') + if (!event.target.files) return toast.error('No files selected.') + const imageFile = new File([e.target.result], event.target.files[0].name, { type: 'image/jpg' }) + setCoverImage(imageFile) + } + reader.readAsArrayBuffer(event.target.files[0]) + } + + return ( +
+ + + + + + {coverImage !== null && ( +
+ cover-preview +
+ )} +
+ +
+
+ ) +} diff --git a/components/collections/creation/MintingDetails.tsx b/components/collections/creation/MintingDetails.tsx new file mode 100644 index 0000000..6d0c12b --- /dev/null +++ b/components/collections/creation/MintingDetails.tsx @@ -0,0 +1,70 @@ +import { FormControl } from 'components/FormControl' +import { FormGroup } from 'components/FormGroup' +import { useNumberInputState } from 'components/forms/FormInput.hooks' +import { InputDateTime } from 'components/InputDateTime' +import React, { useEffect, useState } from 'react' + +import { NumberInput } from '../../forms/FormInput' + +interface MintingDetailsProps { + onChange: (data: MintingDetailsDataProps) => void +} + +export interface MintingDetailsDataProps { + numTokens: number + unitPrice: string + perAddressLimit: number + startTime: string +} + +export const MintingDetails = ({ onChange }: MintingDetailsProps) => { + const [timestamp, setTimestamp] = useState() + + const numberOfTokensState = useNumberInputState({ + id: 'numberoftokens', + name: 'numberoftokens', + title: 'Number Of Tokens', + subtitle: '', + placeholder: '100', + }) + + const unitPriceState = useNumberInputState({ + id: 'unitPrice', + name: 'unitPrice', + title: 'Unit Price', + subtitle: '', + placeholder: '100', + }) + + const perAddressLimitState = useNumberInputState({ + id: 'peraddresslimit', + name: 'peraddresslimit', + title: 'Per Address Limit', + subtitle: '', + placeholder: '1', + }) + + useEffect(() => { + const data: MintingDetailsDataProps = { + numTokens: numberOfTokensState.value, + unitPrice: unitPriceState.value ? (Number(unitPriceState.value) * 1_000_000).toString() : '', + perAddressLimit: perAddressLimitState.value, + startTime: timestamp ? (timestamp.getTime() * 1_000_000).toString() : '', + } + onChange(data) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [numberOfTokensState.value, unitPriceState.value, perAddressLimitState.value, timestamp]) + + return ( +
+ + + + + + setTimestamp(date)} value={timestamp} /> + + +
+ ) +} diff --git a/components/collections/creation/RoyaltyDetails.tsx b/components/collections/creation/RoyaltyDetails.tsx new file mode 100644 index 0000000..d0db2bc --- /dev/null +++ b/components/collections/creation/RoyaltyDetails.tsx @@ -0,0 +1,50 @@ +import { FormGroup } from 'components/FormGroup' +import { useInputState, useNumberInputState } from 'components/forms/FormInput.hooks' +import React, { useEffect } from 'react' + +import { NumberInput, TextInput } from '../../forms/FormInput' + +interface RoyaltyDetailsProps { + onChange: (data: RoyaltyDetailsDataProps) => void +} + +export interface RoyaltyDetailsDataProps { + paymentAddress: string + share: number +} + +export const RoyaltyDetails = ({ onChange }: RoyaltyDetailsProps) => { + const royaltyPaymentAddressState = useInputState({ + id: 'royalty-payment-address', + name: 'royaltyPaymentAddress', + title: 'Payment Address', + subtitle: 'Address to receive royalties', + placeholder: 'stars1234567890abcdefghijklmnopqrstuvwxyz...', + }) + + const royaltyShareState = useNumberInputState({ + id: 'royalty-share', + name: 'royaltyShare', + title: 'Share Percentage', + subtitle: 'Percentage of royalties to be paid', + placeholder: '8', + }) + + useEffect(() => { + const data: RoyaltyDetailsDataProps = { + paymentAddress: royaltyPaymentAddressState.value, + share: royaltyShareState.value, + } + onChange(data) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [royaltyPaymentAddressState.value, royaltyShareState.value]) + + return ( +
+ + + + +
+ ) +} diff --git a/components/collections/creation/UploadDetails.tsx b/components/collections/creation/UploadDetails.tsx new file mode 100644 index 0000000..670171e --- /dev/null +++ b/components/collections/creation/UploadDetails.tsx @@ -0,0 +1,587 @@ +import clsx from 'clsx' +import { Alert } from 'components/Alert' +import Anchor from 'components/Anchor' +import { Conditional } from 'components/Conditional' +import { StyledInput } from 'components/forms/StyledInput' +import { MetadataModal } from 'components/MetadataModal' +import { setBaseTokenUri, setImage, useCollectionStore } from 'contexts/collection' +import type { ChangeEvent } from 'react' +import { useEffect, useState } from 'react' +import { toast } from 'react-hot-toast' +import type { UploadServiceType } from 'services/upload' +import { getAssetType } from 'utils/getAssetType' +import { naturalCompare } from 'utils/sort' + +type UploadMethod = 'new' | 'existing' + +interface UploadDetailsProps { + onChange: (value: UploadDetailsDataProps) => void +} + +export interface UploadDetailsDataProps { + assetFiles: File[] + metadataFiles: File[] + uploadService: UploadServiceType + nftStorageApiKey?: string + pinataApiKey?: string + pinataSecretKey?: string +} + +export const UploadDetails = ({ onChange }: UploadDetailsProps) => { + const baseTokenURI = useCollectionStore().base_token_uri + const [assetFilesArray, setAssetFilesArray] = useState([]) + const [metadataFilesArray, setMetadataFilesArray] = useState([]) + const [updatedMetadataFilesArray, setUpdatedMetadataFilesArray] = useState([]) + const [uploadMethod, setUploadMethod] = useState('new') + const [uploadService, setUploadService] = useState('nft-storage') + const [metadataFileArrayIndex, setMetadataFileArrayIndex] = useState(0) + + const [refreshMetadata, setRefreshMetadata] = useState(false) + const [nftStorageApiKey, setNftStorageApiKey] = useState( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6ZXRocjoweDJBODk5OGI4ZkE2YTM1NzMyYmMxQTRDQzNhOUU2M0Y2NUM3ZjA1RWIiLCJpc3MiOiJuZnQtc3RvcmFnZSIsImlhdCI6MTY1NTE5MTcwNDQ2MiwibmFtZSI6IlRlc3QifQ.IbdV_26bkPHSdd81sxox5AoG-5a4CCEY4aCrdbCXwAE', + ) + const [pinataApiKey, setPinataApiKey] = useState('c8c2ea440c09ee8fa639') + const [pinataSecretKey, setPinataSecretKey] = useState( + '9d6f42dc01eaab15f52eac8f36cc4f0ee4184944cb3cdbcda229d06ecf877ee7', + ) + + 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 selectAssets = (event: ChangeEvent) => { + setAssetFilesArray([]) + setMetadataFilesArray([]) + setUpdatedMetadataFilesArray([]) + console.log(event.target.files) + let reader: FileReader + if (event.target.files === null) return + for (let i = 0; i < event.target.files.length; i++) { + reader = new FileReader() + reader.onload = (e) => { + if (!e.target?.result) return toast.error('Error parsing file.') + if (!event.target.files) return toast.error('No files selected.') + const assetFile = new File([e.target.result], event.target.files[i].name, { type: 'image/jpg' }) + setAssetFilesArray((prev) => [...prev, assetFile]) + } + if (!event.target.files) return toast.error('No file selected.') + reader.readAsArrayBuffer(event.target.files[i]) + reader.onloadend = (e) => { + setAssetFilesArray((prev) => prev.sort((a, b) => naturalCompare(a.name, b.name))) + } + } + } + + const selectMetadata = (event: ChangeEvent) => { + setMetadataFilesArray([]) + setUpdatedMetadataFilesArray([]) + console.log(assetFilesArray) + console.log(event.target.files) + let reader: FileReader + if (event.target.files === null) return toast.error('No files selected.') + for (let i = 0; i < event.target.files.length; i++) { + reader = new FileReader() + reader.onload = async (e) => { + if (!e.target?.result) return toast.error('Error parsing file.') + if (!event.target.files) return toast.error('No files selected.') + if (!JSON.parse(await event.target.files[i].text()).attributes) + return toast.error(`The file with name '${event.target.files[i].name}' doesn't have an attributes list!`) + const metadataFile = new File([e.target.result], event.target.files[i].name, { type: 'application/json' }) + setMetadataFilesArray((prev) => [...prev, metadataFile]) + } + if (!event.target.files) return toast.error('No file selected.') + reader.readAsText(event.target.files[i], 'utf8') + reader.onloadend = (e) => { + setMetadataFilesArray((prev) => prev.sort((a, b) => naturalCompare(a.name, b.name))) + console.log(metadataFilesArray) + } + } + } + + const updateMetadataFileIndex = (index: number) => { + setMetadataFileArrayIndex(index) + setRefreshMetadata((prev) => !prev) + } + + const updateMetadataFileArray = async (updatedMetadataFile: File) => { + metadataFilesArray[metadataFileArrayIndex] = updatedMetadataFile + console.log('Updated Metadata File:') + console.log(JSON.parse(await metadataFilesArray[metadataFileArrayIndex]?.text())) + } + + const checkAssetMetadataMatch = () => { + const metadataFileNames = metadataFilesArray.map((file) => file.name) + const assetFileNames = assetFilesArray.map((file) => file.name.substring(0, file.name.lastIndexOf('.'))) + // Compare the two arrays to make sure they are the same + const areArraysEqual = metadataFileNames.every((val, index) => val === assetFileNames[index]) + if (!areArraysEqual) { + throw new Error('Asset and metadata file names do not match.') + } + } + + useEffect(() => { + try { + checkAssetMetadataMatch() + const data: UploadDetailsDataProps = { + assetFiles: assetFilesArray, + metadataFiles: metadataFilesArray, + uploadService, + nftStorageApiKey, + pinataApiKey, + pinataSecretKey, + } + onChange(data) + } catch (error: any) { + toast.error(error.message) + } + }, [assetFilesArray, metadataFilesArray]) + + return ( +
+
+
+ { + setUploadMethod('existing') + }} + type="radio" + value="Existing" + /> + +
+ +
+ { + setUploadMethod('new') + }} + type="radio" + value="New" + /> + +
+ {baseTokenURI && ( + + + Base Token URI: {baseTokenURI} + + + )} +
+ +
+ + {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 + {' '} + or{' '} + + Pinata + {' '} + and upload your assets & metadata manually to get a base URI for your collection. +

+
+ + +
+
+ + +
+
+ )} + {uploadMethod === 'new' && ( +
+
+
+ { + setUploadService('nft-storage') + }} + type="radio" + value="nft-storage" + /> + +
+ +
+ { + setUploadService('pinata') + }} + type="radio" + value="pinata" + /> + +
+
+ +
+ + + setNftStorageApiKey(e.target.value)} + value={nftStorageApiKey} + /> + +
+
+ + + setPinataApiKey(e.target.value)} + value={pinataApiKey} + /> + + setPinataSecretKey(e.target.value)} + value={pinataSecretKey} + /> + +
+ +
+
+
+
+ +
+ +
+
+ + {assetFilesArray.length > 0 && ( +
+ +
+ +
+
+ )} + 0 && + metadataFilesArray.length > 0 && + assetFilesArray.length !== metadataFilesArray.length + } + > + + The number of assets and metadata files should match. + + + + +
+ {assetFilesArray.length > 0 && ( +
+ {assetFilesArray.map((assetSource, index) => ( +
+
+ 4 * index}> + + + 4 * index + 1}> + + + 4 * index + 2}> + + + 4 * index + 3}> + + +
+
+ ))} +
+ )} +
+
+
+ )} +
+ ) +} diff --git a/components/collections/creation/WhitelistDetails.tsx b/components/collections/creation/WhitelistDetails.tsx new file mode 100644 index 0000000..7ebaabe --- /dev/null +++ b/components/collections/creation/WhitelistDetails.tsx @@ -0,0 +1,96 @@ +import { FormControl } from 'components/FormControl' +import { FormGroup } from 'components/FormGroup' +import { useNumberInputState } from 'components/forms/FormInput.hooks' +import { InputDateTime } from 'components/InputDateTime' +import React, { useEffect, useState } from 'react' + +import { Conditional } from '../../Conditional' +import { NumberInput } from '../../forms/FormInput' +import { JsonPreview } from '../../JsonPreview' +import { WhitelistUpload } from '../../WhitelistUpload' + +interface WhitelistDetailsProps { + onChange: (data: WhitelistDetailsDataProps) => void +} + +export interface WhitelistDetailsDataProps { + isContractAddress: boolean + contractAddress?: string + members?: string[] + unitPrice?: string + startTime?: string + endTime?: string + perAddressLimit?: number + memberLimit?: number +} + +export const WhitelistDetails = ({ onChange }: WhitelistDetailsProps) => { + const [startDate, setStartDate] = useState(undefined) + const [endDate, setEndDate] = useState(undefined) + const [whitelistArray, setWhitelistArray] = useState([]) + + const uniPriceState = useNumberInputState({ + id: 'unit-price', + name: 'unitPrice', + title: 'Unit Price', + subtitle: 'Price of each tokens in collection', + placeholder: '500', + }) + + const memberLimitState = useNumberInputState({ + id: 'member-limit', + name: 'memberLimit', + title: 'Member Limit', + subtitle: 'Limit of the whitelisted members', + placeholder: '1000', + }) + + const perAddressLimitState = useNumberInputState({ + id: 'per-address-limit', + name: 'perAddressLimit', + title: 'Per Address Limit', + subtitle: 'Limit of tokens per address', + placeholder: '5', + }) + + const whitelistFileOnChange = (data: string[]) => { + setWhitelistArray(data) + } + + useEffect(() => { + const data: WhitelistDetailsDataProps = { + isContractAddress: false, + contractAddress: '', + members: whitelistArray, + unitPrice: uniPriceState.value ? (Number(uniPriceState.value) * 1_000_000).toString() : '', + startTime: startDate ? (startDate.getTime() * 1_000_000).toString() : '', + endTime: endDate ? (endDate.getTime() * 1_000_000).toString() : '', + perAddressLimit: perAddressLimitState.value, + memberLimit: memberLimitState.value, + } + onChange(data) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uniPriceState.value, memberLimitState.value, perAddressLimitState.value, startDate, endDate, whitelistArray]) + + return ( +
+ + + + + + + + setStartDate(date)} value={startDate} /> + + + setEndDate(date)} value={endDate} /> + + + 0}> + + + +
+ ) +} diff --git a/components/collections/creation/index.ts b/components/collections/creation/index.ts new file mode 100644 index 0000000..3ebad9e --- /dev/null +++ b/components/collections/creation/index.ts @@ -0,0 +1,5 @@ +export { CollectionDetails } from './CollectionDetails' +export { MintingDetails } from './MintingDetails' +export { RoyaltyDetails } from './RoyaltyDetails' +export { UploadDetails } from './UploadDetails' +export { WhitelistDetails } from './WhitelistDetails' diff --git a/pages/collections/create.tsx b/pages/collections/create.tsx index 9efc003..1aca483 100644 --- a/pages/collections/create.tsx +++ b/pages/collections/create.tsx @@ -1,169 +1,246 @@ /* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable func-names */ - -import clsx from 'clsx' -import { Alert } from 'components/Alert' +import { coin } from '@cosmjs/proto-signing' import Anchor from 'components/Anchor' import Button from 'components/Button' -import { CollectionInfo } from 'components/CollectionInfo' -import { Conditional } from 'components/Conditional' -import { StyledInput } from 'components/forms/StyledInput' -import { MetadataModal } from 'components/MetadataModal' -import { setBaseTokenUri, setImage, useCollectionStore } from 'contexts/collection' +import { + CollectionDetails, + MintingDetails, + RoyaltyDetails, + UploadDetails, + WhitelistDetails, +} from 'components/collections/creation' +import type { CollectionDetailsDataProps } from 'components/collections/creation/CollectionDetails' +import type { MintingDetailsDataProps } from 'components/collections/creation/MintingDetails' +import type { RoyaltyDetailsDataProps } from 'components/collections/creation/RoyaltyDetails' +import type { UploadDetailsDataProps } from 'components/collections/creation/UploadDetails' +import type { WhitelistDetailsDataProps } from 'components/collections/creation/WhitelistDetails' +import { useContracts } from 'contexts/contracts' +import { useWallet } from 'contexts/wallet' import type { NextPage } from 'next' import { NextSeo } from 'next-seo' -import type { ChangeEvent } from 'react' import { useState } from 'react' +import useCollapse from 'react-collapsed' import { toast } from 'react-hot-toast' import type { UploadServiceType } from 'services/upload' import { upload } from 'services/upload' +import { MINTER_CODE_ID, SG721_CODE_ID, WHITELIST_CODE_ID } from 'utils/constants' import { withMetadata } from 'utils/layout' import { links } from 'utils/links' -import { naturalCompare } from 'utils/sort' -import { getAssetType } from '../../utils/getAssetType' +const CollectionCreationPage: NextPage = () => { + const wallet = useWallet() + const { minter: minterContract, whitelist: whitelistContract } = useContracts() -type UploadMethod = 'new' | 'existing' + const { getCollapseProps, getToggleProps, isExpanded } = useCollapse() + const toggleProps = getToggleProps() + const collapseProps = getCollapseProps() -const UploadPage: NextPage = () => { - const baseTokenURI = useCollectionStore().base_token_uri - const [assetFilesArray, setAssetFilesArray] = useState([]) - const [metadataFilesArray, setMetadataFilesArray] = useState([]) - const [updatedMetadataFilesArray, setUpdatedMetadataFilesArray] = useState([]) - const [uploadMethod, setUploadMethod] = useState('new') - const [uploadService, setUploadService] = useState('nft-storage') - const [metadataFileArrayIndex, setMetadataFileArrayIndex] = useState(0) - const [refreshMetadata, setRefreshMetadata] = useState(false) - const [nftStorageApiKey, setNftStorageApiKey] = useState( - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6ZXRocjoweDJBODk5OGI4ZkE2YTM1NzMyYmMxQTRDQzNhOUU2M0Y2NUM3ZjA1RWIiLCJpc3MiOiJuZnQtc3RvcmFnZSIsImlhdCI6MTY1NTE5MTcwNDQ2MiwibmFtZSI6IlRlc3QifQ.IbdV_26bkPHSdd81sxox5AoG-5a4CCEY4aCrdbCXwAE', - ) - const [pinataApiKey, setPinataApiKey] = useState('c8c2ea440c09ee8fa639') - const [pinataSecretKey, setPinataSecretKey] = useState( - '9d6f42dc01eaab15f52eac8f36cc4f0ee4184944cb3cdbcda229d06ecf877ee7', - ) + const [uploadDetails, setUploadDetails] = useState(null) + const [collectionDetails, setCollectionDetails] = useState(null) + const [mintingDetails, setMintingDetails] = useState(null) + const [whitelistDetails, setWhitelistDetails] = useState(null) + const [royaltyDetails, setRoyaltyDetails] = useState(null) - const handleChangeBaseTokenUri = (event: { target: { value: React.SetStateAction } }) => { - setBaseTokenUri(event.target.value.toString()) - } + const [contractAddress, setContractAddress] = useState(null) + const [transactionHash, setTransactionHash] = useState(null) - const handleChangeImage = (event: { target: { value: React.SetStateAction } }) => { - setImage(event.target.value.toString()) - } + const createCollection = async () => { + try { + checkUploadDetails() + checkCollectionDetails() + checkMintingDetails() + checkWhitelistDetails() + checkRoyaltyDetails() - const selectAssets = (event: ChangeEvent) => { - setAssetFilesArray([]) - console.log(event.target.files) - let reader: FileReader - if (event.target.files === null) return - for (let i = 0; i < event.target.files.length; i++) { - reader = new FileReader() - reader.onload = function (e) { - if (!e.target?.result) return toast.error('Error parsing file.') - if (!event.target.files) return toast.error('No files selected.') - const assetFile = new File([e.target.result], event.target.files[i].name, { type: 'image/jpg' }) - setAssetFilesArray((prev) => [...prev, assetFile]) - } - if (!event.target.files) return toast.error('No file selected.') - reader.readAsArrayBuffer(event.target.files[i]) - reader.onloadend = function (e) { - setAssetFilesArray((prev) => prev.sort((a, b) => naturalCompare(a.name, b.name))) - } + const baseUri = await uploadFiles() + const coverImageUri = await upload( + collectionDetails?.imageFile as File[], + uploadDetails?.uploadService as UploadServiceType, + 'cover', + uploadDetails?.nftStorageApiKey as string, + uploadDetails?.pinataApiKey as string, + uploadDetails?.pinataSecretKey as string, + ) + + const whitelist = whitelistDetails?.isContractAddress + ? whitelistDetails.contractAddress + : await instantiateWhitelist() + + await instantate(baseUri, coverImageUri, whitelist) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + toast.error(error.message) } } - const selectMetadata = (event: ChangeEvent) => { - setMetadataFilesArray([]) - setUpdatedMetadataFilesArray([]) - console.log(assetFilesArray) - console.log(event.target.files) - let reader: FileReader - if (event.target.files === null) return toast.error('No files selected.') - for (let i = 0; i < event.target.files.length; i++) { - reader = new FileReader() - reader.onload = async function (e) { - if (!e.target?.result) return toast.error('Error parsing file.') - if (!event.target.files) return toast.error('No files selected.') - if (!JSON.parse(await event.target.files[i].text()).attributes) - return toast.error(`The file with name '${event.target.files[i].name}' doesn't have an attributes list!`) - const metadataFile = new File([e.target.result], event.target.files[i].name, { type: 'application/json' }) - setMetadataFilesArray((prev) => [...prev, metadataFile]) - } - if (!event.target.files) return toast.error('No file selected.') - reader.readAsText(event.target.files[i], 'utf8') - reader.onloadend = function (e) { - setMetadataFilesArray((prev) => prev.sort((a, b) => naturalCompare(a.name, b.name))) - console.log(metadataFilesArray) - } - } - } - const updateMetadata = async () => { - const metadataFileNames = metadataFilesArray.map((file) => file.name) - console.log(metadataFileNames) - const assetFileNames = assetFilesArray.map((file) => file.name.substring(0, file.name.lastIndexOf('.'))) - console.log(assetFileNames) - //compare the two arrays to make sure they are the same - const areArraysEqual = metadataFileNames.every((val, index) => val === assetFileNames[index]) - if (!areArraysEqual) { - return toast.error('Asset and metadata file names do not match.') + const instantiateWhitelist = async () => { + if (!wallet.initialized) throw new Error('Wallet not connected') + if (!whitelistContract) throw new Error('Contract not found') + + const msg = { + members: whitelistDetails?.members, + start_time: whitelistDetails?.startTime, + end_time: whitelistDetails?.endTime, + unit_price: coin(String(Number(whitelistDetails?.unitPrice) * 1000000), 'ustars'), + per_address_limit: whitelistDetails?.perAddressLimit, + member_limit: whitelistDetails?.memberLimit, } - console.log(assetFilesArray) - const assetURI = await upload( - assetFilesArray, - uploadService, - 'assets', - nftStorageApiKey, - pinataApiKey, - pinataSecretKey, + const data = await whitelistContract.instantiate( + WHITELIST_CODE_ID, + msg, + 'Stargaze Whitelist Contract', + wallet.address, ) - console.log(assetURI) - setUpdatedMetadataFilesArray([]) - let reader: FileReader - for (let i = 0; i < metadataFilesArray.length; i++) { - reader = new FileReader() - reader.onload = function (e) { - const metadataJSON = JSON.parse(e.target?.result as string) - metadataJSON.image = `ipfs://${assetURI}/${assetFilesArray[i].name}` - const metadataFileBlob = new Blob([JSON.stringify(metadataJSON)], { - type: 'application/json', - }) - const updatedMetadataFile = new File([metadataFileBlob], metadataFilesArray[i].name, { - type: 'application/json', - }) - updatedMetadataFilesArray.push(updatedMetadataFile) - console.log(`${updatedMetadataFile.name} => ${metadataJSON.image}`) - if (i === metadataFilesArray.length - 1) { - void uploadUpdatedMetadata() - } + + return data.contractAddress + } + + const instantate = async (baseUri: string, coverImageUri: string, whitelist?: string) => { + if (!wallet.initialized) throw new Error('Wallet not connected') + if (!minterContract) throw new Error('Contract not found') + + let royaltyInfo = null + if (royaltyDetails?.paymentAddress && royaltyDetails.share) { + royaltyInfo = { + paymentAddress: royaltyDetails.paymentAddress, + share: royaltyDetails.share, } - reader.readAsText(metadataFilesArray[i], 'utf8') + } + + const msg = { + base_token_uri: baseUri, + num_tokens: mintingDetails?.numTokens, + sg721_code_id: SG721_CODE_ID, + sg721_instantiate_msg: { + name: collectionDetails?.name, + symbol: 'SYMBOL', + minter: wallet.address, + collection_info: { + creator: wallet.address, + description: collectionDetails?.description, + image: coverImageUri, + external_link: collectionDetails?.externalLink, + royalty_info: royaltyInfo, + }, + }, + per_address_limit: mintingDetails?.perAddressLimit, + unit_price: coin(String(Number(mintingDetails?.unitPrice) * 1000000), 'ustars'), + whitelist_address: whitelist, + start_time: mintingDetails?.startTime, + } + + const data = await minterContract.instantiate(MINTER_CODE_ID, msg, 'Stargaze Minter Contract', wallet.address) + + setTransactionHash(data.transactionHash) + setContractAddress(data.contractAddress) + } + + const uploadFiles = async (): Promise => { + if (!uploadDetails) throw new Error('Please upload asset and metadata') + return new Promise((resolve, reject) => { + upload( + uploadDetails.assetFiles, + uploadDetails.uploadService, + 'assets', + uploadDetails.nftStorageApiKey as string, + uploadDetails.pinataApiKey as string, + uploadDetails.pinataSecretKey as string, + ) + .then((assetUri: string) => { + const fileArray: File[] = [] + let reader: FileReader + for (let i = 0; i < uploadDetails.metadataFiles.length; i++) { + reader = new FileReader() + reader.onload = (e) => { + const data: any = JSON.parse(e.target?.result as string) + data.image = `ipfs://${assetUri}/${uploadDetails.assetFiles[i].name}` + const metadataFileBlob = new Blob([JSON.stringify(data)], { + type: 'application/json', + }) + const updatedMetadataFile = new File([metadataFileBlob], uploadDetails.metadataFiles[i].name, { + type: 'application/json', + }) + fileArray.push(updatedMetadataFile) + } + reader.onloadend = () => { + if (i === uploadDetails.metadataFiles.length - 1) { + upload( + fileArray, + uploadDetails.uploadService, + 'metadata', + uploadDetails.nftStorageApiKey as string, + uploadDetails.pinataApiKey as string, + uploadDetails.pinataSecretKey as string, + ) + .then(resolve) + .catch(reject) + } + } + reader.readAsText(uploadDetails.metadataFiles[i], 'utf8') + } + }) + .catch(reject) + }) + } + + const checkUploadDetails = () => { + if (!uploadDetails) { + throw new Error('Please upload asset and metadata') + } + if (uploadDetails.assetFiles.length === 0) { + throw new Error('Please upload assets') + } + if (uploadDetails.metadataFiles.length === 0) { + throw new Error('Please upload metadatas') + } + if (uploadDetails.uploadService === 'nft-storage') { + if (uploadDetails.nftStorageApiKey === '') { + throw new Error('Please enter NFT Storage api key') + } + } else if (uploadDetails.pinataApiKey === '' || uploadDetails.pinataSecretKey === '') { + throw new Error('Please enter Pinata api key and secret key') } } - const uploadUpdatedMetadata = async () => { - setUpdatedMetadataFilesArray(updatedMetadataFilesArray) - const result = await upload( - updatedMetadataFilesArray, - uploadService, - 'metadata', - nftStorageApiKey, - pinataApiKey, - pinataSecretKey, - ) - setBaseTokenUri(`ipfs://${result}`) - console.log(`ipfs://${result}`) + + const checkCollectionDetails = () => { + if (!collectionDetails) throw new Error('Please fill out the collection details') + if (collectionDetails.name === '') throw new Error('Name is required') + if (collectionDetails.description === '') throw new Error('Description is required') + if (collectionDetails.imageFile.length === 0) throw new Error('Cover image is required') } - const updateMetadataFileIndex = (index: number) => { - setMetadataFileArrayIndex(index) - setRefreshMetadata((prev) => !prev) + const checkMintingDetails = () => { + if (!mintingDetails) throw new Error('Please fill out the minting details') + if (mintingDetails.numTokens < 1 || mintingDetails.numTokens > 10000) throw new Error('Invalid number of tokens') + if (Number(mintingDetails.unitPrice) < 500) throw new Error('Invalid unit price') + if (mintingDetails.perAddressLimit < 1 || mintingDetails.perAddressLimit > 50) + throw new Error('Per address limit is required') + if (mintingDetails.startTime === '') throw new Error('Start time is required') } - const updateMetadataFileArray = async (updatedMetadataFile: File) => { - metadataFilesArray[metadataFileArrayIndex] = updatedMetadataFile - console.log('Updated Metadata File:') - console.log(JSON.parse(await metadataFilesArray[metadataFileArrayIndex]?.text())) + const checkWhitelistDetails = () => { + if (!whitelistDetails) throw new Error('Please fill out the whitelist details') + if (whitelistDetails.isContractAddress) { + if (whitelistDetails.contractAddress === '') throw new Error('Contract address is required') + } else { + if (whitelistDetails.members?.length === 0) throw new Error('Whitelist member list cannot be empty') + if (whitelistDetails.unitPrice === '') throw new Error('Whitelist unit price is required') + if (whitelistDetails.startTime === '') throw new Error('Start time is required') + if (whitelistDetails.endTime === '') throw new Error('End time is required') + if (whitelistDetails.perAddressLimit === 0) throw new Error('Per address limit is required') + if (whitelistDetails.memberLimit === 0) throw new Error('Member limit is required') + } + } + + const checkRoyaltyDetails = () => { + if (!royaltyDetails) throw new Error('Please fill out the royalty details') + if (royaltyDetails.share === 0) throw new Error('Royalty share is required') + if (royaltyDetails.paymentAddress === '') throw new Error('Royalty payment address is required') } return ( @@ -184,460 +261,31 @@ const UploadPage: NextPage = () => {
-
-
- { - setUploadMethod('existing') - }} - type="radio" - value="Existing" - /> - -
+ -
- { - setUploadMethod('new') - }} - type="radio" - value="New" - /> - -
- {baseTokenURI && ( - - - Base Token URI: {baseTokenURI} - - - )} +
+ +
-
+
+ +
- {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 - {' '} - or{' '} - - Pinata - {' '} - and upload your assets & metadata manually to get a base URI for your collection. -

-
- - -
-
- - -
-
- )} - {uploadMethod === 'new' && ( -
-
-
- { - setUploadService('nft-storage') - }} - type="radio" - value="nft-storage" - /> - -
+
+ + +
-
- { - setUploadService('pinata') - }} - type="radio" - value="pinata" - /> - -
-
- -
- - - setNftStorageApiKey(e.target.value)} - value={nftStorageApiKey} - /> - -
-
- - - setPinataApiKey(e.target.value)} - value={pinataApiKey} - /> - - setPinataSecretKey(e.target.value)} - value={pinataSecretKey} - /> - -
- -
-
-
-
- -
- -
-
- - {assetFilesArray.length > 0 && ( -
- -
- -
-
- )} - 0 && - metadataFilesArray.length > 0 && - assetFilesArray.length !== metadataFilesArray.length - } - > - - The number of assets and metadata files should match. - - - - -
- {assetFilesArray.length > 0 && ( -
- {assetFilesArray.map((assetSource, index) => ( -
-
- 4 * index}> - - - 4 * index + 1}> - - - 4 * index + 2}> - - - 4 * index + 3}> - - -
-
- ))} -
- )} -
-
-
- )} -
-
) } -export default withMetadata(UploadPage, { center: false }) +export default withMetadata(CollectionCreationPage, { center: false }) diff --git a/services/upload/pinata.ts b/services/upload/pinata.ts index 3ef807d..985de93 100644 --- a/services/upload/pinata.ts +++ b/services/upload/pinata.ts @@ -4,7 +4,7 @@ import axios from 'axios' import { PINATA_ENDPOINT_URL } from 'utils/constants' -export type UploadFileType = 'assets' | 'metadata' +export type UploadFileType = 'assets' | 'metadata' | 'cover' export const uploadToPinata = async ( fileArray: File[], @@ -12,7 +12,6 @@ export const uploadToPinata = async ( pinataSecretKey: string, fileType: UploadFileType, ): Promise => { - console.log('Uploading to Pinata...') const data = new FormData() fileArray.forEach((file) => { data.append('file', file, `${fileType}/${file.name}`)