256일차 리액트(React) - 트위터 웹사이트 완성

2021. 9. 11. 19:59Diary/201~300

 

https://kwongeneral.github.io/0912_React_Twitter/#/

 

React App

 

kwongeneral.github.io

 


배포 설정

 

1. package.json 수정

 

2. 파이어베이스 승인된 도메인 추가

 

3. 파이어베이스 보안 정책 수정

 

4. 비밀키 보안 설정


전체 소스코드

 

1. package.json

 

2. App.js

import {useEffect, useState} from "react";
import React from "react";
// import AppRouter from "components/Router";
import AppRouter from "./Router";
import { authService } from "../fbase";


function App() {
    const [init, set_init] = useState(false);
    // const [isLogin, set_isLogin] = useState(false);
    const [userObj, set_userObj] = useState(null);

    // console.log(authService.currentUser)
    // setTimeout(() => console.log(authService.currentUser), 2000);
    useEffect(() => {
        // authService.onAuthStateChanged((user) => console.log(user));
        authService.onAuthStateChanged((user) => {
            if(user) {
                // set_isLogin(user);
                set_userObj({
                    uid: user.uid,
                    displayName: user.displayName,
                    updateProfile: (args) => user.updateProfile(args)
                });
            } else {
                // set_isLogin(false);
                set_userObj(false);
            }
            set_init(true);
        });
    }, []);

    const refreshUser = () => {
        // set_userObj(authService.currentUser);
        const user = authService.currentUser;
        set_userObj({
            uid: user.uid,
            displayName: user.displayName,
            updateProfile: (args) => user.updateProfile(args)
        })
    };

    return (
        <>
            { init ? ( <AppRouter isLogin={ Boolean(userObj) } userObj={ userObj } refreshUser={ refreshUser } /> ) : ( "초기화중..." ) }
            {/*{ init ? ( <AppRouter isLogin={ isLogin } userObj={ userObj } refreshUser={ refreshUser } /> ) : ( "초기화중..." ) }*/}
        {/*<footer>&copy; {new Date().getFullYear()} Twitter</footer>*/}
        </>
    );
}

// 구조 분해 할당
// function App({ isLogin }) {
//   return (
//       <>
//         <AppRouter isLogin={ isLogin } />
//         <footer>&copy; {new Date().getFullYear()} Twitter</footer>
//       </>
//   );
// }

// 기존 방법
// function App() {
//   const [isLogin, set_isLogin] = useState(false);
//   return (
//       <>
//         <AppRouter isLogin={ isLogin } />
//         <footer>&copy; {new Date().getFullYear()} Twitter</footer>
//       </>
//   );
// }

export default App;

 

3. AuthForm.js

import {useState} from "react";
import {authService} from "../fbase";

const AuthForm = () => {
    const [email, set_email] = useState("");
    const [password, set_password] = useState("");
    const [newAccount, set_newAccount] = useState(true);
    const [error, set_error] = useState("");
    const toggleAccount = () => set_newAccount((prev) => !prev);

    const onChange = (event) => {
        // console.log(event.target.name);
        const { target : { name, value } } = event;
        if(name === "email") {
            set_email(value);
        } else if(name === "password") {
            set_password(value);
        }
    };

    const onSubmit = async (event) => {
        event.preventDefault();
        try {
            let data;

            if(newAccount) {
                data = await authService.createUserWithEmailAndPassword(email, password)
            } else {
                data = await authService.signInWithEmailAndPassword(email, password)
            }
            console.log("데이터 : ", data);
        } catch (error) {
            // console.log("에러 : ", error)
            set_error(error.message)
        }
    }

    return (
        <>
            <form onSubmit={ onSubmit } className="container">
                <input className="authInput" name="email" value={ email } onChange={ onChange } type="email" placeholder="이메일" required/>
                <input className="authInput" name="password" value={ password } onChange={ onChange } type="password" placeholder="비밀번호" required/>
                <input className="authInput authSubmit" type="submit" value={ newAccount ? "회원가입" : "로그인" } required/>
                { error && <span className="authError">{ error }</span>}
            </form>
            <span onClick={ toggleAccount } className="authSwitch">
                { newAccount ? "로그인" : "회원가입" }
            </span>
        </>

    )

}

