DEV Community

Cover image for Creating Your Movie Bookmark Application using Next js, Redux Toolkit, Firebase, and TypeScript
Eboreime ThankGod
Eboreime ThankGod

Posted on • Updated on

Creating Your Movie Bookmark Application using Next js, Redux Toolkit, Firebase, and TypeScript

In this tutorial, we'll demonstrate how to create a movie bookmark application using the Next.js framework (version 13.4.13). We'll also integrate the Redux Toolkit for efficient state management and data fetching, Firebase for storing movie data, and TypeScript for safe type parsing.

You can find the completed code on GitHub, along with a live demo.

Below is a brief video clip demonstrating what we will be building.



Jump Ahead:

Introducing Next js

Next.js is a frontend React framework that offers support for various rendering approaches, including:

  • Client-Side Component Rendering: In Next.js, client components can be utilized by including the "use client" directive at the beginning of the component file. These client components can be rendered during the request phase, eliminating the necessity of being retrieved from the server.
  • Server-Side Component Rendering: By default, Next.js uses server-side rendering, a technique that involves rendering and optional caching of UI components on the server. The cached results can be utilized multiple times without initiating new requests. This approach effectively enhances the optimization of your web application.

With the introduction of the 'App' directory in the early release of Next.js 13, the framework facilitates seamless routing and nested layout structures between pages. Additionally, Next.js provides default Search Engine Optimization (SEO) features for your web applications.

Prerequisites

For this tutorial, you should have the following:

  • Working knowledge of React and Typescript
  • Your computer should have Node.js v16 or a newer version installed.
  • Make sure you have Visual Studio Code installed or any other preferred editor.

Setting up a new Next js project

Creating a new Next.js application is made seamless with the utilization of the create-next-app command. This command takes care of setting up all the necessary components and configurations, simplifying the initial setup process. To begin, open your terminal and enter the following:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

