At NodeConfEU 2022 we unveiled W3:UI beta at our "UCAN Build Apps with IPFS" workshop (You can watch the full video here). The workshop included two code "challenges". Today we will be working on the front-end challenge, which will look like this when we're done:

Screenshot of app with working uploading

What we're building

In this tutorial we'll be using web3ui components to create a selfie app ๐Ÿ’…๐Ÿ“ธ that allows users to take selfies, upload them to IPFS, and display them. Check out the demo.

This tutorial is apt for total beginners and should take about 20-30 minutes to complete depending on your familiarity with the relevant tools.

Overview

There are four basic steps in this tutorial:

1. Set up our framework and environment
2. Copy the file-upload example app into our framework
3. Modify the example by adding a camera and uploading the selfies ๐Ÿ’…๐Ÿ“ธ we take
4. Display our uploads ๐Ÿคฉ

If you are already familiar with creating web apps or want to BYOF (bring your own framework) you should skip โญ๏ธ step 1.

Set up framework

While w3ui is framework-agnostic, we made a few choices for the purposes of this walkthrough; you may want to make different decisions for your use case. We will use React with NextJs and Vercel for hosting. All have excellent documentation, but feel free to get in touch if you get stuck!

Create Next App

To create a new project with Create Next App, Open a terminal, navigate to where you want to keep your app code and run:

yarn create next-app

Enter your project's name when prompted. To follow along precisely with this tutorial, select No when asked if you want to use TypeScript or ESLint. When it's finished run:

cd {your-project-name}
yarn dev

Open your browser and you should see something that looks like this on localhost:

Screenshot of Next starter screen

One last chore: The recent release of Next.js 13 (at the time this blog is written) includes a change that does not play well at present with our project. Go to next.config.js and update your config to include

swcMinify: false

Copy the file-upload example app

To begin go to GitHub and take a look at the example code. In this directory are all the files we are going to use as the basis for our selfie app ๐Ÿ’…๐Ÿ“ธ

App.js

We need to merge App.js from the example project into our new project's _app.js, making some slight adjustments--we need to add the Next AppProps that App receives and return Component (the current page) rather than ContentPage. You can simply copy the code given below and paste it into _app.js

import { useEffect } from "react";
import { AuthProvider, useAuth } from "@w3ui/react-keyring";
import { UploaderProvider } from "@w3ui/react-uploader";
import logo from "./logo.png";

import "../styles/globals.css";

function App({ Component, pageProps }) {
  return (
    <AuthProvider>
      <UploaderProvider>
        <IdentityLoader>
          <div className="vh-100 flex flex-column justify-center items-center sans-serif light-silver">
            <header>
              <img src={logo} width="250" alt="logo" />
            </header>
            <div className="w-90 w-50-ns mw6">
              <Component {...pageProps} />
            </div>
          </div>
        </IdentityLoader>
      </UploaderProvider>
    </AuthProvider>
  );
}

function IdentityLoader({ children }) {
  const { loadDefaultIdentity } = useAuth();
  // eslint-disable-next-line
  useEffect(() => {
    loadDefaultIdentity();
  }, []); // try to load default identity - once.
  return children;
}

export default App;

If you had the example running at this stage, you will see that it broke ๐Ÿ˜ซ We need to install our w3ui components!

yarn add @w3ui/react-keyring && yarn add @w3ui/react-uploader

The auth and upload providers give the app access to state and actions related to authentication and files being uploaded to web3.storage... Still broke! ๐Ÿ˜ซ๐Ÿ˜ซ

To fix: Copy logo.png into the /public folder, which is where Next.js serves static assets. Then, go into _app.js, remove the import of logo, change the value of src on the <img> tag from {logo} to "/logo.png" so it looks like this:

<img src="/logo.png" width="250" alt="logo" />

The app should be working again and you should see the web3.storage logo in the upper-left of the page ๐Ÿฅณ Replace it with your own logo if you like.

Authenticator.js

Create a /components folder in the root of the project. Inside that folder, create a file called Authenticator.js, and copy the following code into it (or copy the folder from the example app into the root of the new project).

import { useState } from 'react'
import { useAuth, AuthStatus } from '@w3ui/react-keyring'

