[PART 22] Creating a Twitter clone with GraphQL, Typescript, and React ( media upload)
Tags: webdev, react, graphql, typescript
Published: 2021-02-03 18:28:56

Hi everyone ;).

As a reminder, I'm doing this [Tweeter challenge](https://devchallenges.io/challenges/rleoQc34THclWx1cFFKH)

[Github repository ( Backend )](https://github.com/ips-coding-challenge/challenge_twitter)

[Github repository ( Frontend )](https://github.com/ips-coding-challenge/challenge_twitter_front)

[Db diagram](https://dbdiagram.io/d/5f75c0243a78976d7b75f7be)

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](https://dev-to-uploads.s3.amazonaws.com/i/5m2g83j1hvwlk8tdfxjg.gif)

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*

```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:
```tsx
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*

```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](https://dev.to/ipscodingchallenge/continuing-my-learning-path-i-did-a-trello-clone-55el). 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*

```typescript
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 is 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*

```tsx
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*

```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*

```typescript
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*

```typescript
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*

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

*src/resolvers/TweetResolver.ts*

```typescript
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*

```typescript
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*

```typescript
@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 a 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*

```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*

```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! ;)

You learned 2-3 things and want to buy me a coffee ;)?
https://www.buymeacoffee.com/ipscoding