DEV Community

Cover image for MemoryLane: Building A Dynamic Time Capsule App with Pinata FILE API - Project
DrPrime01
DrPrime01

Posted on

MemoryLane: Building A Dynamic Time Capsule App with Pinata FILE API - Project

MemoryLane is a digital time capsule application that allows users to preserve moments and thoughts, store them as files, and rediscover them in the future. It combines the nostalgic feeling of the traditional time capsule with the convenience, accessibility, and security of modern cloud storage technology. MemoryLane features digital preservation, future revelations, flexible time-frames, multi-media experiences, personal and collaborative options, security and privacy, and cloud-based reliability. With these features, MemoryLane taps into the users’ emotions and needs by creating shared connections, self-reflection, and a digital legacy, all of which will be achieved with Pinata.
Pinata is a cloud-based file upload service often called the “Internet’s File API”. It’s a decentralized cloud storage solution commonly used in Web3 applications but not limited to dApps. It can also be used in Web2 applications. Pinata offers a seamless experience for developers to upload and manage files through its API, including private file storage with CDN support and customisable access controls, like pre-signed URLs. It also features image optimizations, plugin support, a TypeScript SDK for quick integration, an InterPlanetary File System, a peer-to-peer network for storing and sharing public files, mostly used for Web3 applications, and a Files API, which focuses on providing secure and private file management.
In this article, we will explore the several features of Pinata by building MemoryLane with NextJS.

Technical Implementations

Project Setup

  1. Create a new Next app and select your preferred options. For this article, we will use the TypeScript variant of NextJs.
    npx create-next-app@latest memorylane
    cd memorylane
Enter fullscreen mode Exit fullscreen mode
  1. Install Pinata and the other app’s dependencies. axios will be used on the client side to call the Pinata API setup on our NextJS application. react-dropzone will handle the file upload form input and date-fns will help us format the JavaScript Date object to our desired format.
    npm i pinata axios react-dropzone date-fns
Enter fullscreen mode Exit fullscreen mode
  1. Create a .env.local file to store Pinata environmental variables. Using .env.local rather than .env, we will protect our secret environment variables from exposure when we make commits and push them to GitHub. GitHub automatically ignores the .env.local file because it only exists in our local development environment.
  2. Proceed to Pinata.cloud, login (or sign up if you are new), proceed to the developer’s dashboard, and in the API Keys tab, generate a new key for the MemoryLane application. Name the key memorylane and set its scope to admin. The admin scope allows us to access all the listed endpoints and account settings.

Pinata dashboard - Create API Key

  1. After creating a key, Pinata provides us with an API KEY, API SECRET, and JWT. We will store the JWT in the .env.local file we created earlier.
  2. Navigate to the Gateways tab and copy the domain. Store the domain in the .env.local file as NEXT_PUBLIC_PINATA_GATEWAY_URL. With that done, we should have 2 environment variables in our .env.local file.

With the sixth step, we are done with the project setup and can now begin to build the MemoryLane application.

Building MemoryLane

Back to our code editor, there are only 7 files that are important to us and we’ve created and set up one of them in the Project Setup section, env.local. In this section, we will set up the remaining 6 files and integrate Pinata into the MemoryLane application’s logic.

utils/config.ts

This is the file through which our application will interact with Pinata Files API. Here, we will import PinataSDK from Pinata and create an instance of the class to be exported. The PinataSDK class receives an object of argument, pinataJwt and pinataGateway. Since we already have both arguments in our env variable, we can retrieve them with process.env.VARIABLE_NAME.

    "server only"

    import { PinataSDK } from "pinata"

    export const pinata = new PinataSDK({
      pinataJwt: `${process.env.PINATA_JWT}`,
      pinataGateway: `${process.env.NEXT_PUBLIC_PINATA_GATEWAY_URL}`
    })

    // we declare "server only" at the top of the file to make this class instance only accessible and executable on the server side of our Next app.
Enter fullscreen mode Exit fullscreen mode