export default function Authenticator ({ children }) {
  const { authStatus, identity, registerAndStoreIdentity, cancelRegisterAndStoreIdentity } = useAuth()
  const [email, setEmail] = useState('')

  if (authStatus === AuthStatus.SignedIn) {
    return children
  }

  if (authStatus === AuthStatus.EmailVerification) {
    return (
      <div>
        <h1 className='near-white'>Verify your email address!</h1>
        <p>Click the link in the email we sent to {identity && identity.email} to sign in.</p>
        <form onSubmit={e => { e.preventDefault(); cancelRegisterAndStoreIdentity() }}>
          <button type='submit' className='ph3 pv2'>Cancel</button>
        </form>
      </div>
    )
  }

  const handleRegisterSubmit = async e => {
    e.preventDefault()
    try {
      await registerAndStoreIdentity(email)
    } catch (err) {
      throw new Error('failed to register', { cause: err })
    }
  }

  return (
    <form onSubmit={handleRegisterSubmit}>
      <div className='mb3'>
        <label htmlFor='email' className='db mb2'>Email address:</label>
        <input id='email' className='db pa2 w-100' type='email' value={email} onChange={e => setEmail(e.target.value)} required />
      </div>
      <button type='submit' className='ph3 pv2'>Register</button>
    </form>
  )
}

/**
 * Wrapping a component with this HoC ensures an identity exists.
 */
export function withIdentity (Component) {
  return props => (
    <Authenticator>
      <Component {...props} />
    </Authenticator>
  )
}

Authenticator.js exports an HoC (Higher-order Component), withIdentity, which we will use to wrap our selfie component. It returns the component it wraps if the user is authenticated, otherwise it takes the user through the sign-in flow. Go through the login flow when prompted.

Login screen

ContentPage.js

We now need to merge ContentPage.js from the example app into index.js of our new project, making some adjustments. You can copy the code below into index.js. Note: we renamed the function to Home, removed the css import, and adjusted the relative path for the import of withIdentity since Home lives in the pages folder rather than the components folder

import React, { useState } from "react";
import { useUploader } from "@w3ui/react-uploader";
import { withIdentity } from "../components/Authenticator";

export function Home() {
  const [{ uploadedCarChunks }, uploader] = useUploader();
  const [file, setFile] = useState(null);
  const [dataCid, setDataCid] = useState("");
  const [status, setStatus] = useState("");
  const [error, setError] = useState(null);

  if (!uploader) return null;

  const handleUploadSubmit = async (e) => {
    e.preventDefault();
    try {
      setStatus("uploading");
      const cid = await uploader.uploadFile(file);
      setDataCid(cid);
    } catch (err) {
      console.error(err);
      setError(err);
    } finally {
      setStatus("done");
    }
  };

  if (status === "uploading") {
    return <Uploading file={file} uploadedCarChunks={uploadedCarChunks} />;
  }

  if (status === "done") {
    return error ? (
      <Errored error={error} />
    ) : (
      <Done
        file={file}
        dataCid={dataCid}
        uploadedCarChunks={uploadedCarChunks}
      />
    );
  }

  return (
    <form onSubmit={handleUploadSubmit}>
      <div className="db mb3">
        <label htmlFor="file" className="db mb2">
          File:
        </label>
        <input
          id="file"
          className="db pa2 w-100 ba br2"
          type="file"
          onChange={(e) => setFile(e.target.files[0])}
          required
        />
      </div>
      <button type="submit" className="ph3 pv2">
        Upload
      </button>
    </form>
  );
}

const Uploading = ({ file, uploadedCarChunks }) => (
  <div className="flex items-center">
    <div className="spinner mr3 flex-none" />
    <div className="flex-auto">
      <p className="truncate">Uploading DAG for {file.name}</p>
      {uploadedCarChunks.map(({ cid, size }) => (
        <p key={cid.toString()} className="f7 truncate">
          {cid.toString()} ({size} bytes)
        </p>
      ))}
    </div>
  </div>
);

const Errored = ({ error }) => (
  <div>
    <h1 className="near-white">
      โš ๏ธ Error: failed to upload file: {error.message}
    </h1>
    <p>Check the browser console for details.</p>
  </div>
);

const Done = ({ file, dataCid, uploadedCarChunks }) => (
  <div>
    <h1 className="near-white">Done!</h1>
    <p className="f6 code truncate">{dataCid.toString()}</p>
    <p>
      <a href={`https://w3s.link/ipfs/${dataCid}`} className="blue">
        View {file.name} on IPFS Gateway.
      </a>
    </p>
    <p className="near-white">Chunks ({uploadedCarChunks.length}):</p>
    {uploadedCarChunks.map(({ cid, size }) => (
      <p key={cid.toString()} className="f7 truncate">
        {cid.toString()} ({size} bytes)
      </p>
    ))}
  </div>
);

