Insta-Next: More UI with Mantine

Hoh Shen YienHoh Shen Yien
17 min read

In this part, we will try to complete the remaining profile page and the stories. There are quite a lot of modals and carousel here.

As most of the concepts are the same as the last part, feel free to skip to the next part after skimming through them, but the components here are more complex if you're interested. (unless you wanna try some challenges from this part)

If you decided to skip to this part, fret not, I got you covered. You can download the complete codes from the last part here.

Sneak Peek:

Profile Page

Now that we got the index page done, it's time to finish up the user profile page. We will use the route /users/[username] for this profile page. Do you still remember how to create a dynamic route and get the route parameter from it?

PS: I just noticed that I used the path /user instead of /users for user page, could you do me a favor by replacing all /user/ to /users/

// src/pages/users/[username].tsx
import { useRouter } from "next/router";

const ProfilePage = () => {
  const router = useRouter();
  const { username } = router.query;
  return <div>{username}</div>;
};

export default ProfilePage;

And now, we can just head back to the index page and click on any user, you should be able to see the username displayed in the center.

APIs

We haven't built any API that allows us to read a user's info and the user's posts. Before building it, let's see what are the APIs needed,

For user info, aside from the basic info, we will also need the count for the number of posts, followers and followings.

Meanwhile, for each post, we will also need to get the first image as well as the number of likes it received.

Since these are technically two different information, we will build two separate APIs for each of them

User Info

We will just need to include the 3 additional _count, and search the user based on their username

// src/features/users/findUserInfo.ts
import prisma from "@/utils/prisma";
import attachImage from "../images/attach-image";

const findUserInfo = async (username: string) => {
  const user = await prisma.user.findFirstOrThrow({
    where: {
      username,
    },
    include: {
      _count: {
        select: {
          followers: true,
          followings: true,
          posts: true,
        },
      },
    },
  });

  return await attachImage(user, "user");
};

export default findUserInfo;

Now using it in our API route, note that I have used Prisma's _GetPayload type which converts the arguments into a type, can be a good choice to quickly create type out from complex Prisma types

// src/features/users/findUserInfo.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { AttachImage } from "@/features/images/attach-image";
import { Prisma, User } from "@prisma/client";
import findUserInfo from "@/features/users/findUserInfo";

export type UserInfoData = {
  user: AttachImage<
    Prisma.UserGetPayload<{
      include: {
        _count: {
          select: {
            followers: true;
            followings: true;
            posts: true;
          };
        };
      };
    }>,
    "user"
  >;
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<UserInfoData>
) {
  const { username } = req.query as { username: string };

  try {
    const user = await findUserInfo(username);
    res.status(200).json({ user });
  } catch (exception) {
    res.status(404).end();
  }
}

User Posts

User posts is slightly easier, we just need to query for all posts of the user, and include the count for liked_bys

import prisma from "@/utils/prisma";
import attachImage from "../images/attach-image";

const findUserPosts = async (username: string) => {
  const { posts } = await prisma.user.findFirstOrThrow({
    where: {
      username,
    },
    include: {
      posts: {
        include: {
          _count: {
            select: {
              liked_bys: true,
            },
          },
        },
      },
    },
  });

  // This is certainly bad in performance since we only need 1
  // and we are fetching everything
  return await Promise.all(
    posts.map(async (post) => await attachImage(post, "post"))
  );
};

export default findUserPosts;

I will leave the API part to you, here's the solution if you wish to have a look at it.

Frontend

As usual, before we can query the API, let's add them to our /src/api folder

// src/api/users.ts
export const getUserInfo = async (username: string): Promise<UserInfoData> => {
  const data = await axios.get(`/api/users/${username}`);
  return data.data;
};

// src/api/posts.ts
export const getUserPosts = async (username: string): Promise<UserPostData> => {
  const data = await axios.get(`/api/users/${username}/posts`);
  return data.data;
};

Since we have two separate queries here, we should probably divide the page into two components too: UserInfo and UserPosts.

UserInfo

This part is quite similar to the src/components/users/LikedUser/LikedUser.tsx that we created last part, and it's rather trivial, so I'll leave it to you as a challenge. Here's a starter component for you

