본문 바로가기
  • 기록
React/인프런

[(인프런)따라하며 배우는 노드, 리액트 시리즈] 영화 사이트 만들기

by juserh 2022. 5. 3.

#2 boiler-plate & mongodb 연결

파일 구성

  • client: react
  • server: node.js

#3 The moviedb API 설명

api 사용 시에 동일하게 계속 이용되는 부분은 아예 constant로 값 저장해놓고 사용하자


#4 landing page 만들기(1)

1. 전체적인 template 간단하게 만들기

2. Movie API에서 가져온 모든 데이터를 STATE에 넣기

3. MainImage Component 만들기

4. Grid Card Component 만들기

5. Load More Function 만들기

 


[1] [HPM] Proxy created: / -> http://localhost:5000 [1] i 「wds」: Project is running at http://172.30.1.28/ [1] i 「wds」: webpack output is served from [1] i 「wds」: Content not from webpack is served from C:\2022-prj\boilerplate-mern-stack-master\client\public [1] i 「wds」: 404s will fallback to / [1] Starting the development server... [1] [1] Browserslist: caniuse-lite is outdated. Please run: [1] npx browserslist@latest --update-db [1] Compiled with warnings. [1] [1] ./src/components/views/LandingPage/LandingPage.js [1] Line 2:10: 'FaCode' is defined but never used no-unused-vars [1] [1] ./src/components/views/LoginPage/LoginPage.js [1] Line 74:11: 'dirty' is assigned a value but never used no-unused-vars [1] Line 79:11: 'handleReset' is assigned a value but never used no-unused-vars [1] [1] ./src/components/views/RegisterPage/RegisterPage.js [1] Line 92:11: 'dirty' is assigned a value but never used no-unused-vars [1] Line 97:11: 'handleReset' is assigned a value but never used no-unused-vars [1] [1] Search for the keywords to learn more about each warning. [1] To ignore, add // eslint-disable-next-line to the line before. [1]

npx browserslist@latest --update-db

(react가 설치된, 실행되는 폴더로 터미널 이동해서 위 명령어 입력, 내 경우엔 client폴더로 이동)


{MainMovieImage && (
        <MainImage
          image={`${IMAGE_BASE_URL}w1280${MainMovieImage.backdrop_path}`}
          title={MainMovieImage.original_title}
          text={MainMovieImage.overview}
        />
      )}

MainMovieImage 데이터 가져온 다음 렌더링하고자 할 때 위와 같은 식으로 코드 작성


#5 Grid Card Component


const [Movies, setMovies] = useState([]);
  const [MainMovieImage, setMainMovieImage] = useState(null);

  useEffect(() => {
    const endpoint = `${API_URL}movie/popular?api_key=${API_KEY}&language=en-US&page=1`;

    fetch(endpoint)
      .then((response) => response.json())
      .then((response) => {
        console.log(response.results);
        setMovies(response.results);
        setMainMovieImage(response.results[0]);
      });
  }, []);

grid card 영화들이 안떠서 setMovies([response.results])를

setMovies(response.result)로 수정하니 제대로 작동

response.results 자체가 이미 배열이여서 그런 듯..? 그런데 강의는 어떻게 된건지...


#6 Load More Button

const [Movies, setMovies] = useState([]);
  const [MainMovieImage, setMainMovieImage] = useState(null);
  const [CurrentPage, setCurrentPage] = useState(0);

  useEffect(() => {
    const endpoint = `${API_URL}movie/popular?api_key=${API_KEY}&language=en-US&page=1`;

    fetchMovies(endpoint);
  }, []);

  const fetchMovies = (endpoint) => {
    fetch(endpoint)
      .then((response) => response.json())
      .then((response) => {
        console.log(response);
        setMovies([...Movies, ...response.results]); //여기 배열 주목
        setMainMovieImage(response.results[0]);
        setCurrentPage(response.page);
      });
  };

  const loadMoreItems = () => {
    const endpoint = `${API_URL}movie/popular?api_key=${API_KEY}&language=en-US&page=${
      CurrentPage + 1
    }`;
    fetchMovies(endpoint);
  };

