diff --git a/packages/metavinci/src/App.less b/packages/metavinci/src/App.less index 1bdb2fb..f0d62c2 100644 --- a/packages/metavinci/src/App.less +++ b/packages/metavinci/src/App.less @@ -57,6 +57,11 @@ code { } +.ant-upload.ant-upload-drag { + border-radius: 8px; + border-color: transparent; +} + .tab-title { font-family: Inter; font-style: normal; diff --git a/packages/metavinci/src/components/Confetti/index.tsx b/packages/metavinci/src/components/Confetti/index.tsx new file mode 100644 index 0000000..3c9ae56 --- /dev/null +++ b/packages/metavinci/src/components/Confetti/index.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; + +interface Particle { + top: number, + left: number, + speed: number, + angle: number, + angle_speed: number, + size: number, +} + +export const Confetti = () => { + const [particles, setParticles] = useState>(Array.from({ length: 30 }).map(_ => ({ + top: -Math.random() * 100, + left: Math.random() * 100, + speed: Math.random() * 1 + 3, + angle: 0, + angle_speed: Math.random() * 10 + 5, + size: Math.floor(Math.random() * 8 + 12), + }))) + + useEffect(() => { + const interval = setInterval(() => { + setParticles(parts => parts.map(part => ({ + ...part, + top: (part.top > 130 ? -30 : part.top + part.speed), + angle: (part.angle > 360 ? 0 : part.angle + part.angle_speed), + }))) + }, 70); + + const timeout = setTimeout(() => { + setParticles([]); + clearInterval(interval); + }, 5000); + + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, []) + + const getStyle = (particle: Particle) => ({ + top: `${particle.top}%`, + left: `${particle.left}%`, + transform: `rotate(${particle.angle}deg)`, + width: particle.size, + height: particle.size, + }) + + return
+ {particles.map((particle, idx) => )} +
+} diff --git a/packages/metavinci/src/routes.tsx b/packages/metavinci/src/routes.tsx index 55f90e1..20c92f9 100644 --- a/packages/metavinci/src/routes.tsx +++ b/packages/metavinci/src/routes.tsx @@ -57,13 +57,13 @@ export function Routes() { /> } + path="/auction/create/:step_param?" + component={() => } /> } + path="/auction/:id" + component={() => } /> diff --git a/packages/metavinci/src/views/artCreate/index.tsx b/packages/metavinci/src/views/artCreate/index.tsx index 8a2512b..d456dba 100644 --- a/packages/metavinci/src/views/artCreate/index.tsx +++ b/packages/metavinci/src/views/artCreate/index.tsx @@ -15,7 +15,8 @@ import { } from 'antd'; import { ArtCard } from './../../components/ArtCard'; import { UserSearch } from './../../components/UserSearch'; -import './styles.less'; +import { Confetti } from './../../components/Confetti'; +import './../styles.less'; import { mintNFT } from '../../models'; import { MAX_METADATA_LEN, @@ -167,27 +168,58 @@ const CategoryStep = (props: { confirm: (category: MetadataCategory) => void })

- - - + + + + + + + + + + + + + + + ); @@ -623,54 +655,3 @@ const Congrats = () => { } -interface Particle { - top: number, - left: number, - speed: number, - angle: number, - angle_speed: number, - size: number, -} - -const Confetti = () => { - const [particles, setParticles] = useState>(Array.from({ length: 30 }).map(_ => ({ - top: -Math.random() * 100, - left: Math.random() * 100, - speed: Math.random() * 1 + 3, - angle: 0, - angle_speed: Math.random() * 10 + 5, - size: Math.floor(Math.random() * 8 + 12), - }))) - - useEffect(() => { - const interval = setInterval(() => { - setParticles(parts => parts.map(part => ({ - ...part, - top: (part.top > 130 ? -30 : part.top + part.speed), - angle: (part.angle > 360 ? 0 : part.angle + part.angle_speed), - }))) - }, 70); - - const timeout = setTimeout(() => { - setParticles([]); - clearInterval(interval); - }, 5000); - - return () => { - clearInterval(interval); - clearTimeout(timeout); - }; - }, []) - - const getStyle = (particle: Particle) => ({ - top: `${particle.top}%`, - left: `${particle.left}%`, - transform: `rotate(${particle.angle}deg)`, - width: particle.size, - height: particle.size, - }) - - return
- {particles.map((particle, idx) => )} -
-} diff --git a/packages/metavinci/src/views/auctionCreate/index.tsx b/packages/metavinci/src/views/auctionCreate/index.tsx index 7ae7940..3ec9823 100644 --- a/packages/metavinci/src/views/auctionCreate/index.tsx +++ b/packages/metavinci/src/views/auctionCreate/index.tsx @@ -1,9 +1,662 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { + Steps, + Row, + Button, + Upload, + Col, + Input, + Statistic, + Modal, + Progress, + Spin, + InputNumber, + Select, +} from 'antd'; +import { ArtCard } from './../../components/ArtCard'; +import { UserSearch } from './../../components/UserSearch'; +import { Confetti } from './../../components/Confetti'; +import './../styles.less'; +import { mintNFT } from '../../models'; +import { + MAX_METADATA_LEN, + MAX_OWNER_LEN, + MAX_URI_LENGTH, + Metadata, + NameSymbolTuple, + useConnection, + useWallet, + IMetadataExtension, + MetadataCategory, + useConnectionConfig, +} from '@oyster/common'; +import { getAssetCostToStore, LAMPORT_MULTIPLIER } from '../../utils/assets'; +import { Connection } from '@solana/web3.js'; +import { MintLayout } from '@solana/spl-token'; +import { useHistory, useParams } from 'react-router-dom'; + +const { Step } = Steps; +const { Option } = Select; +const { Dragger } = Upload; + +export enum AuctionCategory { + Single, + Limited, + Open, + Collection +} export const AuctionCreateView = () => { + const connection = useConnection(); + const { env } = useConnectionConfig(); + const { wallet, connected } = useWallet(); + const { step_param }: { step_param: string } = useParams() + const history = useHistory() + + const [step, setStep] = useState(0); + const [saving, setSaving] = useState(false); + const [progress, setProgress] = useState(0); + const [attributes, setAttributes] = useState({ + name: '', + symbol: '', + description: '', + externalUrl: '', + image: '', + royalty: 0, + files: [], + category: MetadataCategory.Image, + }); + + useEffect(() => { + if (step_param) setStep(parseInt(step_param)) + else gotoStep(0) + }, [step_param]) + + const gotoStep = (_step: number) => { + history.push(`/auction/create/${_step.toString()}`) + } + + // store files + const mint = async () => { + const metadata = { + ...(attributes as any), + image: attributes.files && attributes.files?.[0] && attributes.files[0].name, + files: (attributes?.files || []).map(f => f.name), + } + setSaving(true) + const inte = setInterval(() => setProgress(prog => prog + 1), 600) + // Update progress inside mintNFT + await mintNFT(connection, wallet, env, (attributes?.files || []), metadata) + clearInterval(inte) + } + return ( -
- TODO: Auction Create view -
+ <> + + {!saving && + + + + + + + } + + {step === 0 && ( + { + setAttributes({ + ...attributes, + category, + }); + gotoStep(1); + }} + /> + )} + {step === 1 && ( + gotoStep(2)} + /> + )} + + {step === 2 && ( + gotoStep(3)} + /> + )} + {step === 3 && ( + gotoStep(4)} + setAttributes={setAttributes} + /> + )} + {step === 4 && ( + gotoStep(5)} + connection={connection} + /> + )} + {step === 5 && ( + gotoStep(6)} + /> + )} + {step === 6 && ( + + )} + {(0 < step && step < 5) && } + + + ); }; + +const CategoryStep = (props: { confirm: (category: MetadataCategory) => void }) => { + return ( + <> + +

List an item

+

+ First time listing on Metaplex? Read our sellers' guide. +

+
+ + + + + + + + + + + + + + + + + + ); +}; + +const UploadStep = (props: { + attributes: IMetadataExtension; + setAttributes: (attr: IMetadataExtension) => void; + confirm: () => void; +}) => { + const [mainFile, setMainFile] = useState() + const [coverFile, setCoverFile] = useState() + const [image, setImage] = useState("") + + useEffect(() => { + props.setAttributes({ + ...props.attributes, + files: [] + }) + }, []) + + const uploadMsg = (category: MetadataCategory) => { + switch (category) { + case MetadataCategory.Audio: + return "Upload your audio creation (MP3, FLAC, WAV)" + case MetadataCategory.Image: + return "Upload your image creation (PNG, JPG, GIF)" + case MetadataCategory.Video: + return "Upload your video creation (MP4)" + default: + return "Please go back and choose a category" + } + } + + return ( + <> + +

Now, let's upload your creation

+

+ Your file will be uploaded to the decentralized web via Arweave. + Depending on file type, can take up to 1 minute. Arweave is a new type + of storage that backs data with sustainable and perpetual endowments, + allowing users and developers to truly store data forever – for the + very first time. +

+
+ +

{uploadMsg(props.attributes.category)}

+ { + // dont upload files here, handled outside of the control + info?.onSuccess?.({}, null as any) + }} + fileList={mainFile ? [mainFile] : []} + onChange={async info => { + const file = info.file.originFileObj; + if (file) setMainFile(file) + if (props.attributes.category != MetadataCategory.Audio) { + const reader = new FileReader(); + reader.onload = function (event) { + setImage((event.target?.result as string) || '') + } + if (file) reader.readAsDataURL(file) + } + }} + > +
+

Upload your creation

+
+

+ Drag and drop, or click to browse +

+
+
+ {props.attributes.category == MetadataCategory.Audio && + +

Optionally, you can upload a cover image or video (PNG, JPG, GIF, MP4)

+ { + // dont upload files here, handled outside of the control + info?.onSuccess?.({}, null as any) + }} + fileList={coverFile ? [coverFile] : []} + onChange={async info => { + const file = info.file.originFileObj; + if (file) setCoverFile(file) + if (props.attributes.category == MetadataCategory.Audio) { + const reader = new FileReader(); + reader.onload = function (event) { + setImage((event.target?.result as string) || '') + } + if (file) reader.readAsDataURL(file) + } + }} + > +
+

Upload your cover image or video

+
+

+ Drag and drop, or click to browse +

+
+
+ } + + + + + ); +}; + +const InfoStep = (props: { + attributes: IMetadataExtension; + setAttributes: (attr: IMetadataExtension) => void; + confirm: () => void; +}) => { + return ( + <> + +

Describe your creation

+

+ Provide detailed description of your creative process to engage with + your audience. +

+
+ + + {props.attributes.image && ( + + )} + + + + + + + + + + + + + ); +}; + +const RoyaltiesStep = (props: { + attributes: IMetadataExtension; + setAttributes: (attr: IMetadataExtension) => void; + confirm: () => void; +}) => { + const file = props.attributes.image; + + return ( + <> + +

Set royalties for the creation

+

+ A royalty is a payment made by the seller of this item to the creator. + It is charged after every successful auction. +

+
+ + + {file && ( + + )} + + + + + + + + + + ); +}; + +const LaunchStep = (props: { + confirm: () => void; + attributes: IMetadataExtension; + connection: Connection; +}) => { + const files = props.attributes.files || []; + const metadata = { + ...(props.attributes as any), + files: files.map(f => f?.name), + }; + const [cost, setCost] = useState(0); + useEffect(() => { + const rentCall = Promise.all([ + props.connection.getMinimumBalanceForRentExemption( + MintLayout.span, + ), + props.connection.getMinimumBalanceForRentExemption( + MAX_METADATA_LEN, + ), + props.connection.getMinimumBalanceForRentExemption( + MAX_OWNER_LEN, + ) + ]); + + getAssetCostToStore([ + ...files, + new File([JSON.stringify(metadata)], 'metadata.json'), + ]).then(async lamports => { + const sol = lamports / LAMPORT_MULTIPLIER; + + // TODO: cache this and batch in one call + const [mintRent, metadataRent, nameSymbolRent] = await rentCall; + + const uriStr = 'x'; + let uriBuilder = ''; + for (let i = 0; i < MAX_URI_LENGTH; i++) { + uriBuilder += uriStr; + } + + const additionalSol = + (metadataRent + nameSymbolRent + mintRent) / LAMPORT_MULTIPLIER; + + // TODO: add fees based on number of transactions and signers + setCost(sol + additionalSol); + }); + }, [...files, setCost]); + + return ( + <> + +

Launch your creation

+

+ Provide detailed description of your creative process to engage with + your audience. +

+
+ + + {props.attributes.image && ( + + )} + + + + {cost ? ( + + ) : ( + + )} + + + + + + + + ); +}; + +const WaitingStep = (props: { + mint: Function, + progress: number, + confirm: Function, +}) => { + + useEffect(() => { + const func = async () => { + await props.mint() + props.confirm() + } + func() + }, []) + + return ( +
+ +
+ Your creation is being uploaded to the decentralized web... +
+
This can take up to 1 minute.
+
+ ) +} + +const Congrats = () => { + return <> +
+
+ Congratulations! Your creation is now live. +
+
+ + + +
+
+ + +} + diff --git a/packages/metavinci/src/views/artCreate/styles.less b/packages/metavinci/src/views/styles.less similarity index 87% rename from packages/metavinci/src/views/artCreate/styles.less rename to packages/metavinci/src/views/styles.less index abd147e..48c0ae9 100644 --- a/packages/metavinci/src/views/artCreate/styles.less +++ b/packages/metavinci/src/views/styles.less @@ -60,12 +60,34 @@ } .type-btn { - height: 130px; - width: 240px; + height: 80px; + width: 280px; border-radius: 8px; background-color: #1d1d1d; border-width: 0px; - margin-right: 20px; + margin-bottom: 20px; + text-align: left; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + + background: linear-gradient(270deg, #616774 7.29%, #403F4C 100%); + border-radius: 8px; +} + +.type-btn:hover { + background: linear-gradient(270deg, #616774 7.29%, #403F4C 100%) !important; +} +.type-btn:disabled { + pointer-events: none; +} + +.type-btn-description { + color: #aaa; + font-size: 0.7rem; } .action-btn {