components/FileUpload.tsx

This is where we will define react-dropzone, which we installed earlier. React-dropzone is a React library that handles and simplifies file uploads in a React application. It allows us to upload any file type by opening our computer’s library to select one or through drag-and-drop and getting useful information about the file for the component’s UI. You can configure it to upload one or multiple files and restrict the file type to a specific one.

    import { convertFileToUrl } from "@/utils";
    import Image from "next/image";
    import { Dispatch, SetStateAction } from "react";
    import Dropzone, {
      DropzoneInputProps,
      DropzoneRootProps,
    } from "react-dropzone";
    interface DropzonePropTypes {
      getRootProps: (props?: DropzoneRootProps) => DropzoneRootProps;
      getInputProps: (props?: DropzoneInputProps) => DropzoneInputProps;
      isDragActive: boolean;
    }
    export default function FileUpload({
      handleFile,
      file,
    }: {
      handleFile: Dispatch<SetStateAction<File | undefined>>;
      file: File | undefined;
    }) {
      return (
        <Dropzone
          onDrop={(acceptedFiles: File[]) => {
            handleFile(acceptedFiles[0]);
          }}
          accept={{ "image/*": [".png", ".jpg", ".jpeg"] }}
          multiple={false}
        >
          {({ getRootProps, getInputProps, isDragActive }: DropzonePropTypes) => (
            <section className="flex flex-col gap-y-2">
              <label className="text-sm text-gray-500" htmlFor="file-input">
                Choose File
              </label>
              <div
                {...getRootProps()}
                className="border border-dotted bg-gray-100 h-[200px] flex flex-col items-center justify-center rounded-md"
              >
                <input {...getInputProps()} className="sr-only" id="file-input" />
                {!file ? (
                  <>
                    {isDragActive ? (
                      <p>Drop the files here ...</p>
                    ) : (
                      <p className="font-medium cursor-pointer">
                        Drop your file here or{" "}
                        <span className="underline">browse</span>
                      </p>
                    )}
                    <p className="text-gray-500 text-xs">JPG, PNG, PDF - 5MB max</p>
                  </>
                ) : (
                  <Image
                    src={convertFileToUrl(file)}
                    alt="image"
                    height={200}
                    width={320}
                    className="object-cover h-[200px] w-full rounded-md"
                  />
                )}
              </div>
            </section>
          )}
        </Dropzone>
      );
    }

    // The FileUpload form is restricted to only images files
Enter fullscreen mode Exit fullscreen mode

components/TimeCapsuleForm.tsx

This is the form where your input will be received. It uses the useState hook to manage file and openDate, and the isLoading and error states for the form validation. The form sends a POST request to /api/files where Pinata will process the file and we get the file URL in return. This URL and the openDate will be stored in timeCapsules, another state for managing all the created time capsules.

    "use client";
    import { Dispatch, SetStateAction, useState } from "react";
    import { format, parseISO } from "date-fns";
    import axios from "axios";
    import FileUpload from "./FileUpload";
    import { TimeCapsuleStateType } from "@/types";
    export default function TimeCapsuleForm({
      setTimeCapsules,
      timeCapsules,
    }: {
      setTimeCapsules: Dispatch<SetStateAction<TimeCapsuleStateType[]>>;
      timeCapsules: TimeCapsuleStateType[];
    }) {
      const [file, setFile] = useState<File | undefined>();
      const [openDate, setOpenDate] = useState<Date | undefined>();
      const [isLoading, setIsLoading] = useState(false);
      const [error, setError] = useState("");
      const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setError("");
        if (!file) {
          setError("Please select a file.");
          return;
        }
        if (!openDate) {
          setError("Please choose an open date.");
          return;
        }
        setIsLoading(true);
        const formData = new FormData();
        formData.append("file", file);
        try {
          const res = await axios.post(`/api/files`, formData);
          const url = res?.data?.url;
          setTimeCapsules([
            ...timeCapsules,
            { url, openDate, created_at: new Date() },
          ]);
          setFile(undefined);
          setOpenDate(undefined);
        } catch (error: unknown) {
          console.error(error);
          setError(
            "An error occurred while creating the time capsule. Please try again."
          );
        } finally {
          setIsLoading(false);
        }
      };
      const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const selectedDate = parseISO(e.target.value);
        setOpenDate(selectedDate);
      };
      const isFormValid = file && openDate;
      return (
        <form className="flex flex-col gap-y-5 max-w-[480px]" onSubmit={onSubmit}>
          <FileUpload handleFile={setFile} file={file} />
          <div className="flex flex-col gap-y-2">
            <label className="text-sm text-gray-500" htmlFor="open-date">
              Open date
            </label>
            <input
              type="date"
              id="open-date"
              placeholder="Select open date"
              onChange={handleDateChange}
              value={openDate ? format(openDate, "yyyy-MM-dd") : ""}
              min={format(new Date(), "yyyy-MM-dd")}
              className="border py-1.5 px-3 rounded-md"
            />
          </div>
          {error && <p className="text-red-600 text-sm">{error}</p>}
          <button
            type="submit"
            disabled={isLoading || !isFormValid}
            className="bg-black text-white py-2 px-4 rounded-md disabled:opacity-50"
          >
            {isLoading ? "Submitting..." : "Submit"}
          </button>
        </form>
      );
    }
Enter fullscreen mode Exit fullscreen mode

api/files/route.ts

In this file, we create a POST request handler to receive a file from the frontend form, use Pinata SDK to upload the file to the cloud and get a signed URL to return as a response.

    import { NextResponse, NextRequest } from "next/server";
    import { pinata } from "@/utils/config";
    export async function POST(req: NextRequest) {
      try {
        const data = await req.formData();
        const file: File | null = data.get("file") as unknown as File;
        const uploadData = await pinata.upload.file(file);
        const url = await pinata.gateways.createSignedURL({
          cid: uploadData.cid,
          expires: 360000,
        });
        return NextResponse.json({ url }, { status: 200 });
      } catch (e) {
        console.error(e);
        return NextResponse.json(
          { error: "Internal Server Error" },
          { status: 500 }
        );
      }
    }

    // NB: It is important to set the expires property in the createSignedURL method object. Without it, you won't get a URL. Also, the expires property is set in seconds.
Enter fullscreen mode Exit fullscreen mode

components/TimeCapsule.tsx

The TimeCapsule component displays each time capsule created. It receives created_at, openDate, and URL as props. created_at as the name implies is the date the time capsule was created. It helps remind the users about the day they saved that moment for the future, giving a nostalgic feeling. openDate compares with the current date to know when to reveal the time capsule. The URL is used for the img tag src attribute but the image is not revealed until the openDate or is greater than JavaScript new Date() object. The img tag is covered with a black overlay div with an 85% opacity and a lock icon in the middle to show it is locked.

    import Image from "next/image";
    import { format } from "date-fns";
    import InfoIcon from "@/assets/icons/info-icon";
    import LockIcon from "@/assets/icons/lock-icon";
    import { TimeCapsuleStateType } from "@/types";
    export default function TimeCapsule({
      url,
      openDate,
      created_at,
    }: TimeCapsuleStateType) {
      return (
        <div className="flex flex-col gap-y-2 max-w-[240px]">
          <div className="relative">
            <Image
              src={url}
              width={240}
              height={240}
              alt="time capsule"
              className="object-cover aspect-square"
            />
            {new Date() < new Date(openDate) && (
              <div className="absolute bg-black/85 inset-0 flex items-center justify-center">
                <LockIcon />
              </div>
            )}
          </div>
          <p className="text-sm font-medium text-gray-700">
            To be opened on {format(openDate, "dd/MM/yyyy")}
          </p>
          <div className="bg-gray-200 flex space-x-1.5 p-2 rounded-xl">
            <InfoIcon />
            <p className="text-xs text-gray-500">
              This time capsule was created on {format(created_at, "dd/MM/yyyy")}
            </p>
          </div>
        </div>
      );
    }