useState 훅을 이용해서 CurrentPage라는 state과 setCurrentPage라는 state변경함수를 생성한다. 본래 themoviedb의 특정 페이지에 존재하는 영화 리스트들을 받아와 화면에 보여주었는데, 이 페이지 수를 하나씩 늘려가면서 영화 리스트들을 추가로 렌더링해 보여주기 위해서 state을 이용한다. <button>의 onClick속성에 loadMoreItems 함수를 지정하여 버튼을 클릭하면 영화 리스트들이 추가로 보여진다.

또 위 코드들 중 fetchMovies 함수에서

setMovies[...Movies, ...response.results])

이 코드를 주의해서 봐야한다. 그냥 setMovies(response.result)라고 하면 새로 받아온 영화 리스트들만 보여진다. 하지만 우리는 처음 받아온 영화 리스트에 새로 받아온 영화 리스트를 추가하여 보여줄 것이므로 위와 같이 작성해야 한다.


#7 Movie Detail 페이지 만들기

//MovieDetail.js
import React, { useEffect, useState } from "react";
import { API_URL, API_KEY, IMAGE_BASE_URL } from "../../Config";
import MainImage from "../LandingPage/Sections/MainImage";
import MovieInfo from "./Sections/MovieInfo";

function MovieDetail(props) {
  let movieId = props.match.params.movieId;
  const [Movie, setMovie] = useState([]);

  useEffect(() => {
    let endpointCrew = `${API_URL}movie/${movieId}/credits?api_key=${API_KEY}`;

    let endpointInfo = `${API_URL}movie/${movieId}?api_key=${API_KEY}`;
    fetch(endpointInfo)
      .then((response) => response.json())
      .then((response) => {
        console.log(response);
        setMovie(response);
      });
  });
  return (
    <div>
      {/* Header */}
      <MainImage
        image={`${IMAGE_BASE_URL}w1280${Movie.backdrop_path}`}
        title={Movie.original_title}
        text={Movie.overview}
      />
      {/* Body */}
      <div style={{ width: "85%", margin: "1rem auto" }}>
        {/* Movie Info */}
        <MovieInfo movie={Movie} />
        <br />
        {/* Actors Grid */}
      </div>
      <div
        style={{ display: "flex", justifyContent: "center", margin: "2rem" }}
      >
        <button>Toggle Actor View</button>
      </div>
    </div>
  );
}

export default MovieDetail;

 

//MovieInfo.js
import React from "react";
import { Descriptions, Badge } from "antd";

function MovieInfo(props) {
  let { movie } = props;

  return (
    <Descriptions title="Movie Info" bordered>
      <Descriptions.Item label="Title">
        {movie.original_title}
      </Descriptions.Item>
      <Descriptions.Item label="release_date">
        {movie.release_date}
      </Descriptions.Item>
      <Descriptions.Item label="revenue">{movie.revenue}</Descriptions.Item>
      <Descriptions.Item label="runtime">{movie.runtime}</Descriptions.Item>
      <Descriptions.Item label="vote_average" span={2}>
        {movie.vote_average}
      </Descriptions.Item>
      <Descriptions.Item label="vote_count">
        {movie.vote_count}
      </Descriptions.Item>
      <Descriptions.Item label="status">{movie.status}</Descriptions.Item>
      <Descriptions.Item label="popularity">
        {movie.popularity}
      </Descriptions.Item>
    </Descriptions>
  );
}

export default MovieInfo;

 

랜딩페이지에 보여지는 영화 리스트 중 특정 영화(이미지)를 클릭하면 해당 영화의 상세 정보가 보여지는 페이지로 이동한다.

이 과정이 가능하게 하는 코드를 살펴보려면 GridCard 컴포넌트를 봐보자.

//GridCard.js
import React from "react";
import { Col } from "antd";

function GridCards(props) {
  return (
    <Col lg={6} md={8} xs={24}>
      <div style={{ position: "relative" }}>
        <a href={`/movie/${props.movieId}`}>
          <img
            style={{ width: "100%", height: "320px" }}
            src={props.image}
            alt={props.movieName}
          />
        </a>
      </div>
    </Col>
  );
}

export default GridCards;

이 코드를 살펴보면 영화 이미지를 <a>태그로 감싸서 href속성을 달아놓았다. 그래서 /movie/:movieId 페이지로 이동할 수 있는 것이다.

그리고 이 페이지을 실제로 이용하기 위해서는 app.js 파일에 해당 url을 등록해주어야 하는데,