export default AuthForm;

 

4. Navigation.js

import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTwitter } from "@fortawesome/free-brands-svg-icons";
import { faUser } from "@fortawesome/free-solid-svg-icons";

const Navigation = ({ userObj }) => {
    return (
        <nav>
            <ul style={{ display: "flex", justifyContent: "center", marginTop: 50 }}>
                <li>
                    <Link to="/" style={{ marginRight: 10 }}>
                        <FontAwesomeIcon icon={ faTwitter } color={ "#04AAFF" } size="2x" />
                    </Link>
                </li>

                { userObj.displayName !== null ?
                    <li>
                        <Link to="/profile" style={{ marginLeft: 10, display: "flex", flexDirection: "column",
                            alignItems: "center", fontSize: 12 }}>
                            <FontAwesomeIcon icon={ faUser } color={ "#04AAFF" } size="2x" />
                            {/*{ userObj.displayName }의 마이페이지*/}
                        </Link>
                    </li> :
                    <li>
                        <Link to="/profile" style={{ marginLeft: 10, display: "flex", flexDirection: "column",
                            alignItems: "center", fontSize: 12 }}>
                            <FontAwesomeIcon icon={ faUser } color={ "#04AAFF" } size="2x" />
                            {/*{ userObj.displayName }의 마이페이지*/}
                        </Link>
                    </li>
                }
            </ul>
        </nav>
    )
};

export default Navigation;

 

5. Router.js

import { HashRouter as Router, Route, Switch, Redirect } from "react-router-dom"
import { useState } from "react";
import Auth from "../routes/Auth";
import Home from "../routes/Home";
import Navigation from "./Navigation";
import Profile from "../routes/Profile";


// Switch를 사용하면 여러가지 라우터 중 하나만 렌더링할 수 있게 해준다.
// 특정 작업 후, 페이지 이동은 Redirect나 history.push를 사용한다.
const AppRouter = ({ isLogin, userObj, refreshUser }) => {
    return (
        <Router>
            { isLogin && <Navigation userObj={ userObj } /> }
            <Switch>
                { isLogin ? (
                    <div style={{ maxWidth: 890, width: "100%", margin: "0 auto", marginTop: 80, display: "flex",
                        justifyContent: "center" }}>
                        <Route exact path="/">
                            <Home userObj={ userObj } />
                        </Route>
                        <Route exact path="/profile">
                            <Profile userObj={ userObj } refreshUser={ refreshUser } />
                        </Route>
                    </div>
                ) : (
                    <Route exact path="/">
                        <Auth />
                    </Route>
                ) }

                {/*<Redirect from="*" to="/" />*/}
            </Switch>
        </Router>
    )
}

export default AppRouter;

 

6. Tweet.js

import { dbService, storageService } from "../fbase";
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash, faPencilAlt } from "@fortawesome/free-solid-svg-icons";