Enter fullscreen mode Exit fullscreen mode

page.tsx

Lastly, this is where the TimeCapsuleForm and the time capsules are all displayed. It stores the time capsules in a state updated by the browser’s localStorage API. Whenever a time capsule is created, the useEffect hook is notified and it runs a function to serialize and store the newly created time capsule in localStorage. Since, we are not using any external storage or database to store our time capsules, the localStorage API is our best bet. It helps persist our time capsules and retains them even after a page refresh or we close the browser’s session.

    "use client";
    import { useState, useEffect } from "react";
    import TimeCapsule from "@/components/TimeCapsule";
    import TimeCapsuleForm from "@/components/TimeCapsuleForm";
    import { TimeCapsuleStateType } from "@/types";
    import { deserializeTimeCapsule, serializeTimeCapsule } from "@/utils";
    export default function Home() {
      const [timeCapsules, setTimeCapsules] = useState<TimeCapsuleStateType[]>(
        () => {
          if (typeof window !== "undefined") {
            const storedCapsules = localStorage.getItem("timeCapsules");
            if (storedCapsules) {
              const parsedCapsules = JSON.parse(storedCapsules);
              return parsedCapsules.map(deserializeTimeCapsule);
            }
          }
          return [];
        }
      );
      useEffect(() => {
        const serializedCapsules = timeCapsules.map(serializeTimeCapsule);
        localStorage.setItem("timeCapsules", JSON.stringify(serializedCapsules));
      }, [timeCapsules]);
      return (
        <main className="container mx-auto my-10 flex flex-col gap-y-10">
          <div>
            <h2 className="text-2xl md:text-3xl font-semibold mb-5">
              Create a Time Capsule
            </h2>
            <TimeCapsuleForm
              setTimeCapsules={setTimeCapsules}
              timeCapsules={timeCapsules}
            />
          </div>
          <div>
            <h2 className="text-2xl md:text-3xl font-semibold mb-5">
              View Your Time Capsules
            </h2>
            <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
              {timeCapsules &&
                timeCapsules?.map((capsule) => (
                  <TimeCapsule
                    key={capsule.url}
                    url={capsule.url}
                    openDate={capsule.openDate}
                    created_at={capsule.created_at}
                  />
                ))}
            </div>
          </div>
        </main>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Now that the primary files are properly set up, we must update our next.config.mjs file to configure image options to allow the PINATA_GATEWAY_URL domain. This ensures the NextJS Image component properly configures and displays the image from the signed URL.

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      images: {
        remotePatterns: [
          {
            protocol: "https",
            hostname: "your-gateway-url.mypinata.cloud",
          },
        ],
      },
    };
    export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Deployment: Deploying MemoryLane to Vercel

Now that all is said and done, we need to deploy MemoryLane to Vercel so others can use it to create time capsules. Vercel is a cloud-hosting platform for hosting web applications. It provides the tools to build, scale, and secure a faster application.
Log in to your Vercel account or create one if you do not have one, link it with your GitHub account, proceed to your dashboard, and add a new project.

Adding a new project to Vercel

Import MemoryLane from GitHub and enter your environment variables in the Environment Variables section. Ensure you enter them as they are in your .env.local file and hit deploy. Vercel handles the build of your application and provides you with a live link to view your application.

Configuring your project - enter environment variables

Conclusion

In this article, we have explored Pinata, a cloud-based file upload service, and used it to build MemoryLane, a digital time capsule application. Pinata’s use is not limited to Web2 applications as even Web3 developers enjoy its benefits in building dApps, making use of its IPFS SDK. Pinata is applicable on the server side as indicated in our application, and also on the client side, especially when you want to upload a large file.
To learn more about Pinata, visit the documentation.

Cover Image by ChatGPT.

Top comments (0)