230622 - React(유튜브 API) 사용 + 리팩토링

백승연·2023년 6월 22일
1

🚩 React

router을 이용하여 youtube 사이트 구현하기 5일차

📝 설명

  • react 를 사용하여 유튜브 사이트 구현
  • 연관동영상 띄우는 작업
  • 리팩토링 포함(리팩토링 전 코드는 주석처리)


✒️ 코드 작성

입력

App.js

import { Outlet } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
//서버 상태(가져온 데이터)를 관리하는 관리하는 툴
import SearchHeader from "./components/SearchHeader";
import "./App.css";
import { YoutubeApiProvider } from "./context/YoutubeApiContext";

const queryClient = new QueryClient();
//QueryClient클라스로 인스턴스를 만들어 줌

// 
function App() {
  return (
    <>
      <SearchHeader />
      <YoutubeApiProvider>
        <QueryClientProvider client={queryClient}>
          <Outlet />
        </QueryClientProvider>
      </YoutubeApiProvider>
    </>
  );
}

export default App;



index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider} from "react-router-dom";
import App from './App';
import NotFound from './pages/NotFound';
import Videos from './pages/Videos';
import VideoDetail from './pages/VideoDetail';
import './index.css';

const router = createBrowserRouter([
  {
    path:'/',
    element:<App />,
    errorElement:<NotFound />,
    children:[
      { index:true, element:<Videos />},
      { path:'/videos', element:<Videos />},
      { path:'/videos/watch/:videoId', element:<VideoDetail />},
      { path:'/videos/:keyword', element:<Videos />}
    ]
  },
  
])

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);



  • pages 폴더

Videos.jsx

import React, { useContext } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import VideoCard from "../components/VideoCard";
import { useYoutubeApi } from "../context/YoutubeApiContext";
// import { search } from '../api/youtube';
// import FakeYoutube from "../api/fakeYoutube";
// import Youtube from "../api/youtube";

export default function Videos() {
  const { keyword } = useParams();
  const { youtube } = useYoutubeApi(); // context 파일에서 만들어 온 함수 실행

  const {
    isLoading,
    error,
    data: videos,
  } = useQuery(["videos", keyword], () => youtube.search(keyword));

  // const {
  //   isLoading,
  //   error,
  //   data: videos,
  // } = useQuery(["videos", keyword], () => {
  //   // context로 보냄
  //   // const youtube = new FakeYoutube(); // 외부 data를 가지고 오는 함수(생성자)
  //   // const youtube = new Youtube();
  //   return youtube.search(keyword);
  //   // 1. fakeYoutube.js의 함수에 keyword를 인자로 보내줌(검색한 내용)
  //   // 2. context에서 youtube 가져와야 함
  // });
  /*
    { 쿼리값, 에러 , 데이터 이름 }
    const { isLoading, error, data } = useQuery([],fnc,options)
    
    useQuery 
    - useState보다 효율적으로 관리하기 위해 사용(라이브러리)
    - 여기저기 가져다 사용할 수 있음
  */

  // console.log("videos ? ", videos);
  return (
    <div className="w-full max-w-screen-2xl m-auto">
      <div>Videos - {keyword ? ` 🔍 ${keyword}` : "🔥인기동영상"} </div>
      {/* //keyword가 있을때 / 없을때  */}

      {isLoading && <p>Loading...</p>}
      {error && <p>🚨 에러발생 🚨</p>}

      {videos && (
        <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 gap-y-6 p-4">
          {videos.map((video) => (
            <VideoCard key={video.id} video={video} />
          ))}
        </ul>
      )}
    </div>
  );
}



NotFound.jsx

import React from "react";

export default function NotFound() {
  return (
    <div>
      <h3>없는 페이지입니다.</h3>
    </div>
  );
}



  • components 폴더

SearchHeader.jsx

import React, { useState, useEffect } from 'react'
import { BsYoutube, BsSearch, BsPersonCircle, BsThreeDotsVertical } from "react-icons/bs";
import { Link,useNavigate,useParams } from "react-router-dom";