You will be prompted to fill in the following inputs:

  1. What is your project named? movie-bookamrk: let's name the project as movie-bookmark.
  2. Would you like to use TypeScript? No / Yes: TypeScript is required for this project, so select Yes.
  3. Would you like to use ESLint? No / Yes: ESLint is necessary for this project, so choose Yes.
  4. Would you like to use Tailwind CSS? No / Yes: In this tutorial, we will utilize Tailwind CSS for styling, so choose Yes.
  5. Would you like to use src/ directory? No / Yes: For the purpose of this project, we'll go with Yes as we'll be using the src directory.
  6. Would you like to use App Router? (recommended) No / Yes: It's recommended to choose either No or Yes. In this case, choose No.
  7. Would you like to customize the default import alias? No / Yes: Select Yes for default import alias.
  8. What import alias would you like configured? @/*: press Enter to use the @/* alias.

Once you've completed these steps, in the terminal, navigate to the movie-bookmark directory and install the dependencies, then run the server:

# cd into directory
cd movie-bookmark

# install packages
npm install

# start server
npm run dev
Enter fullscreen mode Exit fullscreen mode

Installing packages

In this tutorial, we will proceed with the installation of the necessary packages that are essential for our application. The following packages will be utilized, each accompanied by its respective installation command:

# framer for animations
npm i framer-motion

# firebase for database management
npm i firebase

# react-redux for state management
npm i react-redux

# for notification popups
npm i react-hot-toast

# for creating redux store, slice, and thunks 
npm i @reduxjs/toolkit

# for icons
npm i @mui/icons-material

# for effective styling of the material ui icons
npm i @emotion/styled @emotion/react
Enter fullscreen mode Exit fullscreen mode

Project setup

Setting up the firebase config

Once you have successfully installed all the required packages, proceed to access your Firebase console. Create a fresh Firebase project, and make sure to copy the configuration SDK provided by Firebase. Next, within the root directory of your project, create a file named firebase.config.ts. Paste the previously copied Firebase configuration into this file. Below, you will find the firebase.config.ts configuration file. Be sure to substitute the placeholder values with your actual configuration details.

//firebase.config.ts

import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);

export default app;
Enter fullscreen mode Exit fullscreen mode

The provided code demonstrates the setup of the Firebase app using the configuration stored in firebaseConfig. The initializeApp function produces an app instance, which serves as the gateway to Firebase services. To create instances of the Firestore and authentication services, use the getFirestore and getAuth functions. These functions configure the initially initialized Firebase app (app) with the respective instances.

Understanding the folder strucutre

├── movie-bookmark
    ├── context
        ├── redux.provider.tsx
    ├── data
        ├── movie.ts
    ├── node_modules
    ├── public
    ├── redux
        ├── features
            ├── bookmarkSlice.ts
            ├── bookmarkThunk.ts
        ├── hooks.ts
        ├── store.ts
    ├── src/app
        ├── bookmarks
            ├── page.tsx
        ├── components
            ├── AnimationWrapper.tsx
            ├── Header.tsx
            ├── MovieCard.tsx
        ├── favicon.ico
        ├── global.css
        ├── layout.tsx
        ├── page.tsx
    ├── types
        ├── movie-types.ts
    ├── firebase.config.ts

Enter fullscreen mode Exit fullscreen mode

In the folder structure above, we observe a redux/ folder that houses the features/ folder. Within the features/ folder, we find the bookmarkSlice.ts and bookmarkThunk.ts, along with the hooks.ts and store.ts files. These files will be elaborated upon in the subsequent sections of this tutorial.

Moving on, the src/app directory takes a pivotal role in Next.js as it's where our routes are established. Notably, the layout.ts file serves as the foundational layout for our application, while the page.tsx file represents the primary page that a user sees when accessing localhost:3000.

In Next.js, each subfolder within the src/app directory signifies a distinct route segment. It's imperative that each of these subfolders contains an exported page.tsx file. Examining our current folder structure, we can identify the bookmarks/ folder housing a page.tsx file, which corresponds to the route accessible at localhost:3000/bookmarks.

As we delve further, the components/ folder is used for storing reusable components (for this project: AnimationWrapper.tsx, Header.tsx,and MovieCard.tsx). It's important to note that this folder doesn't serve as a route since it lacks a page.tsx file.

Setting up our redux store

movie-bookmark/redux/store.ts

Following the previously outlined folder structure, create a redux/ folder, inside the folder create a store.ts file then add the following code:

//store.ts

import { configureStore } from "@reduxjs/toolkit";

export const store = configureStore({
  reducer: {},
 middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
      devTools: process.env.NODE_ENV !== "production",
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we import configureStore to create a new Redux configuration called store. This store will hold our imported reducers. We adjust the middleware using getDefaultMiddleware, turning off serializableCheck for non-serializable state values. The devTool option enables the Redux dev tool only in development mode. RootState captures the complete store state using store.getState()'s return type. AppDispatch specifies the dispatch function's type for accurate action dispatching and minimal errors.

movie-bookmark/redux/hooks.ts

Following the previously outlined folder structure, create a new file named hooks.tsinside the redux/ folder and add the following code:

//hooks.ts

import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Enter fullscreen mode Exit fullscreen mode

Frome the code above, instead of repeatedly importing the RootState and AppDispatch types into distinct components, it is preferable to build typed versions of the useDispatch and useSelector hooks. This approach improves the use of these hooks throughout your entire program.

Setting up our redux slice

movie-bookmark/redux/features/bookmarkSlice.ts

Let's start by setting up our bookmarkSlice. To begin, create a types/ declaration folder at the root directory, specifically for our MovieCard component type, bookmark and user state types. These types will later be imported into our bookmarkSlice.ts module.

Create a new file named movie-type.ts inside the types/ folder, and insert the code provided below:

//movie-type.ts

export interface UserProps {
  accessToken: string | any;
  auth: any;
  displayName: string;
  email: string | any;
  emailVerified: boolean;
  isAnonymous: boolean;
  metadata: any;
  phoneNumber: any;
  photoURL: string | any;
  proactiveRefresh: any;
  providerData: any;
  providerId: string | any;
  reloadListener: any | null;
  reloadUserInfo: any;
  tenantId: any | null;
  uid: string;
}

export interface MovieCardProps {
  title: string;
  movieId: number;
  poster_path: string;
  release_date: string;
  backdrop_path: string;
  id?: number;
  movieRating?: number;
  vote_average?: number;
  user: UserProps
}

export interface MovieThunkProp {
  background?: string;
  date?: string;
  poster_path: string;
  id: number;
  title: string;
}
Enter fullscreen mode Exit fullscreen mode

In the provided code snippet, we are introducing three distinct types: UserProps, MovieCardProps, and MovieThunkProps. Each of these types has a specific purpose within our technical implementation, and their explanations are provided below.

The UserProps type pertains to the anticipated payload originating from Firebase authentication. This type describes the structure of the data we expect to receive from this process.

Furthermore, the MovieCardProps type is dedicated to our MovieCard component. This component will be thoroughly examined in the subsequent segment of this article. The MovieCardProps type outlines the expected structure of properties that can be utilized within the MovieCard component.

Lastly, the MovieThunkProps types come into play in the context of the bookmark state as well as interactions with the Firebase database. These types encompass the necessary specifications for handling state related to bookmarking and interfacing with the Firebase database.

Once you have declared the required types, proceed to create a new file named bookmarkSlice.ts within the redux/features/ directory. Insert the following code:

//bookmarkSlice.ts

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { MovieThunkProp, UserProps } from "../../types/movie.type";


interface BookMarkState {
  bookmarked: MovieThunkProp[];
  error: string | null;
  bookmarkError: string | null;
  user: UserProps | null;
}

const initialState: BookMarkState = {
  bookmarked: [],
  error: null,
  bookmarkError: null,
  user: null,
};

const bookmarkSlice = createSlice({
  name: "bookmark",
  initialState,
  reducers: {
    addMovieToBookmarked(state, action: PayloadAction<MovieThunkProp>) {
      state.bookmarked.unshift(action.payload);
    },
    removeFromBookmarked(state, action: PayloadAction<number>) {
      state.bookmarked = state.bookmarked.filter(
        (movie) => movie.id !== action.payload
      );
    },
    addBookmarkFail(state, action: PayloadAction<string>) {
      state.error = action.payload;
    },
    getBookmarkError(state, action: PayloadAction<string>) {
      state.bookmarkError = action.payload;
    },
    setUser(state, action) {
      state.user = action.payload;
    },
    updateBookmarks(state, action: PayloadAction<MovieThunkProp[]>) {
      state.bookmarked = action.payload;
    },
  },
});

export const {
  addMovieToBookmarked,
  removeFromBookmarked,
  addBookmarkFail,
  getBookmarkError,
  setUser,
  updateBookmarks,
} = bookmarkSlice.actions;

export default bookmarkSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

From the code above, we import the createSlice and PayloadAction from redux toolkit, for creating our bookmark slice. We also import our MovieThunKProp and UserProps we created previously.

We then define our state, which holds the following:

  • bookmarked: holds movie objects that are bookmarked.
  • error: A string or null to hold any potential errors related to bookmark actions.
  • bookmarkError: A string or null to hold errors related to bookmark operations.
  • user: A UserProps object or null to store user information.

After defining our state, we then create our slice using the createSlice and name it bookmark, which holds the following reducer functions:

  • addMovieToBookmarked: Adds a movie to the beginning of the bookmarked array.
  • removeFromBookmarked: Removes a movie from the bookmarked array based on its ID.
  • addBookmarkFail: Sets the error field with an error message while adding to the bookmark state.
  • getBookmarkError: Sets the bookmarkError field with an error message while fetching from the database.
  • setUser: Sets the user field with the payload.
  • updateBookmarks: Updates the entire bookmarked array with the payload.

In order to enable Redux's access to our reducers – the components responsible for managing state updates – we need to ensure that our redux/store.ts file includes the appropriate import statement for bookmarkReducer. This particular file was generated in the preceding sections of this guide.:

//store.ts

import { configureStore } from "@reduxjs/toolkit";
import bookmarkReducer from "./features/bookmarkSlice";

export const store = configureStore({
  reducer: {
    bookmark: bookmarkReducer,
  },
 middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
      devTools: process.env.NODE_ENV !== "production",
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

Setting up our redux thunk

movie-bookmark/redux/features/bookmarkThunk.ts

Redux Thunk facilitates the asynchronous process of adding and removing movies from the Firebase bookmarks, as well as fetching movies from the Firebase database.

Navigate to the redux/features directory and generate a new file named bookmarkThunk.ts. This file is responsible for handling user authentication, movie bookmarking, and fetching bookmarked movies using Firebase's Firestore and Authentication services. After creating the bookmarkThunk.ts file, proceed to insert the provided code below:

//bookmarkThunk.ts

import { createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../store";
import { MovieThunkProp } from "../../types/movie.type";
import {
  setDoc,
  deleteDoc,
  collection,
  getDoc,
  doc,
  getDocs,
} from "firebase/firestore";
import { db } from "../../firebase.config";
import {
  addBookmarkFail,
  addMovieToBookmarked,
  getBookmarkError,
  removeFromBookmarked,
  updateBookmarks,
} from "./bookmarkSlice";
import { signOut, signInWithPopup, GoogleAuthProvider } from "firebase/auth";
import { auth } from "../../firebase.config";
import toast from "react-hot-toast";

export const notifySuccess = (message: string) => toast.success(message);
export const notifyError = (message: string) => toast.error(message);

//user logout
export const logout = createAsyncThunk("auth/logout", async () => {
  await signOut(auth);
});

//Google sign-in
export const googleSignIn = createAsyncThunk("auth/googleSignIn", async () => {
  const googleAuthProvider = new GoogleAuthProvider();
  await signInWithPopup(auth, googleAuthProvider);
});

//add movies bookmarks
export const addMovieToBookmarkedDB = createAsyncThunk(
  "bookmark/addMovieToBookmarked",
  async (movie: MovieThunkProp, { dispatch, getState }) => {
    const state = getState() as RootState;
    const user = state.bookmark.user;
    const movieId = movie.id.toString();
    const { background, date, id, poster_path, title } = movie;

    try {
      const bookmarkedItemRef = doc(db, `${user?.uid as string}`, movieId);
      const docSnap = await getDoc(bookmarkedItemRef);

      if (docSnap.exists()) {
        const existItem = docSnap.data();
        dispatch(
          addBookmarkFail(existItem.title + " already an existing item")
        );
      } else {
        notifySuccess(`adding ${title} to bookmarks`);
        await setDoc(doc(db, `${user?.uid as string}`, movieId), {
          background,
          date,
          id,
          poster_path,
          title,
        });
        notifySuccess(`${title} has been successfully added`);
        dispatch(addMovieToBookmarked(movie));
      }
    } catch (error: any) {
      notifyError(`failed to add  ${title}  ${error}`);
      dispatch(
        addBookmarkFail(
          error.response && error.response.data.message
            ? error.response.data.message
            : "Failed to add " + title + ": " + error.message
        )
      );
    }
  }
);

//remove movies from bookmarks
export const removeMovieFromBookmarks = createAsyncThunk(
  "bookmark/removeMovieFromBookmarks",
  async (id: number, { dispatch, getState }) => {
    const state = getState() as RootState;
    const user = state.bookmark.user;
    const movieId = id.toString();
    try {
      dispatch(removeFromBookmarked(id));
      await deleteDoc(doc(db, `${user?.uid as string}`, movieId));
      notifySuccess(`Movie Id: ${id} was successfully deleted`);
    } catch (error: any) {
      notifyError(`failed to remove  ${id}`);
      dispatch({
        type: "ADD_BOOKMARK_FAIL",
        payload:
          error.response && error.response.data.message
            ? error.response.data.message
            : error.message,
      });
    }
  }
);

//retrieve all bookmarked movies
export const getBookmarksFromFirebaseDB = createAsyncThunk(
  "bookmark/getBookmarksFromFirebaseDB",
  async (_, { getState, dispatch }) => {
    const state = getState() as RootState;
    const user = state.bookmark.user;
    const getBookmarkItems = async (db: any) => {
      const bookmarkCol = collection(db, `${user?.uid as string}`);
      const bookmarkSnapshot = await getDocs(bookmarkCol);
      const bookmarkList = bookmarkSnapshot.docs.map(
        (doc) => doc.data() as MovieThunkProp
      );
      return bookmarkList;
    };
    try {
      let allBookmarks = await getBookmarkItems(db);
      dispatch(updateBookmarks(allBookmarks));
    } catch (error: any) {
      dispatch(
        getBookmarkError(
          error.response && error.response.data.message
            ? error.response.data.message
            : error.message
        )
      );
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

The provided code snippet initiates with the import of essential modules, Redux state slices (addBookmarkFail, addMovieToBookmarked, getBookmarkError, removeFromBookmarked, and updateBookmarks), and required types.

Next, we define utility helper functions for notifications: notifySuccess and notifyError. These functions are responsible for presenting success and error messages to users, leveraging the react-hot-toast library.

Furthermore, we create the signOut thunk function, responsible for managing user logout. This function executes asynchronously upon dispatch and aligns with the "auth/logout" action.

We proceed by creating the googleSignIn thunk function, which oversees the Google sign-in procedure. It generates a GoogleAuthProvider instance and employs the signInWithPopup function to initialize the Google sign-in process.

Progressing onwards, the addMovieToBookmarkedDB thunk is established. This function facilitates the addition of movies to the user's bookmarked collection as usersUid/movieId/movieData. By deconstructing background, date, id, poster_path, title from the movie object, it transmits the information to Firestore. Should the movie already be present in the bookmarks, an error action is dispatched. Alternatively, if the movie is successfully added to bookmarks, the success actions are dispatched.

Moving ahead, we construct the removeMovieFromBookmarks thunk, which manages the removal of movies from the user's bookmarked collection. This function interacts with Firestore using the movie's ID, effectively removing it from the collection. Upon successful removal, a notification indicating success is displayed;otherwise, if an issue arises, an error action is dispatched.

Lastly, the getBookmarkFromDB thunk is created to retrieve all bookmarked movies from the Firestore database. Subsequently, it updates the Redux state to reflect the changes.

Making redux accessible in our application

movie-bookmark/context/redux.provider.tsx

After successfully creating our Redux slice and thunk, the next step is to ensure that Redux is accessible throughout our entire application.

Navigate to the root directory of movie-bookmark and establish a new folder named context. Within this folder, create a file named redux.provider.tsx, and insert the provided code snippet:

//redux.provider.tsx

"use client";

import { store } from "../redux/store";
import { Provider } from "react-redux";

export function Providers({ children }: { children: React.ReactNode }) {
  return <Provider store={store}>{children}</Provider>;
}
Enter fullscreen mode Exit fullscreen mode

The code above shows the intergration of the Redux store into our Nextjs application using the Provider component.

From the code above, we utilize the use-client directive to classify this as a client-side component. We then import the store that was created in the earlier section of this article. The Provider component is employed, accepting a children prop that enables the rendering of nested components within the context of the Redux store.

After completing the previous step, navigate to the src/app/layout.tsx file to implement our Provider component within the context of our Next.js application.

//layout.tsx

import "./globals.css";
import type { Metadata } from "next";
import { Montserrat } from "@next/font/google";
import { Inter } from "next/font/google";
import { Providers } from "../../context/redux.provider";

const monstserrat = Montserrat({
  weight: ["400", "700"],
  subsets: ["latin"],
  variable: "--font-monstserrat",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='en'>
      <body className={`${monstserrat.className}  block items-center`}>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the provided code snippet, we've introduced the Provider component, which serves as a wrapper for our application's component structure. Additionally, we've set up the Montserrat font as our chosen font.

Creating our components

movie-bookmark/src/app/components/AnimationWrapper.tsx

Having made redux accessible accross our application, we'll dive into creating our component.

First we create the AnimationWrapper.tsx component which takes in a children props and create an initial animate and exit animations using the framer-motion library.

//AnimationWrapper.tsx

import React, { ReactNode } from "react";
import { motion } from "framer-motion";

const animations = {
  initial: { opacity: 0, x: 10 },
  animate: { opacity: 1, x: 0 },
  exit: { opacity: 0, x: -10 },
};

const AnimatedWrapper = ({ children }: { children: ReactNode }) => {
  return (
    <motion.div
      variants={animations}
      initial='initial'
      animate='animate'
      exit='exit'
      transition={{ duration: 1 }}
    >
      {children}
    </motion.div>
  );
};
export default AnimatedWrapper;
Enter fullscreen mode Exit fullscreen mode

movie-bookmark/src/app/Header.tsx

Moving forward, after creating our AnimationWrapper.tsx component, our next step is to develop the Header.tsx client-component. This component serves multiple purposes:

  • It manages user authentication within the useEffect function, triggering a setUser action.
  • The user information is extracted from the user state using the useAppSelector hook and displayed appropriately.
  • For seamless navigation to the bookmark page, a link is provided, utilizing the usePathname hook.
  • Additionally, this component facilitates the dispatching of logout and googleSignIn thunks, effectively handling potential errors that may arise.
  • To ensure up-to-date notification, the getBookmarksFromFirebaseDB() thunk is invoked. This action continually updates the count of bookmarked movies, which is then indicated as a badge on the bookmark icon.
//Header.tsx

"use client";

import { useState, useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks";
import { googleSignIn } from "../../../redux/features/bookmarkThunk";
import { setUser } from "../../../redux/features/bookmarkSlice";
import { usePathname } from "next/navigation";
import { onAuthStateChanged } from "firebase/auth";
import {
  getBookmarksFromFirebaseDB,
  logout,
} from "../../../redux/features/bookmarkThunk";
import { auth } from "../../../firebase.config";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import Image from "next/image";
import Link from "next/link";

const Header = () => {
  const pathname = usePathname();
  const dispatch = useAppDispatch();
  const user: any = useAppSelector((state) => state.bookmark.user);
  const bookmarkedMovies = useAppSelector((state) => state.bookmark.bookmarked);
  const [firebaseError, setFirebaseError] = useState<string>("");

  const handleGoogleSignIn = async () => {
    try {
      await dispatch(googleSignIn());
    } catch (error: any) {
      setFirebaseError(error.message);
    }
  };

  const handleLogout = () => {
    try {
      dispatch(logout());
    } catch (error: any) {
      setFirebaseError(error.message);
    }
  };

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (currentUser: any) => {
      dispatch(setUser(currentUser));
    });
    dispatch(getBookmarksFromFirebaseDB());

    return () => {
      unsubscribe();
    };
  }, [dispatch]);

  const length = bookmarkedMovies.length;

  return (
    <div className='flex px-4 py-4 items-center bg-black border-b-2 border-slate-600 justify-between'>
      {firebaseError && (
        <h3 className='text-red-600 bg-red-100 px-2 py-2 rounded-md text-sm w-[20em]'>
          {firebaseError}
        </h3>
      )}
      <Link href='/'>
        <h3 className='text-sm text-orange-600'>
          my<span className='text-white'>Bookmarks</span>
        </h3>
      </Link>
      <div className='flex gap-3'>
        <button
          className='px-3 py-2 bg-blue-600 text-sm text-white rounded-full'
          onClick={user ? handleLogout : handleGoogleSignIn}
        >
          {user ? "Sign out" : "Sign in"}
        </button>
        {user && (
          <Image
            src={user?.photoURL ? user?.photoURL : ""}
            alt={user?.email ? user?.email : ""}
            width={500}
            height={500}
            className='w-[30px] h-[30px] rounded-full text-white via-cyan-900 to-stone-500 bg-gradient-to-r max-sm:cursor-pointer'
            data-cy='user-profile-image'
            priority
          />
        )}
        <Link href='/bookmarks'>
          <button
            className={`${
              pathname === "/bookmarked"
                ? "text-orange-400 font-semibold"
                : "text-white"
            } block relative`}
            data-cy='bookmark-icon'
          >
            <span>
              <BookmarkBorderIcon />
            </span>
            {length && (
              <span className='absolute -top-[8px] -right-[10px] w-5 h-5 bg-red-600 rounded-full flex items-center font-normal justify-center text-white text-xs'>
                {user ? `${length}` : "0"}
              </span>
            )}
          </button>
        </Link>
      </div>
    </div>
  );
};
export default Header;
Enter fullscreen mode Exit fullscreen mode

The Header component must be positioned at the top of our web application. To achieve this, navigate to the src.app/layout.tsx file and import the Header component. By placing this component before any child elements, you ensure its display at the top.

//layout.tsx

import "./globals.css";
import type { Metadata } from "next";
import { Montserrat } from "@next/font/google";
import { Inter } from "next/font/google";
import { Providers } from "../../context/redux.provider";
import Header from "./components/Header";

const monstserrat = Montserrat({
  weight: ["400", "700"],
  subsets: ["latin"],
  variable: "--font-monstserrat",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='en'>
      <body className={`${monstserrat.className}  block items-center`}>
        <Providers>
          <Header />
          {children}
        </Providers>
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

movie-bookmark/src/app/MovieCard.tsx

After successfully creating our Header.tsx component, let's proceed to the final component: the MovieCard.tsx component.

This component is designed to display a movie card that includes essential information such as the movie title, release date, poster image, rating (all of which are destructured into our component) and the ability to be bookmarked. The main functionalities of this component are as follows:

  • Bookmarking Feature: The component integrates the addMovieToBookmarkedDB() and removeMovieFromBookmarks() thunks, which are responsible for managing the addition and removal of movies from the Firestore database when dispatched.

  • Existing Movie Check: The component also performs a check to determine if a movie is already present in the bookmark collection using the checkIfItemExists function. It updates the existing state using the setExists function. This update occurs within the useEffect hook, which is triggered whenever an item is added to or removed from the Firestore database.

The user interface (UI) of the component showcases key elements such as the movie poster, title, rating, and release year. Additionally, a bookmark button is provided, allowing users to toggle between bookmarked and unbookmarked states for a particular movie. This dynamic behavior is facilitated by checking the existence of the movie in the Firestore database.

//MovieCard.tsx

/* eslint-disable react-hooks/exhaustive-deps */
"use client";

