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>
);
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>
);
}
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>
)}
</>
);
}
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)
*/
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.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 - 외부에 공개되지 않는 파일
REACT_APP_YOUTUBE_API_KEY=내 키값;
youtube