const Tweet = ({ tweetObj, isOwner }) => {
    const [editing, set_editing] = useState(false);
    const [newTweet, set_newTweet] = useState(tweetObj.text);

    const toggleEditing = () => set_editing((prev) => !prev);

    const onChange = (event) => {
        const { target: { value } } = event;
        set_newTweet(value);
    }

    const onDeleteClick = async () => {
        const ok = window.confirm("삭제하시겠습니까?");
        // console.log("ok : ", ok)
        if(ok) {
            // console.log("tweetObj.id : ", tweetObj.id);
            await dbService.doc(`tweets/${tweetObj.id}`).delete() // 작은 따옴표가 아닌 1 옆의 백틱
            // console.log("onDeleteClick data : ", data)
            if(tweetObj.attachmentUrl !== "") {
                await storageService.refFromURL(tweetObj.attachmentUrl).delete();
            }
        }
    }

    const onSubmit = async (event) => {
        event.preventDefault();
        // console.log("tweetObj.id, newTweet : ", tweetObj.id, newTweet)
        await dbService.doc(`tweets/${tweetObj.id}`).update({ text: newTweet });
        set_editing(false);
    }

    return (
        <div className="tweet">
            { editing ? (
                <>
                    <form onSubmit={ onSubmit } className="container tweetEdit">
                        <input onChange={ onChange } value={ newTweet } required placeholder="수정하기" autoFocus
                               className="formInput"/>
                        <input type="submit" value="수정하기" className="formBtn"/>
                    </form>
                    <button onClick={ toggleEditing } className="formBtn cancelBtn">취소</button>
                </>
            ) : (
                <>
                    <h4>{ tweetObj.text }</h4>
                    { tweetObj.attachmentUrl && (
                        <img src={ tweetObj.attachmentUrl } width="50px" height="50px" />
                    ) }
                    { isOwner && (
                            <div className="tweet__actions">
                                <span onClick={ onDeleteClick }>
                                    <FontAwesomeIcon icon={ faTrash } />
                                </span>
                                <span onClick={ toggleEditing }>
                                    <FontAwesomeIcon icon={ faPencilAlt } />
                                </span>
                            </div>
                        )
                    }
                </>
            )
            }

        </div>
    )
};

export default Tweet;

 

7. TweetFactory.js

import {dbService, storageService} from "../fbase";
import {v4 as uuidv4} from "uuid";
import {useState} from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";

const TweetFactory = ({ userObj }) => {
    const [tweet, set_tweet] = useState("");
    const [attachment, set_attachment] = useState("");

    const onSubmit = async (event) => {
        event.preventDefault();
        if(tweet === "") {
            return
        }
        /*
        await dbService.collection("tweets").add({
            text: tweet,
            createdAt: Date.now(),
            creatorId: userObj.uid,
        })
        set_tweet("");
         */

        let attachmentUrl = "";

        if(attachment !== "")  {
            // storageService.ref().child와 같이 스토리지 레퍼런스의 함수인 child를 사용하면 폴더, 파일 이름을 설정할 수 있다.
            const attachmentRef = storageService.ref().child(`${userObj.uid}/${uuidv4()}`)

            const response = await attachmentRef.putString(attachment, "data_url");
            attachmentUrl = await response.ref.getDownloadURL();
            console.log("response 1 : ", response);
            console.log("response 2 : ", await response.ref.getDownloadURL());
        }

        await dbService.collection("tweets").add({
            text: tweet,
            createdAt: Date.now(),
            creatorId: userObj.uid,
            attachmentUrl
        })
        set_tweet("");
        set_attachment("");
    }

    const onChange = (event) => {
        event.preventDefault();
        const { target: { value } } = event;

        set_tweet(value);
    }

    const onFileChange = (event) => {
        // console.log(event.target.files);
        const { target: { files } } = event;  // files = event.target.files
        const theFile = files[0]
        const reader = new FileReader();

        reader.onloadend = (finishedEvent) => {
            // console.log(finishedEvent);
            const { currentTarget : { result } } = finishedEvent  // result = finishedEvent.currentTarget.result
            set_attachment(result);
        };

        // readAsDataURL 함수는 파일 정보를 인자로 받아서 파일 위치를 URL로 반환해 준다.
        // 이ㅏ 함수는 리액트 생명주기 함수처럼 파일 선택 후, '웹 브라우저가 파일을 인식하는 시점', '웹 브라우저 파일 인식이 끝난 시점' 등을
        // 포함하고 있어서 시점까지 함께 관리해줘야 URL을 얻을 수 있다.
        // reader.readAsDataURL(theFile)
        if(Boolean(theFile)) {
            reader.readAsDataURL(theFile)
        }
    };

    // 파일 취소
    const onClearAttachment = () => set_attachment("");

    return (
        <form onSubmit={ onSubmit } className="factoryForm">
            <div className="factoryInput__container">
                <input className="factoryInput__input" type="text" value={ tweet } onChange={ onChange }
                       placeholder="당신의 생각은?" maxLength={ 120 } />
                <input type="submit" value="&rarr;" className="factoryInput__arrow" />
            </div>
            <label htmlFor="attach-file" className="factoryInput__label">
                <span>사진 추가</span>
                <FontAwesomeIcon icon={ faPlus } />
            </label>
            <input id="attach-file" type="file" accept="image/*" onChange={ onFileChange } style={{ opacity: 0 }} />
            <input type="submit" value="Tweet"/>
            { attachment && (
                <div className="factoryForm__attachment">
                    <img src={ attachment } width="50px" height="50px" style={{ backgroundImage: attachment }}/>
                    <div className="factoryForm__clear" onClick={ onClearAttachment }>
                        <span>초기화</span>
                        <FontAwesomeIcon icon={ faTimes } />
                    </div>
                </div>
            )}
        </form>
    )
};