export default function SearchHeader() {
  const navigate = useNavigate();
  const { keyword } = useParams();
  const [text,setText] = useState('');
  
  const handleSubmit = (e) =>{
    e.preventDefault();
    navigate(`/videos/${text}`);    
  }

  //서치가 있을때만 인풋 텍스트가 보이게
  useEffect(()=>{
    setText(keyword || '')
  },[keyword])

  return (
    <div className="border-b border-zinc-500"> 
      <div className='w-full max-w-screen-2xl m-auto'>
        <header className='flex justify-between p-4'>
          <Link to='/' className='flex items-center'>
            <BsYoutube className='text-4xl text-brand'/>
            <h1 className='text-3xl font-LeagueGothic ml-3 tracking-wide'>Youtube</h1>
            <sup className='text-xs text-zinc-400 ml-2'>KR</sup>
          </Link>

          <form onSubmit={handleSubmit} className='flex justify-between border border-zinc-600 rounded-full pl-6 w-1/2 max-w-2xl '>
            <input 
              type="text" 
              placeholder='검색' 
              value={text}
              onChange={(e)=> setText(e.target.value)} 
              className='bg-zinc-900 text-zinc-400 outline-0 w-full' />
            <button className='border-l border-zinc-600 px-6 bg-zinc-700 rounded-r-full '  >
              <BsSearch />
            </button>
          </form>

          <div className='hidden sm:flex items-center'>
            <BsThreeDotsVertical className='text-lg' />
            <button className=' flex items-center border border-zinc-600 rounded-full p-2 text-sky-500 ml-4 hover:bg-sky-950'>
              <BsPersonCircle />
              <span className='text-sm pl-2'>로그인</span>
            </button>
            
          </div>
          

        </header>
      </div>
    </div>
  )
}



VideoCard.jsx

import React from "react";
import { useNavigate } from "react-router-dom";
import { formatAgo } from "../util/date";

export default function VideoCard({ video }) {
  const { thumbnails, title, channelTitle, publishedAt } = video.snippet;

  const navigate = useNavigate();

  return (
    // *리팩토링
    <li
      className="cursor-pointer"
      onClick={() => {
        // useNavigate() : 링크로 연결 시 객체(상태)까지 같이 전달해줌
        navigate(`/videos/watch/${video.id}`, { state: { video } }); 
        // navigate(`/videos/watch/${video.id}`); 
      }}
    >
      <img className="w-full" src={thumbnails.high.url} alt={title} />
      <div>
        <p className="text-lg mb-1 leading-6 line-clamp-2">{title}</p>
        <p className=" text-sm opacity-70">{channelTitle}</p>
        {/* <p>{format(publishedAt, "ko")}</p> */}
        <p className="text-sm opacity-70">{formatAgo(publishedAt, "ko")}</p>
      </div>
    <</li>

    // <div>{video.snippet.title}</div>
  );
}



ChannelInfo.jsx

import React from "react";
import { useYoutubeApi } from "../context/YoutubeApiContext";
import { useQuery } from "@tanstack/react-query";

export default function ChannelInfo({ id, name }) {
  const { youtube } = useYoutubeApi();

  const { data: url } = useQuery(["channel", id], () =>
    youtube.channelImageURL(id)
  );

  return (
    <div>
      {/* url이 있을 경우만 보이게 설정 */}
      {/* 위에서 받아온 name을 화면에 뿌려줌 */}
      {url && <img src={url} alt={name} />}
      <p>{name}</p>
    </div>
  )
}



RelatedVideos.jsx

import React from "react";
import { useQuery } from "@tanstack/react-query";
import { useYoutubeApi } from "../context/YoutubeApiContext";
import VideoCard from "./VideoCard";

export default function RelatedVideos({ id }) {
  const { youtube } = useYoutubeApi(); // context 파일에서 만들어 온 함수 실행

  const {
    isLoading,
    error,
    data: videos,
  } = useQuery(["related", id], () => youtube.relatedVideos(id));

  return (
    <>
      {isLoading && <p>Loading...</p>}
      {error && <p>🚨 에러발생 🚨</p>}

      {videos && (
        <ul>
          {videos.map((video) => (
            <VideoCard key={video.id} video={video} />
          ))}
        </ul>
      )}
    </>
  );
}



  • api 폴더

fakeYoutubeClient.js

//내부 json로 연결 - 실제 유튜브 API를 가져올 수가 없음(사용제한)
import axios from "axios";

// *리팩토링
// 여기서 class를 만들어서 보냄
export default class FakeYoutubeClient {
  // 키워드를 받아 올 필요가 없음
  async search({ params }) {
    // return relatedToVideoId ? axios.get(`/videos/related.json`) : axios.get(`/videos/search.json`)
    return axios.get(`/videos/{${params.relatedToVideoId ? "related" : "search"}related}.json`);
  }