import { getUserInfo } from "@/api/users";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/router";

const UserInfo = () => {
  const { username } = useRouter().query as { username: string };
  const { data: userInfo, isSuccess } = useQuery({
    queryFn: () => getUserInfo(username),
    queryKey: ["user-info"],
  });

  return <div>{isSuccess && userInfo.user.username}</div>;
};

export default UserInfo;

You should try to create something like this, here are the completed codes on GitHub.

UserPosts

At a glance, UserPosts probably is as simple as UserInfo. However, it requires additional components, that is, another modal. We will work on that later, for now, let's create a UserPost and UserPosts component.

UserPost component itself is rather simple, but do remember that we need to show an overlay when hovering

// src/components/posts/UserPost/UserPost.tsx
import { UserPostData } from "@/pages/api/users/[username]/posts";
import { Unpacked } from "@/utils/types";
import { Image } from "@mantine/core";
import { IoCopy } from "react-icons/io5";
import { FaHeart } from "react-icons/fa";

interface UserPostProps {
  post: Unpacked<UserPostData["posts"]>;
}

const UserPost = ({ post }: UserPostProps) => {
  return (
    <div className="relative">
      <Image
        src={post.images[0].url}
        alt={post.images[0].url}
        classNames={{ image: "aspect-square" }}
        width="100%"
      />
      {post.images.length > 1 && (
        <div className="absolute top-2 right-2">
          <IoCopy className="text-white" size="20px" />
        </div>
      )}
      <div className="absolute top-0 left-0 h-full w-full bg-black/40 flex justify-center items-center opacity-0 hover:opacity-100 transition-all">
        <div className="text-white flex items-center">
          <FaHeart className="mr-2" /> {post._count.liked_bys}
        </div>
      </div>
    </div>
  );
};

export default UserPost;

It looks pretty good now, let's build the entire UserPosts

// src/components/posts/UserPost/UserPosts.tsx
import { getUserPosts } from "@/api/posts";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/router";
import UserPost from "./UserPost";

const UserPosts = () => {
  const { username } = useRouter().query as { username: string };
  const { data, isSuccess } = useQuery({
    queryFn: () => getUserPosts(username),
    queryKey: ["user-posts"],
    enabled: !!username,
  });

  return (
    <div className="grid grid-cols-3 gap-2">
      {isSuccess &&
        data.posts.map((post, index) => <UserPost key={index} post={post} />)}
    </div>
  );
};

export default UserPosts;

Doesn't it look just like Instagram but toned down now?

Here's the final version of our ProfilePage

// src/pages/users/[username].tsx
import UserPosts from "@/components/posts/UserPost/UserPosts";
import UserInfo from "@/components/users/UserInfo";
import { useRouter } from "next/router";

const ProfilePage = () => {
  return (
    <div className="max-w-[940px] space-y-20">
      <UserInfo />
      <UserPosts />
    </div>
  );
};

export default ProfilePage;

PostModal

Just like Instagram, we gotta allow the users to click on the posts to show the post modal. This isn't quite hard, we will reuse the image carousel we created in the last part

However, for future reusability & since we don't have information about the author in UserPost component, we'll need an API for it. This time, we will include post and author as two separate return types since they're used differently

// src/pages/api/posts/[post_id]/index.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { AttachImage } from "@/features/images/attach-image";
import { Post, Prisma, User } from "@prisma/client";
import findSinglePost from "@/features/posts/findSinglePost";

export type PostData = {
  author: AttachImage<User, "user">;
  post: AttachImage<
    Prisma.PostGetPayload<{
      include: {
        _count: {
          select: {
            liked_bys: true;
          };
        };
      };
    }>,
    "post"
  >;
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<PostData>
) {
  const { post_id } = req.query as { post_id: string };

  try {
    const data = await findSinglePost(+post_id);
    res.status(200).json(data);
  } catch (exception) {
    res.status(404).end();
  }
}

If you notice, there's a findSinglePost defined elsewhere, you can try building it yourself or check out how I implemented it here.