export default TweetFactory;

 

8. Auth.js

import { useState } from "react";
import { authService, firebaseInstance } from "../fbase";
import AuthForm from "../components/AuthForm";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTwitter, faGoogle, faGithub } from "@fortawesome/free-brands-svg-icons";

const Auth = () => {

    // 소셜 로그인 기능 : 구글, 깃허브 등등 소셜 로그인 목표 확인
    const onSocialClick = async (event) => {
        // console.log(event.target.name);

        let provider;
        const { target: { name } } = event;

        if(name === "google") {
            provider = new firebaseInstance.auth.GoogleAuthProvider();
        } else if(name === "github") {
            provider = new firebaseInstance.auth.GithubAuthProvider();
        }

        const data = await authService.signInWithPopup(provider);
        console.log("onSocialClick data : ", data)
    };

    return (
        <div className="authContainer">
            <FontAwesomeIcon icon={ faTwitter } color={ "#04AAFF" } size="3x" style={{ marginBottom: 30 }} />
            <AuthForm />
            <div className="authBtns">
                <button onClick={ onSocialClick } name="google" className="authBtn">
                    구글 로그인 <FontAwesomeIcon icon={ faGoogle } />
                </button>
                <button onClick={ onSocialClick } name="github" className="authBtn">
                    깃허브 로그인 <FontAwesomeIcon icon={ faGithub } />
                </button>
            </div>
        </div>
    )
}

export default Auth;

 

9. Home.js

import {useEffect, useState} from "react";
import { dbService, storageService } from "../fbase";
import Tweet from "../components/Tweet";
import { v4 as uuidv4 } from "uuid";
import TweetFactory from "../components/TweetFactory";

