inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

[인프런 워밍업 스터디 클럽 3기 풀스택] 3주차 발자국

jomootgrn88
0

강의수강

recoil

react-intersection-observer

useInfiniteQuery

  const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      initialPageParam: 1,
      queryKey: ["movie", search],
      queryFn: ({ pageParam }) =>
        searchMovies({
          search,
          pageSize,
          page: pageParam,
          favoriteCount,
        }),
      getNextPageParam: (lastPage) => {
        if (lastPage.data.length < pageSize) {
          return null;
        }
        return lastPage.page ? lastPage.page + 1 : null;
      },
    });

미션

사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현

type MovieUpdate = Database["public"]["Tables"]["movie"]["Update"];

export async function updateMovie(movie: MovieUpdate) {
  const supabase = await createServerSupabaseClient();

  const { error } = await supabase
    .from("movie")
    .update({
      ...movie,
    })
    .eq("id", movie.id);

  handleError(error);
}
export default function MovieFavoriteIcon({ favorite }: { favorite: boolean }) {
  return (
    <>
      {favorite ? (
        <i
          className="fa-solid fa-heart font-bold"
          style={{ fontSize: 25, color: "#ff9fd2" }}
        />
      ) : (
        <i
          className="fa-regular fa-heart"
          style={{ fontSize: 25, color: "#fff" }}
        />
      )}
    </>
  );
}

import Link from "next/link";
import type { Movie } from "actions/movie-actions";
import MovieFavoriteIcon from "./movie-favorite-icon";

export default function MovieCard({ movie }: { movie: Movie }) {
  return (
    <div className="col-span-1 relative">
      <img src={movie.image_url} className="w-full" />

      <Link href={`/movies/${movie.id}`}>
        <div className="absolute top-0 right-0 px-1.5 py-2">
          <MovieFavoriteIcon favorite={movie.favorite} />
        </div>
        <div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex justify-center items-center bg-black opacity-0 hover:opacity-80 transition-opacity duration-300">
          <p className="text-xl font-bold text-white">{movie.title}</p>
        </div>
      </Link>
    </div>
  );
}

"use client";

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Movie, updateMovie } from "actions/movie-actions";
import { queryClient } from "config/react-query-client-provider";
import MovieFavoriteIcon from "components/movie-favorite-icon";

export default function UI({ movie }: { movie: Movie }) {
  const [isFavorite, setIsFavorite] = useState(movie.favorite);

  const updateMovieMutation = useMutation({
    mutationFn: () =>
      updateMovie({
        ...movie,
        favorite: !isFavorite,
      }),
    onSuccess: () => {
      setIsFavorite((prev) => !prev);
      queryClient.invalidateQueries({ queryKey: ["movie"] });
    },
  });

  return (
    <div className="flex flex-col md:flex-row items-center">
      <img src={movie.image_url} className="w-1/3 xl:w-1/4" />
      <div className="w-full md:w-2/3 items-center md:items-start flex flex-col p-6 gap-4">
        <div className="flex justify-between items-center w-full">
          <h1 className="text-3xl font-bold">{movie.title}</h1>
          <div>
            <button
              className="bg-pink-50 border-2 border-pink-100 rounded-md px-1.5 py-1"
              onClick={() => updateMovieMutation.mutate()}
            >
              <MovieFavoriteIcon favorite={isFavorite} />
            </button>
          </div>
        </div>

        <p className="text-lg font-medium">{movie.overview}</p>
        <div className="font-bold text-lg">
          <i className="fas fa-star mr-1" />
          Vote Average : {movie.vote_average}
        </div>
        <div className="font-bold text-lg">Popularity: {movie.popularity}</div>
        <div className="font-bold text-lg">
          Release Date: {movie.release_date}
        </div>
      </div>
    </div>
  );
}

찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬

위에서 추가했던 movie 테이블의 favorite 컬럼을 기준으로, 무한 스크롤 movie 요청시 찜한 영화를 우선해 가지고 오는 것을 목표로 했다.

찜한 영화 favoriteMovie , 나머지 기본 영화 remainMovie 를 각각 가지고 오는 방식으로,

  1. 예를 들어 favoriteMovie 가 6개이고 pageSize 가 12인 경우

  1. favoriteMovie 가 13개이고 pageSize 가 12인 경우

favoriteMoive 를 먼저 pageSize 만큼 가지고 오고 favoriteMoive 의 수가 pageSize 보다 작은 경우 remainMovie 를 가지고 오는 방식이다.

여기서 가장 중요한 것은 각각의 영화들의 range 다. 기존 pagepageSize 를 기준으로 range 를 정하는 경우 remainMovie 를 쿼리할 때 중간에 지나가는 경우가 생긴다. 위 2번 째 예의 page = 2일 때 remainMovie 의 범위는 0 ~ 10 이여야 한다. 이전 Movie들의 정보를 알아야 구할 수 있는데, 현재 코드에서는 이를 간단하게 구할 좋은 방법이 떠오르지 않았다. 따라서 supabase에 저장한 favoriteMovie 의 수를 미리 가지고와 이를 이용해 favoriteCount , page, pageNumber 세 개의 값들을 조합해 범위를 구했다.