Using the API, we can now start building the modal, let's just add in the types first

// src/utils/modals/types.ts
...
& {
  [key in typeof postModal]: {
    post: AttachImage<
      Prisma.PostGetPayload<{
        include: {
          _count: {
            select: {
              liked_bys: true;
            };
          };
        };
      }>,
      "post"
    >;
    author: AttachImage<User, "user">;
  };

Then, we can build the modal out, using all these data. However, our current modal is insufficient, notice the title, and paddings? We gotta make the component more robust and better suited to more scenarios.

I'm gonna give you a few hints here

  1. Maybe make the top part shows only if the title exists?

  2. Make padding part of the props

  3. Let's also make an object in openModal which maps the modal name to properties like modal size? This makes each modal have their specific properties, I've decided to pull in Mantine's Parameter for that purpose

    ```typescript const modalProperties: Record< ModalType, Omit[0], "modal" | "innerProps">

    = {

size: "sm", },

size: "1200px", withCloseButton: false, radius: "md", }, }


4. Our carousel currently have a fixed size of 480px, can we make it variable? *(*[*Here*](https://github.com/HohShenYien/insta-next/blob/part5-post-story-modal/src/components/carousel/ImageCarousel.tsx)*'s how I did it)*


While I'd like to show the solution, but this whole blog might get a bit too lengthy, so check out the [GitHub](https://github.com/HohShenYien/insta-next/blob/part5-post-story-modal/src/components/modals/PostModal.tsx) for solution

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1680275886577/5af4591b-59b7-4949-bbcd-5ba79df91384.png align="center")

It might not look as good as Instagram's, but it shows what's needed!

*PS: I noticed that the like button is similar to* `Post.tsx`, *so I also extracted it out and called it* `PostLiked`.

*While I was extracting the file, I encountered a* ***circular dependency*** *error, looking back, it goes* `PostLiked->openModal->PostModal->modals->createPostModal->PostLiked`*, so I moved* `modals` *out to a separate file in* `modals.ts` *to break the chain*.

```typescript
// src/utils/modals/modals.ts
import PostLikedModal from "@/components/modals/PostLikedModal";
import PostModal from "@/components/modals/PostModal";
import { postLikesModal, postModal } from "./constants";

export const modals = {
  [postLikesModal]: PostLikedModal,
  [postModal]: PostModal,
};

Stories

API

We'll come back to the index page and try to get the stories out. As usual, let's create an API for it first.

One challenge here is that, instead of getting all stories, we'll get the stories for each user and attach the images one-by-one

// src/features/stories/findManyStories.ts
import attachImage from "../images/attach-image";
import prisma from "@/utils/prisma";

const findManyStories = async () => {
  const users = await prisma.user.findMany({
    include: {
      stories: true,
    },
  });

  // Bad performance here, better to use SQL instead
  return await Promise.all(
    users.map(async ({ stories, ...user }) => {
      const userWithImage = await attachImage(user, "user");
      const storiesWithImage = await Promise.all(
        stories.map(async (story) => attachImage(story, "story"))
      );
      return { user: userWithImage, stories: storiesWithImage };
    })
  );
};

export default findManyStories;

And let's update the API endpoint

// src/pages/api/stories/index.ts
import { Story, User } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { AttachImage } from "@/features/images/attach-image";
import findManyStories from "@/features/stories/findManyStories";

export type AllStoriesData = {
  stories: {
    user: AttachImage<User, "user">;
    stories: AttachImage<Story, "story">[];
  }[];
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<AllStoriesData>
) {
  const stories = await findManyStories();
  res.status(200).json({ stories });
}

Don't you just think that writing API endpoints using Next.js is so effortless?

UI

There are two core parts to the UI, the carousel and the modal. While the carousel is probably quite simple, the modal may take a bit more effort as it needs to have the ability to navigate.

Let's create a story carousel, to start off, we can first copy over the ImageCarousel and edit from there. Since the story carousel only appears at home, we will make the carousel query for the stories right away.

A few things that we need to modify are the sizes, number of slides to scroll will be 4, make the carousel wider, and remove the indicator.

// src/components/carousels/StoryCarousel.tsx
import { Carousel, Embla, useAnimationOffsetEffect } from "@mantine/carousel";
import { Image } from "@mantine/core";
import { IoChevronBackCircle, IoChevronForwardCircle } from "react-icons/io5";
import { useQuery } from "@tanstack/react-query";
import { getAllStories } from "@/api/stories";

const StoryCarousel = () => {
  const { isSuccess, data: stories } = useQuery({
    queryFn: getAllStories,
    queryKey: ["all-stories"],
  });
  return (
    <Carousel
      height={120}
      maw={630}
      classNames={{
        control:
          "p-0 border-0 text-white/80 blur-[0.5px] backdrop-blur-sm data-[inactive=true]:invisible data-[inactive=true]:cursor-default",
        viewport: "rounded-sm",
      }}
      // changed to match Instagram style
      previousControlIcon={<IoChevronBackCircle size={30} />}
      nextControlIcon={<IoChevronForwardCircle size={30} />}
      slideSize={66}
      slidesToScroll={4}
      draggable={false}
      align={"start"}
      w="630"
    >
      {isSuccess &&
        stories.stories.map((story, index) => {
          return (
            <Carousel.Slide key={index}>
              <Image
                src={story.user.profile_pic?.url}
                alt={story.user.username}
                height={56}
                width={56}
                fit="cover"
              />
            </Carousel.Slide>
          );
        })}
    </Carousel>
  );
};

export default StoryCarousel;

It should look something like this now

I ran yarn seed a few more times to get the data

Not really nice yet, the arrow icons are still off, the images are not rounded, the Instagram gradient and the username are missing. We can easily modify most of those by using classNames

import { Carousel, Embla } from "@mantine/carousel";
import { Image, Text } from "@mantine/core";
import { BiChevronLeft, BiChevronRight } from "react-icons/bi";
import { useQuery } from "@tanstack/react-query";
// don't forget to create the API call
import { getAllStories } from "@/api/stories";

const StoryCarousel = () => {
  const { isSuccess, data: stories } = useQuery({
    queryFn: getAllStories,
    queryKey: ["all-stories"],
  });

  return (
    <Carousel
      height={120}
      maw={630}
      classNames={{
        control:
          "p-0 border-0 text-gray-600 data-[inactive=true]:invisible data-[inactive=true]:cursor-default bg-white",
        controls: "top-[20px]",
        viewport: "rounded-sm",
      }}
      previousControlIcon={<BiChevronLeft size={24} />}
      nextControlIcon={<BiChevronRight size={24} />}
      slideSize={70}
      slidesToScroll={4}
      draggable={false}
      align={"start"}
      w="630"
      slideGap={"sm"}
      // this is to prevent over-scrolling, which is default behaviour
      containScroll="trimSnaps"
    >
      {isSuccess &&
        stories.stories.map((story, index) => {
          return (
            <Carousel.Slide key={index}>
              <div className="flex flex-col items-center space-y-2">
                {/* This part is for the Instagram gradient ring */}
                <div className="bg-gradient-to-bl from-[#D300C5] to-[#FFCE29] rounded-full p-0.5">
                  <Image
                    src={story.user.profile_pic?.url}
                    alt={story.user.username}
                    height={56}
                    width={56}
                    fit="cover"
                    className="rounded-full"
                    classNames={{
                      root: "bg-white p-0.5 !w-[unset]",
                      image: "rounded-full",
                    }}
                  />
                </div>
                <Text className="text-sm truncate w-[70px] text-center">
                  {story.user.username}
                </Text>
              </div>
            </Carousel.Slide>
          );
        })}
    </Carousel>
  );
};

export default StoryCarousel;

Don't forget to modify and include the carousel in our index.tsx page

// src/pages/index.tsx
...
      <div className="space-y-4 flex flex-col items-center">
        <StoryCarousel />
        {posts.isSuccess &&
          posts.data.posts.map((post, index) => (
            <Post post={post} key={index} />
          ))}
      </div>
...

And here we go

Looks pretty neat!

Notice that I've used another icon for the arrow to better match the Instagram theme (which is again a bad example, all icons should be consistent in style)

Modal

Here comes the modal again. The challenging part for the modal is about navigating back and forth, so here's the plan

  1. The modal will refer to the query using useQuery's cache

  2. The current story's index will be passed to the openModal

  3. I will use a Carousel (again) to build it

Ready? Let's go

// src/utils/modals/constants.ts
export const postLikesModal = "PostLikes";
export const postModal = "Post";
export const createPostModal = "CreatePost";
export const storyModal = "Story";

export type ModalType =
  | typeof postLikesModal
  | typeof createPostModal
  | typeof postModal
  | typeof storyModal;

// src/utils/modals/types.ts
...
} & {
  [key in typeof storyModal]: {
    index: number;
  };
};