//app.js
import React, { Suspense } from "react";
import { Route, Switch } from "react-router-dom";
import Auth from "../hoc/auth";
// pages for this product
import LandingPage from "./views/LandingPage/LandingPage.js";
import LoginPage from "./views/LoginPage/LoginPage.js";
import RegisterPage from "./views/RegisterPage/RegisterPage.js";
import NavBar from "./views/NavBar/NavBar";
import Footer from "./views/Footer/Footer";
import MovieDetail from "./views/MovieDetail/MovieDetail";

//null   Anyone Can go inside
//true   only logged in user can go inside
//false  logged in user can't go inside

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <NavBar />
      <div style={{ paddingTop: "69px", minHeight: "calc(100vh - 80px)" }}>
        <Switch>
          <Route exact path="/" component={Auth(LandingPage, null)} />
          <Route exact path="/login" component={Auth(LoginPage, false)} />
          <Route exact path="/register" component={Auth(RegisterPage, false)} />
          <Route
            exact
            path="/movie/:movieId"
            component={Auth(MovieDetail, null)}
          />
        </Switch>
      </div>
      <Footer />
    </Suspense>
  );
}

export default App;

위 코드 중

<Route
            exact
            path="/movie/:movieId"
            component={Auth(MovieDetail, null)}
          />

이 부분을 주의해서 보면 된다.


#8 영화 출연진들 가져오기

//MovieDetail.js
import React, { useEffect, useState } from "react";
import { API_URL, API_KEY, IMAGE_BASE_URL } from "../../Config";
import MainImage from "../LandingPage/Sections/MainImage";
import MovieInfo from "./Sections/MovieInfo";
import GridCards from "../commons/GridCards";
import { Row } from "antd";

function MovieDetail(props) {
  let movieId = props.match.params.movieId;
  const [Movie, setMovie] = useState([]);
  const [Casts, setCasts] = useState([]);
  const [ActorToggle, setActorToggle] = useState(false);

  useEffect(() => {
    let endpointCrew = `${API_URL}movie/${movieId}/credits?api_key=${API_KEY}`;

    let endpointInfo = `${API_URL}movie/${movieId}?api_key=${API_KEY}`;
    fetch(endpointInfo)
      .then((response) => response.json())
      .then((response) => {
        console.log(response);
        setMovie(response);
      });

    fetch(endpointCrew)
      .then((response) => response.json())
      .then((response) => {
        setCasts(response.cast);
      });
  }, {});

  const toggleActorView = () => {
    setActorToggle(!ActorToggle);
  };

  return (
    <div>
      {/* Header */}
      {Movie && (
        <MainImage
          image={`${IMAGE_BASE_URL}w1280${Movie.backdrop_path}`}
          title={Movie.original_title}
          text={Movie.overview}
        />
      )}
      {/* Body */}
      <div style={{ width: "85%", margin: "1rem auto" }}>
        {/* Movie Info */}
        <MovieInfo movie={Movie} />
        <br />
        {/* Actors Grid */}
        <div
          style={{ display: "flex", justifyContent: "center", margin: "2rem" }}
        >
          <button onClick={toggleActorView}>Toggle Actor View </button>
        </div>

        {ActorToggle && (
          <Row gutter={[16, 16]}>
            {Casts &&
              Casts.map((cast, index) => (
                <React.Fragment key={index}>
                  <GridCards
                    image={
                      cast.profile_path
                        ? `${IMAGE_BASE_URL}w500${cast.profile_path}`
                        : null
                    }
                    characterName={cast.name}
                  />
                </React.Fragment>
              ))}
          </Row>
        )}
      </div>
    </div>
  );
}

export default MovieDetail;

배우 출력 관련 주로 봐야하는 것은

  • useState훅으로 [Cast, setCast]
  • useState훅으로 [ActorToggle, setActorToggle]

를 생성하여 각 state으로 cast데이터 정보 받아오고, 버튼 state이 바뀌면서 cast정보 출력 여부를 결정한다는 것이다.


 

위 두개와 같은 warning이 뜨는데 뭐가 문젠지 알아봐야겠다....좀 이따가..


#9,10,11,12 Favorite 버튼 만들기, Favorite 리스트에서 영화 추가 및 삭제

1. Favorite Model 만들기

2. Favorite Button UI 만들기

