moritzfelipe
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # Celo 201 - Build an NFT Minter Dapp with React In this learning module, we will build the frontend for an NFT minting contract. This tutorial will walk you through building an NFT minter with Celo. The project will be a monorepo, meaning that the Hardhat contract environment will be in the same repository as the React frontend. We will be able to deploy an NFT contract to the Celo Alfajores network and interact with it from the frontend. The minter will be able to mint NFTs with attributes for the Celo Alfajores network and show all NFTs that have been minted as well as who currently owns them. The finished minter dapp will behave like this: ![](https://raw.githubusercontent.com/dacadeorg/celo-development-201/main/content/gifs/minter_demo.gif) ### Prerequisites You will need the following for this learning module: - [Node JS](https://nodejs.org/en/download/) - Please make sure you have Node.js v12 or higher installed. - Understanding of Solidity and the Celo Blockchain. - Know how to use Hardhat to develop Solidity contracts. - You should have a basic understanding of [React](https://reactjs.org/): know how to use JSX, props, state, lifecycle methods, and hooks. ### Tech Stack We will use the following tech stack in this tutorial: - [Hardhat](https://hardhat.org/) - A Solidity development environment. - [React](https://reactjs.org/) - A JavaScript library for building user interfaces. - [Bootstrap](https://getbootstrap.com/) - A CSS framework. - [useContractKit](https://github.com/celo-org/use-contractkit) - A React hook to interact with the Celo Blockchain. - [IPFS](https://ipfs.io/) - A distributed file storage system. ## 1. Project Setup In the first section of this tutorial, we will set up the project and install the necessary dependencies. 1. Clone the repository: ```sh git clone https://github.com/dacadeorg/celo-react-boilerplate.git ``` 2. Navigate to the directory: ```sh cd celo-react-boilerplate ``` 3. Install the dependencies: ```sh npm install ``` 4. Create an environment variable for your mnemonic. - Create a file named `.env` in the root directory of the project. - Add your mnemonic into the file, like this: ``` MNEMONIC="YOUR_SECRET_RECOVERY_PHRASE" ``` In this case, we are using a mnemonic from an account created on Metamask. You can copy it from your Metamask account settings. An account created on the Celo extension wallet will not work. You can find more details about the whole process in the [NFT Contract Development with Hardhat](https://hackmd.io/exuZTH2hTqKytn2vxgDmcg) learning module. 5. Run the boilerplate. If you have a Metamask wallet installed that is connected to the Celo network, Alfajores, and have Celo testnet tokens, you can test the boilerplate now. Let's deploy our `counter` contract to the Celo Alfajores testnet: ```sh npx hardhat run --network alfajores scripts/deploy.js ``` Next, start the dapp: ```sh npm start ``` The boilerplate should now behave like this: ![](https://raw.githubusercontent.com/dacadeorg/celo-development-201/main/content/gifs/boilerplate_demo.gif) Let's see how the boilerplate works. ### Folder Structure Your project should now have the following folder structure: ``` ├── contracts ├── public ├── scripts ├── src └── test ``` It is useful to have finished our learning module: [NFT Contract Development with Hardhat](https://hackmd.io/exuZTH2hTqKytn2vxgDmcg). The `contracts`, `scripts`, and `test` folders belong to the Hardhat environment and are explained in more detail in that module. The `contracts` folder contains the smart contracts that Hardhat uses. In the case of the boilerplate, this is an example `Counter` smart contract. The `scripts` folder contains the scripts that Hardhat uses to deploy the smart contracts. In the case of the boilerplate, this is an example `deploy.js` script. The `test` folder contains the tests that Hardhat uses to test the smart contracts. In the case of the boilerplate, this is an example `counter-test.js` script. The `src` folder contains the React frontend. The `public` folder contains the compiled static files that the web server will serve. ## 2. Boilerplate This section will look into the boilerplate that we just installed. Inside the `src` directory, we find the following folders and the index.js and App.js files: ``` ├── src │ ├── components │ ├── contracts │ ├── hooks │ ├── utils │ ├── index.js │ └── App.js ``` In the following sub-sections, we will go through the files and folders and explain what they are used for. However, we will not go into the code of all files to save time and space. Let's start by going through the `index.js`. ### 2.1 index.js In the `index.js` file, we import the React library and the `App` component, as well as CSS files for styling and `ContractKitProvider`, `Alfajores` and `NetworkNames` from `use-contractkit`. In order to connect to a Celo network, we need to wrap the `ContractKitProvider` around the `App` component: ```js ... ReactDOM.render( <React.StrictMode> <ContractKitProvider networks={[Alfajores]} network={{ name: NetworkNames.Alfajores, rpcUrl: "https://alfajores-forno.celo-testnet.org", graphQl: "https://alfajores-blockscout.celo-testnet.org/graphiql", explorer: "https://alfajores-blockscout.celo-testnet.org", chainId: 44787, }} dapp={{ name: "Celo React Boilerplate", description: "A React Boilerplate for Celo Dapps", }} > <App /> </ContractKitProvider> </React.StrictMode>, document.getElementById("root") ); ... ``` The `use-contractkit` provides a `network` variable that contains the network configuration. We can use this to connect to the Celo testnet Alfajores. ### 2.2 App.js The `App.js` file is the main entry point for the React frontend. It contains the `App` component, which is the container for all other components. At the top of the `App.js` file, we import the libraries, components, and hooks we will use. Our `App` component looks like this: ```js // ... const App = function AppWrapper() { const { address, destroy, connect } = useContractKit(); const { balance } = useBalance(); const counterContract = useCounterContract(); return ( <> <Notification /> {address ? ( <Container fluid="md"> <Nav className="justify-content-end pt-3 pb-5"> <Nav.Item> <Wallet address={address} amount={balance.CELO} symbol="CELO" destroy={destroy} /> </Nav.Item> </Nav> <main> <Counter counterContract={counterContract} /> </main> </Container> ) : ( <div className="App"> <header className="App-header"> <Cover connect={connect} /> </header> </div> )} </> ); }; ``` We add the `Notification` component at the top of the JSX of our `App` component. This component is used to display notifications to the user. We will explain the components in more detail in the following sub-sections. If the user is not connected to the Celo network, we display a cover component and pass `useContractKit`'s `connect` method as a prop to the cover component so that the user can connect to the network with their wallet. If the user is connected, we display our dapp. Which mainly consists of the navigation with the `Wallet` component and a main element with the `Counter` component. We pass `useContractKit`'s `address` and `balance.celo` variables as props to the `Wallet` component, as well as the `destroy` method, that enables the user to disconnect from the dapp. The `symbol` prop is used to display the symbol of the token, in this case, `Celo`. The `Counter` component gets the `counterContract` smart contract instance from our `useCounterContract` custom hook and will display a counter that the user can increment and decrement. Let's have a look at the components in more detail. ### 2.3 components Inside the components folder, we find the following files and folders: ``` ├── components │ ├── ui │ ├── Counter.js │ ├── Cover.js │ └── Wallet.js ``` #### 2.3.1 Cover.js The `Cover.js` file contains the `Cover` component. This component contains a landing page used when the user is not connected to the Celo testnet and features a button to connect. #### 2.3.2 Wallet.js The `Wallet` component is a simple component that displays the address as an identicon (a visual representation of the address) and the Celo balance of the account that the dapp is connected to. If we toggle the `Wallet` component, we will see the truncated (shortened) address and a button to disconnect the account. The `address`, `amount`, `symbol` and `destroy` props are passed to the `Wallet` component from the `App` component. #### 2.3.3 Counter.js The `Counter.js` file contains the `Counter` component, which is used to display the current value of a counter, and two buttons to increment and decrement the counter. We use the `counterContract` prop to access an instance of the `Counter` smart contract. With the instance of the contract and the `performAction` method, we can call the `increaseCount` and `decreaseCount` methods of the smart contract. #### 2.3.4 ui The `ui` folder contains the following files: ``` ├── ui │ ├── Identicon.js │ ├── Loader.js │ └── Notification.js ``` The `Identicon` component is used to display the address as an identicon (a visual representation of the address). The identicon is generated using the Metamask's `Jazzicon` library. We use the identicon in the `Wallet` component. The `Loader` component is used to display a loading animation when we are waiting for a response from the Celo network. The `Notification` component is used to display notifications to the user. This component uses the `react-toastify` library to display notifications. We implement `Notification` in the `App` component. ### 2.4 hooks The `hooks` folder contains the following files: ``` ├── hooks │ ├── index.js │ ├── useBalance.js │ ├── useContract.js │ └── useCounterContract.js ``` #### 2.4.1 useBalance.js The `useBalance.js` file contains the `useBalance` custom hook. This hook is used to get the balance of the account that the dapp is connected to. We use contractkit's `kit` method `getTokenBalance` to query the balances of Celo tokens. The `useBalance` hook is implemented in the `App` component. #### 2.4.2 useContract.js The `useContract.js` file contains the `useContract` custom hook. This hook is used to get an instance of a smart contract. `useContract` needs an `abi` and a `contractAddress` as props. Then we can use `kit.web3.eth.contract` to create an instance of the smart contract. The `useContract` hook is implemented in the `useCounterContract` custom hook that we are going to look at next. This hook is very generic and can be used to get an instance of any smart contract. #### 2.4.3 useCounterContract.js The `useCounterContract.js` file contains the `useCounterContract` custom hook. This hook imports the abi and contract address from json files that are generated by the Hardhat deploy script. With the abi and contract address, we can create and export an instance of the `Counter` smart contract. The `useCounterContract` hook is implemented in the `App` component. Let's have a look at some utility functions next. ### 2.5 utils The `utils` folder contains the following files: ``` ├── utils │ ├── constants.js │ ├── counter.js │ └── index.js ``` These files contain constants and utility functions that are used in the dapp. #### 2.5.1 index.js The `index.js` file contains the functions `truncateAddress` and `formatBigNumber`. These functions are used to shorten an address and convert a very large number to a human-readable format. #### 2.5.2 constants.js The `constants.js` file for now just contains the `CELO_DECIMALS` constant, which stores the number of decimals Celo tokens have. We use this constant in the `formatBigNumber` function. #### 2.5.3 counter.js In the `counter.js` file, we implement the `increaseCount` and `decreaseCount` functions. These functions get the `counterContract` instance and `useContractKit`'s `performAction` as props, so we can call the `increaseCount` and `decreaseCount` methods of the smart contract. The `counter.js` file is used in the `Counter` component. Now we can adapt the boilerplate to our needs. ## 3. Minter contract In this section, we will create the smart contract for the minter dapp, create tests for the smart contract and deploy it to the Celo testnet. We will keep this chapter brief; a more detailed explanation of NFTs and Hardhat development is provided in the [NFT Contract Development with Hardhat](https://hackmd.io/exuZTH2hTqKytn2vxgDmcg) learning module. ### 3.1 Contract Inside the `contracts` folder in our root directory, we find the `MyContract.sol` file. Delete that file and create a new one, called `MyNFT.sol`. Add the following code to the file: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.2; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable { using Counters for Counters.Counter; Counters.Counter private _tokenIdCounter; constructor() ERC721("MyNFT", "MNFT") {} function safeMint(address to, string memory uri) public onlyOwner { uint256 tokenId = _tokenIdCounter.current(); _tokenIdCounter.increment(); _safeMint(to, tokenId); _setTokenURI(tokenId, uri); } // The following functions are overrides required by Solidity. function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override(ERC721, ERC721Enumerable) { super._beforeTokenTransfer(from, to, tokenId); } function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) { super._burn(tokenId); } function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) { return super.tokenURI(tokenId); } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721Enumerable) returns (bool) { return super.supportsInterface(interfaceId); } } ``` This contract is a simple NFT contract that has, among other things, the following features: - It has a `mint` function that mints an NFT to a given address. Each NFT has a unique ID generated by the `_tokenIdCounter` counter. - It has a `tokenURI` function that returns the url of the NFT metadata. - It imports the `ER721Enumerable` contract that allows us to keep track of all the NFTs that have been minted. You can find more details about this contract in the [NFT Contract Development with Hardhat](https://hackmd.io/exuZTH2hTqKytn2vxgDmcg) learning module. We import contracts from the `@openzeppelin/contracts` library. To use them, we need to add them to our project first: ```sh npm install @openzeppelin/contracts ``` Now we can compile the contract with the following command: ```sh npx hardhat compile ``` After a successful compilation, you should see the following output: ``` Solidity compilation finished successfully ``` ### 3.2 Tests Let's add some tests for our contract. Inside the `tests` folder in our root directory, we delete the `contracts-test.js` file and create a new one called `nft-test.js`. Add the following code to the file: ```js const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("MyNFT", function () { this.timeout(50000); let myNFT; let owner; let acc1; let acc2; this.beforeEach(async function () { // This is executed before each test // Deploying the smart contract const MyNFT = await ethers.getContractFactory("MyNFT"); [owner, acc1, acc2] = await ethers.getSigners(); myNFT = await MyNFT.deploy(); }); it("Should set the right owner", async function () { expect(await myNFT.owner()).to.equal(owner.address); }); it("Should mint one NFT", async function () { expect(await myNFT.balanceOf(acc1.address)).to.equal(0); const tokenURI = "https://example.com/1"; const tx = await myNFT.connect(owner).safeMint(acc1.address, tokenURI); await tx.wait(); expect(await myNFT.balanceOf(acc1.address)).to.equal(1); }); it("Should set the correct tokenURI", async function () { const tokenURI_1 = "https://example.com/1"; const tokenURI_2 = "https://example.com/2"; const tx1 = await myNFT.connect(owner).safeMint(acc1.address, tokenURI_1); await tx1.wait(); const tx2 = await myNFT.connect(owner).safeMint(acc2.address, tokenURI_2); await tx2.wait(); expect(await myNFT.tokenURI(0)).to.equal(tokenURI_1); expect(await myNFT.tokenURI(1)).to.equal(tokenURI_2); }); }); ``` In the first test, if the NFT contract was deployed correctly and the owner of the NFT is the same address as the address that deployed the contract, then the test should pass. In the second test, we mint an NFT and check if it is minted correctly to the first account. In the third test, we mint two NFTs and check if the tokenURI is set correctly. We explain the tests for this contract in more detail in the [NFT Contract Development with Hardhat](https://hackmd.io/exuZTH2hTqKytn2vxgDmcg) learning module. Now we can run the tests with the following command: ```sh npx hardhat test ``` If the tests pass, you should see something similar to the following output: ``` MyNFT ✓ Should set the right owner ✓ Should mint one NFT (60ms) ✓ Should set the correct tokenURI (93ms) 3 passing (1s) ``` ### 3.3 Deploying the contract In this section, we will deploy the contract to the Celo Alfajores testnet and store the address and the ABI of the contract so that we can use them in the following sections. Inside the `scripts` folder in our root directory, we need to change the content of the `deploy.js` file, so we deploy our `MyNFT` contract. Change the code inside the file to the following: ```js const hre = require("hardhat"); async function main() { const MyNFT = await hre.ethers.getContractFactory("MyNFT"); const myNFT = await MyNFT.deploy(); await myNFT.deployed(); console.log("MyNFT deployed to:", myNFT.address); storeContractData(myNFT); } function storeContractData(contract) { const fs = require("fs"); const contractsDir = __dirname + "/../src/contracts"; if (!fs.existsSync(contractsDir)) { fs.mkdirSync(contractsDir); } fs.writeFileSync( contractsDir + "/MyNFT-address.json", JSON.stringify({ MyNFT: contract.address }, undefined, 2) ); const MyNFTArtifact = artifacts.readArtifactSync("MyNFT"); fs.writeFileSync( contractsDir + "/MyNFT.json", JSON.stringify(MyNFTArtifact, null, 2) ); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); }); ``` We deploy the contract to an address that is generated by the `hardhat` tool and store the address and the ABI of the contract in the `src/contracts` folder. The next step is to deploy the contract with the following command: ```sh npx hardhat run --network alfajores scripts/deploy.js ``` If the deployment was successful, you should see something similar to the following output: ``` MyNFT deployed to: 0xa489D2cf2df2F843C72980542c934F1f89b6DdfE ``` If we look at the `src/contracts` folder, we should see the following files: - `MyNFT-address.json`: This file contains the address of the contract. - `MyNFT.json`: This file contains the ABI of the contract. We can delete the `CounterAddress.json` file and the `Counter.json` file. That's it! Now you can use the contract in the next sections. ## 4. Minter UI In this section, we will create a UI that allows us to mint and view the NFTs that have been minted. Inside the `src/components` folder, we create a new folder called `minter`. Inside the folder, we create a new folder called `nfts`. This is where we will create the UI for the NFTs. ### 4.1 nfts/index.js The `index.js` file is the main component of the minter UI, which contains the UI's logic and is the container for the components that display NFTs and the minting form. At the top of file, we handle our imports: ```js import { useContractKit } from "@celo-tools/use-contractkit"; import React, { useEffect, useState, useCallback } from "react"; import { toast } from "react-toastify"; import PropTypes from "prop-types"; import AddNfts from "./Add"; import Nft from "./Card"; import Loader from "../../ui/Loader"; import { NotificationSuccess, NotificationError } from "../../ui/Notifications"; import { getNfts, createNft, fetchNftContractOwner, } from "../../../utils/minter"; import { Row } from "react-bootstrap"; // ... ``` We import the `AddNfts` and `Nft` components, as well the `getNfts`, `createNft` and `fetchNftContractOwner` functions from the `utils` folder; which we will create later in this tutorial. Next, we create our main component with its state: ```js // ... const NftList = ({ minterContract, name }) => { const { performActions, address } = useContractKit(); const [nfts, setNfts] = useState([]); const [loading, setLoading] = useState(false); const [nftOwner, setNftOwner] = useState(null); // ... ``` The `NftList` component expects a `minterContract` prop, which is the instance of the `MyNFT` contract, and a `name` prop, which is the name of the NFT collection we want to display. As previously described, we use the `useContractKit` hook to get the address of the user and the `performActions` function to perform actions on the `minterContract` contract. Then we use the `useState` hook to fetch the `nfts`, which is the list of NFTs that have been minted, and the `loading` state to manage a loading indicator while the `nfts` are being fetched. The `nftOwner` state is used to store the address of the owner of the NFT contract. Next, we create a callback function that fetches the NFTs from the `minterContract` contract: ```js // ... const getAssets = useCallback(async () => { try { setLoading(true); const allNfts = await getNfts(minterContract); if (!allNfts) return; setNfts(allNfts); } catch (error) { console.log({ error }); } finally { setLoading(false); } }, [minterContract]); // ... ``` The callback function returns a memoized function that only changes if the `minterContract` contract changes. We set the `loading` state to `true` while the NFTs are being fetched. Then we set the `nfts` state to the list of NFTs that have been minted with the `getNfts` function that we imported earlier. Now we add a function that creates a new NFT: ```js // ... const addNft = async (data) => { try { setLoading(true); await createNft(minterContract, performActions, data); toast(<NotificationSuccess text="Updating NFT list...." />); getAssets(); } catch (error) { console.log({ error }); toast(<NotificationError text="Failed to create an NFT." />); } finally { setLoading(false); } }; // ... ``` The function mints a new NFT by calling the `createNft` function, which we imported earlier, and passing the `minterContract` contract, the `performActions` function, and the `data` object. The `data` object contains the NFT's metadata. Next, we create a function that fetches the owner of the NFT contract and a `useEffect` hook that calls the `getAssets` and `fetchContractOwner` functions when `minterContract`, `address`, `getAssets`, or `fetchContractOwner` change: ```js // ... const fetchContractOwner = useCallback(async (minterContract) => { // get the address that deployed the NFT contract const _address = await fetchNftContractOwner(minterContract); setNftOwner(_address); }, []); useEffect(() => { try { if (address && minterContract) { getAssets(); fetchContractOwner(minterContract); } } catch (error) { console.log({ error }); } }, [minterContract, address, getAssets, fetchContractOwner]); // ... ``` Now we can display the NFTs: ```js // ... if (address) { return ( <> {!loading ? ( <> <div className="d-flex justify-content-between align-items-center mb-4"> <h1 className="fs-4 fw-bold mb-0">{name}</h1> {nftOwner === address ? ( <AddNfts save={addNft} address={address} /> ) : null} </div> <Row xs={1} sm={2} lg={3} className="g-3 mb-5 g-xl-4 g-xxl-5"> {nfts.map((_nft) => ( <Nft key={_nft.index} nft={{ ..._nft, }} /> ))} </Row> </> ) : ( <Loader /> )} </> ); } return null; }; // ... ``` We display the `name` of the collection as the page's title. If the user is the owner of the NFT contract, we display the `AddNfts` component, which is a component that allows the user to mint a new NFT. We pass the `addNft` function and the `address` of the user as props to the component. Then we map the list of `nfts` to the `Nft` component, which is a component that displays the NFT's metadata. We pass the `_nft` object's metadata as props to the component. Finally, we make sure that the props are from the right type, that the default props are set to `null`, and export the component: ```js // ... NftList.propTypes = { minterContract: PropTypes.instanceOf(Object), updateBalance: PropTypes.func.isRequired, }; NftList.defaultProps = { minterContract: null, }; export default NftList; ``` That's it! You can find the complete code of the file [here](https://github.com/dacadeorg/celo-nft-minter/blob/master/src/components/minter/nfts/index.js). Next, we add a `Card` component to display the NFT's metadata. ### 4.2 nfts/Card.js We create a new file, `Card.js`, in the `nfts` directory. This file will contain the `Card` component, which is used to display the NFT's metadata: ```js import React from "react"; import PropTypes from "prop-types"; import { Card, Col, Badge, Stack, Row } from "react-bootstrap"; import { truncateAddress } from "../../../utils"; import Identicon from "../../ui/Identicon"; const NftCard = ({ nft }) => { const { image, description, owner, name, index, attributes } = nft; return ( <Col key={index}> <Card className=" h-100"> <Card.Header> <Stack direction="horizontal" gap={2}> <Identicon address={owner} size={28} /> <span className="font-monospace text-secondary"> {truncateAddress(owner)} </span> <Badge bg="secondary" className="ms-auto"> {index} ID </Badge> </Stack> </Card.Header> <div className=" ratio ratio-4x3"> <img src={image} alt={description} style={{ objectFit: "cover" }} /> </div> <Card.Body className="d-flex flex-column text-center"> <Card.Title>{name}</Card.Title> <Card.Text className="flex-grow-1">{description}</Card.Text> <div> <Row className="mt-2"> {attributes.map((attribute, key) => ( <Col key={key}> <div className="border rounded bg-light"> <div className="text-secondary fw-lighter small text-capitalize"> {attribute.trait_type} </div> <div className="text-secondary text-capitalize font-monospace"> {attribute.value} </div> </div> </Col> ))} </Row> </div> </Card.Body> </Card> </Col> ); }; NftCard.propTypes = { // props passed into this component nft: PropTypes.instanceOf(Object).isRequired, }; export default NftCard; ``` This component is pretty simple. We get the NFT's metadata, the `image`, `description`, `owner`, `name`, `index`, and `attributes` from the `nft` prop. Then we display the metadata as a bootstrap `Card` component. We truncate the `owner` address to make it easier to read, with the `truncateAddress` function we mentioned earlier. The user can also create arbitrary attributes for the NFT, which we map and display; the other values should be self-explanatory. Next, we will create a component to create new NFTs. ### 4.3 nfts/Add.js Create a new file `Add.js` in the `nfts` directory. This file will contain the `AddNfts` component, which we will use to mint a new NFT. When the user clicks the `Add` button, we will display a modal with a form for the metadata of the NFT. First, we do our imports: ```js import React, { useState } from "react"; import PropTypes from "prop-types"; import { Button, Modal, Form, FloatingLabel } from "react-bootstrap"; import { uploadToIpfs } from "../../../utils/minter"; const COLORS = ["Red", "Green", "Blue", "Cyan", "Yellow", "Purple"]; const SHAPES = ["Circle", "Square", "Triangle"]; // ... ``` We import the `uploadToIpfs` function from the `utils` folder that we will create later. Then we create two arrays, one for the `COLORS` and one for the `SHAPES`, with some values that we will use as attributes for the NFT. Next, we create the `AddNfts` component: ```js // ... const AddNfts = ({ save, address }) => { const [name, setName] = useState(""); const [ipfsImage, setIpfsImage] = useState(""); const [description, setDescription] = useState(""); const [attributes, setAttributes] = useState([]); const [show, setShow] = useState(false); // ... ``` We create state variables for the metadata of the NFT and a boolean variable that we use to store the state of the modal. Now need some utility functions: ```js // ... // check if all form data has been filled const isFormFilled = () => name && ipfsImage && description && attributes.length > 2; // close the popup modal const handleClose = () => { setShow(false); setAttributes([]); }; // display the popup modal const handleShow = () => setShow(true); // add an attribute to an NFT const setAttributesFunc = (e, trait_type) => { const { value } = e.target; const attributeObject = { trait_type, value, }; const arr = attributes; // check if attribute already exists const index = arr.findIndex((el) => el.trait_type === trait_type); if (index >= 0) { // update the existing attribute arr[index] = { trait_type, value, }; setAttributes(arr); return; } // add a new attribute setAttributes((oldArray) => [...oldArray, attributeObject]); }; // ... ``` We create a function that checks if all the form data has been filled and two functions that we use to open (`handleShow`) and close (`handleClose`) the popup modal. We also need a function (`setAttributesFunc`) that handles the functionality to add attributes to the NFT. In the next step, we will create the form that will be displayed in the popup modal: ```js // ... return ( <> <Button onClick={handleShow} variant="dark" className="rounded-pill px-0" style={{ width: "38px" }} > <i className="bi bi-plus"></i> </Button> {/* Modal */} <Modal show={show} onHide={handleClose} centered> <Modal.Header closeButton> <Modal.Title>Create NFT</Modal.Title> </Modal.Header> <Modal.Body> <Form> <FloatingLabel controlId="inputLocation" label="Name" className="mb-3" > <Form.Control type="text" placeholder="Name of NFT" onChange={(e) => { setName(e.target.value); }} /> </FloatingLabel> <FloatingLabel controlId="inputDescription" label="Description" className="mb-3" > <Form.Control as="textarea" placeholder="description" style={{ height: "80px" }} onChange={(e) => { setDescription(e.target.value); }} /> </FloatingLabel> <Form.Control type="file" className={"mb-3"} onChange={async (e) => { const imageUrl = await uploadToIpfs(e); if (!imageUrl) { alert("failed to upload image"); return; } setIpfsImage(imageUrl); }} placeholder="Product name" ></Form.Control> <Form.Label> <h5>Properties</h5> </Form.Label> <Form.Control as="select" className={"mb-3"} onChange={async (e) => { setAttributesFunc(e, "background"); }} placeholder="Background" > <option hidden>Background</option> {COLORS.map((color) => ( <option key={`background-${color.toLowerCase()}`} value={color.toLowerCase()} > {color} </option> ))} </Form.Control> <Form.Control as="select" className={"mb-3"} onChange={async (e) => { setAttributesFunc(e, "color"); }} placeholder="NFT Color" > <option hidden>Color</option> {COLORS.map((color) => ( <option key={`color-${color.toLowerCase()}`} value={color.toLowerCase()} > {color} </option> ))} </Form.Control> <Form.Control as="select" className={"mb-3"} onChange={async (e) => { setAttributesFunc(e, "shape"); }} placeholder="NFT Shape" > <option hidden>Shape</option> {SHAPES.map((shape) => ( <option key={`shape-${shape.toLowerCase()}`} value={shape.toLowerCase()} > {shape} </option> ))} </Form.Control> </Form> </Modal.Body> <Modal.Footer> <Button variant="outline-secondary" onClick={handleClose}> Close </Button> <Button variant="dark" disabled={!isFormFilled()} onClick={() => { save({ name, ipfsImage, description, ownerAddress: address, attributes, }); handleClose(); }} > Create NFT </Button> </Modal.Footer> </Modal> </> ); }; // ... ``` This part has a lot of code, but it's pretty straightforward. We create a modal that will be displayed when the user clicks on the `+` button. Inside the modal, we display a form that handles the input of the metadata of the NFT. If the user clicks on the `Create NFT` button, we check if all the form data has been filled, and if it has, we call the `save` function to create the NFT. You can find the complete code of the file [here](https://github.com/dacadeorg/celo-nft-minter/blob/master/src/components/minter/nfts/Add.js). Finally, we make sure that the props are from the right type and export the component: ```js // ... AddNfts.propTypes = { save: PropTypes.func.isRequired, address: PropTypes.string.isRequired, }; export default AddNfts; ``` Done! We now have built the UI for our minter; in the next section, we will build the logic. ## 5. Minter Logic In this section, we will create the logic of the minter. ### 5.1 hooks/useMinterContract.js Inside the `hooks` folder in our `src` directory, we can delete the `useCount` and `useCounterContract` files and create a new one called `useMinterContract`. Add the following code to it: ```js import { useContract } from "./useContract"; import MyNFTAbi from "../contracts/MyNFT.json"; import MyNFTContractAddress from "../contracts/MyNFT-address.json"; export const useMinterContract = () => useContract(MyNFTAbi.abi, MyNFTContractAddress.MyNFT); ``` This code is pretty straightforward. We import the `useContract` hook that we discussed earlier and pass the ABI and the address of the contract to the `useContract` function. Now we can create a custom hook that will return the contract instance. ### 5.2 hooks/index.js Now we also need to export the `useMinterContract` hook. We can do this by replacing the `useCounterContract` with `useMinterContract` in the `hooks/index.js` file. ```js export * from "./useMinterContract"; export * from "./useBalance"; export * from "./useContract"; ``` Let's continue with the real logic of the minter. ### 5.3 hooks/minter.js Inside the `utils` folder in our `src` directory, we can delete the `counter.js` file and create a new one called `minter.js`. In our `minter.js` file, we will start with our imports: Let's start again with our imports: ```js import { create as ipfsHttpClient } from "ipfs-http-client"; import axios from "axios"; const client = ipfsHttpClient("https://ipfs.infura.io:5001/api/v0"); // ... ``` We import the `ipfs-http-client` library and the `axios` library. We will use the `axios` library to make requests to an IPFS node. IPFS is a peer-to-peer distributed content storage network. It is a protocol that allows users to store and share data. We will use IPFS to store the metadata of our NFTs and images for the NFTs. We initialize the IPFS `client`. Let's install both `ipfs-http-client` and `axios`: ```sh npm install ipfs-http-client axios ``` Next, let's add a `createNft` function that will mint an NFT: ```js // ... export const createNft = async ( minterContract, performActions, { name, description, ipfsImage, ownerAddress, attributes } ) => { await performActions(async (kit) => { if (!name || !description || !ipfsImage) return; const { defaultAccount } = kit; // convert NFT metadata to JSON format const data = JSON.stringify({ name, description, image: ipfsImage, owner: defaultAccount, attributes, }); try { // save NFT metadata to IPFS const added = await client.add(data); // IPFS url for uploaded metadata const url = `https://ipfs.infura.io/ipfs/${added.path}`; // mint the NFT and save the IPFS url to the blockchain let transaction = await minterContract.methods .safeMint(ownerAddress, url) .send({ from: defaultAccount }); return transaction; } catch (error) { console.log("Error uploading file: ", error); } }); }; // ... ``` As parameters for our function, we will need the `minterContract` and the `performActions` method from the `useContractKit` hook. We also need the metadata of the NFT. With `defaultAccount`, we will get the address of the account that is currently connected to the dapp via the wallet. We call `performActions` to create the transaction that will interact with our contract and mint the NFT. First, we convert the metadata to the JSON format (`data`), then we upload the JSON to IPFS (`added`) and store the path of the JSON on IPFS in the `url` variable. Now that we have the IPFS `url` with the metadata, we can mint the NFT. To mint the NFT, we call the `safeMint` method our `minterContract` contract. We pass the `ownerAddress` and the `url` as parameters. The first parameter determines to which address the NFT will be minted, and the second parameter is the token URI with the metadata. We will make the transactions with the address of the connected account (`defaultAccount`). With the following function, we will add the ability to upload a local image to IPFS: ```js // ... export const uploadToIpfs = async (e) => { const file = e.target.files[0]; if (!file) return; try { const added = await client.add(file, { progress: (prog) => console.log(`received: ${prog}`), }); return `https://ipfs.infura.io/ipfs/${added.path}`; } catch (error) { console.log("Error uploading file: ", error); } }; // ... ``` This part is pretty straightforward. If the user has selected an image, we get the file from the `e` event and upload it to IPFS. We get the `added` object from the IPFS client and return the path of the file. We also need one function to fetch all NFTs that have been minted from our contract. We will call this function `getNfts`: ```js // ... export const getNfts = async (minterContract) => { try { const nfts = []; const nftsLength = await minterContract.methods.totalSupply().call(); for (let i = 0; i < Number(nftsLength); i++) { const nft = new Promise(async (resolve) => { const res = await minterContract.methods.tokenURI(i).call(); const meta = await fetchNftMeta(res); const owner = await fetchNftOwner(minterContract, i); resolve({ index: i, owner, name: meta.data.name, image: meta.data.image, description: meta.data.description, attributes: meta.data.attributes, }); }); nfts.push(nft); } return Promise.all(nfts); } catch (e) { console.log({ e }); } }; // ... ``` We first need to know how many NFTs our `minterContract` has minted in total. We can get this number by calling the `totalSupply` method since our contract has implemented the `ERC721Enumerable` interface. Then, we loop through all the NFTs and fetch the data we want to display. We create a new promise for each NFT, and we call the `tokenURI` method of the `minterContract` contract. We pass the index of the NFT as a parameter for the tokenID and call the `fetchNftMeta` and `fetchNftOwner` functions that we will create next. Finally, when all the promises are resolved, we return the array of NFTs. Now let's add the `fetchNftMeta` function: ```js // ... export const fetchNftMeta = async (ipfsUrl) => { try { if (!ipfsUrl) return null; const meta = await axios.get(ipfsUrl); return meta; } catch (e) { console.log({ e }); } }; // ... ``` We call the `axios` library to fetch the metadata from IPFS and return it. Finally, we create two functions to fetch the owner of an NFT and the owner of the NFT contract: ```js // ... export const fetchNftOwner = async (minterContract, index) => { try { return await minterContract.methods.ownerOf(index).call(); } catch (e) { console.log({ e }); } }; export const fetchNftContractOwner = async (minterContract) => { try { let owner = await minterContract.methods.owner().call(); return owner; } catch (e) { console.log({ e }); } }; ``` The difference between the `fetchNftOwner` and the `fetchNftContractOwner` functions is that the `fetchNftOwner` function will return the address of the owner of the NFT. In contrast, the `fetchNftContractOwner` function will return the address of the owner of the NFT contract. Only the contract owner will be able to mint a new NFT. But we also want to display the address of the current owner of the NFT. You can find the complete code of the file [here](https://github.com/dacadeorg/celo-nft-minter/blob/master/src/utils/minter.js). Now we have implemented all the functions we need for our NFT dapp. In the next section, we will put everything together and clean up the project. ## 6. Finishing the Minter Dapp Let's finish our minter dapp. First, we clean up our components: In our `components` directory we can remove the `Counter.js` component since we don't need it anymore. We can also move the `Cover.js` component to the minter folder. The components minter folder should look like this now: ``` ├── minter │ ├── nfts │ │ ├── Add.js │ │ ├── Card.js │ │ └── index.js │ └── Cover.js ``` Let's adapt our `Cover.js` component a bit to fit the new purpose of the dapp. ### 6.1 Cover.js Our dapp should have a nice cover image. To start, let's create a new folder for our assets, where we can store the cover image. We will create it in the `src` folder, call it `assets` and create a new folder inside it called `img`. Inside this folder, save an image for the cover. In this example, we use this [image](https://github.com/dacadeorg/celo-nft-minter/blob/master/src/assets/img/nft_geo_cover.png) and call it `nft_geo_cover.png` because we want to create an NFT collection of geometric forms. Let's open our `Cover.js` component and change the code to look like this: ```js import React from "react"; import { Button } from "react-bootstrap"; import PropTypes from "prop-types"; const Cover = ({ name, coverImg, connect }) => { if (name) { return ( <div className="d-flex justify-content-center flex-column text-center " style={{ background: "#000", minHeight: "100vh" }} > <div className="mt-auto text-light mb-5"> <div className=" ratio ratio-1x1 mx-auto mb-2" style={{ maxWidth: "320px" }} > <img src={coverImg} alt="" /> </div> <h1>{name}</h1> <p>Please connect your wallet to continue.</p> <Button onClick={() => connect().catch((e) => console.log(e))} variant="outline-light" className="rounded-pill px-3 mt-3" > Connect Wallet </Button> </div> <p className="mt-auto text-secondary">Powered by Celo</p> </div> ); } return null; }; Cover.propTypes = { name: PropTypes.string, }; Cover.defaultProps = { name: "", }; export default Cover; ``` We add a `name` and `coverImg` property to the `Cover` component, so we display the name and cover image of the dapp. We will define these properties in the `App.js` component. ### 6.2 App.js We need to adjust the `App.js` file in our `src` folder to use our new components and implement their functionality. Let's start with the import statements: ```js import React from "react"; import { Container, Nav } from "react-bootstrap"; import { useContractKit } from "@celo-tools/use-contractkit"; import { Notification } from "./components/ui/Notifications"; import Wallet from "./components/Wallet"; import Cover from "./components/minter/Cover"; import Nfts from "./components/minter/nfts"; import { useBalance, useMinterContract } from "./hooks"; import coverImg from "./assets/img/nft_geo_cover.png"; import "./App.css"; // ... ``` We change the path to the `Cover` component to the `minter` folder. We add the `NFTs` component instead of the `Counter` component. We replace the `useCounterContract` hook with the `useMinterContract` hook. And we also add the `coverImg` with the image we created saved earlier. Let's adapt our App component with its state next: ```js // ... const App = function AppWrapper() { const { address, destroy, connect } = useContractKit(); const { balance, getBalance } = useBalance(); const minterContract = useMinterContract(); // ... ``` We add the `getBalance` function to get the current balance of the wallet and replace the `counterContract` with the `minterContract`, and its hook. Finally, we return the App component: ```js // ... return ( <> <Notification /> {address ? ( <Container fluid="md"> <Nav className="justify-content-end pt-3 pb-5"> <Nav.Item> <Wallet address={address} amount={balance.CELO} symbol="CELO" destroy={destroy} /> </Nav.Item> </Nav> <main> <Nfts name="GEO Collection" updateBalance={getBalance} minterContract={minterContract} /> </main> </Container> ) : ( <Cover name="GEO Collection" coverImg={coverImg} connect={connect} /> )} </> ); }; export default App; ``` Here we replace the `Counter` component with the `Nfts` component and add the `coverImg` and `name` props to the `Cover` component. You can find the complete code of the file [here](https://github.com/dacadeorg/celo-nft-minter/blob/master/src/App.js). ### 6.3 Optimizing the Dapp We can do a little bit of optimization to our dapp. In the `public` directory, we can add our `favicon.ico`, `logo192.png`, `logo512.png`, and `manifest.json` files that fit our project. We could also change the `title` and `description` of our dapp in the `index.html` file in the `public` directory and the name in the `package.json` file. ### 6.4 Testing the Dapp Let's test our dapp and check if everything is working as expected. First, we deploy the NFT contract that we want to use to the Celo Alfajores network. ```sh npx hardhat run --network alfajores scripts/deploy.js ``` Now we can start our dapp locally: ```sh npm start ``` Your dapp should be running on [http://localhost:3000](http://localhost:3000). Test your Dapp by connecting and minting an NFT. It should behave like this: ![](https://raw.githubusercontent.com/dacadeorg/celo-development-201/main/content/gifs/minter_demo.gif) ## 7. Deploying to GitHub Pages In the last section, we will briefly look at how to deploy our dapp to GitHub Pages. 1. First, add your project to GitHub. If you don't know how to do this, check out this [GitHub guide](https://docs.github.com/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/adding-an-existing-project-to-github-using-the-command-line). 2. Install the [gh-pages](https://www.npmjs.com/package/gh-pages) package. This will allow us to deploy our dapp to GitHub Pages. ```bash npm install gh-pages ``` 3. In the package.json file, add the following lines: - At the top of the file, add the following line: ``` "homepage": "https://${GithubUsername}.github.io/${RepositoryName}", ``` Replace `${GithubUsername}` and `${RepositoryName}` with your GitHub username and the repository name. - add the following lines at the bottom of the `scripts` section: ``` "predeploy": "npm run build", "deploy": "gh-pages -d build" ``` 4. Push your changes to GitHub. 5. Run `npm run deploy` to deploy your dapp to a new GitHub Pages branch. 6. Go to your repository on github.com and follow the instructions: ![](https://raw.githubusercontent.com/dacadeorg/celo-development-201/master/content/imgs/gh_pages_celo.png) - Click on settings. - Navigate to the "Pages" section. - Select the gh-pages branch as the default branch for your new page. - Click "Save". Done! Now you should be able to see your dapp on `https://${GithubUsername}.github.io/${RepositoryName}`. Awesome! You have successfully created your NFT minter dapp. Now you can go ahead and create your own NFT dapp in our challenge, receive feedback and earn some cUSD!

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password

    or

    By clicking below, you agree to our terms of service.

    Sign in via Facebook Sign in via Twitter Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully