[PART 22] Create a Twitter clone with GraphQL, Typescript, and React ( media upload)

[PART 22] Create a Twitter clone with GraphQL, Typescript, and React ( media upload)

Hi everyone ;).

As a reminder, I'm doing this Tweeter challenge

Github repository ( Backend )

Github repository ( Frontend )

Db diagram

Contrary to what I usually do, I started with the Frontend. This is simply because I wanted to test some image-editing libraries ;). Before explaining what I did, here's what it looks like:

Media upload

For the workflow, it goes like this:

  • The user chooses an image

  • He can edit it, or upload it

  • Once edited or if the image is ok as it is, the image is uploaded to Cloudinary

  • A progress bar is displayed and the user cannot send the tweet until the image is uploaded.

  • The user can cancel the upload if he wants to.

  • Once the upload is finished, I get the Cloudinary URL and add it to the payload.

On the code side, I start with the component TweetForm.tsx.

src/components/tweets/TweetForm.tsx

<label className="btn btn-primary" htmlFor="file">
    <MdImage
        className={`text-xl text-primary mr-1 ${
        uploadMedia
            ? 'cursor-default text-gray5'
        : 'cursor-pointer hover:text-primary_hover'
                  }`}
        />
    <input
        className="hidden"
        type="file"
        id="file"
        onChange={onMediaChange}
        />
</label>

Here is the button that will allow me to select a file.

For the onMediaChange function:

const onMediaChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    e.preventDefault()
    setMediaError(null)
    if (e.target.files && e.target.files.length > 0) {
      const file = e.target.files[0]
      try {
        console.log('file', file)
        validateFiles(file, 5)
        setUploadMedia(file)
      } catch (e) {
        setMediaError(e.message)
        console.log('error with media file', e.message)
      }
    }
  }

The setUploadMedia function allows adding the file to my global store ( recoil ). I can then listen to when I have a file in the UploadMedia component.

src/components/media/UploadMedia.tsx

import 'cropperjs/dist/cropper.css'
import { CSSProperties, useEffect, useState } from 'react'
import { Cropper } from 'react-cropper'
import { MdCancel, MdCloudUpload, MdEdit } from 'react-icons/md'
import { useRecoilState, useSetRecoilState } from 'recoil'
import { finished } from 'stream'
import { useUploadFile } from '../../hooks/useUploadMedia'
import {
  uploadMediaFinishedState,
  uploadMediaProgressState,
  uploadMediaState,
  uploadMediaUrlState,
} from '../../state/mediaState'
import Button from '../Button'
import UploadMediaButton from './UploadMediaButton'
import UploadMediaProgress from './UploadMediaProgress'

const imageStyle: CSSProperties = {
  maxHeight: '300px',
  width: '100%',
  objectFit: 'cover',
}

const UploadMedia = () => {
  // Global State
  const [uploadMediaFile, setUploadMediaFile] = useRecoilState(uploadMediaState)
  const setUploadMediaProgress = useSetRecoilState(uploadMediaProgressState)
  const setUploadMediaURL = useSetRecoilState(uploadMediaUrlState)
  const [uploadFinished, setUploadFinished] = useRecoilState(
    uploadMediaFinishedState
  )

  const [src, setSrc] = useState('')
  const [show, setShow] = useState(false)
  const [cropper, setCropper] = useState<any>()
  const [cropData, setCropData] = useState('')

  const { uploadFile, data, uploading, errors, source } = useUploadFile({
    folder: 'tweeter/medias',
    onUploadProgress: (e, f) => {
      // 95 instead of 100 because there is a slight delay
      // to go to onUploadProgress to onUploadFinished
      // It's more a UX thing...
      setUploadMediaProgress(Math.floor((e.loaded / e.total) * 95))
    },
    onUploadFinished: (e, f) => {
      setUploadMediaProgress(100)
      setUploadFinished(true)
    },
  })

  // Extract the url to have a base64 image to preview
  const extractUrl = (file: any) =>
    new Promise((resolve) => {
      let src
      const reader = new FileReader()
      reader.onload = (e: any) => {
        src = e.target.result
        resolve(src)
      }
      reader.readAsDataURL(file)
    })

  // get the result from the crop
  const getCropData = () => {
    if (typeof cropper !== 'undefined') {
      setCropData(cropper.getCroppedCanvas().toDataURL())
    }
  }

  useEffect(() => {
    if (data) {
      const finalURL = `https://res.cloudinary.com/trucmachin/image/upload/w_800/v1607022210/${data.public_id}.${data.format}`
      setUploadMediaURL(finalURL)
    }
  }, [data])

  // I extract the preview image when a file is selected
  // The uploadeMediaFile is triggered by the the TweetForm input file component
  useEffect(() => {
    const extractPreview = async () => {
      const src: any = await extractUrl(uploadMediaFile)
      setSrc(src)
    }
    if (uploadMediaFile) {
      extractPreview()
    } else {
      setSrc('')
      setCropData('')
      setShow(false)
    }
  }, [uploadMediaFile])

  const cancel = () => {
    setCropData('')
    setSrc('')
    setUploadMediaFile(null)
    setUploadMediaProgress(0)
    setUploadFinished(false)
    if (!finished) {
      source?.cancel('Upload canceled')
    }
  }

  return (
    <div className="my-2">
      {src.length ? (
        <div>
          {!show ? (
            <div className="flex">
              <div className="relative w-full h-auto mx-2">
                <img
                  style={imageStyle}
                  className="rounded-lg"
                  src={cropData ? cropData : src}
                  onClick={() => setShow(true)}
                />
                <UploadMediaProgress />
                {/* Cancel Button */}
                <div className="absolute top-4 left-4">
                  <UploadMediaButton
                    icon={<MdCancel className="media-action" />}
                    onClick={cancel}
                  />
                </div>

                {/* Edit and Upload Button */}
                {!uploadFinished && !uploading && (
                  <div className="absolute top-4 right-4 flex flex-col">
                    <UploadMediaButton
                      icon={<MdEdit className="media-action" />}
                      onClick={() => {
                        setShow(true)
                        setUploadMediaProgress(0)
                      }}
                    />
                    <UploadMediaButton
                      className="mt-2"
                      icon={<MdCloudUpload className="media-action" />}
                      onClick={() => {
                        uploadFile(cropData.length ? cropData : src)
                      }}
                    />
                  </div>
                )}
              </div>
            </div>
          ) : (
            <Cropper
              style={imageStyle}
              className="rounded-lg"
              initialAspectRatio={1}
              src={src}
              zoomable={false}
              viewMode={1}
              guides={true}
              minCropBoxHeight={10}
              minCropBoxWidth={10}
              background={false}
              responsive={true}
              autoCropArea={1}
              checkOrientation={false}
              onInitialized={(instance) => {
                setCropper(instance)
              }}
            />
          )}
          {show && (
            <div className="flex items-center">
              <Button
                variant="primary"
                className="mt-2 mr-2"
                text="Apply"
                onClick={() => {
                  getCropData()
                  setShow(false)
                }}
              />
              <Button
                variant="default"
                className="mt-2"
                text="Cancel"
                onClick={() => {
                  setShow(false)
                  setCropData('')
                }}
              />
            </div>
          )}
        </div>
      ) : null}
    </div>
  )
}

export default UploadMedia

Let's try to explain what I do ;). First of all, as I said, I use useEffect to check if I have a file that has been chosen by the user. If so, I extract the preview from the image and display it. For the edit mode, I use the cropper.js library in its React version. I use the getCropData function to retrieve the modified image. And if I have one I display it instead of my original image.

To upload the image, I use a custom hook that I used for the Trello clone. It's not really generic, and it's possible that I'll have some difficulties when I'll have to deal with the avatar and the cover but I'll see that later since I haven't thought about the implementation yet.

src/hooks/useUploadMedia

import axios, {
  AxiosResponse,
  CancelTokenSource,
  CancelTokenStatic,
} from 'axios'
import { useState } from 'react'

interface useUploadFileProps {
  folder: string
  onUploadProgress: (e: ProgressEvent<EventTarget>, f: File) => void
  onUploadFinished: (e: ProgressEvent<EventTarget>, f: File) => void
  multiple?: boolean
  maxFiles?: number
  maxSize?: number
  fileFormat?: string[]
}

export const useUploadFile = ({
  folder,
  onUploadProgress,
  onUploadFinished,
  multiple = false,
  maxFiles = 1,
  maxSize = 5,
  fileFormat = ['image/jpeg', 'image/jpg', 'image/png'],
}: useUploadFileProps) => {
  const [data, setData] = useState<any>(null)
  const [errors, setErrors] = useState<any[]>([])
  const [uploading, setUploading] = useState<boolean>(false)
  const [source, setSource] = useState<CancelTokenSource | null>(null)

  const createFormData = (file: any) => {
    const formData = new FormData()
    formData.append('file', file)
    formData.append(
      'upload_preset',
      process.env.REACT_APP_CLOUDINARY_UNSIGNED_PRESET!
    )
    formData.append('folder', folder)
    formData.append('multiple', multiple ? 'true' : 'false')
    return formData
  }

  const uploadFile = async (file: any) => {
    setErrors([])
    setUploading(true)

    if (file) {
      try {
        const formData = createFormData(file)
        const cancelToken = axios.CancelToken
        const source = cancelToken?.source()
        setSource(source)
        const res = await axios.post(
          process.env.REACT_APP_CLOUDINARY_URL!,
          formData,
          {
            headers: {
              'Content-Type': 'multipart/form-data',
            },
            cancelToken: source.token,
            onUploadProgress: (e: ProgressEvent<EventTarget>) => {
              try {
                onUploadProgress(e, file)
              } catch (e) {
                console.log('error onUploadProgress', e)
                setErrors((old) => old.concat(e.message))
              }
            },
            onDownloadProgress: (e: ProgressEvent<EventTarget>) => {
              try {
                onUploadFinished(e, file)
                setUploading(false)
              } catch (e) {
                console.log('error onDownloadProgress', e.message)
                setErrors((old) => old.concat(e.message))
              }
            },
          }
        )

        setData(res.data)
      } catch (e) {
        if (axios.isCancel(e)) {
          console.log('Request canceled', e.message)
        }
        console.log('Error from the hook', e)
        setErrors((errors) => errors.concat(e))
        setUploading(false)
      }
    }
  }

  return { uploadFile, data, errors, uploading, source }
}

Here the most interesting are the functions that allow me to listen to the upload's progress and also when the upload ends. I also use a CancelToken provided by Axios and export the source. This allows me to cancel the upload by doing source.cancel(). For the formData, it's specific to Cloudinary so I'll let you see the documentation if you don't understand something ;).

As for the progress bar, nothing special:

src/components/media/UploadMediaProgress

import React from 'react'
import { useRecoilValue } from 'recoil'
import {
  uploadMediaFinishedState,
  uploadMediaProgressState,
} from '../../state/mediaState'

const UploadMediaProgress = () => {
  const progress = useRecoilValue(uploadMediaProgressState)
  const finished = useRecoilValue(uploadMediaFinishedState)
  return progress > 0 ? (
    <div className="absolute inset-0">
      <div className="flex items-center justify-center h-full">
        {!finished ? (
          <div
            style={{ width: '200px' }}
            className="relative bg-black opacity-75 h-5 flex items-center text-sm rounded"
          >
            <div className="absolute inset-0 flex items-center justify-center text-sm text-white font-bold">
              {progress} %
            </div>
            <div
              style={{ width: `${progress}%` }}
              className="h-full bg-primary rounded"
            ></div>
          </div>
        ) : (
          <div className="text-white bg-black opacity-70 px-3 py-1 rounded-lg text-sm">
            Upload finished!
          </div>
        )}
      </div>
    </div>
  ) : null
}

export default React.memo(UploadMediaProgress)

I use "recoil" to retrieve the upload progress and I use it to change the width and display the percentage as well.

I just have to add the received URL and add it to my payload:

