DEV Community

Cover image for Building a File Upload App with TypeScript, React, and Auto-Drive API
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Building a File Upload App with TypeScript, React, and Auto-Drive API

🐙 GitHub | 🎮 Demo

Introduction to the Auto-Drive API and RadzionKit Boilerplate

In this post, we’ll create a simple app for uploading files to a Distributed Storage Network (DSN) using Autonomy's Auto-Drive API. To get started quickly, we’ll fork the RadzionKit repository, which provides a boilerplate Next.js app along with a collection of utilities and components designed to streamline and simplify the development process. You can view the live demo here and access the GitHub repository here.

Manage Files on a Distributed Storage Network with Auto-Drive

Setting Up the App's Views: API Key Entry and File Management

Our app will consist of a single page with two views: one for entering the API key and another for managing files. To determine which view to display, we’ll use the AutoDriveApiKeyGuard component. This component checks whether the API key is set: if the key is available, it renders the child component; otherwise, it displays the SetAutoDriveApiKey component, allowing the user to input their API key.

import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useAutoDriveApiKey } from "../state/autoDriveApiKey"
import { SetAutoDriveApiKey } from "./SetAutoDriveApiKey"

export const AutoDriveApiKeyGuard = ({
  children,
}: ComponentWithChildrenProps) => {
  const [value] = useAutoDriveApiKey()

  if (!value) {
    return <SetAutoDriveApiKey />
  }

  return <>{children}</>
}
Enter fullscreen mode Exit fullscreen mode

Persisting the API Key with Local Storage

We’ll store the API key in local storage so that users won’t need to re-enter it the next time they visit the app. If you’re curious about the implementation of usePersistentState, check out this post.

import {
  PersistentStateKey,
  usePersistentState,
} from "../../state/persistentState"

export const useAutoDriveApiKey = () => {
  return usePersistentState<string | null>(
    PersistentStateKey.AutoDriveApiKey,
    null,
  )
}
Enter fullscreen mode Exit fullscreen mode

Designing the SetAutoDriveApiKey Component

In the SetAutoDriveApiKey component, we’ll display our app’s logo along with an input field where users can enter their Auto-Drive API key. Instead of adding a submit button, we’ll validate the API key dynamically as the user types. To avoid unnecessary API calls, we’ll use the InputDebounce component to debounce input changes, ensuring the validation process only triggers when the user stop typing.

Set Auto-Drive API Key