There will be two carousels here, the inner carousel, which is where the story is, and the outer carousel, which lets you navigate through the stories from different users

The inner one is relatively easier, let's get it out first. Notice that it's basically ImageCarousel with caption, top indicator & some author infos?

Again, we will start off copying the ImageCarousel, and do some minor styling here and there first. Then, we will add the user info along, and that's basically it

// src/components/stories/InnerStoryCarousel.tsx
import { AttachImage } from "@/features/images/attach-image";
import { Embla, useAnimationOffsetEffect, Carousel } from "@mantine/carousel";
import { Avatar, Image, Text } from "@mantine/core";
import { Story, User } from "@prisma/client";
import Link from "next/link";
import { useState } from "react";
import { IoChevronBackCircle, IoChevronForwardCircle } from "react-icons/io5";
import styles from "./StoryStyle.module.css";

interface InnerStoryCarouselProps {
  author: AttachImage<User, "user">;
  stories: AttachImage<Story, "story">[];
}

const InnerStoryCarousel = ({ author, stories }: InnerStoryCarouselProps) => {
  const TRANSITION_DURATION = 200;
  const [embla, setEmbla] = useState<Embla | null>(null);

  // This is needed to solve misaligning slides from Mantine
  // https://mantine.dev/others/carousel/#carousel-container-animation-offset
  useAnimationOffsetEffect(embla, TRANSITION_DURATION);

  return (
    <div className="relative">
      <Carousel
        withIndicators
        height={"90vh"}
        maw={"50vh"}
        classNames={{
          control:
            "p-0 border-0 text-white/80 blur-[0.5px] backdrop-blur-sm data-[inactive=true]:invisible data-[inactive=true]:cursor-default",
          indicator: "bg-white h-1",
          indicators: "top-4 bottom-[unset] px-4 gap-x-2",
          viewport: "rounded-lg overflow-x-hidden",
        }}
        styles={{
          indicator: {
            width: `calc(${100 / stories.length}%)`,
          },
        }}
        // changed to match Instagram style
        previousControlIcon={<IoChevronBackCircle size={30} />}
        nextControlIcon={<IoChevronForwardCircle size={30} />}
        slideSize={"50vh"}
        slidesToScroll={1}
        draggable={false}
        getEmblaApi={setEmbla}
      >
        {stories.map((story, index) => {
          return (
            <Carousel.Slide key={index}>
              <div className="bg-gray-400 relative">
                <Image
                  src={story.image.url}
                  alt={story.image.url}
                  height={"90vh"}
                  width={"50vh"}
                  fit="contain"
                />
                <div
                  className={`absolute top-0 bottom-0 w-full ${styles.blackOverlay}`}
                ></div>
                <div className="absolute bottom-4 absolute-x-center bg-white text-sm w-[90%]">
                  {story.caption}
                </div>
              </div>
            </Carousel.Slide>
          );
        })}
      </Carousel>
      <div className="absolute top-8">
        <div className="flex items-center px-2">
          <Link href={`/users/${author.username}`}>
            <Avatar
              src={author.profile_pic?.url}
              alt={author.username}
              radius="xl"
              size="md"
              className="mr-3 hover:brightness-125"
            />
          </Link>
          <div className="flex space-x-2 items-center text-white">
            <Link href={`/users/${author.username}`}>
              <Text className="font-semibold tracking-wider">
                {author.username}
              </Text>
            </Link>
          </div>
        </div>
      </div>
    </div>
  );
};