export default withIdentity(Home);

If you've followed to this point, the app should now be working! However, it should resemble this:

Functioning uploader example without css added

Css files

Copy spinner.css and tachyons.min.css into the /styles folder. spinner.css defines a spinner animation and tachyons.min.css is minified css for the Tachyons css framework, which makes use of utility classes.

Now go into _app.js and import the css files from /styles as is already done with globals.css.

import "../styles/tachyons.min.css";
import "../styles/spinner.css";

That's much better ๐Ÿคฉ๐Ÿคฉ๐Ÿคฉ The app should now be fully functional--try it out! Pro-tip: Be patient if you choose to upload a large file.

Functioning uploader example with css

Modify the example by adding a camera and using w3ui to upload the selfies ๐Ÿ’…๐Ÿ“ธ we take

Just a few modifications and we'll have our selfie app ๐Ÿ’…๐Ÿ“ธ First, let's install the camera component.

yarn add react-camera-pro && yarn add styled-components

Now that we have a camera, let's wipe out our index.js and rebuild our UI and page functionality from scratch. Replace index.js with the following

import { useRef } from "react";
import { withIdentity } from "../components/Authenticator";
import { Camera } from "react-camera-pro";

export function Home() {
  const camera = useRef(null);

  return (
    <div className="relative">
      <div style={{ width: 300, height: 300 }}>
        <Camera ref={camera} />
      </div>
    </div>
  );
}

export default withIdentity(Home);

Now we've imported our camera and created a reference which will receive the mounted instance of the camera component. You should now see yourself in a live camera.

Screenshot of app with working camera

Let's add the ability to take pictures ๐Ÿ“ธ ๐Ÿ“ธ and upload to IPFS!

First, we need to update our imports to include useState and useUploader (a custom hook provided by react-uploader). We'll use the former to store our images and status updates in state, the latter to upload our images to IPFS.

import { useState, useRef } from "react";
import { useUploader } from '@w3ui/react-uploader'

Now we'll add our state variables and the function that will take and upload photos (just beneath our camera ref).

const camera = useRef(null);
const [, uploader] = useUploader();
const [status, setStatus] = useState("");
const [error, setError] = useState(null);
const [images, setImages] = useState([]);

const printStatus = status === "done" && error ? error.message : status;

const takePhoto = async (e) => {
  e.preventDefault();
  const imgData = camera.current.takePhoto();
  try {
    // Build a DAG from the file data to obtain the root CID.
    setStatus("encoding");
    // use fetch to transform data URL -> blob -> file
    const theFile = await fetch(imgData).then((it) => it.blob());
    setStatus("uploading");
    const cid = await uploader.uploadFile(theFile);
    setImages([{ cid: cid, data: imgData }, ...images]);
  } catch (err) {
    console.error(err);
    setError(err);
  } finally {
    setStatus("done");
  }
};

Display our uploads ๐Ÿคฉ

Finally, let's update our JSX to include a button that takes photos and displays the upload status:

return (
  <div>
    <p>
      <button style={{ cursor: "pointer" }} onClick={takePhoto}>
        Take photo
      </button>{" "}
      {printStatus}
    </p>
    <div className="relative">
      <div style={{ width: 300, height: 300 }}>
        <Camera ref={camera} />
      </div>
    </div>
  </div>
);

Now our app takes and uploads photos to IPFS! Say "cheese!" ๐Ÿ“ธ๐Ÿ˜

Screenshot of app with working uploading

Displaying our images

The last thing we need to do is display our selfies for the world to see ๐Ÿ’… We'll do this using another w3ui component: Uploads List

yarn add @w3ui/react-uploads-list

Now we need to add the provider to _app.js like we did for the other w3ui components. Without it, we won't have access to our uploaded files! Import it and

import { UploadsListProvider } from "@w3ui/react-uploads-list";
return (
  <AuthProvider>
    <UploaderProvider>
      <UploadsListProvider>
        <IdentityLoader>
          <div className="vh-100 flex flex-column justify-center items-center sans-serif light-silver">
            <header>
              <img src="/logo.png" width="250" alt="logo" />
            </header>
            <div className="w-90 w-50-ns mw6">
              <Component {...pageProps} />
            </div>
          </div>
        </IdentityLoader>
      </UploadsListProvider>
    </UploaderProvider>
  </AuthProvider>
);