import { useEffect, useState } from "react";
import { useAppSelector, useAppDispatch } from "../../../redux/hooks";
import {
  addMovieToBookmarkedDB,
  removeMovieFromBookmarks,
} from "../../../redux/features/bookmarkThunk";
import { MovieCardProps } from "../../../types/movie.type";
import { collection, getDocs } from "firebase/firestore";
import { db } from "../../../firebase.config";
import { Toaster } from "react-hot-toast";
import StarIcon from "@mui/icons-material/Star";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import Image from "next/image";

export default function MovieCard({
  title,
  poster_path,
  release_date,
  movieId,
  backdrop_path,
  movieRating,
  user,
}: MovieCardProps) {
  const imagePath = "https://image.tmdb.org/t/p/original";
  const [exists, setExists] = useState(false);
  const bookmarks = useAppSelector((state) => state.bookmark.bookmarked);
  const dispatch = useAppDispatch();

  const movieData = {
    background: backdrop_path,
    date: release_date,
    poster_path: poster_path,
    id: movieId,
    title: title,
  };

  const checkIfItemExists = async () => {
    const bookmarkCol = collection(db, `${user?.uid as string}`);
    const bookmarkSnapshot = await getDocs(bookmarkCol);
    const bookmarkList = bookmarkSnapshot.docs.map((doc) => doc.data());
    const itemExists = bookmarkList.some((item) => item.id === movieId);
    setExists(itemExists);
  };

  const handleAddToBookmark = (movie: any) => {
    try {
      if (user) {
        dispatch(addMovieToBookmarkedDB(movie));
      }
    } catch (error) {
      console.log(error);
    }
  };

  const handleRemoveMovieFromBookmark = (id: number) => {
    if (user) {
      dispatch(removeMovieFromBookmarks(id));
    }
  };

  useEffect(() => {
    checkIfItemExists();
  }, [exists, bookmarks]);

  return (
    <div className='w-fit mt-[20px]'>
      <Toaster />
      <div className='w-[250px]'>
        <Image
          src={imagePath + poster_path}
          alt={title || "movie"}
          className='h-[350px] w-[250px] max-sm:w-[350px] bg-stone-300 transition ease-in-out cursor-pointer hover:brightness-50'
          loading='lazy'
          width={500}
          height={500}
          blurDataURL={imagePath + backdrop_path}
          placeholder='blur'
        />
        <section className='flex items-center justify-between'>
          <div className='block'>
            <h1 className='mt-3 text-sm text-white font-semibold tracking-tight'>
              {title}
            </h1>
            <p className='text-sm flex gap-3 text-slate-400 font-normal mt-1'>
              <span>
                <StarIcon
                  style={{ fontSize: "16px" }}
                  className='text-orange-600'
                />
                {movieRating?.toFixed(1)}
              </span>
              <span>|</span>
              <span> {release_date?.substring(0, 4)}</span>
            </p>
          </div>
          <button
            className='px-3 py-2 text-blue-500 rounded-full hover:text-blue-400'
            onClick={() => {
              if (exists) {
                handleRemoveMovieFromBookmark(movieId);
              } else {
                handleAddToBookmark(movieData);
              }
            }}
          >
            {exists ? (
              <BookmarkIcon
                style={{ fontSize: "20px" }}
                className='text-blue-300  cursor-pointer'
              />
            ) : (
              <BookmarkBorderIcon
                style={{ fontSize: "20px" }}
                className='text-blue-300 cursor-pointer'
              />
            )}
          </button>
        </section>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the given code, you will notice that we are using an imagePath to display our TMDB images. To enable Next.js to access the domain endpoint, we'll configure the domains used in this tutorial within our next.config.ts file. This includes both the TMDB and GOOGLE API domains.

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['image.tmdb.org', 'lh3.googleusercontent.com']
  }
}

