Ecoplus III: Image Handling

12 Feb 20224 min read

After the hackathon, we took a break for a few weeks before embarking on our last two sprints. During the hackathon, I failed to implement image upload and delete functionality, so that naturally became my goal for the remaining weeks. In the third sprint, I added image upload functionality, and in the fourth sprint, I added image delete functionality and ensured a consistent UI across the pages which had image modification operations involved.

New Features

In the last two sprints, here are the features we added:

General

Community

Event

Account

Education

Image handling

Netlify image handling

The problem I faced was that Netlify functions couldn't handle multipart form data such as images properly. It actually took me a lot of time researching this issue, only to find out I couldn't send images to the server the traditional way and had to look for alternative methods.

I settled with sending a base64-encoded string instead of the image file, which worked well. One caveat of this method is that large images would not upload properly as their base64-encoded string would be too large.

Storing images on Supabase

After receiving the base64-encoded string in the serverless function, I decoded it to create a Buffer and sent it to Supabase. On Supabase's side, I also ran into a lot of issues with basic CRUD operations. I needed to add the suitable PostgreSQL policies for the storage we used for storing files. The policies I added allowed anyone to read from the storage, and only authenticated users, aka those with an account, to create, upload, and delete from the storage.

Storage.buckets policies

I added a has_img column to the users table to tell if the user has added or deleted their profile photo. If false, it means that they have no profile picture set - this is the default. If true, it means that they have a profile picture set. The URL is then constructed using the user's userID.

const fileName = `users/${session.user.id}.jpg`;
const buf = Buffer.from(img, 'base64');

const { data: imgData, error: imgError } = await supabase.storage.from('public')
  .upload(fileName, buf, {
    contentType: "image/jpeg",
    upsert: true,
    cacheControl: "0",
  });
if (imgError) throw { error: imgError, session: newSession };
const { data: userData, error: userError } = await supabase
  .from("users")
  .update({ has_img: true })
  .eq("id", session.user.id);
if (userError) throw { error: userError }

return {
  statusCode: 200,
  body: JSON.stringify({
    imgUrl: `https://stolploftqaslfirbfsf.supabase.in/storage/v1/object/public/${imgData.Key}`,
    session: newSession,
  }),
};

Session management

Another problem I ran into was that I needed the user's current login session so that they can upload or delete images. Our current method of keeping the user "logged in" is storing their userID and username in the session storage. This was unsafe as the user can easily change it and cause errors when doing database operations. Hence, I decided to add some form of session handling as well.

To do this, I stored the user's session whenever they signed up or signed in, and sent the session data to the serverless functions every request. I then set the session as shown:

const { session, error: sessionError } = await supabase.auth.setSession(clientSession.refresh_token);
const { data: newSession } = await supabase.auth.refreshSession();


I had to use this method because the Supabase JavaScript client is more suited for using it on the client-side, but it is only safe to do so if I've added proper Row-Level Security (RLS) on the database, which I didn't have time to implement.

Account page UI

After settling most of the backend issues, I proceeded to create the front-end. In the user's account page, I added a button for the user to change their photo.

Account page

On clicking it, a modal form is showed that allows the user to select an image and upload it.

Image upload modal

I also added a delete button, whose operation was not that much of a hassle as uploading the image to implement.

Image delete modal

Putting it all together

After I had done the image upload and delete functionality, Kah Seng used my code as a guide for doing adding the same funcionality to the community and event pages. However, his UI was a bit different, so I changed mine to fit his. He also forgot to add the code for previewing the uploaded image, so I did that for him since he was very busy implementing the other features.

Finally, I had to update pages with comments to show the user's profile photo if they had one. For this part, I made the dynamic addition of elements more efficient by using document.createElement instead of appending a string to the comments container, which was 2x faster. I also took feedback from Kah Seng and added the time a person commented to provide more information and possibly sort by time commented in the future.

function fillComments(elementCss, posts) {
    const commentsFragment = document.createDocumentFragment();

    // Underscored variables will be cloned later.

    // Main container
    const _commentContainer = document.createElement("div");
    _commentContainer.classList.add("rounded-lg", "border-4", "p-4", "flex");

    // To display user's profile picture (if they have one)
    const _pfpImg = document.createElement("img");
    _pfpImg.classList.add("rounded-full", "h-12", "w-12");

    // To display a grey circle (if user doesn't have profile picture)
    const _pfpDiv = document.createElement("div");
    _pfpDiv.classList.add("rounded-full", "bg-gray-200", "h-12", "w-12");
    // Comment container for _username and _comment
    const _commentContent = document.createElement("div");
    _commentContent.classList.add("ml-4", "flow-col");

    const _username = document.createElement("span");
    _username.classList.add("font-bold");

    const _datetime = document.createElement("span");
    _datetime.classList.add("font-light", "text-sm", "ml-2", "text-gray-300");
    _datetime.style.color = "rgb(156 163 175)";

    const _comment = document.createElement("p");
    for (const post of posts) {
        let commentContainer = _commentContainer.cloneNode(true);
        let pfp;
        let commentContent = _commentContent.cloneNode(true);
        let username = _username.cloneNode(true);
        let datetime = _datetime.cloneNode(true);
        let comment = _comment.cloneNode(true);

        commentContainer.setAttribute("data-datetime", post.created_at)
        if (post.has_img) {
            pfp = _pfpImg.cloneNode(true);
            pfp.setAttribute("src", post.imgUrl);
        } else {
            pfp = _pfpDiv.cloneNode(true);
        }
        username.insertAdjacentText("afterbegin", post.username);
        datetime.insertAdjacentText("afterbegin", datetimeFormat(post.created_at));
        comment.insertAdjacentText("afterbegin", post.post);

        commentContent.appendChild(username);
        commentContent.appendChild(datetime);
        commentContent.appendChild(comment);
        commentContainer.appendChild(pfp);
        commentContainer.appendChild(commentContent);
        commentsFragment.append(commentContainer);
    }
    $(elementCss).empty();
    $(elementCss).append(commentsFragment);
}
function datetimeFormat(datetime) {
    const d = new Date(datetime);
    const date = `${d.getDay()}/${d.getMonth() + 1}/${d.getFullYear() % 100}`;
    const h = d.getHours();
    const M = d.getMinutes();
    const time = ` ${h < 10 ? 0 : ""}${h}:${M < 10 ? 0 : ""}${M}`;
    return date + time;
}