  async videos() {
    return axios.get(`/videos/popular.json`);
  }

  async channels() {
    return axios.get(`/videos/channel.json`);
  }
}

// export default class FakeYoutube {
//   constructor() {}

//   // 함수 3개 정의
//   // Videos.jsx에서 keyword를 받아옴
//   async search(keyword) {
//     return keyword ? this.#searchByKeyword(keyword) : this.#popular();
//   }

//   //아이디값만 수정해서 얻어옴
//   async #searchByKeyword(keyword) {
//     return axios
//       .get(`/videos/search.json`)
//       .then((res) => res.data.items)
//       .then((items) => items.map((item) => ({ ...item, id: item.id.videoId })));
//   }

//   async #popular() {
//     return axios.get(`/videos/popular.json`).then((res) => res.data.items);
//   }
// }

/*
export async function search(keyword) {
  return axios
    .get(`/videos/${keyword ? 'search' : 'popular'}.json`)
    .then((res)=>res.data.items)
*/



youtube.js

//실제 유튜브 연결 - 실제 유튜브 API
// https://developers.google.com/youtube/v3/getting-started

// import axios from "axios";

export default class Youtube {
  constructor(apiClient) {
    // 반복되는 부분 변수화
    // this.httpClient = axios.create({
    //   baseURL: "https://youtube.googleapis.com/youtube/v3",
    //   params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    // });

    this.apiClient = apiClient; // 외부에서 받아옴
  }

  // * 리팩토링
  // 함수 3개 정의
  // Videos.jsx에서 keyword를 받아옴
  async search(keyword) {
    return keyword ? this.#searchByKeyword(keyword) : this.#popular();
  }

  // 채널 이미지를 가져오기 위해 필요한 옵션을 정의하는 함수
  async channelImageURL(id) {
    return this.apiClient
      .channels({
        params: {
          part: "snippet",
          id,
        },
      })
      .then((res) => res.data.items[0].snippet.thumbnails.default.url);
  }

  // 관련 비디오를 가져오기 위해 필요한 옵션을 정의하는 함수
  async relatedVideos(id) {
    return this.apiClient
      .search({
        params: {
          part: "snippet",
          maxResults: 25,
          type: "video",
          relatedToVideoId: id,
        },
      })
      .then((res) => res.data.items)
      .then((items) => items.map((item) => ({ ...item, id: item.id.videoId })));
  }


  async #searchByKeyword(keyword) {
    return this.apiClient
      .search({
        params: {
          part: "snippet",
          maxResults: 25,
          type: "video",
          q: keyword,
        },
      })
      .then((res) => res.data.items)
      .then((items) => items.map((item) => ({ ...item, id: item.id.videoId })));
  }

  async #popular() {
    return this.apiClient
      .videos({
        params: {
          part: "snippet",
          maxResults: 25,
          chart: "mostPopular",
        },
      })
      .then((res) => res.data.items);
  }
}

// 기존 youtubeClient.js가 없을 때 youtube.js 코드
// export default class Youtube {
//   constructor() {
//     // 반복되는 부분 변수화
//     // this.httpClient = axios.create({
//     //   baseURL: "https://youtube.googleapis.com/youtube/v3",
//     //   params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
//     // });

//     this.httpClient = httpClient // 외부에서 받아옴
//   }
//   // 함수 3개 정의
//   // Videos.jsx에서 keyword를 받아옴
//   async search(keyword) {
//     return keyword ? this.#searchByKeyword(keyword) : this.#popular();
//   }

//   //아이디값만 수정해서 얻어옴
//   async #searchByKeyword(keyword) {
//     return this.apiClient
//       .seach(`search`, {
//         params: {
//           part: "snippet",
//           maxResults: 25,
//           type: "video",
//           q: keyword, // 키워드로 전달받음
//         },
//       })
//       .then((res) => res.data.items);
//   }

//   async #popular() {
//     return this.httpClient
//       .get(`videos`, {
//         params: {
//           part: "snippet",
//           maxResults: 25,
//           chart: "mostPopular",
//           regionCode: "KR",
//         },
//       })
//       .then((res) => res.data.items);
//   }
// }

/*
export async function search(keyword) {
  return axios
    .get(`/videos/${keyword ? 'search' : 'popular'}.json`)
    .then((res)=>res.data.items)
*/