3. 얼마나 많은 사람이 이 영화를 Favorite 리스트에 넣었는지 그 숫자 정보 얻기

4. 내가 이 영화를 이미 Favorite리스트에 넣었는지 아닌지 정보 얻기

5. 데이터를 화면에 보여주기

 

// server/models/favorite.js
const { Schema } = require("mongoose");
const mongoose = require("mongoose");

const favoriteSchema = mongoose.Schema(
  {
    userFrom: {
      type: Schema.Types.ObjectId,
      ref: "User",
    },
    movieId: {
      type: String,
    },
    movieTitle: {
      type: String,
    },
    moviePost: {
      type: String,
    },
    movieRunTime: {
      type: String,
    },
  },
  { timestamps: true }
);

const Favorite = mongoose.model("Favorite", favoriteSchema);

module.exports = { Favorite };

server 폴더에서 favorite 모델을 생성한다. favorite을 누른 사용자 정보도 저장하기 위해서 userFrom에서는 User모델을 참조하여 userId를 가져온다.


favoriteNumber 실행하니까 위 두 에러가 계속 뜸

1차시도: bcrypt 5.0.0으로 버전 바꿔봄 -> 안됨

2차시도: bcryptjs 라이브러리로 바꿔봄 -> 안됨, 다른 스키마 에러 메시지 뜸

3차시도: ..를 찾아보니 nodejs, vsc 재설치해서 해결했다는데..아놔.....전에는 잘 됐는데 왜 이러지..?

https://www.inflearn.com/questions/42246

 

저 역시 ECONNREFUSED 에러가 생깁니다. - 인프런 | 질문 & 답변

[HPM] Error occurred while trying to proxy request /api/users/auth from localhost:3000 to http://localhost:5000 (ECONNREFUSED) (https://nodejs.org/api/e...

www.inflearn.com

놀랍게도 단순히 오타로 인한 오류였다는 것...!두둥...(^^)

 


favorite 관련 api

const express = require("express");
const router = express.Router();
const { Favorite } = require("../models/Favorite");

router.post("/favoriteNumber", (req, res) => {
  //mongodb에서 favorite숫자를 가져오기
  Favorite.find({ movieId: req.body.movieId }).exec((err, info) => {
    if (err) return res.status(400).send(err);
    //그 다음에 프론트에 다시 숫자 정보 보내주기
    res.status(200).json({ success: true, favoriteNumber: info.length });
  });
});

router.post("/favorited", (req, res) => {
  //내가 이 영화를  Favorite 리스트에 넣었는지 정보를 DB에서 가져오기
  Favorite.find({
    movieId: req.body.movieId,
    userFrom: req.body.userFrom,
  }).exec((err, info) => {
    if (err) return res.status(400).send(err);

    let result = false;
    if (info.length !== 0) {
      result = true;
    }
    res.status(200).json({ success: true, favorited: result });
  });
});

router.post("/removeFromFavorite", (req, res) => {
  Favorite.findOneAndDelete({
    movieId: req.body.movieId,
    userFrom: req.body.userFrom,
  }).exec((err, doc) => {
    if (err) return res.status(400).send(err);
    return res.status(200).json({ success: true });
  });
});

router.post("/addToFavorite", (req, res) => {
  const favorite = new Favorite(req.body);
  favorite.save((err, doc) => {
    if (err) return res.status(400).send(err);
    return res.status(200).json({ success: true });
  });
});

module.exports = router;

 

client에서 데이터 받아오기

import React, { useEffect, useState } from "react";
import Axios from "axios";
import { Button } from "antd";

