Full Stack Arweave Workshop
Building a decentralized video sharing app with Arweave, Bundlr, GraphQL, and Next.js.
Prerequisites
- Node.js installed on your machine
I recommend using either NVM or FNM for Node.js installation
Matic, Arbitrum, or Avalanche tokens
Metamask installed as a browser extension
Fund your Bundlr wallet here with around $1.00 of your preferred currency.
Getting started
To get started, create a new Next.js application
npx create-next-app arweave-app
Next, change into the directory and install the dependencies using either NPM, Yarn, PNPM, or your favoriate package manager:
cd arweave-app
npm install @bundlr-network/client arweave @emotion/css ethers react-select
Overview of some of the dependencies
@emotion/css
- CSS in JavaScript library for styling
react-select
- select input control library for React
@bundlr-network/client
- JavaScript client for interacting with Bundlr network
arweave
- The Arweave JavaScript library
Base setup
Now that the dependencies are installed, create a new file named context.js in the root directory. We will use this file to initialize some React context that we'll be using to provide global state between routes.
// context.js
import { createContext } from 'react'
export const MainContext = createContext()
Next, let's create a new page in the pages directory called _app.js.
Here, we want to get started by enabling the user to sign in to bundlr using their MetaMask wallet.
We'll pass this functionality and some state into other pages so that we can use it there.
Add the following code to pages/app.js:
// pages/_app.js
import '../styles/globals.css'
import { WebBundlr } from "@bundlr-network/client"
import { MainContext } from '../context'
import { useState, useRef } from 'react'
import { providers, utils } from 'ethers'
import { css } from '@emotion/css'
import Link from 'next/link'
function MyApp({ Component, pageProps }) {
const [bundlrInstance, setBundlrInstance] = useState()
const [balance, setBalance] = useState(0)
// set the base currency as matic (this can be changed later in the app)
const [currency, setCurrency] = useState('matic')
const bundlrRef = useRef()
// create a function to connect to bundlr network
async function initialiseBundlr() {
await window.ethereum.enable()
const provider = new providers.Web3Provider(window.ethereum);
await provider._ready()
const bundlr = new WebBundlr("https://node1.bundlr.network", currency, provider)
await bundlr.ready()
setBundlrInstance(bundlr)
bundlrRef.current = bundlr
fetchBalance()
}
// get the user's bundlr balance
async function fetchBalance() {
const bal = await bundlrRef.current.getLoadedBalance()
console.log('bal: ', utils.formatEther(bal.toString()))
setBalance(utils.formatEther(bal.toString()))
}
return (
)
}
const navHeight = 80
const footerHeight = 70
const navStyle = css`
height: ${navHeight}px;
padding: 40px 100px;
border-bottom: 1px solid #ededed;
display: flex;
align-items: center;
`
const homeLinkStyle = css`
display: flex;
flex-direction: row;
align-items: center;
`
const homeLinkTextStyle = css`
font-weight: 200;
font-size: 28;
letter-spacing: 7px;
`
const footerStyle = css`
border-top: 1px solid #ededed;
height: ${footerHeight}px;
padding: 0px 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 200;
letter-spacing: 1px;
font-size: 14px;
`
const containerStyle = css`
min-height: calc(100vh - ${navHeight + footerHeight}px);
width: 900px;
margin: 0 auto;
padding: 40px;
`
export default MyApp
What have we done here?
- Imported the dependencies
- Created some component state, one to hold the instance of Bundlr, the other to hold the user's wallet balance.
- Created a function to connect to bundlr -
initialiseBundlr
- Created a function to fetch the user's balance -
fetchBalance
- Added some basic styling using
emotion
- Added some navigation, a footer, and a link in the footer to the
profile
page that has not yet been created.
Next, let's run the app:
npm run dev
You should see the app load and have a header and a footer! 🎉🎉🎉
Connecting to Bundlr
Next, let's create the UI that will allow the user to choose the currency they'd like to use and connect to Bundlr.
To do so, create a new file in the pages directory named profile.js. Here, add the following code:
import { useState, useContext } from 'react'
import { MainContext } from '../context'
import { css } from '@emotion/css'
import Select from 'react-select'
// list of supported currencies: https://docs.bundlr.network/docs/currencies
const supportedCurrencies = {
matic: 'matic',
ethereum: 'ethereum',
avalanche: 'avalanche',
bnb: 'bnb',
arbitrum: 'arbitrum'
}
const currencyOptions = Object.keys(supportedCurrencies).map(v => {
return {
value: v, label: v
}
})
export default function Profile() {
// use context to get data and functions passed from _app.js
const { balance, bundlrInstance, initialiseBundlr, currency, setCurrency } = useContext(MainContext)
// if the user has not initialized bundlr, allow them to
if (!bundlrInstance) {
return (
)
}
// once the user has initialized Bundlr, show them their balance
return (
💰 Balance {Math.round(balance * 100) / 100}
)
}
const selectContainerStyle = css`
margin: 10px 0px 20px;
`
const containerStyle = css`
padding: 10px 20px;
display: flex;
justify-content: center;
`
const buttonStyle = css`
background-color: black;
color: white;
padding: 12px 40px;
border-radius: 50px;
font-weight: 700;
width: 180;
transition: all .35s;
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, .75);
}
`
const wideButtonStyle = css`
${buttonStyle};
width: 380px;
`
const balanceStyle = css`
padding: 10px 25px;
background-color: rgba(0, 0, 0, .08);
border-radius: 30px;
display: inline-block;
width: 200px;
text-align: center;
`
In this file we've:
- Defined the array of currencies we'd like to support (full list here)
- Used
useContext
to get the functions and state variables defined in pages/app.js - Created a drop-down menu to enable the user to select the currency they'd like to use
- Created a button that allows the user to connect to Bundlr network.
Next let's test it out:
npm run dev
You should see a dropdown menu and be able to connect to Bundlr with your wallet! 🎉🎉🎉
Saving a video
Next, let's add the code that will allow user's to upload and save a video to Arweave with Bundlr.
Create a new file named utils.js
in the root directory and add the following code:
import Arweave from 'arweave'
export const arweave = Arweave.init({})
export const APP_NAME = 'SOME_UNIQUE_APP_NAME'
Next, update pages/profile.js with the following code (new code is commented):
import { useState, useContext } from 'react'
import { MainContext } from '../context'
import { css } from '@emotion/css'
import Select from 'react-select'
// New imports
import { APP_NAME } from '../utils'
import { useRouter } from 'next/router'
import { utils } from 'ethers'
const supportedCurrencies = {
matic: 'matic',
ethereum: 'ethereum',
avalanche: 'avalanche',
bnb: 'bnb',
arbitrum: 'arbitrum'
}
const currencyOptions = Object.keys(supportedCurrencies).map(v => {
return {
value: v, label: v
}
})
export default function Profile() {
const { balance, bundlrInstance, initialiseBundlr, currency, setCurrency } = useContext(MainContext)
// New local state variables
const [file, setFile] = useState()
const [localVideo, setLocalVideo] = useState()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [fileCost, setFileCost] = useState()
const [URI, setURI] = useState()
// router will allow us to programatically route after file upload
const router = useRouter()
// when the file is uploaded, save to local state and calculate cost
function onFileChange(e) {
const file = e.target.files[0]
if (!file) return
checkUploadCost(file.size)
if (file) {
const video = URL.createObjectURL(file)
setLocalVideo(video)
let reader = new FileReader()
reader.onload = function (e) {
if (reader.result) {
setFile(Buffer.from(reader.result))
}
}
reader.readAsArrayBuffer(file)
}
}
// save the video to Arweave
async function uploadFile() {
if (!file) return
const tags = [{ name: 'Content-Type', value: 'video/mp4' }]
try {
let tx = await bundlrInstance.uploader.upload(file, tags)
setURI(`http://arweave.net/)
} catch (err) {
console.log('Error uploading video: ', err)
}
}
async function checkUploadCost(bytes) {
if (bytes) {
const cost = await bundlrInstance.getPrice(bytes)
setFileCost(utils.formatEther(cost.toString()))
}
}
// save the video and metadata to Arweave
async function saveVideo() {
if (!file || !title || !description) return
const tags = [
{ name: 'Content-Type', value: 'text/plain' },
{ name: 'App-Name', value: APP_NAME }
]
const video = {
title,
description,
URI,
createdAt: new Date(),
createdBy: bundlrInstance.address,
}
try {
let tx = await bundlrInstance.createTransaction(JSON.stringify(video), { tags })
await tx.sign()
const { data } = await tx.upload()
console.log(`http://arweave.net/${data.id}`)
setTimeout(() => {
router.push('/')
}, 2000)
} catch (err) {
console.log('error uploading video with metadata: ', err)
}
}
if (!bundlrInstance) {
return (
)
}
{/* most of this UI is also new */}
return (
💰 Balance {Math.round(balance * 100) / 100}
{ /* if there is a video save to local state, display it */}
{
localVideo && (
)
}
{/* display calculated upload cast */}
{
fileCost && Cost to upload: {Math.round((fileCost) * 1000) / 1000} MATIC
}