const Home = ({ userObj }) => {
    // console.log("Home userObj : ", userObj)
    const [tweets, set_tweets] = useState([]);

    // const getTweets = async () => {
    //     const dbTweets = await dbService.collection("tweets").get();
    //     // dbTweets.forEach((document) => console.log("document.data() : ", document.data()))
    //     // dbTweets.forEach((document) => set_tweets((prev) => [document.data(), ...prev]))
    //     dbTweets.forEach((document) => {
    //         const tweetObject = { ...document.data(), id: document.id };
    //         set_tweets((prev) => [tweetObject, ...prev])
    //     })
    // }

    useEffect(() => {
        // onSnapshot 함수도 get 함수와 마찬가지로 스냅샷을 반환한다.
        // 스냅샷에는 문서 스냅샷들이 포함되어 있는데, 문서 스냅샷들은 snapshot.docs와 같이 얻어낼 수 있다.
        // 여기에 map 함수를 적용해서 문서 스냅샷에서 원하는 값만 뽑아서 다시 배열화 할 수 있다.
        // forEach 함수는 배열 요소를 순회하면서 매 순회마다 set_tweets 함수를 사용해야 하지만,
        // map 함수는 순회하며 만든 배열을 반환하므로 반환한 배열을 1번만 set_tweets 함수에 전달하면 되니 훨씬 효율적이다.
        dbService
            .collection("tweets")
            .orderBy("createdAt", "desc")
            .onSnapshot((snapshot => {
            const newArray = snapshot.docs.map((document) => ({
                id: document.id, ...document.data()
            }));
            set_tweets(newArray)
        }))
    }, [])
    // console.log("tweets : ", tweets);



    return (
        <div className="container">
            <TweetFactory userObj={ userObj } />
            <div style={{ marginTop: 30 }}>
                { tweets.map((tweet) => (
                    // <div key={ tweet.id }>
                    //     <h4>{ tweet.text }</h4>
                    // </div>
                    <Tweet key={ tweet.id } tweetObj={ tweet } isOwner={ tweet.creatorId === userObj.uid } />
                )) }
            </div>
        </div>

    )
}

export default Home;

 

10. Profile.js

import {authService, dbService} from "../fbase";
import { useHistory } from "react-router-dom";
import {useEffect, useState} from "react";

const Profile = ({ userObj, refreshUser }) => {
    const history = useHistory();
    const [newDisplayName, setNewDisplayName] = useState(userObj.displayName != null ? userObj.displayName : "");

    const onLogOutClick = () => {
        authService.signOut();
        history.push("/");
    }

    const onChange = (event) => {
        const { target: { value } } = event;
        setNewDisplayName(value);
    }

    const onSubmit = async (event) => {
        event.preventDefault();
        if(userObj.displayName !== newDisplayName) {
            await userObj.updateProfile({ displayName: newDisplayName });
            refreshUser();
        }
    };

    /*
    const getMyTweets = async () => {
        const tweets = await dbService
                        .collection("tweets")
                        .where("creatorId", "==", userObj.uid)
                        .orderBy("createdAt", "asc")
                        .get();
        
        console.log(tweets.docs.map((doc) => doc.data()))
    };
     */
    
    // Profile 컴포넌트가 렌더링 된 이후, 실행 될 함수 ( useEffect )
    useEffect(() => {
        // getMyTweets();
    }, [])

    return (
        <div className="container">
            <form onSubmit={ onSubmit } className="profileForm">
                <input type="text" placeholder="닉네임" onChange={ onChange } value={ newDisplayName } autoFocus
                       className="formInput"/>
                <input type="submit" value="프로필 수정" className="formBtn" style={{ marginTop: 10 }}/>
            </form>
            <span onClick={ onLogOutClick } className="formBtn cancelBtn logOut">
                로그아웃
            </span>
        </div>
    )
}

export default Profile;

 

11. fabase.js

import firebase from "firebase/compat";
import "firebase/auth"

// 파이어베이스 데이터베이스
import "firebase/firestore"

// 파이어베이스 저장소
import "firebase/storage"

const firebaseConfig = {
  apiKey: process.env.REACT_APP_API_KEY,
  authDomain: process.env.REACT_APP_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_APP_ID
};

// export default firebase.initializeApp(firebaseConfig)
firebase.initializeApp(firebaseConfig)

export const authService = firebase.auth();

// 소셜 로그인 기능 : 위의 auth 함수에는 소셜 로그인에 필요한 provider가 없다.
export const firebaseInstance = firebase;

// 파이어베이스 데이터베이스
export const dbService = firebase.firestore();

// 파이어베이스 저장소
export const storageService = firebase.storage();

 

12. index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
// import firebase from "firebase/compat";
import firebase from "./fbase";
// console.log(firebase)
import "./styles.css"

ReactDOM.render(<React.StrictMode><App /></React.StrictMode>, document.getElementById('root'));