Now let's create a component that displays each of the images in our uploads list. Create the file ImageListItem.js in the /components folder and paste the following:


export default function ImageListItem({ cid, data }) {
  if (/bagb/.test(`${cid}`)) {
    return <li key={cid}>CAR cid: {cid}</li>;
  }
  const imgUrl = `https://w3s.link/ipfs/${cid}`;
  const imgSrc = data || imgUrl;
  return (
    <li key={cid} className="dib mh2">
      <a href={imgUrl}>
        <img width="200px" alt="camera output" src={imgSrc} />
      </a>
    </li>
  );
}

Import the ImageListItem component into index.js and use it as follows... We'll grab our data with useUploadsList, another custom hook from w3ui.

import { useUploadsList } from '@w3ui/react-uploads-list'
const camera = useRef(null);
const [, uploader] = useUploader();
const [status, setStatus] = useState("");
const [error, setError] = useState(null);
const [images, setImages] = useState([]);

const {
loading,
error: listError,
data: listData,
reload: listReload,
} = useUploadsList();
const printListData = (listData && listData.results) || []
return (
  <div>
    <p>
      <button style={{ cursor: "pointer" }} onClick={takePhoto}>
        Take photo
      </button>{" "}
      {printStatus}
    </p>
    <div className="relative">
      <div style={{ width: 300, height: 300 }}>
        <Camera ref={camera} />
      </div>
    </div>
    <ul className="list">
      {images.map(({ cid, data }) => (
        <ImageListItem key={cid} cid={cid} data={data} />
      ))}
      {printListData.map(({ dataCid: cid }) => (
        <ImageListItem key={cid} cid={cid} />
      ))}
    </ul>
  </div>
);

One last touch with Tachyons: in _app.js, add pt5 to the className of the first div and h-100 to the child div, adding top padding to the app layout and making the page full height.

  <IdentityLoader>
    <div className="vh-100 pt5 flex flex-column justify-center items-center sans-serif light-silver">
      <header>
        <img src="/logo.png" width="250" alt="logo" />
      </header>
      <div className="w-90 w-50-ns mw6 h-100">
        <Component {...pageProps} />
      </div>
    </div>
  </IdentityLoader>

That's it! We now have a fully-functioning and stylish, selfie app ๐Ÿ’…๐Ÿ“ธ! The final product should look like this:

Screenshot of app with working uploading

And here is the final state of index.js

import { useState, useRef } from "react";
import { withIdentity } from "../components/Authenticator";
import { Camera } from "react-camera-pro";
import { useUploader } from "@w3ui/react-uploader";
import { useUploadsList } from "@w3ui/react-uploads-list";
import ImageListItem from "../components/ImageListItem";

export function Home() {
  const camera = useRef(null);
  const [, uploader] = useUploader();
  const [status, setStatus] = useState("");
  const [error, setError] = useState(null);
  const [images, setImages] = useState([]);

  const {
    loading,
    error: listError,
    data: listData,
    reload: listReload,
  } = useUploadsList();
  const printListData = (listData && listData.results) || [];

  const printStatus = status === "done" && error ? error.message : status;

  const takePhoto = async (e) => {
    e.preventDefault();
    const imgData = camera.current.takePhoto();
    try {
      // Build a DAG from the file data to obtain the root CID.
      setStatus("encoding");
      // use fetch to transform data URL -> blob -> file
      const theFile = await fetch(imgData).then((it) => it.blob());
      setStatus("uploading");
      const cid = await uploader.uploadFile(theFile);
      setImages([{ cid: cid, data: imgData }, ...images]);
    } catch (err) {
      console.error(err);
      setError(err);
    } finally {
      setStatus("done");
    }
  };

  return (
    <div>
      <p>
        <button style={{ cursor: "pointer" }} onClick={takePhoto}>
          Take photo
        </button>{" "}
        {printStatus}
      </p>
      <div className="relative">
        <div style={{ width: 300, height: 300 }}>
          <Camera ref={camera} />
        </div>
      </div>
      <ul className="list">
        {images.map(({ cid, data }) => (
          <ImageListItem key={cid} cid={cid} data={data} />
        ))}
        {printListData.map(({ dataCid: cid }) => (
          <ImageListItem key={cid} cid={cid} />
        ))}
      </ul>
    </div>
  );
}

export default withIdentity(Home);

We hope you had fun. Stay tuned for more tutorials using w3ui to build awesome stuff! And once again don't hesitate to reach out with questions or thoughts! Happy coding.

Full demo code here