Merge pull request #78 from metaplex-foundation/feature/video
Feature/video
This commit is contained in:
commit
e922c3ba95
|
@ -51,7 +51,12 @@ export enum MetadataCategory {
|
|||
VR = 'vr',
|
||||
}
|
||||
|
||||
type FileOrString = File | string;
|
||||
export type MetadataFile = {
|
||||
uri: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type FileOrString = MetadataFile | string;
|
||||
|
||||
export interface IMetadataExtension {
|
||||
name: string;
|
||||
|
@ -61,6 +66,8 @@ export interface IMetadataExtension {
|
|||
description: string;
|
||||
// preview image absolute URI
|
||||
image: string;
|
||||
animation_url?: string;
|
||||
|
||||
// stores link to item on meta
|
||||
external_url: string;
|
||||
|
||||
|
@ -72,7 +79,6 @@ export interface IMetadataExtension {
|
|||
maxSupply?: number;
|
||||
creators?: {
|
||||
address: string;
|
||||
verified: boolean;
|
||||
shares: number;
|
||||
}[];
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Data,
|
||||
Creator,
|
||||
findProgramAddress,
|
||||
MetadataCategory,
|
||||
} from '@oyster/common';
|
||||
import React from 'react';
|
||||
import { MintLayout, Token } from '@solana/spl-token';
|
||||
|
@ -48,6 +49,7 @@ export const mintNFT = async (
|
|||
symbol: string;
|
||||
description: string;
|
||||
image: string | undefined;
|
||||
animation_url: string | undefined;
|
||||
external_url: string;
|
||||
properties: any;
|
||||
creators: Creator[] | null;
|
||||
|
@ -60,28 +62,31 @@ export const mintNFT = async (
|
|||
if (!wallet?.publicKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataContent = {
|
||||
name: metadata.name,
|
||||
symbol: metadata.symbol,
|
||||
description: metadata.description,
|
||||
seller_fee_basis_points: metadata.sellerFeeBasisPoints,
|
||||
image: metadata.image,
|
||||
animation_url: metadata.animation_url,
|
||||
external_url: metadata.external_url,
|
||||
properties: {
|
||||
...metadata.properties,
|
||||
creators: metadata.creators?.map(creator => {
|
||||
return {
|
||||
address: creator.address.toBase58(),
|
||||
share: creator.share,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const realFiles: File[] = [
|
||||
...files,
|
||||
new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
name: metadata.name,
|
||||
symbol: metadata.symbol,
|
||||
description: metadata.description,
|
||||
seller_fee_basis_points: metadata.sellerFeeBasisPoints,
|
||||
image: metadata.image,
|
||||
external_url: metadata.external_url,
|
||||
properties: {
|
||||
...metadata.properties,
|
||||
creators: metadata.creators?.map(creator => {
|
||||
return {
|
||||
address: creator.address.toBase58(),
|
||||
verified: creator.verified,
|
||||
share: creator.share,
|
||||
};
|
||||
}),
|
||||
},
|
||||
}),
|
||||
JSON.stringify(metadataContent),
|
||||
],
|
||||
'metadata.json',
|
||||
),
|
||||
|
@ -139,22 +144,11 @@ export const mintNFT = async (
|
|||
mintKey,
|
||||
);
|
||||
|
||||
instructions.push(
|
||||
Token.createMintToInstruction(
|
||||
TOKEN_PROGRAM_ID,
|
||||
mintKey,
|
||||
recipientKey,
|
||||
payerPublicKey,
|
||||
[],
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const metadataAccount = await createMetadata(
|
||||
new Data({
|
||||
symbol: metadata.symbol,
|
||||
name: metadata.name,
|
||||
uri: `https://-------.---/rfX69WKd7Bin_RTbcnH4wM3BuWWsR_ZhWSSqZBLYdMY`,
|
||||
uri: ' '.repeat(64), // size of url for arweave
|
||||
sellerFeeBasisPoints: metadata.sellerFeeBasisPoints,
|
||||
creators: metadata.creators,
|
||||
}),
|
||||
|
@ -212,8 +206,8 @@ export const mintNFT = async (
|
|||
await fetch(
|
||||
// TODO: add CNAME
|
||||
env.startsWith('mainnet-beta')
|
||||
? 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFileProd-1'
|
||||
: 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile-1',
|
||||
? 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFileProd2'
|
||||
: 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile2',
|
||||
{
|
||||
method: 'POST',
|
||||
body: data,
|
||||
|
@ -246,7 +240,18 @@ export const mintNFT = async (
|
|||
metadataAccount,
|
||||
);
|
||||
|
||||
// // This mint, which allows limited editions to be made, stays with user's wallet.
|
||||
updateInstructions.push(
|
||||
Token.createMintToInstruction(
|
||||
TOKEN_PROGRAM_ID,
|
||||
mintKey,
|
||||
recipientKey,
|
||||
payerPublicKey,
|
||||
[],
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
// This mint, which allows limited editions to be made, stays with user's wallet.
|
||||
const printingMint = createMint(
|
||||
updateInstructions,
|
||||
payerPublicKey,
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
padding: 24px 24px 0 24px;
|
||||
min-height: 120px;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.ant-avatar.ant-avatar-circle {
|
||||
|
|
|
@ -12,9 +12,12 @@ const { Meta } = Card;
|
|||
|
||||
export interface ArtCardProps extends CardProps {
|
||||
pubkey?: PublicKey;
|
||||
|
||||
image?: string;
|
||||
file?: string;
|
||||
animationURL?: string;
|
||||
|
||||
category?: MetadataCategory;
|
||||
|
||||
name?: string;
|
||||
symbol?: string;
|
||||
description?: string;
|
||||
|
@ -22,7 +25,7 @@ export interface ArtCardProps extends CardProps {
|
|||
preview?: boolean;
|
||||
small?: boolean;
|
||||
close?: () => void;
|
||||
endAuctionAt?: number;
|
||||
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
@ -33,7 +36,7 @@ export const ArtCard = (props: ArtCardProps) => {
|
|||
small,
|
||||
category,
|
||||
image,
|
||||
file,
|
||||
animationURL,
|
||||
name,
|
||||
preview,
|
||||
creators,
|
||||
|
@ -79,8 +82,8 @@ export const ArtCard = (props: ArtCardProps) => {
|
|||
<ArtContent
|
||||
pubkey={pubkey}
|
||||
|
||||
extension={file || image}
|
||||
uri={image}
|
||||
animationURL={animationURL}
|
||||
category={category}
|
||||
|
||||
preview={preview}
|
||||
|
|
|
@ -1,25 +1,27 @@
|
|||
import React, { Ref, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import { MetadataCategory } from '@oyster/common';
|
||||
import { MetadataCategory, MetadataFile } from '@oyster/common';
|
||||
import { MeshViewer } from '../MeshViewer';
|
||||
import { ThreeDots } from '../MyLoader';
|
||||
import { useCachedImage, useExtendedArt } from '../../hooks';
|
||||
import { Stream, StreamPlayerApi } from '@cloudflare/stream-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
import { getLast } from '../../utils/utils';
|
||||
|
||||
const MeshArtContent = ({
|
||||
uri,
|
||||
animationUrl,
|
||||
className,
|
||||
style,
|
||||
files,
|
||||
}: {
|
||||
uri?: string;
|
||||
animationUrl?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
files?: string[];
|
||||
files?: (MetadataFile | string)[];
|
||||
}) => {
|
||||
const renderURL = files && files.length > 0 ? files[0] : uri;
|
||||
const renderURL = files && files.length > 0 && typeof files[0] === 'string' ? files[0] : animationUrl;
|
||||
const { isLoading } = useCachedImage(renderURL || '', true);
|
||||
|
||||
if (isLoading) {
|
||||
|
@ -62,16 +64,18 @@ const CachedImageContent = ({
|
|||
}
|
||||
|
||||
const VideoArtContent = ({
|
||||
extension,
|
||||
className,
|
||||
style,
|
||||
files,
|
||||
uri,
|
||||
animationURL,
|
||||
active,
|
||||
}: {
|
||||
extension?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
files?: string[];
|
||||
files?: (MetadataFile | string)[];
|
||||
uri?: string;
|
||||
animationURL?: string;
|
||||
active?: boolean;
|
||||
}) => {
|
||||
const [playerApi, setPlayerApi] = useState<StreamPlayerApi>();
|
||||
|
@ -90,9 +94,13 @@ const VideoArtContent = ({
|
|||
}, [active, playerApi]);
|
||||
|
||||
const likelyVideo = (files || []).filter((f, index, arr) => {
|
||||
if(typeof f !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: filter by fileType
|
||||
return arr.length >= 2 ? index === 1 : index === 0;
|
||||
})[0];
|
||||
})?.[0] as string;
|
||||
|
||||
const content = (
|
||||
likelyVideo && likelyVideo.startsWith('https://watch.videodelivery.net/') ? (
|
||||
|
@ -116,15 +124,17 @@ const VideoArtContent = ({
|
|||
<video
|
||||
className={className}
|
||||
playsInline={true}
|
||||
autoPlay={false}
|
||||
autoPlay={true}
|
||||
muted={true}
|
||||
controls={true}
|
||||
controlsList="nodownload"
|
||||
style={style}
|
||||
loop={true}
|
||||
poster={extension}
|
||||
poster={uri}
|
||||
>
|
||||
<source src={likelyVideo} type="video/mp4" style={style} />
|
||||
{likelyVideo && <source src={likelyVideo} type="video/mp4" style={style} />}
|
||||
{animationURL && <source src={animationURL} type="video/mp4" style={style} />}
|
||||
{files?.filter(f => typeof f !== 'string').map((f: any) => <source src={f.uri} type={f.type} style={style} />)}
|
||||
</video>
|
||||
)
|
||||
);
|
||||
|
@ -145,7 +155,7 @@ export const ArtContent = ({
|
|||
pubkey,
|
||||
|
||||
uri,
|
||||
extension,
|
||||
animationURL,
|
||||
files,
|
||||
}: {
|
||||
category?: MetadataCategory;
|
||||
|
@ -158,24 +168,29 @@ export const ArtContent = ({
|
|||
active?: boolean;
|
||||
allowMeshRender?: boolean;
|
||||
pubkey?: PublicKey | string,
|
||||
|
||||
extension?: string;
|
||||
uri?: string;
|
||||
files?: string[];
|
||||
animationURL?: string;
|
||||
files?: (MetadataFile | string)[];
|
||||
}) => {
|
||||
const id = typeof pubkey === 'string' ? pubkey : pubkey?.toBase58() || '';
|
||||
|
||||
const { ref, data } = useExtendedArt(id);
|
||||
|
||||
if(pubkey && data) {
|
||||
files = data.properties.files?.filter(f => typeof f === 'string') as string[];
|
||||
files = data.properties.files;
|
||||
uri = data.image;
|
||||
animationURL = data.animation_url;
|
||||
category = data.properties.category;
|
||||
}
|
||||
|
||||
if (allowMeshRender&& (extension?.endsWith('.glb') || category === 'vr')) {
|
||||
animationURL = animationURL || '';
|
||||
|
||||
const animationUrlExt = new URLSearchParams(getLast(animationURL.split("?"))).get("ext");
|
||||
|
||||
if (allowMeshRender && (category === 'vr' || animationUrlExt === 'glb' || animationUrlExt === 'gltf')) {
|
||||
return <MeshArtContent
|
||||
uri={uri}
|
||||
animationUrl={animationURL}
|
||||
className={className}
|
||||
style={style}
|
||||
files={files}/>;
|
||||
|
@ -183,10 +198,11 @@ export const ArtContent = ({
|
|||
|
||||
const content = category === 'video' ? (
|
||||
<VideoArtContent
|
||||
extension={extension}
|
||||
className={className}
|
||||
style={style}
|
||||
files={files}
|
||||
uri={uri}
|
||||
animationURL={animationURL}
|
||||
active={active}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
font-weight: 600;
|
||||
padding: 24px 24px 0px 24px;
|
||||
white-space: normal !important;
|
||||
min-height: 120px;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.ant-avatar.ant-avatar-circle,
|
||||
|
|
|
@ -6,16 +6,18 @@ import { AuctionView, useArt } from '../../hooks';
|
|||
import { ArtContent } from '../ArtContent';
|
||||
import { AuctionCard } from '../AuctionCard';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMeta } from '../../contexts';
|
||||
|
||||
interface IPreSaleBanner {
|
||||
auction?: AuctionView;
|
||||
}
|
||||
|
||||
export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
|
||||
const { isLoading } = useMeta();
|
||||
const id = auction?.thumbnail.metadata.pubkey;
|
||||
const art = useArt();
|
||||
|
||||
if (!auction) {
|
||||
if (isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
|
@ -31,7 +33,7 @@ export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
|
|||
<h2 className="art-title">
|
||||
{art.title}
|
||||
</h2>
|
||||
<AuctionCard
|
||||
{auction && <AuctionCard
|
||||
auctionView={auction}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
|
@ -54,7 +56,7 @@ export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
|
|||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
/>}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
|
|
@ -22,5 +22,12 @@
|
|||
"image": "https://pbs.twimg.com/profile_images/1393353972371623938/ZMWvvptg_400x400.jpg",
|
||||
"description": "",
|
||||
"background": ""
|
||||
},
|
||||
"SoL351y4uKWtbH14AU1Rhiao96aBM4u57bMi5Vj2XJc": {
|
||||
"name": "Solana",
|
||||
"image": "https://pbs.twimg.com/profile_images/1299400345144049665/sPxnVXa7_400x400.jpg",
|
||||
"description": "Account used by Solana to mint official NFTs.",
|
||||
"background": ""
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,6 @@ import {
|
|||
Vault,
|
||||
setProgramIds,
|
||||
useConnectionConfig,
|
||||
useWallet,
|
||||
AuctionDataExtended,
|
||||
MAX_AUCTION_DATA_EXTENDED_SIZE,
|
||||
AuctionDataExtendedParser,
|
||||
|
@ -373,18 +372,19 @@ export function MetaProvider({ children = null as any }) {
|
|||
isMetadataPartOfStore(result, store, whitelistedCreatorsByCreator)
|
||||
) {
|
||||
await result.info.init();
|
||||
updateStateValue(
|
||||
'metadataByMasterEdition',
|
||||
result.info.masterEdition?.toBase58() || '',
|
||||
result,
|
||||
);
|
||||
setState((data) => ({
|
||||
...data,
|
||||
metadata: [...data.metadata.filter(m => m.pubkey.equals(pubkey)), result],
|
||||
metadataByMasterEdition: {
|
||||
...data.metadataByMasterEdition,
|
||||
[result.info.masterEdition?.toBase58() || '']: result,
|
||||
},
|
||||
metadataByMint: {
|
||||
...data.metadataByMint,
|
||||
[result.info.mint.toBase58()]: result
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// TODO: BL
|
||||
// setMetadataByMint(latest => {
|
||||
// updateMints(latest);
|
||||
// return latest;
|
||||
// });
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -414,6 +414,7 @@ export function MetaProvider({ children = null as any }) {
|
|||
}, [
|
||||
connection,
|
||||
updateStateValue,
|
||||
setState,
|
||||
updateMints,
|
||||
store,
|
||||
whitelistedCreatorsByCreator,
|
||||
|
@ -503,7 +504,7 @@ const queryExtendedMetadata = async (
|
|||
MintParser,
|
||||
false,
|
||||
) as ParsedAccount<MintInfo>;
|
||||
if (mint.info.supply.gt(new BN(1)) || mint.info.decimals !== 0) {
|
||||
if (!mint.info.supply.eqn(1) || mint.info.decimals !== 0) {
|
||||
// naive not NFT check
|
||||
delete mintToMetadata[key];
|
||||
} else {
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
export const cleanName = (name: string): string => {
|
||||
export const cleanName = (name?: string): string | undefined => {
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return name.replaceAll(' ', '-');
|
||||
};
|
||||
|
||||
export const getLast = <T>(arr: T[]) => {
|
||||
if (arr.length <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return arr[arr.length - 1];
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useArt, useExtendedArt } from './../../hooks';
|
|||
|
||||
import './index.less';
|
||||
import { ArtContent } from '../../components/ArtContent';
|
||||
import { shortenAddress, useConnection, useWallet } from '@oyster/common';
|
||||
import { shortenAddress, TokenAccount, useConnection, useUserAccounts, useWallet } from '@oyster/common';
|
||||
import { MetaAvatar } from '../../components/MetaAvatar';
|
||||
import { sendSignMetadata } from '../../actions/sendSignMetadata';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
|
@ -15,10 +15,18 @@ const { Content } = Layout;
|
|||
export const ArtView = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { wallet } = useWallet();
|
||||
|
||||
const connection = useConnection();
|
||||
const art = useArt(id);
|
||||
const { ref, data } = useExtendedArt(id);
|
||||
|
||||
// const { userAccounts } = useUserAccounts();
|
||||
|
||||
// const accountByMint = userAccounts.reduce((prev, acc) => {
|
||||
// prev.set(acc.info.mint.toBase58(), acc);
|
||||
// return prev;
|
||||
// }, new Map<string, TokenAccount>());
|
||||
|
||||
const description = data?.description;
|
||||
|
||||
const pubkey = wallet?.publicKey?.toBase58() || '';
|
||||
|
@ -55,6 +63,7 @@ export const ArtView = () => {
|
|||
className="artwork-image"
|
||||
pubkey={id}
|
||||
active={true}
|
||||
allowMeshRender={true}
|
||||
/>
|
||||
</Col>
|
||||
{/* <Divider /> */}
|
||||
|
@ -79,6 +88,31 @@ export const ArtView = () => {
|
|||
<span className="creator-name">
|
||||
{creator.name || shortenAddress(creator.address || '')}
|
||||
</span>
|
||||
{/* <Button
|
||||
onClick={async () => {
|
||||
if(!art.mint) {
|
||||
return;
|
||||
}
|
||||
const mint = new PublicKey(art.mint);
|
||||
|
||||
const account = accountByMint.get(art.mint);
|
||||
if(!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = wallet?.publicKey;
|
||||
|
||||
if(!owner) {
|
||||
return;
|
||||
}
|
||||
const instructions: any[] = [];
|
||||
await updateMetadata(undefined, undefined, true, mint, owner, instructions)
|
||||
|
||||
sendTransaction(connection, wallet, instructions, [], true);
|
||||
}}
|
||||
>
|
||||
Mark as Sold
|
||||
</Button> */}
|
||||
<div style={{ marginLeft: 10 }}>
|
||||
{!creator.verified &&
|
||||
(creator.address === pubkey ? (
|
||||
|
|
|
@ -30,12 +30,13 @@ import {
|
|||
shortenAddress,
|
||||
MetaplexModal,
|
||||
MetaplexOverlay,
|
||||
MetadataFile,
|
||||
} from '@oyster/common';
|
||||
import { getAssetCostToStore, LAMPORT_MULTIPLIER } from '../../utils/assets';
|
||||
import { Connection, PublicKey } from '@solana/web3.js';
|
||||
import { MintLayout } from '@solana/spl-token';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { cleanName } from '../../utils/utils';
|
||||
import { cleanName, getLast } from '../../utils/utils';
|
||||
import { AmountLabel } from '../../components/AmountLabel';
|
||||
import useWindowDimensions from '../../utils/layout';
|
||||
|
||||
|
@ -56,12 +57,14 @@ export const ArtCreateView = () => {
|
|||
const [progress, setProgress] = useState<number>(0);
|
||||
const [nft, setNft] =
|
||||
useState<{ metadataAccount: PublicKey } | undefined>(undefined);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [attributes, setAttributes] = useState<IMetadataExtension>({
|
||||
name: '',
|
||||
symbol: '',
|
||||
description: '',
|
||||
external_url: '',
|
||||
image: '',
|
||||
animation_url: undefined,
|
||||
seller_fee_basis_points: 0,
|
||||
creators: [],
|
||||
properties: {
|
||||
|
@ -85,22 +88,17 @@ export const ArtCreateView = () => {
|
|||
|
||||
// store files
|
||||
const mint = async () => {
|
||||
const fileNames = (attributes?.properties?.files || []).map(f =>
|
||||
typeof f === 'string' ? f : f.name,
|
||||
);
|
||||
const files = (attributes?.properties?.files || []).filter(
|
||||
f => typeof f !== 'string',
|
||||
) as File[];
|
||||
const metadata = {
|
||||
name: attributes.name,
|
||||
symbol: attributes.symbol,
|
||||
creators: attributes.creators,
|
||||
description: attributes.description,
|
||||
sellerFeeBasisPoints: attributes.seller_fee_basis_points,
|
||||
image: fileNames && fileNames?.[0] && fileNames[0],
|
||||
image: attributes.image,
|
||||
animation_url: attributes.animation_url,
|
||||
external_url: attributes.external_url,
|
||||
properties: {
|
||||
files: fileNames,
|
||||
files: attributes.properties.files,
|
||||
category: attributes.properties?.category,
|
||||
},
|
||||
};
|
||||
|
@ -165,6 +163,8 @@ export const ArtCreateView = () => {
|
|||
<UploadStep
|
||||
attributes={attributes}
|
||||
setAttributes={setAttributes}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
confirm={() => gotoStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
@ -172,6 +172,7 @@ export const ArtCreateView = () => {
|
|||
{step === 2 && (
|
||||
<InfoStep
|
||||
attributes={attributes}
|
||||
files={files}
|
||||
setAttributes={setAttributes}
|
||||
confirm={() => gotoStep(3)}
|
||||
/>
|
||||
|
@ -186,6 +187,7 @@ export const ArtCreateView = () => {
|
|||
{step === 4 && (
|
||||
<LaunchStep
|
||||
attributes={attributes}
|
||||
files={files}
|
||||
confirm={() => gotoStep(5)}
|
||||
connection={connection}
|
||||
/>
|
||||
|
@ -283,14 +285,16 @@ const CategoryStep = (props: {
|
|||
const UploadStep = (props: {
|
||||
attributes: IMetadataExtension;
|
||||
setAttributes: (attr: IMetadataExtension) => void;
|
||||
files: File[],
|
||||
setFiles: (files: File[]) => void,
|
||||
confirm: () => void;
|
||||
}) => {
|
||||
const [mainFile, setMainFile] = useState<any>();
|
||||
const [coverFile, setCoverFile] = useState<any>();
|
||||
const [image, setImage] = useState<string>('');
|
||||
const [imageURL, setImageURL] = useState<string>('');
|
||||
const [imageURLErr, setImageURLErr] = useState<string>('');
|
||||
const disableContinue = (!mainFile && !image) || !!imageURLErr;
|
||||
const [coverFile, setCoverFile] = useState<File | undefined>(props.files?.[0]);
|
||||
const [mainFile, setMainFile] = useState<File | undefined>(props.files?.[1]);
|
||||
|
||||
const [customURL, setCustomURL] = useState<string>('');
|
||||
const [customURLErr, setCustomURLErr] = useState<string>('');
|
||||
const disableContinue = (!coverFile) || !!customURLErr;
|
||||
|
||||
useEffect(() => {
|
||||
props.setAttributes({
|
||||
|
@ -344,46 +348,63 @@ const UploadStep = (props: {
|
|||
very first time.
|
||||
</p>
|
||||
</Row>
|
||||
<Row className="content-action" style={{ marginBottom: 5 }}>
|
||||
<h3>{uploadMsg(props.attributes.properties?.category)}</h3>
|
||||
<Dragger
|
||||
accept={acceptableFiles(props.attributes.properties?.category)}
|
||||
style={{ padding: 20, background: 'rgba(255, 255, 255, 0.08)' }}
|
||||
multiple={false}
|
||||
customRequest={info => {
|
||||
// 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;
|
||||
<Row className="content-action">
|
||||
<h3>
|
||||
Upload a cover image (PNG, JPG, GIF)
|
||||
</h3>
|
||||
<Dragger
|
||||
accept=".png,.jpg,.gif,.mp4"
|
||||
style={{ padding: 20 }}
|
||||
multiple={false}
|
||||
customRequest={info => {
|
||||
// dont upload files here, handled outside of the control
|
||||
info?.onSuccess?.({}, null as any);
|
||||
}}
|
||||
fileList={coverFile ? [coverFile as any] : []}
|
||||
onChange={async info => {
|
||||
const file = info.file.originFileObj;
|
||||
if (file) setCoverFile(file);
|
||||
}}
|
||||
>
|
||||
<div className="ant-upload-drag-icon">
|
||||
<h3 style={{ fontWeight: 700 }}>
|
||||
Upload your cover image (PNG, JPG, GIF)
|
||||
</h3>
|
||||
</div>
|
||||
<p className="ant-upload-text">Drag and drop, or click to browse</p>
|
||||
</Dragger>
|
||||
</Row>
|
||||
{(props.attributes.properties?.category !== MetadataCategory.Image) && (
|
||||
<Row className="content-action" style={{ marginBottom: 5, marginTop: 30 }}>
|
||||
<h3>{uploadMsg(props.attributes.properties?.category)}</h3>
|
||||
<Dragger
|
||||
accept={acceptableFiles(props.attributes.properties?.category)}
|
||||
style={{ padding: 20, background: 'rgba(255, 255, 255, 0.08)' }}
|
||||
multiple={false}
|
||||
customRequest={info => {
|
||||
// dont upload files here, handled outside of the control
|
||||
info?.onSuccess?.({}, null as any);
|
||||
}}
|
||||
fileList={mainFile ? [mainFile as any] : []}
|
||||
onChange={async info => {
|
||||
const file = info.file.originFileObj;
|
||||
|
||||
// Reset image URL
|
||||
setImageURL('');
|
||||
setImageURLErr('');
|
||||
// Reset image URL
|
||||
setCustomURL('');
|
||||
setCustomURLErr('');
|
||||
|
||||
if (file) setMainFile(file);
|
||||
if (
|
||||
props.attributes.properties?.category !== MetadataCategory.Audio
|
||||
) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
setImage((event.target?.result as string) || '');
|
||||
};
|
||||
if (file) reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
onRemove={() => {
|
||||
setMainFile(null);
|
||||
setImage('');
|
||||
}}
|
||||
>
|
||||
<div className="ant-upload-drag-icon">
|
||||
<h3 style={{ fontWeight: 700 }}>Upload your creation</h3>
|
||||
</div>
|
||||
<p className="ant-upload-text">Drag and drop, or click to browse</p>
|
||||
</Dragger>
|
||||
</Row>
|
||||
if (file) setMainFile(file);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setMainFile(undefined);
|
||||
}}
|
||||
>
|
||||
<div className="ant-upload-drag-icon">
|
||||
<h3 style={{ fontWeight: 700 }}>Upload your creation</h3>
|
||||
</div>
|
||||
<p className="ant-upload-text">Drag and drop, or click to browse</p>
|
||||
</Dragger>
|
||||
</Row>)}
|
||||
<Form.Item
|
||||
style={{
|
||||
width: '100%',
|
||||
|
@ -394,72 +415,33 @@ const UploadStep = (props: {
|
|||
label={<h3>OR use absolute URL to content</h3>}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
validateStatus={imageURLErr ? 'error' : 'success'}
|
||||
help={imageURLErr}
|
||||
validateStatus={customURLErr ? 'error' : 'success'}
|
||||
help={customURLErr}
|
||||
>
|
||||
<Input
|
||||
disabled={!!mainFile}
|
||||
placeholder="http://example.com/path/to/image"
|
||||
value={imageURL}
|
||||
onChange={ev => setImageURL(ev.target.value)}
|
||||
onFocus={() => setImageURLErr('')}
|
||||
value={customURL}
|
||||
onChange={ev => setCustomURL(ev.target.value)}
|
||||
onFocus={() => setCustomURLErr('')}
|
||||
onBlur={() => {
|
||||
if (!imageURL) {
|
||||
setImageURLErr('');
|
||||
if (!customURL) {
|
||||
setCustomURLErr('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL and save
|
||||
new URL(imageURL);
|
||||
setImage(imageURL);
|
||||
new URL(customURL);
|
||||
setCustomURL(customURL);
|
||||
setCustomURLErr('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setImageURLErr('Please enter a valid absolute URL');
|
||||
setCustomURLErr('Please enter a valid absolute URL');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{props.attributes.properties?.category === MetadataCategory.Audio && (
|
||||
<Row className="content-action">
|
||||
<h3>
|
||||
Optionally, you can upload a cover image or video (PNG, JPG, GIF,
|
||||
MP4)
|
||||
</h3>
|
||||
<Dragger
|
||||
accept=".png,.jpg,.gif,.mp4"
|
||||
style={{ padding: 20 }}
|
||||
multiple={false}
|
||||
customRequest={info => {
|
||||
// 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.properties?.category === MetadataCategory.Audio
|
||||
) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
setImage((event.target?.result as string) || '');
|
||||
};
|
||||
if (file) reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ant-upload-drag-icon">
|
||||
<h3 style={{ fontWeight: 700 }}>
|
||||
Upload your cover image or video (PNG, JPG, GIF, MP4)
|
||||
</h3>
|
||||
</div>
|
||||
<p className="ant-upload-text">Drag and drop, or click to browse</p>
|
||||
</Dragger>
|
||||
<h3 style={{ marginTop: 30 }}>OR use absolute URL to content</h3>
|
||||
<Input />
|
||||
</Row>
|
||||
)}
|
||||
<Row>
|
||||
<Button
|
||||
type="primary"
|
||||
|
@ -470,16 +452,24 @@ const UploadStep = (props: {
|
|||
...props.attributes,
|
||||
properties: {
|
||||
...props.attributes.properties,
|
||||
files: imageURL
|
||||
? [imageURL]
|
||||
: [mainFile, coverFile]
|
||||
files: [coverFile, mainFile, customURL]
|
||||
.filter(f => f)
|
||||
.map(
|
||||
f => new File([f], cleanName(f.name), { type: f.type }),
|
||||
f => {
|
||||
const uri = typeof f === 'string' ? f : (cleanName(f?.name) || '');
|
||||
const type = typeof f === 'string' || !f ? 'unknown' : f.type || (getLast(f.name.split('.')) || 'unknown');
|
||||
|
||||
return ({
|
||||
uri,
|
||||
type
|
||||
}) as MetadataFile;
|
||||
},
|
||||
),
|
||||
},
|
||||
image: imageURL || image,
|
||||
image: cleanName(coverFile?.name) || '',
|
||||
animation_url: cleanName(mainFile && mainFile.name),
|
||||
});
|
||||
props.setFiles([coverFile, mainFile].filter(f => f) as File[]);
|
||||
props.confirm();
|
||||
}}
|
||||
style={{ marginTop: 24 }}
|
||||
|
@ -497,17 +487,55 @@ interface Royalty {
|
|||
amount: number;
|
||||
}
|
||||
|
||||
const useArtworkFiles = (files: File[], attributes: IMetadataExtension) => {
|
||||
const [data, setData] = useState<{ image: string, animation_url: string }>({ image: '', animation_url: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if(attributes.image) {
|
||||
const file = files.find(f => f.name === attributes.image);
|
||||
if(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
setData((data: any) => {
|
||||
return {
|
||||
...(data || {}),
|
||||
image: (event.target?.result as string) || '',
|
||||
}
|
||||
});
|
||||
};
|
||||
if (file) reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
if(attributes.animation_url) {
|
||||
const file = files.find(f => f.name === attributes.animation_url);
|
||||
if(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (event) {
|
||||
setData((data: any) => {
|
||||
return {
|
||||
...(data || {}),
|
||||
animation_url: (event.target?.result as string) || '',
|
||||
}
|
||||
});
|
||||
};
|
||||
if (file) reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}, [files, attributes]);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const InfoStep = (props: {
|
||||
attributes: IMetadataExtension;
|
||||
files: File[],
|
||||
setAttributes: (attr: IMetadataExtension) => void;
|
||||
confirm: () => void;
|
||||
}) => {
|
||||
const [creators, setCreators] = useState<Array<UserValue>>([]);
|
||||
const [royalties, setRoyalties] = useState<Array<Royalty>>([]);
|
||||
const { wallet } = useWallet();
|
||||
|
||||
const file = props.attributes.properties.files?.[0];
|
||||
const fileName = typeof file === 'string' ? file : file?.name;
|
||||
const { image, animation_url } = useArtworkFiles(props.files, props.attributes);
|
||||
|
||||
useEffect(() => {
|
||||
setRoyalties(
|
||||
|
@ -530,8 +558,8 @@ const InfoStep = (props: {
|
|||
<Col>
|
||||
{props.attributes.image && (
|
||||
<ArtCard
|
||||
image={props.attributes.image}
|
||||
file={fileName || ''}
|
||||
image={image}
|
||||
animationURL={animation_url}
|
||||
category={props.attributes.properties?.category}
|
||||
name={props.attributes.name}
|
||||
symbol={props.attributes.symbol}
|
||||
|
@ -893,19 +921,13 @@ const RoyaltiesStep = (props: {
|
|||
const LaunchStep = (props: {
|
||||
confirm: () => void;
|
||||
attributes: IMetadataExtension;
|
||||
files: File[],
|
||||
connection: Connection;
|
||||
}) => {
|
||||
const files = (props.attributes.properties?.files || []).filter(
|
||||
f => typeof f !== 'string',
|
||||
) as File[];
|
||||
const fileNames = (props.attributes.properties?.files || []).map(f =>
|
||||
typeof f === 'string' ? f : f?.name,
|
||||
);
|
||||
const metadata = {
|
||||
...(props.attributes as any),
|
||||
files: fileNames,
|
||||
};
|
||||
const [cost, setCost] = useState(0);
|
||||
const { image, animation_url } = useArtworkFiles(props.files, props.attributes);
|
||||
const files = props.files;
|
||||
const metadata = props.attributes;
|
||||
useEffect(() => {
|
||||
const rentCall = Promise.all([
|
||||
props.connection.getMinimumBalanceForRentExemption(MintLayout.span),
|
||||
|
@ -932,7 +954,7 @@ const LaunchStep = (props: {
|
|||
// TODO: add fees based on number of transactions and signers
|
||||
setCost(sol + additionalSol);
|
||||
});
|
||||
}, [files, setCost]);
|
||||
}, [files, metadata, setCost]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -947,8 +969,8 @@ const LaunchStep = (props: {
|
|||
<Col>
|
||||
{props.attributes.image && (
|
||||
<ArtCard
|
||||
image={props.attributes.image}
|
||||
file={fileNames?.[0] || ''}
|
||||
image={image}
|
||||
animationURL={animation_url}
|
||||
category={props.attributes.properties?.category}
|
||||
name={props.attributes.name}
|
||||
symbol={props.attributes.symbol}
|
||||
|
|
|
@ -416,6 +416,7 @@ export const InnerBillingView = ({
|
|||
<ArtContent
|
||||
pubkey={id}
|
||||
className="artwork-image"
|
||||
allowMeshRender={true}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
|
|
|
@ -64,6 +64,7 @@ export const AuctionItem = ({
|
|||
className="artwork-image stack-item"
|
||||
style={style}
|
||||
active={active}
|
||||
allowMeshRender={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -32,7 +32,11 @@ import {
|
|||
PriceFloorType,
|
||||
IPartialCreateAuctionArgs,
|
||||
} from '@oyster/common';
|
||||
import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
|
||||
import {
|
||||
Connection,
|
||||
LAMPORTS_PER_SOL,
|
||||
PublicKey,
|
||||
} from '@solana/web3.js';
|
||||
import { MintLayout } from '@solana/spl-token';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { capitalize } from 'lodash';
|
||||
|
|
Loading…
Reference in New Issue