export default InnerStoryCarousel;

It's quite something, but maybe we can make the text looks better? I decided to round it a little, with some extra padding

...
                <div className="absolute bottom-4 absolute-x-center bg-white text-sm w-[90%] p-2 rounded-md">
                  {story.caption}
                </div>
...

Somewhat better

Next, it's time to build the outer carousel!

Honestly, I had a lot of difficulties in achieving that, but somehow I came across this and I was like maybe it can work? And voila, it did work despite having some lags. Here's what you can try

  1. Referring to the Embia example, we can include it in our codes to scale when scrolling across (I copied the carousel from InnerStoryCarousel to start with)

     ...
       const [emblaApi, setEmblaApi] = useState<Embla | null>(null);
       const [tweenValues, setTweenValues] = useState<number[]>([]);
    
       useAnimationOffsetEffect(emblaApi, 400);
    
       const onScroll = useCallback(() => {
         if (!emblaApi) return;
    
         const engine = emblaApi.internalEngine();
         const scrollProgress = emblaApi.scrollProgress();
    
         const styles = emblaApi.scrollSnapList().map((scrollSnap, index) => {
           if (!emblaApi.slidesInView().includes(index)) return 0.667;
           let diffToTarget = scrollSnap - scrollProgress;
    
           if (engine.options.loop) {
             // It basically calculates the percentage of the slide that is in the view
             engine.slideLooper.loopPoints.forEach((loopItem) => {
               const target = loopItem.target().get();
               if (index === loopItem.index && target !== 0) {
                 const sign = Math.sign(target);
                 if (sign === -1) diffToTarget = scrollSnap - (1 + scrollProgress);
                 if (sign === 1) diffToTarget = scrollSnap + (1 - scrollProgress);
               }
             });
           }
           // just a random scaling speed that I feel smooth
           const tweenValue = 1 - Math.abs(diffToTarget * 2.3);
           return numberWithinRange(tweenValue, 0, 1);
         });
         setTweenValues(styles);
       }, [emblaApi, setTweenValues]);
    
       useEffect(() => {
         if (!emblaApi) return;
    
         onScroll();
         emblaApi.on("scroll", () => {
           flushSync(() => onScroll());
         });
         emblaApi.on("reInit", onScroll);
       }, [emblaApi, onScroll]);
     ...
     <Carousel.Slide key={index} className="origin-center rounded-md">
               <div
                 style={{
                   ...(tweenValues.length && {
                     transform: `scale(${tweenValues[index]})`,
                   }),
                 }}
               >
     ...
    
  2. Then, we make the carousel's overflow to be visible, most carousels out there work by maintaining a long horizontal flex, and moving the flex left & right to show only the parts intended.

    Originally,

    After making the overflow visible

  3. From the tween values, we can notice that it is 0.667 for slides out of view and for the slide in view, 1. By relying on this information, we can display differently based on the tween values.

    When the tween value is >= 0.99 (to make transition smoother) you can swap to InnerStoryCarousel, otherwise, it'll be the default slide

     ...
     {/* tweenValues will be [NaN] if there's only one value */}
     {tweenValues.length == 1 || tweenValues[index] > 0.99 ? (
                   <InnerStoryCarousel author={story.user} stories={story.stories} />
                 ) : (
                   <div
                     className="bg-gray-400 relative rounded-lg overflow-hidden cursor-pointer"
                     onClick={() => emblaApi?.scrollTo(index)}
                   >
                     <Image
                       src={story.stories[0].image.url}
                       alt={story.stories[0].image.url}
                       height={"90vh"}
                       width={"50vh"}
                       fit="contain"
                     />
                     <div className="absolute top-0 left-0 h-full w-full bg-black/50 flex justify-center items-center">
                       <div className="flex flex-col items-center">
                         <GradientBorderAvatar
                           src={story.user.profile_pic?.url ?? ""}
                           alt={story.user.username}
                           // since the slide is scaled down to 0.667, I want to get
                           // size 54, so 1/0.667 * 54 = 81
                           size={81}
                         />
                         <Text className="font-semibold text-lg tracking-wider text-white mt-2">
                           {story.user.username}
                         </Text>
                       </div>
                     </div>
                   </div>
                 )}
     ...
    