module.exports = nextConfig
Enter fullscreen mode Exit fullscreen mode

Creating our movies page

movie-bookmark/src/app/page.tsx

Before we start creating our movie page, we must provide it with the necessary movie data. In this tutorial, we will utilize static movie data acquired from the TMDB API.

To get started, navigate to the root directory named movie-bookmark. Inside the data folder, go ahead and create a file called movie.ts. Once you've completed this step, insert the following static movie data:

//data/movie.ts

export const results = [
  {
    adult: false,
    backdrop_path: "/znUYFf0Sez5lUmxPr3Cby7TVJ6c.jpg",
    id: 447277,
    title: "The Little Mermaid",
    original_language: "en",
    original_title: "The Little Mermaid",
    poster_path: "/ym1dxyOk4jFcSl4Q2zmRrA5BEEN.jpg",
    media_type: "movie",
    genre_ids: [12, 10751, 14, 10749],
    popularity: 2078.238,
    release_date: "2023-05-18",
    video: false,
    vote_average: 6.346,
    vote_count: 1015,
  },
  {
    adult: false,
    backdrop_path: "/ctMserH8g2SeOAnCw5gFjdQF8mo.jpg",
    id: 346698,
    title: "Barbie",
    original_language: "en",
    original_title: "Barbie",
    poster_path: "/iuFNMS8U5cb6xfzi51Dbkovj7vM.jpg",
    media_type: "movie",
    genre_ids: [35, 12, 14],
    popularity: 6058.224,
    release_date: "2023-07-19",
    video: false,
    vote_average: 7.631,
    vote_count: 1130,
  },
  {
    adult: false,
    backdrop_path: "/7drO1kYgQ0PnnU87sAnBEphYrSM.jpg",
    id: 1083862,
    title: "Resident Evil: Death Island",
    original_language: "ja",
    original_title: "バイオハザード:デスアイランド",
    poster_path: "/qayga07ICNDswm0cMJ8P3VwklFZ.jpg",
    media_type: "movie",
    genre_ids: [16, 28, 27],
    popularity: 813.767,
    release_date: "2023-06-22",
    video: false,
    vote_average: 8.347,
    vote_count: 160,
  },
  {
    adult: false,
    backdrop_path: "/fm6KqXpk3M2HVveHwCrBSSBaO0V.jpg",
    id: 872585,
    title: "Oppenheimer",
    original_language: "en",
    original_title: "Oppenheimer",
    poster_path: "/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
    media_type: "movie",
    genre_ids: [18, 36],
    popularity: 1449.266,
    release_date: "2023-07-19",
    video: false,
    vote_average: 8.385,
    vote_count: 668,
  },
  {
    adult: false,
    backdrop_path: "/yF1eOkaYvwiORauRCPWznV9xVvi.jpg",
    id: 298618,
    title: "The Flash",
    original_language: "en",
    original_title: "The Flash",
    poster_path: "/rktDFPbfHfUbArZ6OOOKsXcv0Bm.jpg",
    media_type: "movie",
    genre_ids: [28, 12, 878],
    popularity: 5930.081,
    release_date: "2023-06-13",
    video: false,
    vote_average: 6.923,
    vote_count: 1603,
  },
  {
    adult: false,
    backdrop_path: "/pMCvRynXABgLBMKHYa2UXjTBMsU.jpg",
    id: 615,
    title: "Futurama",
    original_language: "en",
    original_name: "Futurama",
    poster_path: "/7RRHbCUtAsVmKI6FEMzZB6Re88P.jpg",
    media_type: "tv",
    genre_ids: [16, 35, 10765],
    popularity: 677.546,
    release_date: "1999-03-28",
    vote_average: 8.398,
    vote_count: 2755,
    origin_country: ["US"],
  },
  {
    adult: false,
    backdrop_path: "/sa9vB0xb3OMU6iSMkig8RBbdESq.jpg",
    id: 113962,
    title: "Special Ops: Lioness",
    original_language: "en",
    original_name: "Special Ops: Lioness",
    poster_path: "/rXCzevakJoAN1qnZY0nAQPSLVRv.jpg",
    media_type: "tv",
    genre_ids: [18],
    popularity: 270.057,
    release_date: "2023-07-23",
    vote_average: 8.2,
    vote_count: 41,
    origin_country: ["US"],
  },
  {
    adult: false,
    backdrop_path: "/2vFuG6bWGyQUzYS9d69E5l85nIz.jpg",
    id: 667538,
    title: "Transformers: Rise of the Beasts",
    original_language: "en",
    original_title: "Transformers: Rise of the Beasts",
    poster_path: "/gPbM0MK8CP8A174rmUwGsADNYKD.jpg",
    media_type: "movie",
    genre_ids: [28, 12, 878],
    popularity: 5458.192,
    release_date: "2023-06-06",
    video: false,
    vote_average: 7.469,
    vote_count: 1909,
  },
  {
    adult: false,
    backdrop_path: "/kIMYSzp1fH1H9adKplekLD9BuNi.jpg",
    id: 1003581,
    title: "Justice League: Warworld",
    original_language: "en",
    original_title: "Justice League: Warworld",
    poster_path: "/9tx4cD3cuHhrdqLwFw8TTbfSKH2.jpg",
    media_type: "movie",
    genre_ids: [16, 28, 878],
    popularity: 121.482,
    release_date: "2023-07-25",
    video: false,
    vote_average: 7.421,
    vote_count: 19,
  },
  {
    adult: false,
    backdrop_path: "/av2wp3R978lp1ZyCOHDHOh4FINM.jpg",
    id: 736769,
    title: "They Cloned Tyrone",
    original_language: "en",
    original_title: "They Cloned Tyrone",
    poster_path: "/hnzXoDaK346U4ByfvQenu2DZnTg.jpg",
    media_type: "movie",
    genre_ids: [35, 878, 9648],
    popularity: 128.81,
    release_date: "2023-06-14",
    video: false,
    vote_average: 7.022,
    vote_count: 115,
  },
];

