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.
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}</>
}
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,
)
}
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.
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>
)
}
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>
)
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>
)
}
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>
)
}
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)
},
})
}
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])
}
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>
)
}
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,
}),
})
}
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")
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>
)
}
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}
/>
)
}
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}
/>
)
}
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!