src/components/tweets/TweetForm.tsx

const payload: any = {
        body: newBody ?? body,
        hashtags,
        url: shortenedURLS ? shortenedURLS[0].shorten : null,
        ...(type && { type }),
        ...(tweet_id && { parent_id: tweet_id }),
        ...(uploadMediaUrl && { media: uploadMediaUrl }),
      }

By the way, I discovered that you can add conditional properties with this syntax ;).

I think I can move on to the Backend ;).

Backend

I start by creating a medias table.

src/db/migrations/create_medias_table

import * as Knex from 'knex'

export async function up(knex: Knex): Promise<void> {
  return knex.schema.createTable('medias', (t) => {
    t.bigIncrements('id')
    t.string('url').notNullable()
    t.integer('tweet_id').unsigned().notNullable().unique()
    t.integer('user_id').unsigned().notNullable()
    t.timestamps(false, true)

    t.foreign('tweet_id').references('id').inTable('tweets').onDelete('CASCADE')
    t.foreign('user_id').references('id').inTable('users').onDelete('CASCADE')
  })
}

export async function down(knex: Knex): Promise<void> {
  return knex.raw('DROP TABLE medias CASCADE')
}

tweet_id is unique because I decided that only one image can be uploaded per tweet.

src/entities/Media.ts

import { Field, ObjectType } from 'type-graphql'

@ObjectType()
class Media {
  @Field()
  id: number

  @Field()
  url: string

  user_id: number

  tweet_id: number
}

export default Media

src/dto/AddTweetPayload.ts

@Field({ nullable: true })
@IsUrl()
media?: string

src/resolvers/TweetResolver.ts

try {
      let tweet: any
      let newMedia: any
      await db.transaction(async (trx) => {
        ;[tweet] = await db('tweets')
          .insert({
            body,
            type,
            parent_id,
            user_id: userId,
          })
          .returning('*')
          .transacting(trx)

        if (media) {
          ;[newMedia] = await db('medias')
            .insert({
              url: media,
              user_id: userId,
              tweet_id: tweet.id,
            })
            .returning(['id', 'url'])
            .transacting(trx)
        }
      })
 ...catch(e)

When sending an image, I consider it to be as important as the text. That's why in this case I use a database transaction. If the addition of the image goes wrong, the tweet will not be inserted. I didn't do it for hashtags because I thought it was less important.

I also add the inserted media when I return the tweet.

As for the feed, I add another dataloader:

src/dataloaders.ts

mediaDataloader: new DataLoader<number, Media, unknown>(async (ids) => {
    const medias = await db('medias').whereIn('tweet_id', ids)

    return ids.map((id) => medias.find((m) => m.tweet_id === id))
  }),

src/resolvers/TweetResolver.ts

@FieldResolver(() => Media)
  async media(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      dataloaders: { mediaDataloader },
    } = ctx

    return await mediaDataloader.load(tweet.id)
  }

I also added some tests to check that everything was working properly and I have a problem actually. When I run the test to add media on its own, it works correctly. But when I run it with the test suite, the test doesn't pass (you can find the commented code in the tweets.test.ts file). I don't know where this comes from yet.

I forgot something in the Frontend ;)

I was going to stop here, but maybe it would be a good idea to put our image on our feed so we didn't work for anything :D.

src/components/tweets.ts/Tweet.tsx

{/* Media? */}
{tweet.media && <MyImage src={tweet.media.url} />}

And for the MyImage component, I used the "react-lazy-load-image-component" library.

src/components/MyImage.tsx

import { LazyLoadImage } from 'react-lazy-load-image-component'
import 'react-lazy-load-image-component/src/effects/blur.css'

type MyImageProps = {
  src: string
  alt?: string
}

const MyImage = ({ src, alt }: MyImageProps) => {
  return (
    <LazyLoadImage
      className="h-tweetImage object-cover rounded-lg w-full mt-4"
      src={src}
      alt={alt}
      effect="blur"
    />
  )
}

export default MyImage

That will be all for today ;)

Bye and take care! ;)