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

백승연·2023년 6월 26일
0

🚩 React

router을 이용하여 youtube 사이트 구현하기 완성

📝 설명

  • 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 { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'
import VideoCard from '../components/VideoCard';
import { useYoutubeApi } from '../context/YoutubeApiContext'

export default function Videos() {
  const { keyword } = useParams();
  const { youtube } = useYoutubeApi();  //함수사용 

  const { 
    isLoading, 
    error, 
    data:videos 
  } = useQuery( ['videos',keyword], () => youtube.search(keyword),
  {staleTime:1000*60*3})
  /*
    const { isLoading, error, data } = useQuery([],fnc,options)
  */

  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-x-4 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, type}) {
  const {thumbnails,title, channelTitle,publishedAt } = video.snippet;

  const navigate = useNavigate();
  const isRelated = type === 'related'; 

  return (
    <li className={isRelated ? 'flex gap-x-4 mb-4 cursor-pointer' : 'cursor-pointer'} onClick={()=>{ navigate(`/videos/watch/${video.id}`,{state:{video}} )}}>
      <img className={isRelated ? 'w-40 rounded-lg' : 'w-full rounded-lg'} src={thumbnails.medium.url} alt={title} />
      <div>
        <p className={
          isRelated 
          ? 'text-base mt-0 mb-1 leading-4 line-clamp-2' 
          : 'text-lg mt-3 mb-1 leading-6 line-clamp-2'
          }>{title}</p>
        <p className={isRelated ? 'text-xs opacity-80' :'text-sm opacity-80'}>{channelTitle}</p>
        <p className={isRelated ? 'text-xs opacity-50' :'text-sm opacity-50'}>{formatAgo(publishedAt,'ko')}</p>              
      </div>
    </li>
  )
}



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),
    {staleTime:1000*60*10})   //10분간은 캐시된 걸 씀



  return (
    <div className='flex my-4 items-center'>
      {url && <img src={url}  alt={name} className="w-10 h-10 rounded-full" />}
      <p className='text-lg ml-4'>{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 RealatedVideos({id}) {
  const { youtube } = useYoutubeApi();

  const { 
    isLoading, 
    error, 
    data:videos 
  } = useQuery( ['related',id], () => youtube.relatedVideos(id),
  {staleTime:1000*60*10})

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

      {videos && ( 
        <ul className=''>
          {videos.map((video)=>(
            <VideoCard key={video.id} video={video} type="related" />
          ))}
        </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.apiClient = apiClient;
  }

  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: 10,
          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개의 댓글