You can try to code the remaining parts for the OuterStoryCarousel.tsx, or refer to the full implementation here.

You will also need to style the StoryModal a little, but since it's quite trivial, you can refer to it here. Finally, to get the modal showing up properly, you have to update the openModal to style the background, and include the sizes

import { modals } from "@mantine/modals";
import {
  ModalType,
  postLikesModal,
  postModal,
  createPostModal,
  storyModal,
} from "./constants";
import { ModalInnerProps } from "./types";
import { Box, clsx } from "@mantine/core";

interface OpenModalProps<T extends ModalType> {
  type: T;
  innerProps: ModalInnerProps[T];
}

// Here is the mapping of modal name to their specific properties
// I'm taking the properties from the function itself, excluding
// two kkeys that will be passed for sure
const modalProperties: Record<
  ModalType,
  Omit<Parameters<typeof modals.openContextModal>[0], "modal" | "innerProps">
> = {
  [createPostModal]: {},
  [postLikesModal]: {
    size: "sm",
  },
  [postModal]: {
    size: "1200px",
    withCloseButton: false,
    radius: "md",
  },
  [storyModal]: {
    fullScreen: true,
    classNames: {
      content: clsx("!overflow-hidden", "bg-neutral-800"),
      close: "!bg-transparent text-white hover:text-gray-400",
    },
  },
};