import { useEffect, useState } from "react"
import { useAutoDriveApiKey } from "../state/autoDriveApiKey"
import { Center } from "@lib/ui/layout/Center"
import { vStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"
import { TextInput } from "@lib/ui/inputs/TextInput"
import { useMutation } from "@tanstack/react-query"
import { createAutoDriveApi, Scope, apiCalls } from "@autonomys/auto-drive"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { getErrorMessage } from "@lib/utils/getErrorMessage"
import styled from "styled-components"
import { ProductLogo } from "../../product/ProductLogo"
import { isWrongApiKey } from "../utils/isWrongApiKey"

const Content = styled.div`
  ${vStack({
    gap: 20,
    alignItems: "center",
    fullWidth: true,
  })}

  max-width: 320px;
`

const Status = styled.div`
  min-height: 20px;
  ${vStack({
    alignItems: "center",
  })}
`

export const SetAutoDriveApiKey = () => {
  const [, setValue] = useAutoDriveApiKey()

  const { mutate, ...mutationState } = useMutation({
    mutationFn: async (apiKey: string) => {
      const api = createAutoDriveApi({ apiKey })
      await apiCalls.getRoots(api, {
        scope: Scope.Global,
        limit: 1,
        offset: 0,
      })

      return apiKey
    },
    onSuccess: setValue,
  })

  const [inputValue, setInputValue] = useState("")

  useEffect(() => {
    if (inputValue) {
      mutate(inputValue)
    }
  }, [inputValue, mutate])

  return (
    <Center>
      <Content>
        <ProductLogo />
        <InputDebounce
          value={inputValue}
          onChange={setInputValue}
          render={({ value, onChange }) => (
            <TextInput
              value={value}
              onValueChange={onChange}
              autoFocus
              placeholder="Enter your Auto-Drive API key to continue"
            />
          )}
        />
        <Status>
          <MatchQuery
            value={mutationState}
            error={(error) => (
              <Text color="alert">
                {isWrongApiKey(error)
                  ? "Wrong API Key"
                  : getErrorMessage(error)}
              </Text>
            )}
            pending={() => <Text>Loading...</Text>}
          />
        </Status>
      </Content>
    </Center>
  )
}
Enter fullscreen mode Exit fullscreen mode

Validating the API Key Dynamically

To validate the API key, we’ll make an arbitrary API call and assume the key is valid if no error is thrown. For managing and displaying the pending and error states, we’ll use the MatchQuery component from RadzionKit. This component simplifies rendering by displaying different content based on the state of a mutation or query.

Once the API key is stored in local storage, the AutoDriveApiKeyGuard component will render its child component, which in this case is the ManageStorage component.

import styled from "styled-components"
import { centeredContentColumn } from "@lib/ui/css/centeredContentColumn"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import { HStack, VStack } from "@lib/ui/css/stack"
import { ProductLogo } from "../../product/ProductLogo"
import { ExitStorage } from "./ExitStorage"
import { ManageFiles } from "../files/ManageFiles"
import { UploadFile } from "../upload/UploadFile"

export const Container = styled.div`
  ${centeredContentColumn({
    contentMaxWidth: 600,
  })}

  ${verticalPadding(80)}
`

export const ManageStorage = () => (
  <Container>
    <VStack gap={60}>
      <HStack fullWidth alignItems="center" justifyContent="space-between">
        <ProductLogo />
        <ExitStorage />
      </HStack>
      <UploadFile />
      <ManageFiles />
    </VStack>
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

Logging Out: Clearing the API Key

To "log out" and clear their API key, users can click the "Exit" button located in the top-right corner of the ManageStorage component.

import { HStack } from "@lib/ui/css/stack"
import { useAutoDriveApiKey } from "../state/autoDriveApiKey"
import { Button } from "@lib/ui/buttons/Button"
import { LogOutIcon } from "@lib/ui/icons/LogOutIcon"

export const ExitStorage = () => {
  const [, setValue] = useAutoDriveApiKey()

  return (
    <Button kind="secondary" onClick={() => setValue(null)}>
      <HStack alignItems="center" gap={8}>
        <LogOutIcon />
        Exit
      </HStack>
    </Button>
  )
}
Enter fullscreen mode Exit fullscreen mode

File Upload: Drag-and-Drop Functionality

To upload a file, users can either click on the dropzone or drag and drop the file into it. The UploadFile component handles the upload process, showing a spinner while the file is being uploaded. If the upload fails, an error message will be displayed to inform the user.

import { useDropzone } from "react-dropzone"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { centerContent } from "@lib/ui/css/centerContent"
import { Spinner } from "@lib/ui/loaders/Spinner"
import { Text } from "@lib/ui/text"
import { TakeWholeSpace } from "@lib/ui/css/takeWholeSpace"
import { interactive } from "@lib/ui/css/interactive"
import { transition } from "@lib/ui/css/transition"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { CloudUploadIcon } from "@lib/ui/icons/CloudUploadIcon"
import { useUploadFileMutation } from "./mutations/useUploadFileMutation"
import { UploadFileInputContent } from "./UploadFileInputContent"

const Container = styled.div`
  width: 100%;
  height: 280px;
`

const Input = styled(TakeWholeSpace)`
  ${centerContent};
  ${interactive};
  ${transition};
  font-weight: 500;
  &:hover {
    color: ${getColor("contrast")};
    background: ${getColor("mist")};
  }
  border: 2px dashed ${getColor("primary")};
  ${borderRadius.m};
`

const PendingContainer = styled(TakeWholeSpace)`
  ${centerContent};
  ${borderRadius.m};
  border: 2px dashed ${getColor("mistExtra")};
`

export const UploadFile = () => {
  const { mutate, status } = useUploadFileMutation()

  const { getRootProps, getInputProps } = useDropzone({
    maxFiles: 1,
    onDrop: (acceptedFiles) => {
      const [file] = acceptedFiles
      if (file) {
        mutate(file)
      }
    },
  })

  return (
    <Container>
      {status === "pending" ? (
        <PendingContainer>
          <UploadFileInputContent
            title="Please wait"
            subTitle="Uploading the file..."
            icon={<Spinner />}
          />
        </PendingContainer>
      ) : (
        <Input {...getRootProps()}>
          <input {...getInputProps()} />
          <UploadFileInputContent
            title="Upload to Blockchain"
            subTitle="Drop it here or click to select"
            icon={<CloudUploadIcon />}
          >
            {status === "error" && (
              <Text size={14} weight="500" color="alert">
                Failed to upload the file.
              </Text>
            )}
          </UploadFileInputContent>
        </Input>
      )}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Uploading Files with the Auto-Drive SDK

Uploading files is straightforward with the Auto-Drive SDK, which provides a simple uploadFileFromInput function. We simply retrieve the file from the input and call this function to handle the upload.

import { uploadFileFromInput } from "@autonomys/auto-drive"
import { useInvalidateQueries } from "@lib/ui/query/hooks/useInvalidateQueries"
import { useMutation } from "@tanstack/react-query"
import { filesQueryKey } from "../../files/queries/useFilesQuery"
import { useAutoDriveApi } from "../../state/autoDriveApi"

export const useUploadFileMutation = () => {
  const api = useAutoDriveApi()

  const invalidate = useInvalidateQueries()

  return useMutation({
    mutationFn: (file: File) => uploadFileFromInput(api, file).promise,
    onSuccess: () => {
      invalidate(filesQueryKey)
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Ensuring API Key Presence with the useAutoDriveApi Hook

Since we can be certain that the API key is set when the ManageStorage component is rendered, we can safely use the useAutoDriveApi hook. This hook asserts the presence of the API key and creates an instance of the Auto-Drive API for seamless interaction.

import { usePresentState } from "@lib/ui/state/usePresentState"
import { useAutoDriveApiKey } from "./autoDriveApiKey"
import { useMemo } from "react"
import { createAutoDriveApi } from "@autonomys/auto-drive"

export const useAutoDriveApi = () => {
  const [apiKey] = usePresentState(useAutoDriveApiKey())

  return useMemo(() => createAutoDriveApi({ apiKey }), [apiKey])
}
Enter fullscreen mode Exit fullscreen mode

Displaying Files with the ManageFiles Component

To display the files, we use the ManageFiles component. Since the API doesn’t return all files at once, we utilize the PaginatedView component to handle pagination. When the user scrolls to the bottom of the page, the onRequestToLoadMore function is triggered to fetch the next page of files.

import { Text } from "@lib/ui/text"
import { ManageFile } from "./ManageFile"
import { useFilesQuery } from "./queries/useFilesQuery"
import { CurrentFileProvider } from "./state/currentFile"
import { usePaginatedResultItems } from "@lib/ui/query/hooks/usePaginatedResultItems"
import { PaginatedView } from "@lib/ui/pagination/PaginatedView"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { VStack } from "@lib/ui/css/stack"

export const ManageFiles = () => {
  const { data, fetchNextPage, isFetchingNextPage, isLoading, hasNextPage } =
    useFilesQuery()

  const items = usePaginatedResultItems(data, (response) => response.rows)

  return (
    <VStack gap={16}>
      <PaginatedView
        onRequestToLoadMore={fetchNextPage}
        isLoading={isLoading || isFetchingNextPage}
        hasNextPage={hasNextPage}
      >
        {isEmpty(items) && !isLoading ? (
          <Text>You have no files 😴</Text>
        ) : (
          items.map((file) => (
            <CurrentFileProvider value={file} key={file.headCid}>
              <ManageFile />
            </CurrentFileProvider>
          ))
        )}
      </PaginatedView>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Handling Pagination with PaginatedView and useInfiniteQuery

To query the API with pagination, we use the useInfiniteQuery hook. We start with an offset of 0 and increment it by a limit of 20 for each subsequent page. Fetching stops when the offset exceeds the total count of files.

import { useInfiniteQuery } from "@tanstack/react-query"
import { apiCalls, ObjectSummary, Scope } from "@autonomys/auto-drive"
import { useAutoDriveApi } from "../../state/autoDriveApi"
import { PaginatedResult } from "@autonomys/auto-drive/dist/api/models/common"

export const filesQueryKey = ["files"]

const limit = 20

export const useFilesQuery = () => {
  const api = useAutoDriveApi()

  return useInfiniteQuery({
    queryKey: filesQueryKey,
    initialPageParam: 0,
    getNextPageParam: (
      { totalCount }: PaginatedResult<ObjectSummary>,
      allPages,
    ) => {
      const offset = allPages.length * limit
      return offset < totalCount ? offset : null
    },
    queryFn: async ({ pageParam }) =>
      apiCalls.getRoots(api, {
        scope: Scope.User,
        limit,
        offset: pageParam,
      }),
  })
}
Enter fullscreen mode Exit fullscreen mode

Avoiding Prop Drilling with the CurrentFileProvider

To avoid prop drilling within the ManageFile component, we use the CurrentFileProvider to supply the current file to its children. Since this is a common pattern, we leverage a helper function, getValueProviderSetup, to streamline the creation of the necessary hooks and provider.

import { ObjectSummary } from "@autonomys/auto-drive"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"

export const { useValue: useCurrentFile, provider: CurrentFileProvider } =
  getValueProviderSetup<ObjectSummary>("CurrentFile")
Enter fullscreen mode Exit fullscreen mode

The ManageFile component displays the file name or CID and includes buttons for downloading and deleting the file.

import styled from "styled-components"
import { Text } from "@lib/ui/text"
import { FileIcon } from "@lib/ui/icons/FileIcon"
import { getColor } from "@lib/ui/theme/getters"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { HStack, hStack } from "@lib/ui/css/stack"
import { DeleteFile } from "./DeleteFile"
import { DownloadFile } from "./DownloadFile"
import { useCurrentFile } from "./state/currentFile"

const Indicator = styled(FileIcon)`
  color: ${getColor("textSupporting")};
  font-size: 20px;
`

const Container = styled.div`
  height: 56px;
  background: ${getColor("foreground")};
  ${borderRadius.m};
  ${horizontalPadding(16)};
  padding-right: 8px;
  ${hStack({ alignItems: "center", justifyContent: "space-between", gap: 20 })};
`

export const ManageFile = () => {
  const { name, headCid } = useCurrentFile()

  return (
    <Container>
      <Text style={{ flexWrap: "nowrap" }} centerVertically={{ gap: 8 }}>
        <Indicator />
        <Text as="span" cropped>
          {name ?? headCid}
        </Text>
      </Text>
      <HStack alignItems="center" gap={4}>
        <DownloadFile />
        <DeleteFile />
      </HStack>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Downloading Files with the Auto-Drive SDK

To DownloadFile, we pass the API instance and CID to the Auto-SDK’s downloadFile function. This function returns a stream, which we convert to a buffer before using the initiateFileDownload utility from RadzionKit to complete the file download process.

import { downloadFile } from "@autonomys/auto-drive"
import { IconButton } from "@lib/ui/buttons/IconButton"
import { DownloadIcon } from "@lib/ui/icons/DownloadIcon"
import { useMutation } from "@tanstack/react-query"
import { useAutoDriveApi } from "../state/autoDriveApi"
import { useCurrentFile } from "./state/currentFile"
import { initiateFileDownload } from "@lib/ui/utils/initiateFileDownload"

export const DownloadFile = () => {
  const { name, headCid, type } = useCurrentFile()
  const api = useAutoDriveApi()

  const { mutate, isPending } = useMutation({
    mutationFn: async () => {
      const stream = await downloadFile(api, headCid)

      let file = Buffer.alloc(0)
      for await (const chunk of stream) {
        file = Buffer.concat([file, chunk])
      }

      initiateFileDownload({ type, value: file, name: name ?? headCid })
    },
  })

  return (
    <IconButton
      kind="secondary"
      size="l"
      icon={<DownloadIcon />}
      title="Download file"
      onClick={() => mutate()}
      isPending={isPending}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Deleting Files and Refreshing the File List

To delete a file, we call the Auto-Drive SDK’s markObjectAsDeleted function with the file’s CID. Additionally, we invalidate the filesQueryKey to refresh the file list after deletion, just as we did following a file upload.

import { IconButton } from "@lib/ui/buttons/IconButton"
import { TrashBinIcon } from "@lib/ui/icons/TrashBinIcon"
import { useMutation } from "@tanstack/react-query"
import { useAutoDriveApi } from "../state/autoDriveApi"
import { apiCalls } from "@autonomys/auto-drive"
import { useInvalidateQueries } from "@lib/ui/query/hooks/useInvalidateQueries"
import { filesQueryKey } from "./queries/useFilesQuery"
import { useCurrentFile } from "./state/currentFile"

export const DeleteFile = () => {
  const { headCid } = useCurrentFile()

  const api = useAutoDriveApi()

  const invalidate = useInvalidateQueries()

  const { mutate, isPending } = useMutation({
    mutationFn: () => apiCalls.markObjectAsDeleted(api, { cid: headCid }),
    onSuccess: () => {
      invalidate(filesQueryKey)
    },
  })

  return (
    <IconButton
      kind="alertSecondary"
      size="l"
      icon={<TrashBinIcon />}
      title="Delete file"
      onClick={() => mutate()}
      isPending={isPending}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Conclusion: Simplifying Decentralized Storage Integration

By combining the power of the Auto-Drive SDK with a streamlined React setup, we’ve created a simple yet effective app for managing files on a Distributed Storage Network. From uploading and downloading files to handling pagination and deletions, this project demonstrates how easily developers can integrate decentralized storage into their applications. Happy coding!

Top comments (0)