[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 ( Frontend )
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:
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! ;)