'trouble uploading a cropped image to cloudinary using remix and react-easy-crop

Code updated with a working solution

I want to apologize ahead of time if im asking the wrong questions here, if it isnt apparent im lacking in experience. moving forward

what im trying to do:

like the title says, im trying to upload a cropped image to cloudinary using react-easy-crop and remix.

let me start with whats working:

uploading an image to cloudinary works perfectly. there is a fantastic example on the remix.run github on uploading images to cloudinary.

context: im making a twitter clone as a person project. the image uploads to cloudinary and responds with a link to the image which gets set as an attribute on a user object. why? so i dont fill mongodb with images.

whats supposed to be happening/the flow:

after selecting the image, the crop dialogue pops up. after cropping the image there is a preview of the cropped image

at this point the cropped image is supposed to upload to cloudinary, respond with the link and post the link to my database.

what i think is going on

i think, im having issues converting from a blob back to an image or base64 file.

i apologize if im making no sense, im burnet out with this but still want to solve it. heres the code:

also, try to ignore all the 'any' types. just trying to get this working at this point. UploadBannerImage.tsx

import type { SyntheticEvent } from 'react'
import { useEffect, useState } from 'react';

import type { ActionFunction, UploadHandler } from "@remix-run/node";

import {
  json,
  unstable_composeUploadHandlers as composeUploadHandlers,
  unstable_createMemoryUploadHandler as createMemoryUploadHandler,
  unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/node";

import { Form, useActionData } from "@remix-run/react";

import { uploadImage } from "~/utils/utils.server";

import Cropper from 'react-easy-crop'
import getCroppedImg from '~/utils/getCroppedImg';

import type { Area, Point } from 'react-easy-crop'

type ActionData = {
  errorMsg?: string;
  imgSrc?: string;
};

export const action: ActionFunction = async ({ request }) => {
  const uploadHandler: UploadHandler = composeUploadHandlers(
    async ({ name, data }) => {
      if (name !== "img") return null

      const uploadedImage = await uploadImage(data)
      return uploadedImage.secure_url;
    },
    createMemoryUploadHandler()
  );

  const formData = await parseMultipartFormData(request, uploadHandler);
  const imgSrc = formData.get("img");

  // placeholder function to log the src of the image
  // in the main app the imgSrc will be the link posted to the database
  async function logger(src: FormDataEntryValue): Promise<any> {
    console.log('imgSrc: ', src.toString())
  }

  if (!imgSrc) return json({ error: "something wrong" });
  return json({ imgSrc }, await logger(imgSrc));
};

export default function Index() {
  const data = useActionData<ActionData>();
  const [file, setFile] = useState<File | null>(null)
  const [fileToCrop, setFileToCrop] = useState<string>('')
  const [crop, setCrop] = useState<Point>({ x: 2, y: 2 });
  const [zoom, setZoom] = useState(1);
  const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area>()
  const [croppedImage, setCroppedImage] = useState<Blob | null>(null)
  const [imageToUpload, setImageToUpload] = useState<string>()
  const [previewImage, setPreviewImage] = useState<string>()

  useEffect(() => {
    if (!croppedImage) return;
    let cropped: Blob | string = URL.createObjectURL(croppedImage)
    setPreviewImage(cropped)

    const convertCropped = () => {
      const reader = new FileReader()
      reader.readAsDataURL(croppedImage)
      reader.onloadend = () => {
        setImageToUpload(reader.result as string)
      }
      reader.onerror = () => {
        console.error('error')
      }
    }
    convertCropped()

  }, [file, croppedImage])

  const onSelectFile = async (e: SyntheticEvent) => {
    const target = e.target as HTMLInputElement
    if (!target.files || target.files?.length === 0) {
      setFile(null)
      return
    }
    setFile(target.files[0])
    setFileToCrop(URL.createObjectURL(target.files[0]))
  }

  const onCropComplete = (_croppedArea: Area, croppedAreaPixels: Area) => {
    setCroppedAreaPixels(croppedAreaPixels);
  };

  const onCrop = async () => {
    setCroppedImage(await getCroppedImg(fileToCrop, croppedAreaPixels as Area))
    setFile(null)
  };

  const cancelImage = () => setFile(null)

  return (
    <div className="text-center mt-56">
      <label htmlFor="img-field"></label>


      <input id="img-field" type="file" name="img" accept="image/*" onChange={onSelectFile} />

      {file && (
        <>
          <div className="fixed bg-black top-0 left-0 right-0 bottom-0 z-10 opacity-50"></div>
          <div className="fixed top-0 left-0 right-0 bottom-20 z-20">
            <Cropper
              image={fileToCrop}
              crop={crop}
              zoom={zoom}
              aspect={1}
              onCropChange={setCrop}
              onCropComplete={onCropComplete}
              onZoomChange={setZoom}
            />
          </div>
          <div className="fixed bottom-0 w-full h-[100px] z-20 mb-10">
            <div className="place-content-center">
              <input
                type="range"
                min={1}
                max={3}
                step={0.05}
                value={zoom}
                onInput={(e: any) => {
                  setZoom(e.target.value);
                }}
                className="w-1/2"
              ></input>
            </div>
            <div className="place-content-center mt-12 mb-10">
              <button
                type='button'
                className="bg-rose-400 m-5"
                onClick={() => cancelImage()}
              >
                clear image
              </button>
              <button
                type='button'
                className="bg-purple-800 m-5"
                onClick={onCrop}
              >
                Crop
              </button>
            </div>
          </div>
        </>
      )}

      {croppedAreaPixels && !data?.imgSrc ? (
        <>
          <Form method="post" encType="multipart/form-data">
            <input
              name="img"
              type='hidden'
              value={imageToUpload}
            />
            <img
              src={previewImage}
              alt=''
            />
            <button
              type="submit"
              className="bg-slate-400 m-5"
            >
              upload banner
            </button>
          </Form>
        </>
      ) : null}

      {data?.errorMsg && <h2>{data.errorMsg}</h2>}
      {data?.imgSrc && (
        <>
          <h2>uploaded image</h2>
          <img src={data.imgSrc} alt={'' || "Upload result"} />
        </>
      )}
    </div>
  );
}

utils.server.ts

import cloudinary from "cloudinary";
import { writeAsyncIterableToWritable } from "@remix-run/node";

cloudinary.v2.config({
  cloud_name: process.env.CLOUDINARY_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET_KEY,
});

async function uploadImage(data: AsyncIterable<Uint8Array>) { // AsyncIterable<Uint8Array>
  const uploadPromise = new Promise(async (resolve, reject) => {
    const uploadStream = cloudinary.v2.uploader.upload_stream({ 
      folder: "randy-demo", 
    },
      (error, result) => {
        if (error) { 
          reject(error); return; 
        } resolve(result);
      }); 
      await writeAsyncIterableToWritable(data, uploadStream);
  });

  return uploadPromise as Promise<{ secure_url: string }>
}

// console.log("configs", cloudinary.v2.config());
export { uploadImage };
**getCroppedImg.ts**
import type { Area } from 'react-easy-crop'

const createImage = (url: string): Promise<HTMLImageElement> =>
  new Promise((resolve, reject) => {
    const image = new Image();
    image.addEventListener("load", () => resolve(image));
    image.addEventListener("error", (error) => reject(error));
    image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
    image.src = url;
  });

function getRadianAngle(degreeValue: number) {
  return (degreeValue * Math.PI) / 180;
}

export default async function getCroppedImg(imageSrc: string, pixelCrop: Area, rotation = 0): Promise<Blob | null> {
  const image = await createImage(imageSrc)
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");

  const maxSize = Math.max(image.width, image.height);
  const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));

  // set each dimensions to double largest dimension to allow for a safe area for the
  // image to rotate in without being clipped by canvas context
  canvas.width = safeArea;
  canvas.height = safeArea;

  // translate canvas context to a central location on image to allow rotating around the center.
  ctx?.translate(safeArea / 2, safeArea / 2);
  ctx?.rotate(getRadianAngle(rotation));
  ctx?.translate(-safeArea / 2, -safeArea / 2);

  // draw rotated image and store data.
  ctx?.drawImage(
    image,
    safeArea / 2 - image.width * 0.5,
    safeArea / 2 - image.height * 0.5
  );
  const data = ctx?.getImageData(0, 0, safeArea, safeArea) as ImageData

  // set canvas width to final desired crop size - this will clear existing context
  canvas.width = pixelCrop.width;
  canvas.height = pixelCrop.height;

  // paste generated rotate image with correct offsets for x,y crop values.
  ctx?.putImageData(
    data,
    Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
    Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
  );

  // ! this is the one that kind of works
  return new Promise((resolve) => {
    canvas.toBlob((file) => {
      // console.log(file);
      resolve(file);
      //returns file which is the blob
    }, "image/jpeg");
  });
}

thanks for looking. any help is appreciated.



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source