function getRange(favoriteCount: number, page: number, pageSize: number) {
  const start = (page - 1) * pageSize; // 현재 페이지의 시작 인덱스
  const end = start + pageSize - 1; // 현재 페이지의 마지막 인덱스

  const favStart = start < favoriteCount ? start : favoriteCount;
  const favEnd = Math.min(end, favoriteCount - 1);
  const favLength = Math.max(favEnd - favStart + 1, 0);

  const remStart = Math.max(0, start - favoriteCount);
  const remEnd = remStart + (pageSize - favLength) - 1;
  const remLength = Math.max(remEnd - remStart + 1, 0);

  return {
    favoriteRange: favLength > 0 ? [favStart, favEnd] : [],
    remainRange: remLength > 0 ? [remStart, remEnd] : [],
  };
}

getRange 함수는 favoriteCount , page, pageNumber 세 개의 값들을 이용해 favoriteMovie , remainMovie 의 범위를 구해 반환하는 함수다.

현재 페이지의 시작 인덱스 start 가 찜한 영화의 수 favoriteCount 보다 작은 경우 이번 페이지에서는 찜한 영화만을 가지고 올 거기에 favStartstart 가 된다. 크거나 같은 경우에는 찜한 영화가 부족함으로 favoriteCount 값으로 설정한다.

favEnd = endfavoriteCount 보다 큰 경우, 존재하는 찜한 영화 개수까지만 가져온다.

remStart = 찜한 영화 개수를 넘어서야 나머지 영화를 가지고 오므로 start - favoriteCount 가 0보다 큰 경우인지 확인하면 값을 설정

remEnd = 찜한 영화 개수(favLength ) 를 채운 후 남은 공간만큼 나머지 영화를 가지고 온다

최종 변경 코드

actions/movie-actions.ts

async function searchMoviesByFavorite(
  search: string,
  range: number[],
  isFavorite: boolean
) {
  if (range.length === 0) {
    return [];
  }
  const [start, end] = range;

  const supabase = await createServerSupabaseClient();

  const { data, error } = await supabase
    .from("movie")
    .select("*")
    .like("title", `%${search}%`)
    .eq("favorite", isFavorite)
    .order("id")
    .range(start, end);

  handleError(error);

  return data;
}

export async function searchMovies({
  search,
  page,
  pageSize,
  favoriteCount,
}: {
  search: string;
  page: number;
  pageSize: number;
  favoriteCount: number;
}) {
  const { favoriteRange, remainRange } = getRange(
    favoriteCount,
    page,
    pageSize
  );
  const [favoriteMovies, remainMovies] = await Promise.all([
    searchMoviesByFavorite(search, favoriteRange, true),
    searchMoviesByFavorite(search, remainRange, false),
  ]);

  const data = [...favoriteMovies, ...remainMovies];

  return {
    data,
    page,
    pageSize,
    hasNextPage: data.length === pageSize,
  };
}

app/page.tsx

// page.tsx
import { createServerSupabaseClient } from "utils/supabase/server";
import UI from "./ui";

export const metadata = {
  title: "TMDBFLIX",
  description: "Netflix clone using TMDB API",
};

export default async function Page() {
  const supabase = await createServerSupabaseClient();
  const { count } = await supabase
    .from("movie")
    .select("*", { count: "exact", head: true })
    .eq("favorite", true);

  return <UI favoriteCount={count} />;
}

// ui.tsx
"use client";

import MovieCardList from "components/movie-card-list";

export default function UI({ favoriteCount }: { favoriteCount: number }) {
  return (
    <main className="mt-14 mb-12">
      <MovieCardList favoriteCount={favoriteCount} />
    </main>
  );
}

MovieCardList 컴포넌트에서 사용할 수 있도록 총 찜한 영화의 수를 쿼리해서 prop으로 전달

components/Movie-card-list.tsx

export default function MovieCardList({
  favoriteCount,
}: {
  favoriteCount: number;
}) {
  const search = useRecoilValue(searchState);

  const pageSize = 12;

  const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      initialPageParam: 1,
      queryKey: ["movie", search],
      queryFn: ({ pageParam }) =>
        searchMovies({
          search,
          pageSize,
          page: pageParam,
          favoriteCount,
        }),
      getNextPageParam: (lastPage) => {
        if (lastPage.data.length < pageSize) {
          return null;
        }
        return lastPage.page ? lastPage.page + 1 : null;
      },
    });
    ...
  }

찜한 영화의 수 favoriteCountuseInfiniteQuery queryFn searchMovies 에 추가로 전달

답변 0