function openModal<T extends ModalType>({
  type,
  innerProps,
}: OpenModalProps<T>) {
  modals.openContextModal({
    padding: 0,
    modal: type,
    innerProps,
    closeButtonProps: { size: 28 },
    radius: "lg",
    centered: true,
    scrollAreaComponent: Box as any,
    ...modalProperties[type],
    classNames: {
      header: "absolute bg-transparent top-2 right-2",
      close: "!bg-transparent text-black hover:text-gray-800",
      inner: "overflow-hidden",
      content: "!overflow-hidden",
      ...modalProperties[type].classNames,
    },
  });
}

export default openModal;

While doing, I noticed that I'll need the gradient border avatar again, so I moved it to another component

// src/components/avatars/GradientBorderAvatar.tsx
import { Image } from "@mantine/core";

interface GradientBorderAvatarProps {
  src: string;
  alt: string;
  size: number;
}

const GradientBorderAvatar = ({
  src,
  alt,
  size,
}: GradientBorderAvatarProps) => {
  return (
    <div className="bg-gradient-to-bl from-[#D300C5] to-[#FFCE29] rounded-full p-0.5">
      <Image
        src={src}
        alt={alt}
        height={size}
        width={size}
        fit="cover"
        className="rounded-full"
        classNames={{
          root: "bg-white p-0.5 !w-[unset]",
          image: "rounded-full",
        }}
      />
    </div>
  );
};

export default GradientBorderAvatar;

and refactored the StoryCarousel.tsx

...
              <div
                className="flex flex-col items-center space-y-2 cursor-pointer"
                onClick={() =>
                  openModal({ type: storyModal, innerProps: { index } })
                }
              >
                <GradientBorderAvatar
                  src={story.user.profile_pic?.url ?? ""}
                  alt={story.user.username}
                  size={56}
                />
                <Text className="text-sm truncate w-[70px] text-center">
                  {story.user.username}
                </Text>
              </div>
...

And... we're done! Here's how it looks now

Doesn't it look amazing?

Summary

In this part, you implemented the missing profile page and story modal. While most of the things here overlapped with the content from the last part, this part focused a lot on carousel.

I hope you did learn something useful here! As usual, here's the completed implementation for this part: GitHub

1
Subscribe to my newsletter

Read articles from Hoh Shen Yien directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Hoh Shen Yien
Hoh Shen Yien

My name is Hoh Shen Yien, I'm a Malaysian fullstack developer who likes to read and write sometimes 🤩