Enter fullscreen mode Exit fullscreen mode

After inserting the movie data, the movie page component functions as the primary webpage of the application and is reachable by navigating to the route localhost:3000. Within this component, our initial task is to substitute the default JSX code that is created by Next.js upon installation. Instead, we will integrate the subsequent code:

"use client";

import { useEffect } from 'react';
import { results } from "../../data/movie";
import { useAppSelector } from "../../redux/hooks";
import MovieCard from "./components/MovieCard";
import AnimatedWrapper from "./components/AnimationWrapper";
import { getBookmarksFromFirebaseDB } from "../../redux/features/bookmarkThunk";
import { useAppDispatch } from '../../redux/hooks';


export default function Home() {
  const user: any = useAppSelector((state) => state.bookmark.user);
  const dispatch = useAppDispatch();


  useEffect(() => {
    dispatch(getBookmarksFromFirebaseDB());
  }, [dispatch])

  return (
    <>
      <div className='flex flex-col items-center justify-center h-auto px-4 py-4'>
        <AnimatedWrapper>
          <div className='grid grid-cols-4 max-md:grid-cols-2 gap-6  items-center max-sm:flex max-sm:justify-center max-sm:flex-col'>
            {results.map((movie) => {
              return (
                <div key={movie?.id}>
                  <MovieCard
                    title={movie?.title as string}
                    movieId={movie?.id as number}
                    poster_path={movie?.poster_path as string}
                    backdrop_path={movie?.backdrop_path as string}
                    release_date={movie?.release_date as string}
                    movieRating={movie?.vote_average as number}
                    user={user}
                  />
                </div>
              );
            })}
          </div>
        </AnimatedWrapper>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The provided code snippet involves importing various components and data. We first import the static data from data/movie.ts, which contains movie information. Additionally, we import the MovieCard component, previously designed for presenting movie details, and the AnimationWrapper component, responsible for adding a fading effect to our movie cards.

Next, we proceed to iterate through the movie data. During this iteration, we generate individual instances of the MovieCard for each movie. We ensure that pertinent details like title, IDs, paths, dates, ratings, and user information are appropriately passed on.

To maintain the accurate display of bookmarked movies present in Firestore, we employ the getBookmarksFromDB thunk. This thunk is dispatched and triggers updates whenever a re-render occurs. By employing this approach, we guarantee that movies existing in Firestore and marked as bookmarks are correctly displayed on the page.

Creating our bookmarks page

movie-bookmark/src/app/bookmarks/page.tsx

After completing the setup of our movies page, our next step involves establishing the bookmark page functionality. This page is responsible for retrieving bookmarks from the Firebase database and displaying them.

To proceed, navigate to the src/app/bookmarks directory. Inside this directory, create a new file named page.tsx. Once the file is created, insert the provided code:

"use client";

import { useState, useEffect } from "react";
import {useAppSelector, useAppDispatch } from "../../../redux/hooks";
import {
  getBookmarksFromFirebaseDB,
  removeMovieFromBookmarks,
} from "../../../redux/features/bookmarkThunk";
import { Toaster } from "react-hot-toast";
import DeleteIcon from "@mui/icons-material/Delete";
import AnimatedWrapper from "../components/AnimationWrapper";
import Image from "next/image";


const Bookmark = () => {
  const imagePath = "https://image.tmdb.org/t/p/original";
  const bookmarkedMovies = useAppSelector((state) => state.bookmark.bookmarked);
  const dispatch = useAppDispatch();

  useEffect(() => {
    dispatch(getBookmarksFromFirebaseDB());
  }, [dispatch]);



  return (
    <AnimatedWrapper>
      <div className='flex flex-col items-center justify-center px-6 py-6 w-[100vw] overflow-x-hidden'>
        <Toaster />
        <h1 className='font-semibold mb-[20px] text-white'>My Bookmarks</h1>
        <div>
          {bookmarkedMovies?.length === 0 ? (
            <h2>Sorry no bookmarks :(</h2>
          ) : (
            <>
              <div className='grid grid-cols-4 max-md:grid-cols-2 gap-6 items-center max-sm:flex max-sm:justify-center max-sm:flex-col'>
                {bookmarkedMovies?.map((movie: any) => (
                  <div key={movie?.id} className='w-[250px]'>
                    <Image
                      src={imagePath + movie?.poster_path}
                      alt={`${movie?.title || ""}`}
                      className='h-[350px] w-[250px] bg-stone-300 transition ease-in-out cursor-pointer hover:brightness-50'
                      loading='lazy'
                      width={500}
                      height={500}
                      blurDataURL={imagePath + movie?.poster_path}
                      placeholder='blur'
                    />
                    <div className='flex gap-2 relative -mt-[20em] float-right px-2'>
                      <button
                        title='bookmark movie'
                        className={`text-xs bg-white text-slate-500 px-3 py-3 hover:scale-110 transition ease-in-out rounded-full`}
                        onClick={() =>
                          dispatch(removeMovieFromBookmarks(movie?.id))
                        }
                      >
                        <DeleteIcon className='text-red-500' />
                      </button>
                    </div>
                    <h1 className='mt-3 text-sm text-white font-semibold tracking-tight'>
                      {movie?.title}
                    </h1>
                    <p className='text-slate-400 font-normal mt-1'>
                      <span>{movie?.date?.substring(0, 4)}</span>
                    </p>
                  </div>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    </AnimatedWrapper>
  );
};

export default Bookmark;
Enter fullscreen mode Exit fullscreen mode

In the provided code snippet, we retrieve the bookmarked movie from Firestore by executing the getBookmarksFromFirebaseDB thunk. This action updates the state of our bookmarkMovies. If users wish to remove movies from their bookmarks, this action is initiated through Redux, specifically using the removeMovieFromBookmarks action.

Running our application

Congratulations! You've made it to this point. To witness our application in action, follow these steps:

  1. Open your command prompt.
  2. Enter the following command to run the application:
npm run dev
Enter fullscreen mode Exit fullscreen mode

You can view your application live by accessing at http://localhost:3000.

Image description

Conclusion

This article guides you through the process of configuring and utilizing Redux Toolkit and Firebase within your Next.js 13.4 application. Furthermore, we illustrate the creation of a functional movie bookmark feature, showcasing the prowess of the Redux store in your Next.js project. By integrating these tools, you can proficiently handle your application's state and retrieve data from Firebase in an organized and streamlined manner.

I trust that you have found this tutorial beneficial. If you possess any feedback or questions, kindly share them in the comments section.

Top comments (2)

Collapse
 
ngozi profile image
Eunice

👏👏👏

Collapse
 
ebenezer15 profile image
Ebenezer-15

👏👏👏