function Favorite(props) {
  const movieId = props.movieId;
  const userFrom = props.userFrom;
  const movieTitle = props.movieInfo.title;
  const moviePost = props.movieInfo.backdrop_path;
  const movieRunTime = props.movieInfo.runtime;

  const [FavoriteNumber, setFavoriteNumber] = useState(0);
  const [Favorited, setFavorited] = useState(false);

  let variables = {
    userFrom: userFrom,
    movieId: movieId,
    movieTitle: movieTitle,
    moviePost: moviePost,
    movieRunTime: movieRunTime,
  };
  useEffect(() => {
    Axios.post("/api/favorite/favoriteNumber", variables).then((response) => {
      if (response.data.success) {
        console.log(response.data);
        setFavoriteNumber(response.data.favoriteNumber);
      } else {
        alert("숫자 정보를 가져오는데 실패했습니다.");
      }
    });

    Axios.post("/api/favorite/favorited", variables).then((response) => {
      if (response.data.success) {
        console.log(response.data);
        setFavorited(response.data.favorited);
      } else {
        alert("정보를 가져오는데 실패했습니다.");
      }
    });
  });

  const onClickFavorite = () => {
    if (Favorited) {
      Axios.post("/api/favorite/removeFromFavorite", variables).then(
        (response) => {
          if (response.data.success) {
            setFavorited(!Favorited);
            setFavoriteNumber(FavoriteNumber - 1);
          } else {
            alert("Favorite 리스트에서 지우는 것을 실패했습니다.");
          }
        }
      );
    } else {
      Axios.post("/api/favorite/addToFavorite", variables).then((response) => {
        if (response.data.success) {
          setFavorited(!Favorited);
          setFavoriteNumber(FavoriteNumber + 1);
        } else {
          alert("Favorite 리스트에 추가하는 것을 실패했습니다.");
        }
      });
    }
  };
  return (
    <div>
      <Button onClick={onClickFavorite}>
        {Favorited ? "Not Favorite" : "Add to favorite"} {FavoriteNumber}
      </Button>
    </div>
  );
}

export default Favorite;

#13, 14 Favorite 페이지 만들기

favoirte movie 리스트 전달 api(server)

router.post("/getFavoriteMovie", (req, res) => {
  Favorite.find({ userFrom: req.body.userFrom }).exec((err, favorites) => {
    if (err) return res.status(400).send(err);
    return res.status(200).json({ success: true, favorites });
  });
});

 

favorite movie 페이지(client)

import React, { useEffect, useState } from "react";
import "./FavoritePage.css";
import Axios from "axios";
import { Popover } from "antd";
import { IMAGE_BASE_URL } from "../../Config";

function FavoritePage() {
  const [Favorites, setFavorites] = useState([]);
  useEffect(() => {
    fetchFavoriteMovie();
  }, []);

  const fetchFavoriteMovie = () => {
    Axios.post("/api/favorite/getFavoriteMovie", {
      userFrom: localStorage.getItem("userId"),
    }).then((response) => {
      if (response.data.success) {
        console.log(response.data);
        setFavorites(response.data.favorites);
      } else {
        alert("영화 정보를 가져오는데 실패했습니다.");
      }
    });
  };

  const onClickDelete = (movieId, userFrom) => {
    const variables = {
      movieId,
      userFrom,
    };
    Axios.post("api/favorite/removeFromFavorite", variables).then(
      (response) => {
        if (response.data.success) {
          fetchFavoriteMovie();
        } else {
          alert("리스트에서 지우는데 실패했습니다.");
        }
      }
    );
  };

  const renderCards = Favorites.map((favorite, index) => {
    const content = (
      <div>
        {favorite.moviePost ? (
          <img src={`${IMAGE_BASE_URL}w500${favorite.moviePost}`} />
        ) : (
          "no image"
        )}
      </div>
    );
    return (
      <tr key={index}>
        <Popover content={content} title={`${favorite.movieTitle}`}>
          <td>{favorite.movieTitle}</td>
        </Popover>
        <td>{favorite.movieRunTime} mins</td>
        <td>
          <button
            onClick={() => onClickDelete(favorite.movieId, favorite.userFrom)}
          >
            Remove
          </button>
        </td>
      </tr>
    );
  });
  return (
    <div style={{ width: "85%", margin: "3rem auto" }}>
      <h2>FavoritePage</h2>
      <hr />

      <table>
        <thead>
          <tr>
            <th>Movie Title</th>
            <th>Movie RunTime</th>
            <td>Remove from favorite</td>
          </tr>
        </thead>
        <tbody>{renderCards}</tbody>
      </table>
    </div>
  );
}

export default FavoritePage;

favorite movie remove버튼 눌렀을 때는 이전에 만들어놓은 removeFromFavorite api를 동일하게 사용한다.

그리고 remove버튼 클릭 후 화면에 변경된 리스트를 보이기 위해 영화리스트를 다시 불러와 fetch

<tbody>안에 영화를 renderCards 변수로 따로 만들어 놓은 거 확인하기