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 { 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>
);
}
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>
)}
</>
)
}
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)
*/
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