面白そう!と思って取り掛かりましたが、そのままだと動かず都度修正していきながらゴールにたどり着いたので後学のためまとめておきます。
GitHubでソースコード共有しています。
手順を実施する上での変更点
02. API Keyの取得しpostmanからAPIを送る
- TMDBの APIキーを発行するために一般利用で申請を上げる必要があった
- Application Summaryを記載する必要があるのだが、日本語でいくら入力してもエラーがでて進めなかったので「英語」で入力したところすんなりを追った
03. Reactの環境構築とAPI取得の下準備
古いNode.jsだったのでバージョン変更のためにnをグローバルにインストールして、NodeのバージョンをあげつつYarnもいれる。
$ npm install -g n $ npm install --global yarn $ n v16.2.0 $ npm install sass axios
ちなみに条件にあわないnodeをつかうと以下のエラーで怒られた。
error postcss@8.2.6: The engine "node" is incompatible with this module. Expected version "^10 || ^12 || >=14". Got "13.9.0" error Found incompatible module.
その後npx
というnpmにバンドルされているコマンドを用いて、create-react-app
というコマンドを叩きReact + TypeScript
の環境を構築する。
$ npx create-react-app clone-netflix --template typescript
※npxはnpmのようなパッケージマネージャーだがコマンド(ここでいうcreate-react-app
)を叩く時はnpmよりもnpxの方が楽に叩けるらしい。またgistに登録されているものをインストールせずに叩けたりするとか
その後サーバを起動してブラウザの http://localhost:3000 にアクセスできればOKとのこと。
$ cd clone-netflix $ yarn start
所感としてyarn start
でサーバ起動するの遅いですね。PythonやGoでWebサーバ立ち上げる時の方がだいぶ早いので、開発フェーズでこの辺り時間かかると嫌だなあ。
clone-netflix/src配下の以下を削除しました。
- setupTests.tx
- logo.svg
- App.test.tsx
その後clone-netflix/src/App.tsx
を作成しました。そのままだと全然動かなかったのでGitHubのコードを参考にしながら手を加えました。
import React from 'react'; import './App.css'; import { Row } from "./Row"; import { requests } from "./request"; function App() { return ( <div className="App"> <Row title="NETFLIX ORIGUINALS" fetchUrl={requests.feachNetflixOriginals} isLargeRow /> <Row title="Top Rated" fetchUrl={requests.feactTopRated} /> <Row title="Action Movies" fetchUrl={requests.feactActionMovies} /> <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} /> <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} /> <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} /> <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} /> </div> ); } export default App;
- clone-netflix/src/axios.jsを作成(手順通り)
- clone-netflix/src/request.jsを作成(手順通り)
04 Rowコンポーネントの作成
エラーが出たのでここも手を加えています。(title, fetchUrl, isLargeRow
とか)
import React, { useState, useEffect } from "react"; import "./Row.scss"; import axios from "./axios"; const base_url = "https://image.tmdb.org/t/p/original"; type Props = { title: string; fetchUrl: string; isLargeRow?: boolean; }; type Movie = { id: string; name: string; title: string; original_name: string; poster_path: string; backdrop_path: string; }; export const Row = ({ title, fetchUrl, isLargeRow }: Props) => { const [movies, setMovies] = useState<Movie[]>([]); useEffect(() => { async function fetchData() { const request = await axios.get(fetchUrl); setMovies(request.data.results); return request; } fetchData(); }, [fetchUrl]); console.log(movies); return( <div className="Row"> <h2>{title}</h2> <div className="Row-posters"> {/* ポスターコンテンツ */} {movies.map((movie, i) => ( <img key={movie.id} className={`Row-poster ${isLargeRow && "Row-poster-large"}`} src={`${base_url}${ isLargeRow ? movie.poster_path : movie.backdrop_path }`} alt={movie.name} /> ))} </div> </div> ); };
.Row { margin-left: 20px; color: #fff; &-posters { display: flex; overflow-y: hidden; overflow-x: scroll; padding: 20px; &::-webkit-scrollbar { display: none; } } &-poster { object-fit: contain; width: 100%; max-height: 100px; margin: 10px; transition: transform 450ms; &-large { max-height: 250px; &:hover { transform: scale(1.09); } } &:hover { transform: scale(1.08); } } }
05 Bannerコンポーネントの作成
import React, { useState, useEffect } from "react"; import axios from "./axios"; import { requests } from "./request"; import "./Banner.scss"; type movieProps = { title?: string; name?: string; orignal_name?: string; backdrop_path?: string; overview?: string; }; export const Banner = () => { const [movie, setMovie] = useState<movieProps>({}); useEffect(() => { async function fetchData() { const request = await axios.get(requests.feachNetflixOriginals); console.log(request.data.result); //apiからランダムで値を取得している setMovie( request.data.results[ Math.floor(Math.random() * request.data.results.length - 1) ] ); return request; } fetchData(); }, []); console.log(movie); // descriptionの切り捨てよう関数 function truncate(str: any, n: number) { // undefinedを弾く if (str !== undefined) { return str.length > n ? str?.substr(0, n - 1) + "..." : str; } } return ( <header className="Banner" style={{ backgroundSize: "cover", backgroundImage: `url("https://image.tmdb.org/t/p/original${movie?.backdrop_path}")`, backgroundPosition: "center center", }} > <div className="Banner-contents"> <h1 className="banner-title"> {movie?.title || movie?.name || movie?.orignal_name} </h1> <div className="Banner-buttons"> <button className="Banner-button">Play</button> <button className="Banner-button">My List</button> </div> <h1 className="Banner-description">{truncate(movie?.overview, 150)}</h1> </div> <div className="Banner-fadeBottom" /> </header> ); };
.Banner { color: #fff; object-fit: contain; height: 448px; &-contents { margin-left: 30px; padding-top: 140px; height: 190px; } &-title { font-size: 3rem; font-weight: 800; padding-bottom: 0.3rem; } &-description { width: 45rem; line-height: 1.3; padding-top: 1rem; font-size: 0.8rem; max-width: 360px; height: 80px; } &-button { cursor: pointer; color: #fff; outline: none; border: none; font-weight: 700; border-radius: 0.2vw; padding-left: 2rem; padding-right: 2rem; margin-right: 1rem; padding-top: 0.5rem; background-color: rgba(51, 51, 51, 0.5); padding-bottom: 0.5rem; &:hover { color: #000; background-color: #e6e6e6; transition: all 0.2s; } } &-fadeBottom { height: 7.4rem; background-image: linear-gradient( 180deg, transparent, rgba(37, 37, 37, 0.61), #111 ); } }
このあとApp.tsx
をいじります
import React from 'react'; import './App.css'; import { Row } from "./Row"; import { Banner } from "./Banner"; // ★<- 追加 import { requests } from "./request"; function App() { return ( <div className="App"> <Banner /> // ★<- 追加 <Row title="NETFLIX ORIGUINALS" fetchUrl={requests.feachNetflixOriginals} isLargeRow /> <Row title="Top Rated" fetchUrl={requests.feactTopRated} /> <Row title="Action Movies" fetchUrl={requests.feactActionMovies} /> <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} /> <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} /> <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} /> <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} /> </div> ); } export default App;
06 Navコンポーネントの追加
import React, { useState, useEffect } from "react"; import "./Nav.scss"; type Props = { className?: string; }; export const Nav = (props: Props) => { const [show, setShow] = useState(false); useEffect(() => { const handleShow = () => { if (window.scrollY > 100) { setShow(true); } else { setShow(false); } }; window.addEventListener("scroll", handleShow); return () => { window.removeEventListener("scroll", handleShow); }; }, []); return ( <div className={`Nav ${show && "Nav-black"}`}> <img className="Nav-logo" src="https://upload.wikimedia.org/wikipedia/commons/thumb/0/08/Netflix_2015_logo.svg/1920px-Netflix_2015_logo.svg.png" alt="Netflix Logo" /> <img className="Nav-avater" src="https://i.pinimg.com/originals/0d/dc/ca/0ddccae723d85a703b798a5e682c23c1.png" alt="Avatar" /> </div> ); };
.Nav { position: fixed; top: 0; width: 100%; height: 30px; padding: 20px; z-index: 1; display: flex; justify-content: space-between; /* Animations */ transition-timing-function: ease-in; transition: all 0.5s; &-black { background-color: #111; } &-logo { position: fixed; left: 20px; width: 80px; object-fit: contain; } &-avater { position: fixed; right: 20px; width: 30px; object-fit: contain; } }
このあとApp.tsx
をいじります
import React from 'react'; import './App.css'; import { Row } from "./Row"; import { Banner } from "./Banner"; import { Nav } from "./Nav"; // ★<- 追加 import { requests } from "./request"; function App() { return ( <div className="App"> <Nav /> // ★<- 追加 <Banner /> <Row title="NETFLIX ORIGUINALS" fetchUrl={requests.feachNetflixOriginals} isLargeRow /> <Row title="Top Rated" fetchUrl={requests.feactTopRated} /> <Row title="Action Movies" fetchUrl={requests.feactActionMovies} /> <Row title="Comedy Movies" fetchUrl={requests.feactComedyMovies} /> <Row title="Horror Movies" fetchUrl={requests.feactHorrorMovies} /> <Row title="Romance Movies" fetchUrl={requests.feactRomanceMovies} /> <Row title="DOcumentaries" fetchUrl={requests.feactDocumentMovies} /> </div> ); } export default App;
07 映画の画像クリックでトレイラーを表示する
モジュールをインストールする
$ npm install movie-trailer $ npm install react-youtube
外部からAPI_KEYを呼べるようにする
// const -> export const にする export const API_KEY = "xxxx"; export const requests ={ feachTrending:`/trending/all/week?api_key=${API_KEY}&language=en-us`, feachNetflixOriginals:`/discover/tv?api_key=${API_KEY}&with_networks=213`, feactTopRated:`/discover/tv?api_key=${API_KEY}&languager=en-us`, feactActionMovies:`/discover/tv?api_key=${API_KEY}&with_genres=28`, feactComedyMovies:`/discover/tv?api_key=${API_KEY}&with_genres=35`, feactHorrorMovies:`/discover/tv?api_key=${API_KEY}&with_genres=27`, feactRomanceMovies:`/discover/tv?api_key=${API_KEY}&with_genres=10749`, feactDocumentMovies:`/discover/tv?api_key=${API_KEY}&with_genres=99`, }
YouTubeの紹介動画を引っ張れるようにする。サンプルコードのままだと動画と映画がリンクしていなかったので、GitHubのコードを引っ張ってきて対応した。(ちなみに動画がない映画がほとんどなので、色々なサムネイルをクリックしてみないとちゃんと動くかわからない)
import React, { useState, useEffect } from "react"; import YouTube from "react-youtube"; // add import "./Row.scss"; import axios from "./axios"; import { API_KEY } from "./request" // add const movieTrailer = require("movie-trailer"); // add const base_url = "https://image.tmdb.org/t/p/original"; type Props = { title: string; fetchUrl: string; isLargeRow?: boolean; }; type Movie = { id: string; name: string; title: string; original_name: string; poster_path: string; backdrop_path: string; }; // add type Options = { height: string; width: string; playerVars: { autoplay: 0 | 1 | undefined; }; }; export const Row = ({ title, fetchUrl, isLargeRow }: Props) => { const [movies, setMovies] = useState<Movie[]>([]); // add const [trailerUrl, setTrailerUrl] = useState<string | null>(""); useEffect(() => { async function fetchData() { const request = await axios.get(fetchUrl); setMovies(request.data.results); return request; } fetchData(); }, [fetchUrl]); // add const opts: Options = { height: "390", width: "640", playerVars: { // https://developers.google.com/youtube/player_parameters autoplay: 1, }, }; // add const handleClick = async (movie: Movie) => { if (trailerUrl) { setTrailerUrl(""); } else { let trailerurl = await axios.get( `/movie/${movie.id}/videos?api_key=` + API_KEY ); setTrailerUrl(trailerurl.data.results[0]?.key); } movieTrailer(movie?.name || movie?.title || movie?.original_name || "") .then((url: string) => { const urlParams = new URLSearchParams(new URL(url).search); setTrailerUrl(urlParams.get("v")); }) .catch((error: any) => console.log(error.message)); }; return( <div className="Row"> <h2>{title}</h2> <div className="Row-posters"> {/* ポスターコンテンツ */} {movies.map((movie, i) => ( <img key={movie.id} className={`Row-poster ${isLargeRow && "Row-poster-large"}`} src={`${base_url}${ isLargeRow ? movie.poster_path : movie.backdrop_path }`} alt={movie.name} onClick={() => handleClick(movie)} /> ))} </div> {/* add */} {trailerUrl && <YouTube videoId={trailerUrl} opts={opts} />} </div> ); };
08 firebaseでデプロイ
firehose init
をしたところ以前使っていた時の認証が中途半端に残っているエラーが発生
[debug] [2021-05-21T09:25:31.828Z] <<< HTTP RESPONSE 400 {"pragma":"no-cache","date":"Fri, 21 May 2021 09:25:31 GMT","cache-control":"no-cache, no-store, max-age=0, must-revalidate","expires":"Mon, 01 Jan 1990 00:00:00 GMT","content-type":"application/json; charset=utf-8","vary":"X-Origin, Referer, Origin,Accept-Encoding","server":"scaffolding on HTTPServer2","x-xss-protection":"0","x-frame-options":"SAMEORIGIN","x-content-type-options":"nosniff","alt-svc":"h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"","accept-ranges":"none","transfer-encoding":"chunked"} [debug] [2021-05-21T09:25:31.830Z] >>> HTTP REQUEST POST https://cloudresourcemanager.googleapis.com/v1/projects {"projectId":"netflix-clone","name":"netflix-clone"} [debug] [2021-05-21T09:25:32.679Z] <<< HTTP RESPONSE 401 {"www-authenticate":"Bearer realm=\"https://accounts.google.com/\", error=\"invalid_token\"","vary":"X-Origin, Referer, Origin,Accept-Encoding","content-type":"application/json; charset=UTF-8","date":"Fri, 21 May 2021 09:25:32 GMT","server":"ESF","cache-control":"private","x-xss-protection":"0","x-frame-options":"SAMEORIGIN","x-content-type-options":"nosniff","server-timing":"gfet4t7; dur=653","alt-svc":"h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"","accept-ranges":"none","transfer-encoding":"chunked"} [debug] [2021-05-21T09:25:32.680Z] <<< HTTP RESPONSE BODY {"error":{"code":401,"message":"Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.","status":"UNAUTHENTICATED"}} [debug] [2021-05-21T09:25:32.807Z] FirebaseError: HTTP Error: 401, Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project. at module.exports (/usr/local/lib/node_modules/firebase-tools/lib/responseToError.js:38:12) at Request._callback (/usr/local/lib/node_modules/firebase-tools/lib/api.js:41:35) at Request.self.callback (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:185:22) at Request.emit (node:events:365:28) at Request.emit (node:domain:470:12) at Request.<anonymous> (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:1154:10) at Request.emit (node:events:365:28) at Request.emit (node:domain:470:12) at IncomingMessage.<anonymous> (/usr/local/lib/node_modules/firebase-tools/node_modules/request/request.js:1076:12) at Object.onceWrapper (node:events:471:28) at IncomingMessage.emit (node:events:377:35) at IncomingMessage.emit (node:domain:470:12) at endReadableNT (node:internal/streams/readable:1312:12) at processTicksAndRejections (node:internal/process/task_queues:83:21) [error] [error] Error: Failed to create project. See firebase-debug.log for more info.
そのため firebase initしたらError: HTTP Error: 401が出た - haayaaa’s diary を参考にして以下のコマンドを実行し解決。
$ npm install -g firebase-tools $ firebase login --reauth --no-localhost
その後、initする
$ firehose init
- Hostingを選択しスペースキーを押下し、Enterを押下
- Create New Projectを選択(適当な名前)
Project information: - Project ID: netflix-clone-lirlia - Project Name: netflix-clone-lirlia Firebase console is available at https://console.firebase.google.com/project/netflix-clone-lirlia/overview i Using project netflix-clone-lirlia (netflix-clone-lirlia) === Hosting Setup Your public directory is the folder (relative to your project directory) that will contain Hosting assets to be uploaded with firebase deploy. If you have a build process for your assets, use your build's output directory. ? What do you want to use as your public directory? build ? Configure as a single-page app (rewrite all urls to /index.html)? No ? Set up automatic builds and deploys with GitHub? No i Writing configuration info to firebase.json... i Writing project information to .firebaserc... i Writing gitignore file to .gitignore... ✔ Firebase initialization complete!
$ firebase deploy
↓デプロイ成功しました。