youtubeClient.js

//실제 유튜브 연결 - 실제 유튜브 API
// https://developers.google.com/youtube/v3/getting-started
import axios from "axios";

export default class YoutubeClient {
  constructor() {
    // 반복되는 부분 변수화
    this.httpClient = axios.create({
      baseURL: "https://youtube.googleapis.com/youtube/v3",
      params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    });
  }

  // *리팩토링
  // 옵션만 처리
  async search(params) {
    return this.httpClient.get("search", params);
  }

  async videos(params) {
    return this.httpClient.get("videos", params);
  }

  async channels(params) {
    return this.httpClient.get("channels", params);
  }

  // export default class Youtube {
  //   constructor() {
  //     // 반복되는 부분 변수화
  //     this.httpClient = axios.create({
  //       baseURL: "https://youtube.googleapis.com/youtube/v3",
  //       params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
  //     });
  //   }
  // //아이디값만 수정해서 얻어옴
  // async #searchByKeyword(keyword) {
  //   return this.httpClient
  //     .get(`search`, {
  //       params: {
  //         part: "snippet",
  //         maxResults: 25,
  //         type: "video",
  //         q: keyword, // 키워드로 전달받음
  //       },
  //     })
  //     .then((res) => res.data.items);
  // }

  // async #popular() {
  //   return this.httpClient
  //     .get(`videos`, {
  //       params: {
  //         part: "snippet",
  //         maxResults: 25,
  //         chart: "mostPopular",
  //         regionCode: "KR",
  //       },
  //     })
  //     .then((res) => res.data.items);
  // }
}

/*
export async function search(keyword) {
  return axios
    .get(`/videos/${keyword ? 'search' : 'popular'}.json`)
    .then((res)=>res.data.items)
*/



  • context 폴더

YoutubeApiContext.js

// context와 관련 된 파일을 모아 둔 js파일
import { createContext, useContext } from "react";
import Youtube from "../api/youtube";
// import FakeYoutubeClient from "../api/fakeYoutubeClient";
import YoutubeClient from "../api/youtubeClient";
// import FakeYoutube from "../api/fakeYoutubeClient";

export const YoutubeApiContext = createContext();

// *리팩토링
// const client = new FakeYoutubeClient(); // 로컬 json을 불러온 가짜 API
const client = new YoutubeClient(); // 실제 API
const youtube = new Youtube(client);

// context가 적용될 영역을 지정하는 함수 정의
export function YoutubeApiProvider({ children }) {
  return (
    <YoutubeApiContext.Provider value={{ youtube }}>
      {children}
    </YoutubeApiContext.Provider>
  );
}

// youtube value값에 접근할 수 있도록 하는 함수(Videos.jsx)
export function useYoutubeApi() {
  return useContext(YoutubeApiContext);
}

/*
  위와 같이 설정하면 youtube api 불러온 것을 모든 컴포넌트에서 불러다 쓸 수 있음
*/

// export const YoutubeApiContext = createContext();

// // const youtube = new FakeYoutube();
// const youtube = new Youtube();

// // context가 적용될 영역을 지정하는 함수 정의
// export function YoutubeApiProvider({ children }) {
//   return (
//     <YoutubeApiContext.Provider value={{youtube}}>
//       {children}
//     </YoutubeApiContext.Provider>
//   );
// }

// // youtube value값에 접근할 수 있도록 하는 함수(Videos.jsx)
// export function useYoutubeApi() {
//   return useContext(YoutubeApiContext);
// }

// /*
//   위와 같이 설정하면 youtube api 불러온 것을 모든 컴포넌트에서 불러다 쓸 수 있음
// */



  • util 폴더

util.js

import React from "react";
import { format, register } from "timeago.js";
import koLocale from "timeago.js/lib/lang/ko"; // n months ago를 한글로 표시

register("ko", koLocale);

export function formatAgo(data, lang = "en_US") {
  return format(data, lang);
}

// 단순한 시간을 n일 전과 같은 형식으로 바꿔주는 함수
// https://www.npmjs.com/package/timeago.js/v/4.0.0-beta.3



  • .env 파일
    .env
# .env - 외부에 공개되지 않는 파일
REACT_APP_YOUTUBE_API_KEY=내 키값;

출력

  • 이미지로 대체

youtube







🔗 참고 링크 & 도움이 되는 링크






profile
공부하는 벨로그

0개의 댓글